From fe6805fb547d4a8afbe306a410f7d860a6c7a0bc Mon Sep 17 00:00:00 2001 From: wario_is_here Date: Sun, 19 Apr 2026 21:09:34 +0200 Subject: [PATCH] Make /client/:address/block-template mode-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously returned a solo-shaped coinbase (miner at 100%, or fee+miner with dev-fee config) regardless of the miner's actual mode. For PPLNS and group-solo users that's misleading — the real coinbase is a multi-output distribution across pool/group members, not a solo payout. Now picks payoutInformation to match the real coinbase the pool would produce: - solo: unchanged (miner address, optionally fee + miner) - pplns: PplnsService.getPayoutDistribution(coinbasevalue) - group-solo: GroupSoloService.getPayoutDistribution(groupId, coinbasevalue) Response now also includes `mode` and `payoutInformation` fields so the UI can show the right coinbase shape without guessing, plus `groupId` when mode is group-solo. Falls back to solo if the PPLNS/group distribution is empty (e.g. fresh pool with no shares yet) — matches StratumV1Client behavior on an empty PPLNS port. ## Refactor Extracted mode detection into a dedicated `MiningModeService`. Both PplnsController.getMiningMode (REST endpoint) and the block-template endpoint in AppController now delegate to it instead of duplicating the group-then-pplns lookup logic. Moves the detailed mode-detection test cases to mining-mode.service.spec.ts where the logic now lives; PplnsController's spec is simplified to a delegation check. ## Impact on existing specs AppController's constructor gained three new deps (MiningModeService, PplnsService, GroupSoloService). All four spec files that instantiate AppController for HTTP-integration tests are updated to provide the new deps as simple mocks — no existing assertions change. --- src/app.controller.block-template.spec.ts | 6 ++ ...p.controller.client-block-template.spec.ts | 6 ++ src/app.controller.core-info.spec.ts | 6 ++ src/app.controller.peerinfo.spec.ts | 6 ++ src/app.controller.ts | 64 +++++++++++++---- src/app.module.ts | 2 + .../pplns/pplns.controller.spec.ts | 68 +++---------------- src/controllers/pplns/pplns.controller.ts | 15 +--- src/services/mining-mode.service.spec.ts | 55 +++++++++++++++ src/services/mining-mode.service.ts | 46 +++++++++++++ 10 files changed, 191 insertions(+), 83 deletions(-) create mode 100644 src/services/mining-mode.service.spec.ts create mode 100644 src/services/mining-mode.service.ts diff --git a/src/app.controller.block-template.spec.ts b/src/app.controller.block-template.spec.ts index eb2905a0..7b772c8d 100644 --- a/src/app.controller.block-template.spec.ts +++ b/src/app.controller.block-template.spec.ts @@ -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; @@ -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(); diff --git a/src/app.controller.client-block-template.spec.ts b/src/app.controller.client-block-template.spec.ts index 8751528f..8025351c 100644 --- a/src/app.controller.client-block-template.spec.ts +++ b/src/app.controller.client-block-template.spec.ts @@ -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; @@ -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(); diff --git a/src/app.controller.core-info.spec.ts b/src/app.controller.core-info.spec.ts index 4ade19cd..1c71c5e9 100644 --- a/src/app.controller.core-info.spec.ts +++ b/src/app.controller.core-info.spec.ts @@ -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; @@ -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(); diff --git a/src/app.controller.peerinfo.spec.ts b/src/app.controller.peerinfo.spec.ts index 4bfd6456..6535a22d 100644 --- a/src/app.controller.peerinfo.spec.ts +++ b/src/app.controller.peerinfo.spec.ts @@ -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'; @@ -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(); diff --git a/src/app.controller.ts b/src/app.controller.ts index 4f3f6f52..f89006c6 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -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 ''; @@ -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; @@ -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'); @@ -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 } + : {}), }; } diff --git a/src/app.module.ts b/src/app.module.ts index 15113552..104631ef 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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'; @@ -192,6 +193,7 @@ const ORMModules = [ PplnsBalanceService, GroupSoloService, GroupService, + MiningModeService, ], }) export class AppModule { diff --git a/src/controllers/pplns/pplns.controller.spec.ts b/src/controllers/pplns/pplns.controller.spec.ts index 2217754e..d8cef812 100644 --- a/src/controllers/pplns/pplns.controller.spec.ts +++ b/src/controllers/pplns/pplns.controller.spec.ts @@ -4,68 +4,18 @@ import { PplnsController } from './pplns.controller'; describe('PplnsController.getMiningMode', () => { - function setup(opts: { - distribution?: { address: string; difficulty: number; percent: number }[]; - groupForAddress?: Record; - }) { - 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' }); - }); }); diff --git a/src/controllers/pplns/pplns.controller.ts b/src/controllers/pplns/pplns.controller.ts index 7a3589c7..be618902 100644 --- a/src/controllers/pplns/pplns.controller.ts +++ b/src/controllers/pplns/pplns.controller.ts @@ -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, ) {} /** @@ -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); } /** diff --git a/src/services/mining-mode.service.spec.ts b/src/services/mining-mode.service.spec.ts new file mode 100644 index 00000000..5813c8fb --- /dev/null +++ b/src/services/mining-mode.service.spec.ts @@ -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; + }) { + 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' }); + }); +}); diff --git a/src/services/mining-mode.service.ts b/src/services/mining-mode.service.ts new file mode 100644 index 00000000..7e5f874d --- /dev/null +++ b/src/services/mining-mode.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; + +import { PplnsService } from './pplns.service'; +import { GroupService } from './group.service'; + +export type MiningMode = 'solo' | 'pplns' | 'group-solo'; + +export interface MiningModeResult { + mode: MiningMode; + /** Present only when mode === 'group-solo'. */ + groupId?: string; +} + +/** + * Derives the mining mode for a given BTC address by combining the + * GroupService's address→group cache and the PPLNS share window membership. + * + * Resolution order: + * 1. Active group membership → 'group-solo' + * 2. Shares in the PPLNS window → 'pplns' + * 3. Otherwise → 'solo' + * + * Used by /api/pplns/mode/:address (dashboard routing in the UI) and by the + * /api/client/:address/block-template endpoint (to reflect the real coinbase + * shape the miner would produce). + */ +@Injectable() +export class MiningModeService { + + constructor( + private readonly pplnsService: PplnsService, + private readonly groupService: GroupService, + ) {} + + async getMode(address: string): Promise { + const group = this.groupService.getGroupForAddress(address); + if (group && group.active) { + return { mode: 'group-solo', groupId: group.groupId }; + } + const distribution = await this.pplnsService.getCurrentDistribution(); + if (distribution.some(d => d.address === address)) { + return { mode: 'pplns' }; + } + return { mode: 'solo' }; + } +}