Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/app.controller.block-template.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { ConfigService } from '@nestjs/config';
import { StratumV1JobsService } from './services/stratum-v1-jobs.service';
import { MetricsService } from './services/metrics.service';
import { LiveHashrateService } from './services/live-hashrate.service';
import { MiningModeService } from './services/mining-mode.service';
import { PplnsService } from './services/pplns.service';
import { GroupSoloService } from './services/group-solo.service';

describe('AppController /api/info/block-template', () => {
let app: NestFastifyApplication;
Expand All @@ -43,6 +46,9 @@ describe('AppController /api/info/block-template', () => {
{ provide: StratumV1JobsService, useValue: { newMiningJob$: of({}), getNextId: jest.fn() } },
{ provide: MetricsService, useValue: {} },
{ provide: LiveHashrateService, useValue: {} },
{ provide: MiningModeService, useValue: { getMode: jest.fn().mockResolvedValue({ mode: 'solo' }) } },
{ provide: PplnsService, useValue: { isEnabled: () => false, getPayoutDistribution: jest.fn() } },
{ provide: GroupSoloService, useValue: { isEnabled: () => false, getPayoutDistribution: jest.fn() } },
],
}).compile();

Expand Down
6 changes: 6 additions & 0 deletions src/app.controller.client-block-template.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import { ConfigService } from '@nestjs/config';
import { StratumV1JobsService, IJobTemplate } from './services/stratum-v1-jobs.service';
import { MetricsService } from './services/metrics.service';
import { LiveHashrateService } from './services/live-hashrate.service';
import { MiningModeService } from './services/mining-mode.service';
import { PplnsService } from './services/pplns.service';
import { GroupSoloService } from './services/group-solo.service';

describe('AppController /api/client/:address/block-template', () => {
let app: NestFastifyApplication;
Expand Down Expand Up @@ -93,6 +96,9 @@ describe('AppController /api/client/:address/block-template', () => {
},
{ provide: MetricsService, useValue: {} },
{ provide: LiveHashrateService, useValue: {} },
{ provide: MiningModeService, useValue: { getMode: jest.fn().mockResolvedValue({ mode: 'solo' }) } },
{ provide: PplnsService, useValue: { isEnabled: () => false, getPayoutDistribution: jest.fn() } },
{ provide: GroupSoloService, useValue: { isEnabled: () => false, getPayoutDistribution: jest.fn() } },
],
}).compile();

Expand Down
6 changes: 6 additions & 0 deletions src/app.controller.core-info.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { ConfigService } from '@nestjs/config';
import { StratumV1JobsService } from './services/stratum-v1-jobs.service';
import { MetricsService } from './services/metrics.service';
import { LiveHashrateService } from './services/live-hashrate.service';
import { MiningModeService } from './services/mining-mode.service';
import { PplnsService } from './services/pplns.service';
import { GroupSoloService } from './services/group-solo.service';

describe('AppController /api/info/core', () => {
let app: NestFastifyApplication;
Expand All @@ -43,6 +46,9 @@ describe('AppController /api/info/core', () => {
{ provide: StratumV1JobsService, useValue: { newMiningJob$: of({}), getNextId: jest.fn() } },
{ provide: MetricsService, useValue: {} },
{ provide: LiveHashrateService, useValue: {} },
{ provide: MiningModeService, useValue: { getMode: jest.fn().mockResolvedValue({ mode: 'solo' }) } },
{ provide: PplnsService, useValue: { isEnabled: () => false, getPayoutDistribution: jest.fn() } },
{ provide: GroupSoloService, useValue: { isEnabled: () => false, getPayoutDistribution: jest.fn() } },
],
}).compile();

Expand Down
6 changes: 6 additions & 0 deletions src/app.controller.peerinfo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import { ConfigService } from '@nestjs/config';
import { StratumV1JobsService } from './services/stratum-v1-jobs.service';
import { MetricsService } from './services/metrics.service';
import { LiveHashrateService } from './services/live-hashrate.service';
import { MiningModeService } from './services/mining-mode.service';
import { PplnsService } from './services/pplns.service';
import { GroupSoloService } from './services/group-solo.service';
import { of } from 'rxjs';

import { IPeerInfo } from './models/bitcoin-rpc/IPeerInfo';
Expand Down Expand Up @@ -45,6 +48,9 @@ describe('AppController info/peers', () => {
{ provide: StratumV1JobsService, useValue: { newMiningJob$: of({}), getNextId: jest.fn() } },
{ provide: MetricsService, useValue: {} },
{ provide: LiveHashrateService, useValue: {} },
{ provide: MiningModeService, useValue: { getMode: jest.fn().mockResolvedValue({ mode: 'solo' }) } },
{ provide: PplnsService, useValue: { isEnabled: () => false, getPayoutDistribution: jest.fn() } },
{ provide: GroupSoloService, useValue: { isEnabled: () => false, getPayoutDistribution: jest.fn() } },
],
}).compile();

Expand Down
64 changes: 52 additions & 12 deletions src/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import * as bitcoinjs from 'bitcoinjs-lib';
import { generateFormattedTimeSlots } from './utils/timeslot.utils';

import { LiveHashrateService } from './services/live-hashrate.service';
import { MiningModeService } from './services/mining-mode.service';
import { PplnsService } from './services/pplns.service';
import { GroupSoloService } from './services/group-solo.service';

function extractHost(addr: string): string {
if (!addr) return '';
Expand Down Expand Up @@ -86,6 +89,9 @@ export class AppController {
private readonly stratumV1JobsService: StratumV1JobsService,
private readonly metricsService: MetricsService,
private readonly liveHashrateService: LiveHashrateService,
private readonly miningModeService: MiningModeService,
private readonly pplnsService: PplnsService,
private readonly groupSoloService: GroupSoloService,
) {
const packagePath = join(__dirname, '..', 'package.json');
this.version = JSON.parse(readFileSync(packagePath, 'utf8')).version;
Expand Down Expand Up @@ -142,19 +148,48 @@ export class AppController {
public async clientBlockTemplate(@Param('address') address: string) {
const tpl = await firstValueFrom(this.stratumV1JobsService.newMiningJob$);

const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS');
const devFeePercent = parseFloat(
this.configService.get('DEV_FEE_PERCENT') ?? '1.5',
);

// Build payoutInformation that matches the actual coinbase the pool would
// produce for this miner. In PPLNS/group-solo the coinbase is a multi-output
// distribution, not a solo payout — returning a solo-shaped template here
// would mislead the miner.
const modeResult = await this.miningModeService.getMode(address);
let payoutInformation;
if (devFeeAddress == null || devFeeAddress.length < 1) {
payoutInformation = [{ address, percent: 100 }];
} else {
payoutInformation = [
{ address: devFeeAddress, percent: devFeePercent },
{ address, percent: 100 - devFeePercent },
];
let mode: 'solo' | 'pplns' | 'group-solo' = modeResult.mode;

if (modeResult.mode === 'group-solo' && modeResult.groupId && this.groupSoloService.isEnabled()) {
const distribution = await this.groupSoloService.getPayoutDistribution(
modeResult.groupId,
tpl.blockData.coinbasevalue,
);
if (distribution && distribution.length > 0) {
payoutInformation = distribution;
}
} else if (modeResult.mode === 'pplns' && this.pplnsService.isEnabled()) {
const distribution = await this.pplnsService.getPayoutDistribution(
tpl.blockData.coinbasevalue,
);
if (distribution && distribution.length > 0) {
payoutInformation = distribution;
}
}

// Fall back to solo if: mode is solo, or mode lookup produced an empty
// distribution (e.g. PPLNS window is empty right now). Behavior matches
// what StratumV1Client does on a solo port.
if (!payoutInformation) {
mode = 'solo';
const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS');
const devFeePercent = parseFloat(
this.configService.get('DEV_FEE_PERCENT') ?? '1.5',
);
if (devFeeAddress == null || devFeeAddress.length < 1) {
payoutInformation = [{ address, percent: 100 }];
} else {
payoutInformation = [
{ address: devFeeAddress, percent: devFeePercent },
{ address, percent: 100 - devFeePercent },
];
}
}

const networkConfig = this.configService.get('NETWORK');
Expand Down Expand Up @@ -194,6 +229,11 @@ export class AppController {
blockTemplate,
blockHex: block.toHex(),
coinbaseTxHex: job.getCoinbaseTxHex(),
mode,
payoutInformation,
...(modeResult.mode === 'group-solo' && modeResult.groupId
? { groupId: modeResult.groupId }
: {}),
};
}

Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { PplnsPayoutHistoryEntity } from './ORM/pplns-balance/pplns-payout-histo
import { PplnsController } from './controllers/pplns/pplns.controller';
import { GroupSoloService } from './services/group-solo.service';
import { GroupService } from './services/group.service';
import { MiningModeService } from './services/mining-mode.service';
import { PplnsGroupEntity } from './ORM/pplns-group/pplns-group.entity';
import { PplnsGroupMemberEntity } from './ORM/pplns-group/pplns-group-member.entity';
import { PplnsGroupBlockHistoryEntity } from './ORM/pplns-group/pplns-group-block-history.entity';
Expand Down Expand Up @@ -192,6 +193,7 @@ const ORMModules = [
PplnsBalanceService,
GroupSoloService,
GroupService,
MiningModeService,
],
})
export class AppModule {
Expand Down
68 changes: 9 additions & 59 deletions src/controllers/pplns/pplns.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,68 +4,18 @@ import { PplnsController } from './pplns.controller';

describe('PplnsController.getMiningMode', () => {

function setup(opts: {
distribution?: { address: string; difficulty: number; percent: number }[];
groupForAddress?: Record<string, { groupId: string; active: boolean } | undefined>;
}) {
const pplnsService = {
getCurrentDistribution: jest.fn().mockResolvedValue(opts.distribution ?? []),
it('delegates to MiningModeService.getMode', async () => {
const miningModeService = {
getMode: jest.fn().mockResolvedValue({ mode: 'pplns' }),
};
const groupService = {
getGroupForAddress: jest.fn((address: string) => opts.groupForAddress?.[address]),
};
const controller = new PplnsController(pplnsService as any, groupService as any);
return { controller, pplnsService, groupService };
}

it('returns group-solo when the address is in an active group', async () => {
const { controller } = setup({
groupForAddress: {
'bc1qalice': { groupId: 'grp-1', active: true },
},
});
const res = await controller.getMiningMode('bc1qalice');
expect(res).toEqual({ mode: 'group-solo', groupId: 'grp-1' });
});
const controller = new PplnsController(
{} as any,
miningModeService as any,
);

it('ignores inactive group membership and falls through to pplns/solo', async () => {
// Group exists but hasn't reached min 2 members yet → inactive.
// The address isn't routed to group-solo until activation.
const { controller } = setup({
groupForAddress: {
'bc1qalice': { groupId: 'grp-1', active: false },
},
distribution: [
{ address: 'bc1qalice', difficulty: 100, percent: 100 },
],
});
const res = await controller.getMiningMode('bc1qalice');
expect(res).toEqual({ mode: 'pplns' });
});
const res = await controller.getMiningMode('bc1qfoo');

it('returns pplns when the address has shares in the window but no group', async () => {
const { controller } = setup({
distribution: [
{ address: 'bc1qbob', difficulty: 200, percent: 100 },
],
});
const res = await controller.getMiningMode('bc1qbob');
expect(miningModeService.getMode).toHaveBeenCalledWith('bc1qfoo');
expect(res).toEqual({ mode: 'pplns' });
});

it('returns solo when the address has no group and no PPLNS window shares', async () => {
const { controller } = setup({
distribution: [
{ address: 'bc1qother', difficulty: 100, percent: 100 },
],
});
const res = await controller.getMiningMode('bc1qcharlie');
expect(res).toEqual({ mode: 'solo' });
});

it('returns solo when no groups exist and no PPLNS activity at all', async () => {
const { controller } = setup({});
const res = await controller.getMiningMode('bc1qnew');
expect(res).toEqual({ mode: 'solo' });
});
});
15 changes: 3 additions & 12 deletions src/controllers/pplns/pplns.controller.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { PplnsService } from '../../services/pplns.service';
import { GroupService } from '../../services/group.service';
import { MiningModeService } from '../../services/mining-mode.service';

@Controller('pplns')
export class PplnsController {

constructor(
private readonly pplnsService: PplnsService,
private readonly groupService: GroupService,
private readonly miningModeService: MiningModeService,
) {}

/**
Expand All @@ -21,16 +21,7 @@ export class PplnsController {
*/
@Get('mode/:address')
async getMiningMode(@Param('address') address: string) {
const group = this.groupService.getGroupForAddress(address);
if (group && group.active) {
return { mode: 'group-solo', groupId: group.groupId };
}
const distribution = await this.pplnsService.getCurrentDistribution();
const inPplns = distribution.some(d => d.address === address);
if (inPplns) {
return { mode: 'pplns' };
}
return { mode: 'solo' };
return this.miningModeService.getMode(address);
}

/**
Expand Down
55 changes: 55 additions & 0 deletions src/services/mining-mode.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
jest.mock('node-telegram-bot-api', () => jest.fn());

import { MiningModeService } from './mining-mode.service';

describe('MiningModeService.getMode', () => {

function setup(opts: {
distribution?: { address: string; difficulty: number; percent: number }[];
groupForAddress?: Record<string, { groupId: string; active: boolean } | undefined>;
}) {
const pplnsService = {
getCurrentDistribution: jest.fn().mockResolvedValue(opts.distribution ?? []),
};
const groupService = {
getGroupForAddress: jest.fn((address: string) => opts.groupForAddress?.[address]),
};
return new MiningModeService(pplnsService as any, groupService as any);
}

it('returns group-solo when the address is in an active group', async () => {
const svc = setup({
groupForAddress: { 'bc1qalice': { groupId: 'grp-1', active: true } },
});
expect(await svc.getMode('bc1qalice')).toEqual({ mode: 'group-solo', groupId: 'grp-1' });
});

it('ignores inactive group membership and falls through to pplns/solo', async () => {
// Group exists but hasn't reached min 2 members yet → inactive.
// The address isn't routed to group-solo until activation.
const svc = setup({
groupForAddress: { 'bc1qalice': { groupId: 'grp-1', active: false } },
distribution: [{ address: 'bc1qalice', difficulty: 100, percent: 100 }],
});
expect(await svc.getMode('bc1qalice')).toEqual({ mode: 'pplns' });
});

it('returns pplns when the address has shares in the window but no group', async () => {
const svc = setup({
distribution: [{ address: 'bc1qbob', difficulty: 200, percent: 100 }],
});
expect(await svc.getMode('bc1qbob')).toEqual({ mode: 'pplns' });
});

it('returns solo when the address has no group and no PPLNS window shares', async () => {
const svc = setup({
distribution: [{ address: 'bc1qother', difficulty: 100, percent: 100 }],
});
expect(await svc.getMode('bc1qcharlie')).toEqual({ mode: 'solo' });
});

it('returns solo when no groups exist and no PPLNS activity at all', async () => {
const svc = setup({});
expect(await svc.getMode('bc1qnew')).toEqual({ mode: 'solo' });
});
});
Loading