Add payout modes and production switchover schema

This commit is contained in:
Ben
2026-06-20 13:59:05 -04:00
parent a33c530268
commit a9d425f32f
61 changed files with 4164 additions and 1029 deletions
@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AcceptedShareHighScores1781309000000 implements MigrationInterface {
public name = 'AcceptedShareHighScores1781309000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "accepted_share_high_score" (
"id" bigserial PRIMARY KEY,
"scope" varchar(16) NOT NULL,
"payoutMode" varchar(16) NOT NULL DEFAULT 'all',
"bucketDate" date NOT NULL DEFAULT DATE '1970-01-01',
"submissionDifficulty" numeric NOT NULL DEFAULT 0,
"acceptedAt" timestamptz,
"bucket" timestamptz,
"blockHeight" bigint,
"address" varchar(128),
"clientName" varchar(256),
"protocol" varchar(16),
"createdAt" timestamptz NOT NULL DEFAULT NOW(),
"updatedAt" timestamptz NOT NULL DEFAULT NOW()
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_accepted_share_high_score_scope"
ON "accepted_share_high_score" ("scope", "payoutMode", "bucketDate")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_high_score_best"
ON "accepted_share_high_score" ("payoutMode", "submissionDifficulty" DESC, "acceptedAt" DESC)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "accepted_share_high_score"`);
}
}
@@ -0,0 +1,220 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PayoutModes1781300000000 implements MigrationInterface {
public name = 'PayoutModes1781300000000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "accepted_share_entity" ADD COLUMN IF NOT EXISTS "payoutMode" varchar(16) NOT NULL DEFAULT 'solo'`);
await queryRunner.query(`ALTER TABLE "client_entity" ADD COLUMN IF NOT EXISTS "payoutMode" varchar(16) NOT NULL DEFAULT 'solo'`);
await queryRunner.query(`ALTER TABLE "blocks_entity" ADD COLUMN IF NOT EXISTS "payoutMode" varchar(16) NOT NULL DEFAULT 'solo'`);
await queryRunner.query(`ALTER TABLE "share_rollup_batch" ADD COLUMN IF NOT EXISTS "payoutMode" varchar(16) NOT NULL DEFAULT 'pplns'`);
await queryRunner.query(`ALTER TABLE "share_rollup_batch_summary" ADD COLUMN IF NOT EXISTS "payoutMode" varchar(16) NOT NULL DEFAULT 'pplns'`);
await queryRunner.query(`ALTER TABLE "payout_snapshot" ADD COLUMN IF NOT EXISTS "payoutMode" varchar(16) NOT NULL DEFAULT 'pplns'`);
await queryRunner.query(`ALTER TABLE "payout_snapshot_entry" ADD COLUMN IF NOT EXISTS "payoutMode" varchar(16) NOT NULL DEFAULT 'pplns'`);
await queryRunner.query(`ALTER TABLE "payout_history" ADD COLUMN IF NOT EXISTS "payoutMode" varchar(16) NOT NULL DEFAULT 'pplns'`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_accounting_lookup"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_unique_submission"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_share_rollup_batch_range"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_share_rollup_batch_finalized_end"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_share_rollup_summary_address_batch"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_snapshot_latest"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_snapshot_template_window"`);
await queryRunner.query(`DROP INDEX IF EXISTS "UQ_payout_history_block_address"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_history_address_created"`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_accounting_lookup"
ON "accepted_share_entity" ("payoutMode", "address", "clientName", "acceptedAt" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_mode_order"
ON "accepted_share_entity" ("payoutMode", "shareIndex")
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_accepted_share_unique_submission"
ON "accepted_share_entity" ("acceptedAt", "payoutMode", "protocol", "sessionId", "jobId", "nonce", "ntime", "version", "extraNonce2")
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_share_rollup_batch_range"
ON "share_rollup_batch" ("payoutMode", "startShareIndex", "endShareIndex")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_share_rollup_batch_finalized_end"
ON "share_rollup_batch" ("payoutMode", "endShareIndex" DESC)
WHERE "status" = 'finalized'
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_share_rollup_summary_address_batch"
ON "share_rollup_batch_summary" ("payoutMode", "address", "clientName", "blockHeight", "batchId")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_payout_snapshot_latest"
ON "payout_snapshot" ("payoutMode", "status", "createdAt" DESC, "id" DESC)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_payout_snapshot_template_window"
ON "payout_snapshot" ("payoutMode", "method", "blockHeight", "coinbaseValueSats", "windowEndShareIndex")
WHERE "status" = 'finalized'
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_payout_history_block_address"
ON "payout_history" ("payoutMode", "blockHeight", "address")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_payout_history_address_created"
ON "payout_history" ("payoutMode", "address", "createdAt" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_blocks_payout_mode_height"
ON "blocks_entity" ("payoutMode", "height" DESC, "createdAt" DESC)
`);
await this.recreateContinuousAggregates(queryRunner);
await queryRunner.query(`
ALTER TABLE "accepted_share_entity"
SET (
timescaledb.compress_segmentby = '"payoutMode","address","clientName"',
timescaledb.compress_orderby = '"acceptedAt" DESC'
)
`).catch(() => undefined);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_blocks_payout_mode_height"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_mode_order"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_accounting_lookup"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_unique_submission"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_share_rollup_batch_range"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_share_rollup_batch_finalized_end"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_share_rollup_summary_address_batch"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_snapshot_latest"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_snapshot_template_window"`);
await queryRunner.query(`DROP INDEX IF EXISTS "UQ_payout_history_block_address"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_history_address_created"`);
await queryRunner.query(`ALTER TABLE "payout_history" DROP COLUMN IF EXISTS "payoutMode"`);
await queryRunner.query(`ALTER TABLE "payout_snapshot_entry" DROP COLUMN IF EXISTS "payoutMode"`);
await queryRunner.query(`ALTER TABLE "payout_snapshot" DROP COLUMN IF EXISTS "payoutMode"`);
await queryRunner.query(`ALTER TABLE "share_rollup_batch_summary" DROP COLUMN IF EXISTS "payoutMode"`);
await queryRunner.query(`ALTER TABLE "share_rollup_batch" DROP COLUMN IF EXISTS "payoutMode"`);
await queryRunner.query(`ALTER TABLE "blocks_entity" DROP COLUMN IF EXISTS "payoutMode"`);
await queryRunner.query(`ALTER TABLE "client_entity" DROP COLUMN IF EXISTS "payoutMode"`);
await queryRunner.query(`ALTER TABLE "accepted_share_entity" DROP COLUMN IF EXISTS "payoutMode"`);
}
private async recreateContinuousAggregates(queryRunner: QueryRunner): Promise<void> {
await this.dropContinuousAggregate(queryRunner, 'accepted_share_block_10m');
await this.dropContinuousAggregate(queryRunner, 'accepted_share_1d');
await this.dropContinuousAggregate(queryRunner, 'accepted_share_1h');
await this.dropContinuousAggregate(queryRunner, 'accepted_share_10m');
await queryRunner.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_10m"
WITH (timescaledb.continuous) AS
SELECT
time_bucket(INTERVAL '10 minutes', "acceptedAt") AS "bucket",
"payoutMode",
"address",
"clientName",
"sessionId",
"clientId",
SUM("creditedDifficulty") AS "shares",
COUNT(*) AS "acceptedCount",
ROUND(((SUM("creditedDifficulty") * 4294967296) / 600)) AS "hashRate"
FROM "accepted_share_entity"
GROUP BY "bucket", "payoutMode", "address", "clientName", "sessionId", "clientId"
WITH NO DATA
`);
await queryRunner.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_1h"
WITH (timescaledb.continuous) AS
SELECT
time_bucket(INTERVAL '1 hour', "acceptedAt") AS "bucket",
"payoutMode",
"address",
"clientName",
SUM("creditedDifficulty") AS "shares",
COUNT(*) AS "acceptedCount"
FROM "accepted_share_entity"
GROUP BY "bucket", "payoutMode", "address", "clientName"
WITH NO DATA
`);
await queryRunner.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_1d"
WITH (timescaledb.continuous) AS
SELECT
time_bucket(INTERVAL '1 day', "acceptedAt") AS "bucket",
"payoutMode",
"address",
SUM("creditedDifficulty") AS "shares",
COUNT(*) AS "acceptedCount"
FROM "accepted_share_entity"
GROUP BY "bucket", "payoutMode", "address"
WITH NO DATA
`);
await queryRunner.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_block_10m"
WITH (timescaledb.continuous) AS
SELECT
time_bucket(INTERVAL '10 minutes', "acceptedAt") AS "bucket",
"payoutMode",
"blockHeight",
SUM("creditedDifficulty") AS "shares",
COUNT(*) AS "acceptedCount",
MAX("networkDifficulty") AS "networkDifficulty",
MAX("submissionDifficulty") AS "bestSubmissionDifficulty"
FROM "accepted_share_entity"
GROUP BY "bucket", "payoutMode", "blockHeight"
WITH NO DATA
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_10m',
start_offset => INTERVAL '2 days',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => TRUE
)
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_1h',
start_offset => INTERVAL '30 days',
end_offset => INTERVAL '10 minutes',
schedule_interval => INTERVAL '10 minutes',
if_not_exists => TRUE
)
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_1d',
start_offset => INTERVAL '365 days',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour',
if_not_exists => TRUE
)
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_block_10m',
start_offset => INTERVAL '180 days',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => TRUE
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_block_10m_height_bucket"
ON "accepted_share_block_10m" ("payoutMode", "blockHeight" DESC, "bucket" DESC)
`);
}
private async dropContinuousAggregate(queryRunner: QueryRunner, viewName: string): Promise<void> {
await queryRunner.query(`SELECT remove_continuous_aggregate_policy('${viewName}', if_exists => TRUE)`).catch(() => undefined);
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "${viewName}" CASCADE`);
}
}
@@ -0,0 +1,125 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ShareRollupStoragePolicy1781305000000 implements MigrationInterface {
public name = 'ShareRollupStoragePolicy1781305000000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`TRUNCATE TABLE "payout_balance", "share_rollup_batch" RESTART IDENTITY CASCADE`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_share_rollup_summary_address_batch"`);
await queryRunner.query(`ALTER TABLE "share_rollup_batch_summary" DROP CONSTRAINT IF EXISTS "share_rollup_batch_summary_pkey"`);
await queryRunner.query(`
SELECT create_hypertable(
'"share_rollup_batch_summary"',
'lastShareAt',
chunk_time_interval => INTERVAL '1 day',
if_not_exists => TRUE,
migrate_data => FALSE
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_share_rollup_summary_batch"
ON "share_rollup_batch_summary" ("batchId", "payoutMode")
`);
await queryRunner.query(`
ALTER TABLE "share_rollup_batch_summary" SET (
timescaledb.compress,
timescaledb.compress_orderby = '"lastShareAt" DESC',
timescaledb.compress_segmentby = '"payoutMode","batchId"'
)
`);
await queryRunner.query(`SELECT remove_compression_policy('share_rollup_batch_summary', if_exists => TRUE)`);
await queryRunner.query(`
SELECT add_compression_policy(
'share_rollup_batch_summary',
INTERVAL '24 hours',
if_not_exists => TRUE
)
`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_accounting_lookup"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_client_lookup"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_order"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_accepted_at"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_round_best"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_protocol_work"`);
await queryRunner.query(`
CREATE OR REPLACE PROCEDURE prune_share_rollup_batches(job_id int, config jsonb)
LANGUAGE PLPGSQL
AS $$
DECLARE
retention interval := COALESCE((config ->> 'retention')::interval, INTERVAL '30 days');
deleted_count bigint;
BEGIN
DELETE FROM "share_rollup_batch"
WHERE "status" = 'finalized'
AND "finalizedAt" IS NOT NULL
AND "finalizedAt" < NOW() - retention;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RAISE LOG 'prune_share_rollup_batches deleted % finalized batches older than %', deleted_count, retention;
END;
$$
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM timescaledb_information.jobs
WHERE proc_name = 'prune_share_rollup_batches'
) THEN
PERFORM add_job(
'prune_share_rollup_batches'::regproc,
INTERVAL '1 day',
jsonb_build_object('retention', '30 days')
);
END IF;
END;
$$
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`SELECT delete_job(job_id) FROM timescaledb_information.jobs WHERE proc_name = 'prune_share_rollup_batches'`);
await queryRunner.query(`DROP PROCEDURE IF EXISTS prune_share_rollup_batches(int, jsonb)`);
await queryRunner.query(`SELECT remove_compression_policy('share_rollup_batch_summary', if_exists => TRUE)`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_share_rollup_summary_batch"`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_share_rollup_summary_address_batch"
ON "share_rollup_batch_summary" ("payoutMode", "address", "clientName", "blockHeight", "batchId")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_accounting_lookup"
ON "accepted_share_entity" ("payoutMode", "address", "clientName", "acceptedAt" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_client_lookup"
ON "accepted_share_entity" ("clientId", "acceptedAt" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_order"
ON "accepted_share_entity" ("shareIndex" DESC)
WHERE "shareIndex" IS NOT NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_accepted_at"
ON "accepted_share_entity" ("acceptedAt" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_round_best"
ON "accepted_share_entity" ("submissionDifficulty" DESC, "acceptedAt" DESC, "blockHeight")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_protocol_work"
ON "accepted_share_entity" ("protocol", "workSource", "workProtocol", "acceptedAt" DESC)
`);
}
}
@@ -0,0 +1,149 @@
import { UserAgentReportService } from './user-agent-report.service';
describe('UserAgentReportService', () => {
const originalApiOnly = process.env.API_ONLY;
afterEach(() => {
if (originalApiOnly == null) {
delete process.env.API_ONLY;
} else {
process.env.API_ONLY = originalApiOnly;
}
});
it('uses Redis live presence in API-only processes when workers are connected', async () => {
process.env.API_ONLY = 'true';
const userAgentReport = {
find: jest.fn().mockResolvedValue([
{
userAgent: 'db-view',
count: '1',
bestDifficulty: 0,
totalHashRate: '0',
},
]),
};
const clientRepository = {
find: jest.fn().mockResolvedValue([{ id: 'client-1' }]),
};
const redisMessagingService = {
getJsonCache: jest.fn().mockResolvedValue(null),
getAllClientPresence: jest.fn().mockResolvedValue([
{
clientId: 'client-1',
address: 'tb1qworker',
clientName: 'sv2gateway',
sessionId: 'session',
userAgent: 'unknown/sv2',
startTime: '2026-01-01T00:00:00.000Z',
lastSeen: '2026-01-01T00:00:10.000Z',
hashRate: 123,
bestDifficulty: 456,
},
]),
setJsonCache: jest.fn().mockResolvedValue(undefined),
removeClientPresence: jest.fn().mockResolvedValue(undefined),
};
const service = new UserAgentReportService(
userAgentReport as any,
clientRepository as any,
redisMessagingService as any,
);
await expect(service.getReport()).resolves.toEqual([
{
userAgent: 'unknown/sv2',
count: '1',
bestDifficulty: 456,
totalHashRate: '123',
},
]);
expect(userAgentReport.find).not.toHaveBeenCalled();
});
it('falls back to the materialized view in API-only processes when live presence is empty', async () => {
process.env.API_ONLY = 'true';
const viewRows = [
{
userAgent: 'db-view',
count: '1',
bestDifficulty: 0,
totalHashRate: '0',
},
];
const userAgentReport = {
find: jest.fn().mockResolvedValue(viewRows),
};
const clientRepository = {
find: jest.fn().mockResolvedValue([]),
};
const redisMessagingService = {
getJsonCache: jest.fn().mockResolvedValue([]),
getAllClientPresence: jest.fn().mockResolvedValue([]),
setJsonCache: jest.fn().mockResolvedValue(undefined),
removeClientPresence: jest.fn().mockResolvedValue(undefined),
};
const service = new UserAgentReportService(
userAgentReport as any,
clientRepository as any,
redisMessagingService as any,
);
await expect(service.getReport()).resolves.toEqual(viewRows);
});
it('filters and removes stale Redis presence rows with no active database client', async () => {
process.env.API_ONLY = 'true';
const userAgentReport = {
find: jest.fn().mockResolvedValue([]),
};
const clientRepository = {
find: jest.fn().mockResolvedValue([{ id: 'active-client' }]),
};
const redisMessagingService = {
getJsonCache: jest.fn().mockResolvedValue(null),
getAllClientPresence: jest.fn().mockResolvedValue([
{
clientId: 'active-client',
address: 'tb1qactive',
clientName: 'worker',
sessionId: 'active',
userAgent: 'bitaxe',
startTime: '2026-01-01T00:00:00.000Z',
lastSeen: '2026-01-01T00:00:10.000Z',
hashRate: 100,
bestDifficulty: 200,
},
{
clientId: 'stale-client',
address: 'tb1qstale',
clientName: 'worker',
sessionId: 'stale',
userAgent: 'bitaxe',
startTime: '2026-01-01T00:00:00.000Z',
lastSeen: '2026-01-01T00:00:10.000Z',
hashRate: 300,
bestDifficulty: 400,
},
]),
setJsonCache: jest.fn().mockResolvedValue(undefined),
removeClientPresence: jest.fn().mockResolvedValue(undefined),
};
const service = new UserAgentReportService(
userAgentReport as any,
clientRepository as any,
redisMessagingService as any,
);
await expect(service.getReport()).resolves.toEqual([
{
userAgent: 'bitaxe',
count: '1',
bestDifficulty: 200,
totalHashRate: '100',
},
]);
expect(redisMessagingService.removeClientPresence)
.toHaveBeenCalledWith('stale-client', 'tb1qstale');
});
});
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { ClientEntity } from '../../client/client.entity';
import { UserAgentReportView } from './user-agent-report.view';
@@ -29,10 +29,17 @@ export class UserAgentReportService {
return null;
});
if (cachedReport != null) {
return cachedReport;
if (cachedReport.length > 0) {
return cachedReport;
}
}
if (process.env.API_ONLY == 'true') {
const liveReport = await this.refreshLiveReport();
if (liveReport.length > 0) {
return liveReport;
}
return await this.userAgentReport.find();
}
@@ -54,6 +61,7 @@ export class UserAgentReportService {
private async buildLiveReport() {
const presences = await this.redisMessagingService.getAllClientPresence();
const activePresences = await this.filterActivePresences(presences);
const rows = new Map<string, {
userAgent: string;
count: number;
@@ -61,7 +69,7 @@ export class UserAgentReportService {
totalHashRate: number;
}>();
presences.forEach(presence => {
activePresences.forEach(presence => {
const userAgent = presence.userAgent == null || presence.userAgent.length === 0
? 'Other'
: presence.userAgent;
@@ -98,6 +106,29 @@ export class UserAgentReportService {
return report;
}
private async filterActivePresences(presences: Awaited<ReturnType<RedisMessagingService['getAllClientPresence']>>) {
if (presences.length === 0) {
return [];
}
const activeClients = await this.clientRepository.find({
select: {
id: true,
},
where: {
id: In(presences.map(presence => presence.clientId)),
},
});
const activeIds = new Set(activeClients.map(client => client.id));
const stalePresences = presences.filter(presence => !activeIds.has(presence.clientId));
void Promise.all(stalePresences.map(presence => {
return this.redisMessagingService.removeClientPresence(presence.clientId, presence.address)
.catch(() => undefined);
}));
return presences.filter(presence => activeIds.has(presence.clientId));
}
public async refreshReport() {
try {
await this.userAgentReport.query(`REFRESH MATERIALIZED VIEW user_agent_report_view`);
@@ -1,10 +1,10 @@
import { Column, Entity, Index, PrimaryColumn, PrimaryGeneratedColumn } from 'typeorm';
import { PayoutMode } from '../../types/payout-mode';
@Entity()
@Index('IDX_accepted_share_accounting_lookup', ['address', 'clientName', 'acceptedAt'])
@Index('IDX_accepted_share_client_lookup', ['clientId', 'acceptedAt'])
@Index('IDX_accepted_share_order', ['shareIndex'])
@Index('IDX_accepted_share_unique_submission', ['acceptedAt', 'protocol', 'sessionId', 'jobId', 'nonce', 'ntime', 'version', 'extraNonce2'], { unique: true })
@Index('IDX_accepted_share_mode_order', ['payoutMode', 'shareIndex'])
@Index('IDX_accepted_share_unique_submission', ['acceptedAt', 'payoutMode', 'protocol', 'sessionId', 'jobId', 'nonce', 'ntime', 'version', 'extraNonce2'], { unique: true })
export class AcceptedShareEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -18,6 +18,9 @@ export class AcceptedShareEntity {
@Column({ length: 16, type: 'varchar' })
protocol: 'sv1' | 'sv1_tls' | 'sv2' | 'sv2_jdp' | 'datum';
@Column({ length: 16, type: 'varchar', default: 'solo' })
payoutMode: PayoutMode;
@Column({ length: 16, type: 'varchar', default: 'pool_template' })
workSource: 'pool_template' | 'miner_template';
+5 -1
View File
@@ -1,5 +1,6 @@
import { Column, Entity } from 'typeorm';
import { PayoutMode } from '../../types/payout-mode';
import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn';
import { TrackedEntity } from '../utils/TrackedEntity.entity';
@@ -18,6 +19,9 @@ export class BlocksEntity extends TrackedEntity {
@Column()
worker: string;
@Column({ length: 16, type: 'varchar', default: 'solo' })
payoutMode: PayoutMode;
@Column({ length: 8, type: 'varchar' })
sessionId: string;
@@ -31,6 +35,6 @@ export class BlocksEntity extends TrackedEntity {
blockSubmissionResult?: string;
@Column({ type: 'bigint', nullable: true })
payoutSnapshotId?: string;
payoutSnapshotId?: string | null;
}
+129
View File
@@ -30,6 +30,9 @@ describe('BlocksService', () => {
id: 9,
blockSubmissionResult: 'SUCCESS!',
payoutSnapshotId: '7',
minerAddress: 'tb1qminer',
worker: 'worker',
sessionId: 'abcd1234',
});
const service = new BlocksService({} as any, repository as any);
@@ -43,6 +46,88 @@ describe('BlocksService', () => {
expect(repository.save).not.toHaveBeenCalled();
});
it('keeps real miner attribution when a duplicate TDP submit arrives', async () => {
const repository = createRepository();
repository.findOne.mockResolvedValue({
id: 9,
blockSubmissionResult: 'SUCCESS!',
payoutSnapshotId: '7',
minerAddress: 'tb1qminer',
worker: 'sv2gateway',
sessionId: 'abcd1234',
});
const service = new BlocksService({} as any, repository as any);
await service.save({
blockData: createBlockHex(),
minerAddress: 'tb1qpool',
worker: 'tdp',
sessionId: '00000010',
blockSubmissionResult: 'SUCCESS!',
payoutSnapshotId: '8',
});
expect(repository.update).toHaveBeenCalledWith(9, {
blockSubmissionResult: 'SUCCESS!',
payoutSnapshotId: '8',
});
expect(repository.save).not.toHaveBeenCalled();
});
it('clears payout snapshot metadata when a duplicate successful solo submit arrives', async () => {
const repository = createRepository();
repository.findOne.mockResolvedValue({
id: 9,
blockSubmissionResult: 'SUCCESS!',
payoutSnapshotId: '7',
minerAddress: 'tb1qminer',
worker: 'worker',
sessionId: 'abcd1234',
});
const service = new BlocksService({} as any, repository as any);
await service.save({
blockData: createBlockHex(),
blockSubmissionResult: 'SUCCESS!',
payoutSnapshotId: null,
});
expect(repository.update).toHaveBeenCalledWith(9, {
blockSubmissionResult: 'SUCCESS!',
payoutSnapshotId: null,
});
expect(repository.save).not.toHaveBeenCalled();
});
it('repairs internal TDP attribution when a duplicate real miner submit arrives', async () => {
const repository = createRepository();
repository.findOne.mockResolvedValue({
id: 9,
blockSubmissionResult: 'SUCCESS!',
payoutSnapshotId: '7',
minerAddress: 'tb1qpool',
worker: 'tdp',
sessionId: '00000010',
});
const service = new BlocksService({} as any, repository as any);
await service.save({
blockData: createBlockHex(),
minerAddress: 'tb1qminer',
worker: 'sv2gateway',
sessionId: 'abcd1234',
blockSubmissionResult: 'duplicate',
payoutSnapshotId: '8',
});
expect(repository.update).toHaveBeenCalledWith(9, {
minerAddress: 'tb1qminer',
worker: 'sv2gateway',
sessionId: 'abcd1234',
});
expect(repository.save).not.toHaveBeenCalled();
});
it('returns one public found-block row per block hash or legacy height', async () => {
const repository = createRepository();
repository.find.mockResolvedValue([
@@ -57,6 +142,50 @@ describe('BlocksService', () => {
{ height: 124, minerAddress: 'addr2', worker: 'b', sessionId: '2222', blockHash: 'hash-b' },
{ height: 123, minerAddress: 'addr1', worker: 'a', sessionId: '1111', blockHash: null },
]);
expect(repository.find).toHaveBeenCalledWith(expect.objectContaining({
where: expect.arrayContaining([
expect.objectContaining({ blockSubmissionResult: expect.objectContaining({ _value: expect.arrayContaining(['SUCCESS!', 'datum-gateway-submit-expected']) }) }),
expect.objectContaining({ blockSubmissionResult: expect.objectContaining({ _type: 'isNull' }) }),
]),
}));
});
it('includes DATUM gateway-submitted candidates for address found-block lookups', async () => {
const repository = createRepository();
repository.find.mockResolvedValue([
{
height: 4991348,
minerAddress: 'tb1qminer',
worker: 'datum',
sessionId: '28ff7236',
blockHash: 'hash-datum',
blockSubmissionResult: 'datum-gateway-submit-expected',
},
]);
const service = new BlocksService({} as any, repository as any);
await expect(service.getFoundBlocksByAddress('tb1qminer')).resolves.toEqual([
{
height: 4991348,
minerAddress: 'tb1qminer',
worker: 'datum',
sessionId: '28ff7236',
blockHash: 'hash-datum',
blockSubmissionResult: 'datum-gateway-submit-expected',
},
]);
expect(repository.find).toHaveBeenCalledWith(expect.objectContaining({
where: expect.arrayContaining([
expect.objectContaining({
minerAddress: 'tb1qminer',
blockSubmissionResult: expect.objectContaining({ _value: expect.arrayContaining(['SUCCESS!', 'datum-gateway-submit-expected']) }),
}),
expect.objectContaining({
minerAddress: 'tb1qminer',
blockSubmissionResult: expect.objectContaining({ _type: 'isNull' }),
}),
]),
}));
});
function createRepository() {
+52 -12
View File
@@ -1,14 +1,17 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import * as bitcoinjs from 'bitcoinjs-lib';
import { DataSource, IsNull, Repository } from 'typeorm';
import { DataSource, In, IsNull, Repository } from 'typeorm';
import { BlocksEntity } from './blocks.entity';
import { PayoutMode } from '../../types/payout-mode';
@Injectable()
export class BlocksService {
private static readonly PUBLIC_FOUND_BLOCK_RESULTS = ['SUCCESS!', 'datum-gateway-submit-expected'];
constructor(
private dataSource: DataSource,
@@ -24,15 +27,35 @@ export class BlocksService {
const existing = blockHash == null
? null
: await this.blocksRepository.findOne({
select: { id: true, blockSubmissionResult: true, payoutSnapshotId: true },
select: {
id: true,
blockSubmissionResult: true,
payoutSnapshotId: true,
minerAddress: true,
worker: true,
sessionId: true,
payoutMode: true,
},
where: { blockHash },
});
if (existing != null) {
const update: Partial<BlocksEntity> = {};
if (this.isSuccessfulBlockSubmission(block.blockSubmissionResult)) {
await this.blocksRepository.update(existing.id, {
blockSubmissionResult: 'SUCCESS!',
payoutSnapshotId: block.payoutSnapshotId ?? existing.payoutSnapshotId,
});
update.blockSubmissionResult = 'SUCCESS!';
update.payoutSnapshotId = Object.prototype.hasOwnProperty.call(block, 'payoutSnapshotId')
? block.payoutSnapshotId ?? null
: existing.payoutSnapshotId;
}
if (this.shouldReplaceAttribution(existing, block)) {
update.minerAddress = block.minerAddress;
update.worker = block.worker;
update.sessionId = block.sessionId;
}
if (block.payoutMode != null && existing.payoutMode !== block.payoutMode) {
update.payoutMode = block.payoutMode;
}
if (Object.keys(update).length > 0) {
await this.blocksRepository.update(existing.id, update);
}
return;
}
@@ -43,20 +66,21 @@ export class BlocksService {
});
}
public async getFoundBlocks() {
public async getFoundBlocks(payoutMode?: PayoutMode) {
const rows = await this.blocksRepository.find({
select: {
height: true,
minerAddress: true,
worker: true,
sessionId: true,
payoutMode: true,
blockHash: true,
blockSubmissionResult: true,
createdAt: true,
},
where: [
{ blockSubmissionResult: 'SUCCESS!' },
{ blockSubmissionResult: IsNull() },
{ ...(payoutMode == null ? {} : { payoutMode }), blockSubmissionResult: In(BlocksService.PUBLIC_FOUND_BLOCK_RESULTS) },
{ ...(payoutMode == null ? {} : { payoutMode }), blockSubmissionResult: IsNull() },
],
order: {
height: 'DESC',
@@ -66,20 +90,21 @@ export class BlocksService {
return this.uniqueFoundBlocks(rows);
}
public async getFoundBlocksByAddress(address: string) {
public async getFoundBlocksByAddress(address: string, payoutMode?: PayoutMode) {
const rows = await this.blocksRepository.find({
select: {
height: true,
minerAddress: true,
worker: true,
sessionId: true,
payoutMode: true,
blockHash: true,
blockSubmissionResult: true,
createdAt: true,
},
where: [
{ minerAddress: address, blockSubmissionResult: 'SUCCESS!' },
{ minerAddress: address, blockSubmissionResult: IsNull() },
{ minerAddress: address, ...(payoutMode == null ? {} : { payoutMode }), blockSubmissionResult: In(BlocksService.PUBLIC_FOUND_BLOCK_RESULTS) },
{ minerAddress: address, ...(payoutMode == null ? {} : { payoutMode }), blockSubmissionResult: IsNull() },
],
order: {
height: 'DESC',
@@ -122,4 +147,19 @@ export class BlocksService {
private isSuccessfulBlockSubmission(result?: string | null): boolean {
return result == null || result === 'SUCCESS!';
}
private shouldReplaceAttribution(existing: Partial<BlocksEntity>, incoming: Partial<BlocksEntity>): boolean {
if (!incoming.minerAddress || !incoming.worker) {
return false;
}
if (this.isInternalAttribution(incoming)) {
return false;
}
return this.isInternalAttribution(existing);
}
private isInternalAttribution(block: Partial<BlocksEntity>): boolean {
const worker = block.worker?.toLowerCase();
return worker === 'tdp' || block.minerAddress === 'sv2-tdp';
}
}
@@ -2,11 +2,20 @@ import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { normalizePayoutMode, PayoutMode } from '../../types/payout-mode';
const HASHES_PER_DIFFICULTY = 4294967296;
const CHART_BUCKET_SECONDS = 600;
const CHART_WINDOW = '24 hours';
const SITE_CHART_WINDOW = '7 days';
interface AcceptedShareChartFilter {
address?: string;
clientName?: string;
clientId?: string;
payoutMode?: PayoutMode;
}
@Injectable()
export class ClientStatisticsService {
@@ -21,6 +30,10 @@ export class ClientStatisticsService {
return this.getAcceptedShareChartData('', [], limit, SITE_CHART_WINDOW);
}
public async getChartDataForSiteByPayoutMode(payoutMode?: PayoutMode, limit: number = 144 * 7) {
return this.getAcceptedShareChartDataByPayoutMode({ payoutMode }, limit, SITE_CHART_WINDOW);
}
public async getChartDataForAddress(address: string) {
return this.getAcceptedShareChartData(
'AND "address" = $1',
@@ -30,14 +43,33 @@ export class ClientStatisticsService {
);
}
public async getChartDataForAddressByPayoutMode(address: string, payoutMode?: PayoutMode) {
return this.getAcceptedShareChartDataByPayoutMode({ address, payoutMode }, 144, CHART_WINDOW);
}
public async getHashRateForGroup(address: string, clientName: string) {
const result = await this.dataSource.query(`
WITH bounds AS (
SELECT time_bucket(INTERVAL '10 minutes', NOW()) AS current_bucket
),
completed AS (
SELECT COALESCE(SUM("shares"), 0) AS "creditedDifficulty"
FROM "accepted_share_10m", bounds
WHERE "address" = $1
AND "clientName" = $2
AND "bucket" > bounds.current_bucket - INTERVAL '1 hour'
AND "bucket" < bounds.current_bucket
),
live AS (
SELECT COALESCE(SUM("creditedDifficulty"), 0) AS "creditedDifficulty"
FROM "accepted_share_entity", bounds
WHERE "address" = $1
AND "clientName" = $2
AND "acceptedAt" >= bounds.current_bucket
)
SELECT
COALESCE((SUM("creditedDifficulty") * ${HASHES_PER_DIFFICULTY}) / ${CHART_BUCKET_SECONDS}, 0) AS "hashRate"
FROM "accepted_share_entity"
WHERE "address" = $1
AND "clientName" = $2
AND "acceptedAt" > NOW() - INTERVAL '1 hour'
COALESCE(((completed."creditedDifficulty" + live."creditedDifficulty") * ${HASHES_PER_DIFFICULTY}) / ${CHART_BUCKET_SECONDS}, 0) AS "hashRate"
FROM completed, live
`, [address, clientName]);
return parseFloat(result[0]?.hashRate ?? '0');
@@ -52,6 +84,10 @@ export class ClientStatisticsService {
);
}
public async getChartDataForGroupByPayoutMode(address: string, clientName: string, payoutMode?: PayoutMode) {
return this.getAcceptedShareChartDataByPayoutMode({ address, clientName, payoutMode }, 144, CHART_WINDOW);
}
public async getChartDataForSession(clientId: string) {
return this.getAcceptedShareChartData(
'AND "clientId" = $1',
@@ -61,6 +97,10 @@ export class ClientStatisticsService {
);
}
public async getChartDataForSessionByPayoutMode(clientId: string, payoutMode?: PayoutMode) {
return this.getAcceptedShareChartDataByPayoutMode({ clientId, payoutMode }, 144, CHART_WINDOW);
}
private async getAcceptedShareChartData(filterSql: string, params: unknown[], limit: number, windowSql: string) {
const query = `
WITH bounds AS (
@@ -101,4 +141,80 @@ export class ClientStatisticsService {
};
});
}
private async getAcceptedShareChartDataByPayoutMode(
filter: AcceptedShareChartFilter,
limit: number,
windowSql: string,
) {
const { whereSql, params } = this.buildModeChartWhere(filter);
const query = `
WITH bounds AS (
SELECT
NOW() - INTERVAL '${windowSql}' AS since,
time_bucket(INTERVAL '10 minutes', NOW()) AS current_bucket
)
SELECT
"label",
"payoutMode",
"data",
"shares",
"acceptedCount"
FROM (
SELECT
"bucket" AS "label",
"payoutMode",
ROUND((SUM("shares") * ${HASHES_PER_DIFFICULTY}) / ${CHART_BUCKET_SECONDS}) AS "data",
SUM("shares") AS "shares",
SUM("acceptedCount") AS "acceptedCount"
FROM "accepted_share_10m", bounds
WHERE "bucket" > bounds.since
AND "bucket" < bounds.current_bucket
${whereSql}
GROUP BY "bucket", "payoutMode"
ORDER BY "bucket" DESC
LIMIT ${limit * 2}
) AS limited_rows
ORDER BY "label", "payoutMode"
`;
const result = await this.dataSource.query(query, params);
return result.map(res => {
return {
label: new Date(res.label).toISOString(),
payoutMode: res.payoutMode,
data: res.data,
shares: Number(res.shares ?? 0),
acceptedCount: Number(res.acceptedCount ?? 0),
};
});
}
private buildModeChartWhere(filter: AcceptedShareChartFilter): { whereSql: string; params: string[] } {
const where: string[] = [];
const params: string[] = [];
if (filter.address != null) {
params.push(filter.address);
where.push(`"address" = $${params.length}`);
}
if (filter.clientName != null) {
params.push(filter.clientName);
where.push(`"clientName" = $${params.length}`);
}
if (filter.clientId != null) {
params.push(filter.clientId);
where.push(`"clientId" = $${params.length}`);
}
if (filter.payoutMode != null) {
params.push(normalizePayoutMode(filter.payoutMode));
where.push(`"payoutMode" = $${params.length}`);
}
return {
whereSql: where.length > 0 ? `AND ${where.join(' AND ')}` : '',
params,
};
}
}
+4
View File
@@ -1,5 +1,6 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { PayoutMode } from '../../types/payout-mode';
import { TrackedEntity } from '../utils/TrackedEntity.entity';
@@ -24,6 +25,9 @@ export class ClientEntity extends TrackedEntity {
@Column({ length: 8, type: 'varchar', })
sessionId: string;
@Column({ length: 16, type: 'varchar', default: 'solo' })
payoutMode: PayoutMode;
@Column({ length: 128, type: 'varchar', nullable: true })
userAgent: string;
+18 -1
View File
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { ClientEntity } from './client.entity';
@@ -62,6 +62,23 @@ export class ClientService {
return await this.clientRepository.count();
}
public async getActiveIds(ids: string[]): Promise<Set<string>> {
if (ids.length === 0) {
return new Set();
}
const clients = await this.clientRepository.find({
select: {
id: true,
},
where: {
id: In(ids),
},
});
return new Set(clients.map(client => client.id));
}
public async getByAddress(address: string): Promise<ClientEntity[]> {
return await this.clientRepository.find({
where: {
@@ -23,15 +23,14 @@ describe('buildPayoutDistribution', () => {
payoutSats: entry.payoutSats,
balanceAfterSats: entry.balanceAfterSats,
}))).toEqual([
{ address: ADDRESS_A, payoutSats: 1000, balanceAfterSats: -334 },
{ address: ADDRESS_A, payoutSats: 667, balanceAfterSats: 0 },
{ address: ADDRESS_B, payoutSats: 333, balanceAfterSats: 0 },
]);
expect(result.entries.find(entry => entry.address === ADDRESS_B))
.toEqual(expect.objectContaining({ includedInCoinbase: false, balanceAfterSats: 333 }));
expect(result.distributedSats).toBe(1000);
expect(result.unallocatedRemainderSats).toBe(0);
});
it('should carry sub-dust work forward as signed credit', () => {
it('should include small active miner payouts instead of reallocating their work', () => {
const result = buildPayoutDistribution({
addressWork: [
{ address: ADDRESS_A, creditedDifficulty: 999, acceptedShareCount: 999 },
@@ -48,17 +47,18 @@ describe('buildPayoutDistribution', () => {
const paid = result.entries.find(entry => entry.address === ADDRESS_A);
expect(pending).toEqual(expect.objectContaining({
includedInCoinbase: false,
includedInCoinbase: true,
grossPayoutSats: 1,
payoutSats: 0,
balanceAfterSats: 1,
payoutSats: 1,
balanceAfterSats: 0,
}));
expect(paid).toEqual(expect.objectContaining({
includedInCoinbase: true,
payoutSats: 1000,
balanceAfterSats: -1,
payoutSats: 999,
balanceAfterSats: 0,
}));
expect(result.distributedSats).toBe(1000);
expect(result.unallocatedRemainderSats).toBe(0);
});
it('should trim outputs that do not fit the coinbase weight budget', () => {
@@ -77,10 +77,16 @@ describe('buildPayoutDistribution', () => {
expect(result.payoutOutputs).toHaveLength(1);
expect(result.payoutOutputs[0].address).toBe(ADDRESS_A);
expect(result.payoutOutputs[0]).toEqual(expect.objectContaining({
payoutSats: 3000,
balanceAfterSats: -1889,
}));
expect(result.entries.find(entry => entry.address === ADDRESS_B))
.toEqual(expect.objectContaining({ includedInCoinbase: false, balanceAfterSats: 1000 }));
expect(result.entries.find(entry => entry.address === ADDRESS_C))
.toEqual(expect.objectContaining({ includedInCoinbase: false, balanceAfterSats: 888 }));
.toEqual(expect.objectContaining({ includedInCoinbase: false, balanceAfterSats: 889 }));
expect(result.distributedSats).toBe(3000);
expect(result.unallocatedRemainderSats).toBe(0);
});
it('should apply positive and negative carried balances to the next distribution', () => {
+154 -28
View File
@@ -73,6 +73,11 @@ interface ComputedAddress {
includedInCoinbase: boolean;
}
interface AllocationTarget<T> {
item: T;
weight: number;
}
export function buildPayoutDistribution(input: PayoutDistributionInput): PayoutDistributionResult {
const coinbaseValueSats = Math.max(0, Math.floor(input.coinbaseValueSats));
const minOutputSats = Math.max(DUST_LIMIT_SATS, Math.floor(input.minOutputSats));
@@ -120,6 +125,15 @@ export function buildPayoutDistribution(input: PayoutDistributionInput): PayoutD
};
}
const fairShareSats = allocateIntegerSats(
[...workByAddress.entries()].map(([address, work]) => ({
item: address,
weight: work.creditedDifficulty,
})),
minerRewardSats,
totalCreditedDifficulty,
);
const computations = new Map<string, ComputedAddress>();
const considered = new Set<string>([...workByAddress.keys(), ...balanceByAddress.keys()]);
@@ -128,7 +142,7 @@ export function buildPayoutDistribution(input: PayoutDistributionInput): PayoutD
const creditedDifficulty = work?.creditedDifficulty ?? 0;
const acceptedShareCount = work?.acceptedShareCount ?? 0;
const payoutWeight = creditedDifficulty > 0 ? creditedDifficulty / totalCreditedDifficulty : 0;
const rawFairSats = Math.floor(payoutWeight * minerRewardSats);
const rawFairSats = fairShareSats.get(address) ?? 0;
const balanceBeforeSats = balanceByAddress.get(address) ?? 0;
const targetSats = rawFairSats + balanceBeforeSats;
computations.set(address, {
@@ -148,47 +162,31 @@ export function buildPayoutDistribution(input: PayoutDistributionInput): PayoutD
const feeWeight = feeSats > 0 ? outputWeightForAddress(feeAddress) : 0;
let usedWeight = COINBASE_BASE_WEIGHT + COINBASE_WITNESS_COMMITMENT_WEIGHT + feeWeight;
const effectiveBudget = coinbaseWeightBudget - BUDGET_SAFETY_MARGIN_WEIGHT;
allocatePayoutTargets(computations, minerRewardSats, totalCreditedDifficulty);
const eligible = [...computations.values()]
.filter(row => row.targetSats >= minOutputSats)
.sort((a, b) => b.targetSats - a.targetSats || a.address.localeCompare(b.address));
.filter(row => row.payoutSats > 0 && (row.creditedDifficulty > 0 || row.payoutSats >= minOutputSats))
.sort((a, b) => b.payoutSats - a.payoutSats || b.targetSats - a.targetSats || a.address.localeCompare(b.address));
for (const row of eligible) {
const outputWeight = outputWeightForAddress(row.address);
if (usedWeight + outputWeight > effectiveBudget) {
row.balanceAfterSats = row.targetSats;
continue;
}
row.includedInCoinbase = true;
row.payoutSats = row.targetSats;
row.balanceAfterSats = 0;
row.balanceAfterSats = row.targetSats - row.payoutSats;
usedWeight += outputWeight;
}
let distributedToMiners = 0;
for (const row of computations.values()) {
distributedToMiners += row.payoutSats;
}
const remainder = minerRewardSats - distributedToMiners;
if (remainder > 0) {
const keptActive = [...computations.values()]
.filter(row => row.includedInCoinbase && row.creditedDifficulty > 0)
.sort((a, b) => b.creditedDifficulty - a.creditedDifficulty || a.address.localeCompare(b.address));
if (keptActive.length > 0) {
let assigned = 0;
for (const row of keptActive) {
const bonus = Math.floor((remainder * row.creditedDifficulty) / totalCreditedDifficulty);
row.payoutSats += bonus;
row.balanceAfterSats -= bonus;
assigned += bonus;
}
const residual = remainder - assigned;
if (residual > 0) {
keptActive[0].payoutSats += residual;
keptActive[0].balanceAfterSats -= residual;
}
if (!row.includedInCoinbase && row.payoutSats > 0) {
row.payoutSats = 0;
row.balanceAfterSats = row.targetSats;
}
}
redistributeUnfitMinerPayouts(computations, minerRewardSats);
const feeEntry = feeSats > 0
? {
address: feeAddress,
@@ -243,6 +241,134 @@ export function buildPayoutDistribution(input: PayoutDistributionInput): PayoutD
};
}
function allocatePayoutTargets(
computations: Map<string, ComputedAddress>,
minerRewardSats: number,
totalCreditedDifficulty: number,
): void {
const positiveTargets = [...computations.values()]
.filter(row => row.targetSats > 0);
const totalPositiveTarget = positiveTargets.reduce((sum, row) => sum + row.targetSats, 0);
if (totalPositiveTarget <= 0) {
const activeRows = [...computations.values()]
.filter(row => row.creditedDifficulty > 0);
const activeAllocations = allocateIntegerSats(
activeRows.map(row => ({ item: row.address, weight: row.creditedDifficulty })),
minerRewardSats,
totalCreditedDifficulty,
);
for (const row of activeRows) {
row.payoutSats = activeAllocations.get(row.address) ?? 0;
row.balanceAfterSats = row.targetSats - row.payoutSats;
}
return;
}
if (totalPositiveTarget <= minerRewardSats) {
for (const row of positiveTargets) {
row.payoutSats = row.targetSats;
row.balanceAfterSats = 0;
}
const surplus = minerRewardSats - totalPositiveTarget;
if (surplus <= 0) {
return;
}
const activeRows = positiveTargets.filter(row => row.creditedDifficulty > 0);
const activeWeight = activeRows.reduce((sum, row) => sum + row.creditedDifficulty, 0);
const surplusAllocations = allocateIntegerSats(
activeRows.map(row => ({ item: row.address, weight: row.creditedDifficulty })),
surplus,
activeWeight,
);
for (const row of activeRows) {
const extra = surplusAllocations.get(row.address) ?? 0;
row.payoutSats += extra;
row.balanceAfterSats -= extra;
}
return;
}
const cappedAllocations = allocateIntegerSats(
positiveTargets.map(row => ({ item: row.address, weight: row.targetSats })),
minerRewardSats,
totalPositiveTarget,
);
for (const row of positiveTargets) {
row.payoutSats = cappedAllocations.get(row.address) ?? 0;
row.balanceAfterSats = row.targetSats - row.payoutSats;
}
}
function redistributeUnfitMinerPayouts(
computations: Map<string, ComputedAddress>,
minerRewardSats: number,
): void {
const distributedToMiners = [...computations.values()]
.filter(row => row.includedInCoinbase)
.reduce((sum, row) => sum + row.payoutSats, 0);
const remainder = minerRewardSats - distributedToMiners;
if (remainder <= 0) {
return;
}
const keptActive = [...computations.values()]
.filter(row => row.includedInCoinbase && row.creditedDifficulty > 0);
const keptDifficulty = keptActive.reduce((sum, row) => sum + row.creditedDifficulty, 0);
if (keptDifficulty <= 0) {
return;
}
const bonusAllocations = allocateIntegerSats(
keptActive.map(row => ({ item: row.address, weight: row.creditedDifficulty })),
remainder,
keptDifficulty,
);
for (const row of keptActive) {
const bonus = bonusAllocations.get(row.address) ?? 0;
row.payoutSats += bonus;
row.balanceAfterSats -= bonus;
}
}
function allocateIntegerSats<T extends string>(
targets: AllocationTarget<T>[],
amountSats: number,
totalWeight: number,
): Map<T, number> {
const result = new Map<T, number>();
if (amountSats <= 0 || totalWeight <= 0) {
return result;
}
let assigned = 0;
const fractions = targets
.filter(target => target.weight > 0)
.map(target => {
const exact = (target.weight * amountSats) / totalWeight;
const whole = Math.floor(exact);
assigned += whole;
result.set(target.item, whole);
return {
item: target.item,
weight: target.weight,
fraction: exact - whole,
};
})
.sort((a, b) => b.fraction - a.fraction || b.weight - a.weight || a.item.localeCompare(b.item));
let residual = amountSats - assigned;
for (const target of fractions) {
if (residual <= 0) {
break;
}
result.set(target.item, (result.get(target.item) ?? 0) + 1);
residual--;
}
return result;
}
function mergeFeeEntry(
feeEntry: PayoutDistributionEntry,
minerEntries: Omit<PayoutDistributionEntry, 'rank'>[],
@@ -1,12 +1,14 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { PayoutMode } from '../../types/payout-mode';
const bigintTransformer = {
from: (value: string | number | null): number => Number(value ?? 0),
to: (value: number): number => Math.trunc(value ?? 0),
};
@Entity({ name: 'payout_history' })
@Index('UQ_payout_history_block_address', ['blockHeight', 'address'], { unique: true })
@Index('UQ_payout_history_block_address', ['payoutMode', 'blockHeight', 'address'], { unique: true })
@Index('IDX_payout_history_address_created', ['address', 'createdAt'])
export class PayoutHistoryEntity {
@PrimaryGeneratedColumn({ type: 'bigint' })
@@ -18,6 +20,9 @@ export class PayoutHistoryEntity {
@Column({ type: 'bigint' })
payoutSnapshotId: string;
@Column({ length: 16, type: 'varchar', default: 'pplns' })
payoutMode: PayoutMode;
@Column({ length: 62, type: 'varchar' })
address: string;
@@ -1,5 +1,6 @@
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { PayoutMode } from '../../types/payout-mode';
import { PayoutSnapshotEntity } from './payout-snapshot.entity';
@Entity({ name: 'payout_snapshot_entry' })
@@ -12,6 +13,9 @@ export class PayoutSnapshotEntryEntity {
@Column({ type: 'bigint' })
snapshotId: string;
@Column({ length: 16, type: 'varchar', default: 'pplns' })
payoutMode: PayoutMode;
@ManyToOne(() => PayoutSnapshotEntity, snapshot => snapshot.entries, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'snapshotId' })
snapshot: PayoutSnapshotEntity;
@@ -1,9 +1,10 @@
import { Column, Entity, Index, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { PayoutMode } from '../../types/payout-mode';
import { PayoutSnapshotEntryEntity } from './payout-snapshot-entry.entity';
@Entity({ name: 'payout_snapshot' })
@Index('IDX_payout_snapshot_latest', ['status', 'createdAt'])
@Index('IDX_payout_snapshot_latest', ['payoutMode', 'status', 'createdAt'])
@Index('IDX_payout_snapshot_window', ['windowEndShareIndex'])
export class PayoutSnapshotEntity {
@PrimaryGeneratedColumn({ type: 'bigint' })
@@ -12,6 +13,9 @@ export class PayoutSnapshotEntity {
@Column({ length: 32, type: 'varchar' })
method: string;
@Column({ length: 16, type: 'varchar', default: 'pplns' })
payoutMode: PayoutMode;
@Column({ length: 16, type: 'varchar', default: 'finalized' })
status: 'finalized';
@@ -16,6 +16,7 @@ describe('PayoutSnapshotService', () => {
process.env.PAYOUT_SNAPSHOT_ENABLED = 'true';
process.env.PAYOUT_METHOD = 'pplns';
process.env.PAYOUT_WINDOW_FACTOR = '4';
process.env.PAYOUT_BOOTSTRAP_WINDOW = 'false';
process.env.PAYOUT_MAX_COINBASE_OUTPUTS = '10';
process.env.PAYOUT_MIN_OUTPUT_SATS = '546';
process.env.PAYOUT_FEE_PERCENT = '0';
@@ -62,12 +63,13 @@ describe('PayoutSnapshotService', () => {
totalCreditedDifficulty: 100,
totalAcceptedShareCount: '5',
eligibleAddressCount: 2,
includedOutputCount: 1,
includedOutputCount: 2,
distributedSats: '1000',
unallocatedRemainderSats: '0',
}])
.mockResolvedValueOnce([
{ address: ADDRESS_A, payoutSats: '1000' },
{ address: ADDRESS_A, payoutSats: '600' },
{ address: ADDRESS_B, payoutSats: '400' },
]);
const snapshot = await service.createSnapshotForTemplate({
@@ -86,10 +88,67 @@ describe('PayoutSnapshotService', () => {
unallocatedRemainderSats: '0',
}));
expect(snapshot.payoutOutputs).toEqual([
{ address: ADDRESS_A, amountSats: 1000, percent: 100 },
{ address: ADDRESS_A, amountSats: 600, percent: 60 },
{ address: ADDRESS_B, amountSats: 400, percent: 40 },
]);
expect(manager.query).toHaveBeenCalledTimes(9);
expect(manager.query.mock.calls[0][1]).toEqual([100]);
expect(manager.query.mock.calls[0][1]).toEqual([100, 'pplns']);
});
it('should bootstrap the PPLNS window from paid block count up to the configured factor', async () => {
process.env.PAYOUT_BOOTSTRAP_WINDOW = 'true';
service = new PayoutSnapshotService(dataSource as unknown as DataSource);
manager.query
.mockResolvedValueOnce([{ paidBlockCount: 1 }])
.mockResolvedValueOnce([{
startBatchId: '10',
endBatchId: '12',
windowStartShareIndex: '1000',
windowEndShareIndex: '2000',
totalCreditedDifficulty: '50',
totalAcceptedShareCount: '3',
}])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ address: ADDRESS_A, creditedDifficulty: 30, acceptedShareCount: 2 },
{ address: ADDRESS_B, creditedDifficulty: 20, acceptedShareCount: 1 },
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: '56' }])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{
id: '56',
method: 'pplns',
blockHeight: 900001,
coinbaseValueSats: '1000',
windowStartShareIndex: '1000',
windowEndShareIndex: '2000',
totalCreditedDifficulty: 50,
totalAcceptedShareCount: '3',
eligibleAddressCount: 2,
includedOutputCount: 2,
distributedSats: '1000',
unallocatedRemainderSats: '0',
}])
.mockResolvedValueOnce([
{ address: ADDRESS_A, payoutSats: '600' },
{ address: ADDRESS_B, payoutSats: '400' },
]);
await expect(service.createSnapshotForTemplate({
blockHeight: 900001,
coinbaseValueSats: 1000,
networkDifficulty: 25,
})).resolves.toEqual(expect.objectContaining({
id: '56',
totalCreditedDifficulty: 50,
}));
expect(manager.query.mock.calls[0][0]).toContain('COUNT(DISTINCT "blockHeight")');
expect(manager.query.mock.calls[1][1]).toEqual([50, 'pplns']);
expect(manager.query.mock.calls[5][1][5]).toBe(50);
expect(manager.query.mock.calls[5][1][6]).toBe(2);
});
it('should not create a snapshot when there is no finalized rollup work', async () => {
@@ -131,12 +190,13 @@ describe('PayoutSnapshotService', () => {
totalCreditedDifficulty: 100,
totalAcceptedShareCount: '5',
eligibleAddressCount: 2,
includedOutputCount: 1,
includedOutputCount: 2,
distributedSats: '1000',
unallocatedRemainderSats: '0',
}])
.mockResolvedValueOnce([
{ address: ADDRESS_A, payoutSats: '1000' },
{ address: ADDRESS_A, payoutSats: '600' },
{ address: ADDRESS_B, payoutSats: '400' },
]);
const snapshot = await service.createSnapshotForTemplate({
@@ -147,7 +207,8 @@ describe('PayoutSnapshotService', () => {
expect(snapshot.id).toBe('55');
expect(snapshot.payoutOutputs).toEqual([
{ address: ADDRESS_A, amountSats: 1000, percent: 100 },
{ address: ADDRESS_A, amountSats: 600, percent: 60 },
{ address: ADDRESS_B, amountSats: 400, percent: 40 },
]);
expect(manager.query).toHaveBeenCalledTimes(4);
});
@@ -159,21 +220,21 @@ describe('PayoutSnapshotService', () => {
.mockResolvedValueOnce([
{
address: ADDRESS_A,
payoutSats: '1000',
payoutSats: '600',
balanceBeforeSats: '0',
balanceAfterSats: '-400',
balanceAfterSats: '0',
creditedDifficulty: 60,
includedInCoinbase: true,
rowType: 'coinbase',
},
{
address: ADDRESS_B,
payoutSats: '0',
payoutSats: '400',
balanceBeforeSats: '0',
balanceAfterSats: '400',
balanceAfterSats: '0',
creditedDifficulty: 40,
includedInCoinbase: false,
rowType: 'pending',
includedInCoinbase: true,
rowType: 'coinbase',
},
])
.mockResolvedValueOnce([])
@@ -210,4 +271,45 @@ describe('PayoutSnapshotService', () => {
});
expect(manager.query).toHaveBeenCalledTimes(2);
});
it('should return the latest current expected PPLNS payout for an address', async () => {
dataSource.query.mockResolvedValueOnce([{
snapshotId: '55',
blockHeight: 900000,
coinbaseValueSats: '1000',
distributedSats: '1000',
totalCreditedDifficulty: '100',
includedOutputCount: 2,
createdAt: new Date('2026-06-16T12:00:00.000Z'),
payoutMode: 'pplns',
payoutSats: '600',
grossPayoutSats: '600',
creditedDifficulty: '60',
payoutWeight: '0.6',
}]);
await expect(service.getLatestExpectedPayoutForAddress(ADDRESS_A)).resolves.toEqual({
snapshotId: '55',
blockHeight: 900000,
payoutMode: 'pplns',
payoutSats: 600,
grossPayoutSats: 600,
creditedDifficulty: 60,
payoutWeight: 0.6,
coinbaseValueSats: 1000,
distributedSats: 1000,
totalCreditedDifficulty: 100,
includedOutputCount: 2,
createdAt: new Date('2026-06-16T12:00:00.000Z'),
percent: 60,
});
expect(dataSource.query.mock.calls[0][1]).toEqual([ADDRESS_A, 'pplns']);
expect(dataSource.query.mock.calls[0][0]).toContain('WITH latest_snapshot AS');
});
it('should return null when the latest PPLNS snapshot does not pay the address', async () => {
dataSource.query.mockResolvedValueOnce([]);
await expect(service.getLatestExpectedPayoutForAddress(ADDRESS_A)).resolves.toBeNull();
});
});
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import type { AddressObject } from '../../models/MiningJob';
import { PayoutMode } from '../../types/payout-mode';
import {
buildPayoutDistribution,
PayoutAddressWork,
@@ -23,6 +24,7 @@ export interface PayoutSnapshotForTemplate {
distributedSats: string;
unallocatedRemainderSats: string;
payoutOutputs: AddressObject[];
payoutMode: PayoutMode;
}
export interface PayoutFinalizationResult {
@@ -33,17 +35,36 @@ export interface PayoutFinalizationResult {
balanceRows?: number;
}
export interface ExpectedPayout {
snapshotId: string;
blockHeight: number;
payoutMode: PayoutMode;
payoutSats: number;
grossPayoutSats: number;
creditedDifficulty: number;
payoutWeight: number;
coinbaseValueSats: number;
distributedSats: number;
totalCreditedDifficulty: number;
includedOutputCount: number;
createdAt: Date;
percent: number;
}
const DEFAULT_MAX_COINBASE_OUTPUTS = 10;
const DEFAULT_MIN_OUTPUT_SATS = 546;
const DEFAULT_PAYOUT_METHOD = 'pplns';
const DEFAULT_PAYOUT_WINDOW_FACTOR = 4;
const DEFAULT_COINBASE_WEIGHT_BUDGET = 50_000;
const DEFAULT_PAYOUT_BOOTSTRAP_WINDOW = true;
const PPLNS_PAYOUT_MODE: PayoutMode = 'pplns';
@Injectable()
export class PayoutSnapshotService {
private readonly snapshotsEnabled = this.readBoolean('PAYOUT_SNAPSHOT_ENABLED', true);
private readonly method = process.env.PAYOUT_METHOD ?? DEFAULT_PAYOUT_METHOD;
private readonly windowFactor = this.readPositiveNumber('PAYOUT_WINDOW_FACTOR', DEFAULT_PAYOUT_WINDOW_FACTOR);
private readonly bootstrapWindow = this.readBoolean('PAYOUT_BOOTSTRAP_WINDOW', DEFAULT_PAYOUT_BOOTSTRAP_WINDOW);
private readonly maxCoinbaseOutputs = this.readPositiveInt('PAYOUT_MAX_COINBASE_OUTPUTS', DEFAULT_MAX_COINBASE_OUTPUTS);
private readonly minOutputSats = this.readNonNegativeInt('PAYOUT_MIN_OUTPUT_SATS', DEFAULT_MIN_OUTPUT_SATS);
private readonly feeAddress = process.env.PAYOUT_FEE_ADDRESS?.trim() ?? '';
@@ -64,7 +85,8 @@ export class PayoutSnapshotService {
}
return this.dataSource.transaction(async manager => {
const windowTargetDifficulty = input.networkDifficulty * this.windowFactor;
const effectiveWindowFactor = await this.getEffectiveWindowFactor(manager);
const windowTargetDifficulty = input.networkDifficulty * effectiveWindowFactor;
const window = await this.getPplnsWindow(manager, windowTargetDifficulty);
if (window == null || this.toNumber(window.totalCreditedDifficulty) <= 0) {
return null;
@@ -107,6 +129,7 @@ export class PayoutSnapshotService {
const [snapshotRow] = await manager.query(`
INSERT INTO "payout_snapshot" (
"method",
"payoutMode",
"status",
"blockHeight",
"coinbaseValueSats",
@@ -129,35 +152,37 @@ export class PayoutSnapshotService {
"unallocatedRemainderSats"
) VALUES (
$1,
$2,
'finalized',
$2::bigint,
$3::bigint,
$4::numeric,
$4::bigint,
$5::numeric,
$6::numeric,
NULLIF($7, ''),
$8::bigint,
$9::int,
$7::numeric,
NULLIF($8, ''),
$9::bigint,
$10::int,
$11::bigint,
$11::int,
$12::bigint,
$13::bigint,
$14::bigint,
$15::numeric,
$16::bigint,
$17::int,
$15::bigint,
$16::numeric,
$17::bigint,
$18::int,
$19::bigint,
$20::bigint
$19::int,
$20::bigint,
$21::bigint
)
RETURNING "id"::text AS "id"
`, [
this.method,
PPLNS_PAYOUT_MODE,
input.blockHeight,
input.coinbaseValueSats.toString(),
input.networkDifficulty,
windowTargetDifficulty,
this.windowFactor,
effectiveWindowFactor,
this.feeAddress,
distribution.feeSats.toString(),
this.minOutputSats,
@@ -182,14 +207,36 @@ export class PayoutSnapshotService {
});
}
private async getEffectiveWindowFactor(manager: EntityManager): Promise<number> {
if (!this.bootstrapWindow) {
return this.windowFactor;
}
const paidBlockCount = await this.getFinalizedPayoutBlockCount(manager);
return Math.min(this.windowFactor, Math.max(1, paidBlockCount + 1));
}
private async getFinalizedPayoutBlockCount(manager: EntityManager): Promise<number> {
const [row] = await manager.query(`
SELECT COUNT(DISTINCT "blockHeight")::int AS "paidBlockCount"
FROM "payout_history"
WHERE "payoutMode" = $1
`, [PPLNS_PAYOUT_MODE]);
return this.toNumber(row?.paidBlockCount);
}
public async finalizeSnapshotForBlock(input: {
payoutSnapshotId?: string | null;
blockHeight: number;
blockSubmissionResult?: string | null;
payoutMode?: PayoutMode;
}): Promise<PayoutFinalizationResult> {
if (!this.snapshotsEnabled) {
return { finalized: false, reason: 'disabled' };
}
if ((input.payoutMode ?? PPLNS_PAYOUT_MODE) !== PPLNS_PAYOUT_MODE) {
return { finalized: false, reason: 'missing-snapshot' };
}
if (!this.isSuccessfulBlockSubmission(input.blockSubmissionResult)) {
return { finalized: false, reason: 'missing-snapshot' };
}
@@ -202,8 +249,9 @@ export class PayoutSnapshotService {
SELECT "id"::text AS "id", "coinbaseValueSats"::text AS "coinbaseValueSats"
FROM "payout_snapshot"
WHERE "id" = $1::bigint
AND "payoutMode" = $2
LIMIT 1
`, [input.payoutSnapshotId]);
`, [input.payoutSnapshotId, PPLNS_PAYOUT_MODE]);
if (snapshot == null) {
return { finalized: false, reason: 'missing-snapshot' };
}
@@ -212,8 +260,9 @@ export class PayoutSnapshotService {
SELECT "id"::text AS "id"
FROM "payout_history"
WHERE "blockHeight" = $1::bigint
AND "payoutMode" = $2
LIMIT 1
`, [input.blockHeight]);
`, [input.blockHeight, PPLNS_PAYOUT_MODE]);
if (existing != null) {
return {
finalized: false,
@@ -233,8 +282,9 @@ export class PayoutSnapshotService {
"rowType"
FROM "payout_snapshot_entry"
WHERE "snapshotId" = $1::bigint
AND "payoutMode" = $2
ORDER BY "rank"
`, [input.payoutSnapshotId]);
`, [input.payoutSnapshotId, PPLNS_PAYOUT_MODE]);
if (entries.length === 0) {
return { finalized: false, reason: 'missing-snapshot' };
}
@@ -283,6 +333,7 @@ export class PayoutSnapshotService {
INSERT INTO "payout_history" (
"blockHeight",
"payoutSnapshotId",
"payoutMode",
"address",
"paidSats",
"percent",
@@ -293,16 +344,18 @@ export class PayoutSnapshotService {
$1::bigint,
$2::bigint,
$3,
$4::bigint,
$5::numeric,
$6::bigint,
$4,
$5::bigint,
$6::numeric,
$7::bigint,
$8
$8::bigint,
$9
)
ON CONFLICT ("blockHeight", "address") DO NOTHING
ON CONFLICT ("payoutMode", "blockHeight", "address") DO NOTHING
`, [
input.blockHeight,
input.payoutSnapshotId,
PPLNS_PAYOUT_MODE,
entry.address,
payoutSats.toString(),
coinbaseValue > 0 && isCoinbase ? (payoutSats / coinbaseValue) * 100 : 0,
@@ -336,15 +389,79 @@ export class PayoutSnapshotService {
SELECT "id"::text AS "id"
FROM "payout_snapshot"
WHERE "status" = 'finalized'
AND "payoutMode" = $1
ORDER BY "createdAt" DESC, "id" DESC
LIMIT 1
`);
`, [PPLNS_PAYOUT_MODE]);
if (snapshot?.id == null) {
return null;
}
return this.dataSource.transaction(manager => this.getSnapshotById(manager, snapshot.id));
}
public async getLatestExpectedPayoutForAddress(address: string): Promise<ExpectedPayout | null> {
const [row] = await this.dataSource.query(`
WITH latest_snapshot AS (
SELECT
"id",
"blockHeight",
"coinbaseValueSats",
"distributedSats",
"totalCreditedDifficulty",
"includedOutputCount",
"createdAt",
"payoutMode"
FROM "payout_snapshot"
WHERE "status" = 'finalized'
AND "payoutMode" = $2
ORDER BY "createdAt" DESC, "id" DESC
LIMIT 1
)
SELECT
s."id"::text AS "snapshotId",
s."blockHeight"::int AS "blockHeight",
s."coinbaseValueSats"::text AS "coinbaseValueSats",
s."distributedSats"::text AS "distributedSats",
s."totalCreditedDifficulty"::text AS "totalCreditedDifficulty",
s."includedOutputCount"::int AS "includedOutputCount",
s."createdAt" AS "createdAt",
e."payoutMode" AS "payoutMode",
e."payoutSats"::text AS "payoutSats",
e."grossPayoutSats"::text AS "grossPayoutSats",
e."creditedDifficulty"::text AS "creditedDifficulty",
e."payoutWeight"::text AS "payoutWeight"
FROM latest_snapshot s
JOIN "payout_snapshot_entry" e ON e."snapshotId" = s."id"
WHERE e."address" = $1
AND e."payoutMode" = $2
AND e."includedInCoinbase" = true
AND e."payoutSats" > 0
LIMIT 1
`, [address, PPLNS_PAYOUT_MODE]);
if (row == null) {
return null;
}
const payoutSats = this.toNumber(row.payoutSats);
const coinbaseValueSats = this.toNumber(row.coinbaseValueSats);
return {
snapshotId: row.snapshotId,
blockHeight: this.toNumber(row.blockHeight),
payoutMode: row.payoutMode,
payoutSats,
grossPayoutSats: this.toNumber(row.grossPayoutSats),
creditedDifficulty: this.toNumber(row.creditedDifficulty),
payoutWeight: this.toNumber(row.payoutWeight),
coinbaseValueSats,
distributedSats: this.toNumber(row.distributedSats),
totalCreditedDifficulty: this.toNumber(row.totalCreditedDifficulty),
includedOutputCount: this.toNumber(row.includedOutputCount),
createdAt: row.createdAt,
percent: coinbaseValueSats > 0 ? (payoutSats / coinbaseValueSats) * 100 : 0,
};
}
private async getPplnsWindow(manager: EntityManager, windowTargetDifficulty: number): Promise<{
startBatchId: string;
endBatchId: string;
@@ -370,6 +487,7 @@ export class PayoutSnapshotService {
) AS "difficultyAfter"
FROM "share_rollup_batch"
WHERE "status" = 'finalized'
AND "payoutMode" = $2
),
selected AS MATERIALIZED (
SELECT *
@@ -384,7 +502,7 @@ export class PayoutSnapshotService {
COALESCE(SUM("creditedDifficulty"), 0)::numeric AS "totalCreditedDifficulty",
COALESCE(SUM("acceptedShareCount"), 0)::bigint AS "totalAcceptedShareCount"
FROM selected
`, [windowTargetDifficulty]);
`, [windowTargetDifficulty, PPLNS_PAYOUT_MODE]);
if (window?.startBatchId == null || window?.windowEndShareIndex == null) {
return null;
@@ -404,6 +522,8 @@ export class PayoutSnapshotService {
FROM "share_rollup_batch_summary" s
JOIN "share_rollup_batch" b ON b."id" = s."batchId"
WHERE b."status" = 'finalized'
AND b."payoutMode" = $3
AND s."payoutMode" = $3
AND b."endShareIndex" >= $1::bigint
AND b."endShareIndex" <= $2::bigint
GROUP BY s."address"
@@ -411,6 +531,7 @@ export class PayoutSnapshotService {
`, [
window.windowStartShareIndex,
window.windowEndShareIndex,
PPLNS_PAYOUT_MODE,
]);
return rows.map(row => ({
address: row.address,
@@ -439,6 +560,7 @@ export class PayoutSnapshotService {
SELECT "id"::text AS "id"
FROM "payout_snapshot"
WHERE "method" = $1
AND "payoutMode" = $5
AND "status" = 'finalized'
AND "blockHeight" = $2::bigint
AND "coinbaseValueSats" = $3::bigint
@@ -450,6 +572,7 @@ export class PayoutSnapshotService {
input.blockHeight,
input.coinbaseValueSats.toString(),
input.windowEndShareIndex,
PPLNS_PAYOUT_MODE,
]);
if (snapshot?.id == null) {
@@ -463,6 +586,7 @@ export class PayoutSnapshotService {
SELECT
"id"::text AS "id",
"method",
"payoutMode",
"blockHeight"::int AS "blockHeight",
"coinbaseValueSats"::text AS "coinbaseValueSats",
"windowStartShareIndex"::text AS "windowStartShareIndex",
@@ -475,7 +599,8 @@ export class PayoutSnapshotService {
"unallocatedRemainderSats"::text AS "unallocatedRemainderSats"
FROM "payout_snapshot"
WHERE "id" = $1::bigint
`, [id]);
AND "payoutMode" = $2
`, [id, PPLNS_PAYOUT_MODE]);
if (snapshot == null) {
return null;
}
@@ -486,14 +611,16 @@ export class PayoutSnapshotService {
"payoutSats"::text AS "payoutSats"
FROM "payout_snapshot_entry"
WHERE "snapshotId" = $1::bigint
AND "payoutMode" = $2
AND "includedInCoinbase" = true
ORDER BY "rank"
`, [id]);
`, [id, PPLNS_PAYOUT_MODE]);
const coinbaseValue = Number(snapshot.coinbaseValueSats);
return {
id: snapshot.id,
method: snapshot.method,
payoutMode: snapshot.payoutMode,
blockHeight: this.toNumber(snapshot.blockHeight),
coinbaseValueSats: snapshot.coinbaseValueSats,
windowStartShareIndex: snapshot.windowStartShareIndex,
@@ -518,6 +645,7 @@ export class PayoutSnapshotService {
await manager.query(`
INSERT INTO "payout_snapshot_entry" (
"snapshotId",
"payoutMode",
"address",
"creditedDifficulty",
"acceptedShareCount",
@@ -532,19 +660,21 @@ export class PayoutSnapshotService {
) VALUES (
$1::bigint,
$2,
$3::numeric,
$4::bigint,
$5::numeric,
$6::bigint,
$3,
$4::numeric,
$5::bigint,
$6::numeric,
$7::bigint,
$8::bigint,
$9::bigint,
$10::boolean,
$11,
$12::int
$10::bigint,
$11::boolean,
$12,
$13::int
)
`, [
snapshotId,
PPLNS_PAYOUT_MODE,
entry.address,
entry.creditedDifficulty,
entry.acceptedShareCount,
@@ -561,7 +691,8 @@ export class PayoutSnapshotService {
private limitCoinbaseOutputs(entries: PayoutDistributionEntry[]): PayoutDistributionEntry[] {
let included = 0;
return entries.map(entry => {
let removedPayoutSats = 0;
const limitedEntries = entries.map(entry => {
if (!entry.includedInCoinbase) {
return entry;
}
@@ -569,6 +700,7 @@ export class PayoutSnapshotService {
if (included <= this.maxCoinbaseOutputs) {
return entry;
}
removedPayoutSats += entry.payoutSats;
return {
...entry,
payoutSats: 0,
@@ -576,6 +708,43 @@ export class PayoutSnapshotService {
includedInCoinbase: false,
};
});
if (removedPayoutSats <= 0) {
return limitedEntries;
}
const keptActive = limitedEntries
.filter(entry => entry.includedInCoinbase && entry.creditedDifficulty > 0);
const keptDifficulty = keptActive.reduce((sum, entry) => sum + entry.creditedDifficulty, 0);
if (keptDifficulty <= 0) {
return limitedEntries;
}
let assigned = 0;
const allocations = keptActive
.map(entry => {
const exact = (entry.creditedDifficulty * removedPayoutSats) / keptDifficulty;
const whole = Math.floor(exact);
assigned += whole;
return {
entry,
whole,
fraction: exact - whole,
};
})
.sort((a, b) => b.fraction - a.fraction || b.entry.creditedDifficulty - a.entry.creditedDifficulty || a.entry.address.localeCompare(b.entry.address));
let residual = removedPayoutSats - assigned;
for (const allocation of allocations) {
const extra = allocation.whole + (residual > 0 ? 1 : 0);
if (residual > 0) {
residual--;
}
allocation.entry.payoutSats += extra;
allocation.entry.balanceAfterSats -= extra;
}
return limitedEntries;
}
private isSuccessfulBlockSubmission(result?: string | null): boolean {
@@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AcceptedShareEntity } from '../accepted-share/accepted-share.entity';
import { ShareAccountingService } from './share-accounting.service';
import { ShareHighScoreService } from './share-high-score.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([AcceptedShareEntity])],
providers: [ShareAccountingService],
exports: [TypeOrmModule, ShareAccountingService],
providers: [ShareAccountingService, ShareHighScoreService],
exports: [TypeOrmModule, ShareAccountingService, ShareHighScoreService],
})
export class ShareAccountingModule { }
@@ -48,6 +48,7 @@ describe('ShareAccountingService', () => {
expect(repository.create).toHaveBeenCalledWith(expect.objectContaining({
acceptedAt,
payoutMode: 'solo',
nonce: '123',
ntime: '456',
version: '536870912',
@@ -55,10 +56,54 @@ describe('ShareAccountingService', () => {
}));
expect(repository.insert).toHaveBeenCalledWith([expect.objectContaining({
protocol: 'sv1',
payoutMode: 'solo',
creditedDifficulty: 1024,
})]);
});
it('should compact only PPLNS shares into payout rollup batches', async () => {
process.env.SHARE_ROLLUP_ENABLED = 'true';
const manager = {
query: jest.fn()
.mockResolvedValueOnce([{ locked: true }])
.mockResolvedValueOnce([{ lastProcessedShareIndex: '10' }])
.mockResolvedValueOnce([{ startShareIndex: '11', endShareIndex: '20', acceptedShareCount: 10 }])
.mockResolvedValueOnce([{ startAcceptedAt: new Date('2026-06-14T00:00:00Z'), endAcceptedAt: new Date('2026-06-14T00:01:00Z'), acceptedShareCount: '10', creditedDifficulty: '1000' }])
.mockResolvedValueOnce([{ batchId: '99' }])
.mockResolvedValueOnce([]),
};
const repository = {
manager: {
transaction: jest.fn((callback: any) => callback(manager)),
},
};
const service = new ShareAccountingService(repository as any);
await expect(service.processPendingShareRollupBatch()).resolves.toEqual(expect.objectContaining({
processed: true,
payoutMode: 'pplns',
batchId: '99',
startShareIndex: '11',
endShareIndex: '20',
}));
expect(manager.query).toHaveBeenNthCalledWith(
2,
expect.stringContaining('"payoutMode" = $1'),
['pplns'],
);
expect(manager.query).toHaveBeenNthCalledWith(
3,
expect.stringContaining('"payoutMode" = $4'),
['10', expect.any(Number), expect.any(Number), 'pplns'],
);
expect(manager.query).toHaveBeenNthCalledWith(
6,
expect.stringContaining('"payoutMode" = $4'),
['99', '11', '20', 'pplns'],
);
});
it('should accept TLS SV1 protocol labels for accounting', async () => {
const repository = {
create: jest.fn(value => value),
@@ -295,7 +340,8 @@ describe('ShareAccountingService', () => {
currentRoundAcceptedShares: '11',
workSinceLastBlock: '352',
currentRoundNetworkDifficulty: '1000',
}]),
}])
.mockResolvedValueOnce([]),
};
const service = new ShareAccountingService(repository as any, redis as any);
@@ -314,12 +360,55 @@ describe('ShareAccountingService', () => {
expect(repository.query).toHaveBeenNthCalledWith(
2,
expect.stringContaining('FROM "accepted_share_block_10m"'),
[],
);
expect(repository.query).toHaveBeenNthCalledWith(
3,
expect.stringContaining('FROM "accepted_share_high_score"'),
['all'],
);
expect(repository.query).not.toHaveBeenCalledWith(
expect.stringContaining('FROM "accepted_share_entity"'),
);
});
it('should retain best submitted share after raw share retention removes older rows', async () => {
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '3',
totalCreditedDifficulty: '96',
acceptedSharesLast10Minutes: '2',
creditedDifficultyLast10Minutes: '64',
acceptedSharesLastHour: '3',
creditedDifficultyLastHour: '96',
acceptedSharesLastDay: '3',
creditedDifficultyLastDay: '96',
hashRateLast10Minutes: '458129844.9',
hashRateLastHour: '114532461.2',
latestShareAt: new Date('2026-06-07T12:10:00Z'),
}])
.mockResolvedValueOnce([{
bestSubmissionDifficulty: '4096',
bestSubmissionDifficultyAt: new Date('2026-06-07T12:10:00Z'),
currentRoundAcceptedShares: '11',
workSinceLastBlock: '352',
currentRoundNetworkDifficulty: '1000',
}])
.mockResolvedValueOnce([{
bestSubmissionDifficulty: '8192',
bestSubmissionDifficultyAt: new Date('2026-06-01T12:10:00Z'),
}]),
};
const service = new ShareAccountingService(repository as any);
await expect(service.refreshPoolSummary()).resolves.toEqual(expect.objectContaining({
bestSubmissionDifficulty: 8192,
bestSubmissionDifficultyAt: '2026-06-01T12:10:00.000Z',
workSinceLastBlock: 352,
}));
});
it('should cache accounting summaries briefly to protect hot dashboard endpoints', async () => {
process.env.SHARE_ACCOUNTING_SUMMARY_CACHE_MS = '1000';
const repository = {
@@ -370,6 +459,7 @@ describe('ShareAccountingService', () => {
await expect(service.processPendingShareRollupBatch()).resolves.toEqual({
processed: true,
payoutMode: 'pplns',
batchId: '7',
startShareIndex: '11',
endShareIndex: '20',
@@ -386,7 +476,7 @@ describe('ShareAccountingService', () => {
expect(manager.query).toHaveBeenNthCalledWith(
6,
expect.stringContaining('INSERT INTO "share_rollup_batch_summary"'),
['7', '11', '20'],
['7', '11', '20', 'pplns'],
);
});
@@ -4,9 +4,11 @@ import { Repository } from 'typeorm';
import { AcceptedShareEntity } from '../accepted-share/accepted-share.entity';
import { RedisMessagingService } from '../../services/redis-messaging.service';
import { normalizePayoutMode, PayoutMode } from '../../types/payout-mode';
export interface AcceptedShareRecord {
protocol: 'sv1' | 'sv1_tls' | 'sv2' | 'sv2_jdp' | 'datum';
payoutMode?: PayoutMode;
workSource?: 'pool_template' | 'miner_template';
workProtocol?: 'pool' | 'sv2_jdp' | 'datum';
acceptedAt?: Date;
@@ -64,6 +66,7 @@ export interface SessionShareSummary {
export interface ShareRollupBatchResult {
processed: boolean;
reason?: 'disabled' | 'locked' | 'no-shares';
payoutMode?: PayoutMode;
batchId?: string;
startShareIndex?: string;
endShareIndex?: string;
@@ -75,6 +78,7 @@ interface AccountingFilter {
address?: string;
clientName?: string;
clientId?: string;
payoutMode?: PayoutMode;
}
const HASHES_PER_DIFFICULTY = 4294967296;
@@ -123,6 +127,7 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
public async recordAcceptedShare(record: AcceptedShareRecord): Promise<AcceptedShareEntity> {
const acceptedShare = this.acceptedShareRepository.create({
...record,
payoutMode: normalizePayoutMode(record.payoutMode),
workSource: record.workSource ?? 'pool_template',
workProtocol: record.workProtocol ?? 'pool',
acceptedAt: record.acceptedAt ?? new Date(),
@@ -186,11 +191,13 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
return { processed: false, reason: 'locked' };
}
const payoutMode: PayoutMode = 'pplns';
const [lastRow] = await manager.query(`
SELECT COALESCE(MAX("endShareIndex"), 0)::text AS "lastProcessedShareIndex"
FROM "share_rollup_batch"
WHERE "status" = 'finalized'
`);
AND "payoutMode" = $1
`, [payoutMode]);
const lastProcessedShareIndex = lastRow?.lastProcessedShareIndex ?? '0';
const [rangeRow] = await manager.query(`
@@ -201,6 +208,7 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
SELECT MIN("shareIndex") AS "firstUnstableShareIndex"
FROM "accepted_share_entity", last_state
WHERE "shareIndex" > last_state."lastProcessedShareIndex"
AND "payoutMode" = $4
AND "acceptedAt" > NOW() - ($2::int * INTERVAL '1 second')
),
stable_bound AS MATERIALIZED (
@@ -210,6 +218,7 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
SELECT MAX("shareIndex")
FROM "accepted_share_entity", last_state
WHERE "shareIndex" > last_state."lastProcessedShareIndex"
AND "payoutMode" = $4
)
ELSE (SELECT "firstUnstableShareIndex" FROM first_unstable) - 1
END AS "maxStableShareIndex"
@@ -219,6 +228,7 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
FROM "accepted_share_entity", last_state, stable_bound
WHERE "shareIndex" > last_state."lastProcessedShareIndex"
AND "shareIndex" <= stable_bound."maxStableShareIndex"
AND "payoutMode" = $4
ORDER BY "shareIndex" ASC
LIMIT $3::int
)
@@ -231,6 +241,7 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
lastProcessedShareIndex,
this.shareRollupSafetyLagSeconds,
this.shareRollupMaxSharesPerBatch,
payoutMode,
]);
if (rangeRow?.startShareIndex == null || rangeRow?.endShareIndex == null || this.toNumber(rangeRow.acceptedShareCount) === 0) {
@@ -246,7 +257,8 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
FROM "accepted_share_entity"
WHERE "shareIndex" >= $1::bigint
AND "shareIndex" <= $2::bigint
`, [rangeRow.startShareIndex, rangeRow.endShareIndex]);
AND "payoutMode" = $3
`, [rangeRow.startShareIndex, rangeRow.endShareIndex, payoutMode]);
if (statsRow?.startAcceptedAt == null || this.toNumber(statsRow.acceptedShareCount) === 0) {
return { processed: false, reason: 'no-shares' };
@@ -256,6 +268,7 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
INSERT INTO "share_rollup_batch" (
"startShareIndex",
"endShareIndex",
"payoutMode",
"startAcceptedAt",
"endAcceptedAt",
"acceptedShareCount",
@@ -265,10 +278,11 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
) VALUES (
$1::bigint,
$2::bigint,
$3::timestamptz,
$3,
$4::timestamptz,
$5::bigint,
$6::numeric,
$5::timestamptz,
$6::bigint,
$7::numeric,
'finalized',
NOW()
)
@@ -276,6 +290,7 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
`, [
rangeRow.startShareIndex,
rangeRow.endShareIndex,
payoutMode,
statsRow.startAcceptedAt,
statsRow.endAcceptedAt,
statsRow.acceptedShareCount,
@@ -285,6 +300,7 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
await manager.query(`
INSERT INTO "share_rollup_batch_summary" (
"batchId",
"payoutMode",
"address",
"clientName",
"protocol",
@@ -297,6 +313,7 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
)
SELECT
$1::bigint AS "batchId",
"payoutMode",
"address",
"clientName",
"protocol",
@@ -309,15 +326,18 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
FROM "accepted_share_entity"
WHERE "shareIndex" >= $2::bigint
AND "shareIndex" <= $3::bigint
GROUP BY "address", "clientName", "protocol", "blockHeight"
AND "payoutMode" = $4
GROUP BY "payoutMode", "address", "clientName", "protocol", "blockHeight"
`, [
batchRow.batchId,
rangeRow.startShareIndex,
rangeRow.endShareIndex,
payoutMode,
]);
return {
processed: true,
payoutMode,
batchId: batchRow.batchId,
startShareIndex: rangeRow.startShareIndex,
endShareIndex: rangeRow.endShareIndex,
@@ -327,9 +347,11 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
});
}
public async getPoolSummary(): Promise<ShareAccountingSummary> {
public async getPoolSummary(payoutMode?: PayoutMode): Promise<ShareAccountingSummary> {
const mode = payoutMode == null ? undefined : normalizePayoutMode(payoutMode);
const poolSummaryCacheKey = mode == null ? this.poolSummaryCacheKey : `${this.poolSummaryCacheKey}:${mode}`;
const cached = await this.redisMessagingService
?.getJsonCache<ShareAccountingSummary>(this.poolSummaryCacheKey)
?.getJsonCache<ShareAccountingSummary>(poolSummaryCacheKey)
.catch(error => {
console.error(`Pool accounting summary cache read failed: ${error.message}`);
return null;
@@ -342,20 +364,22 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
return this.emptySummary();
}
return this.getSummary({});
return this.getSummary({ payoutMode: mode });
}
public async refreshPoolSummary(): Promise<ShareAccountingSummary> {
const summary = await this.withPoolRollupOverlay(await this.getSummary({}));
public async refreshPoolSummary(payoutMode?: PayoutMode): Promise<ShareAccountingSummary> {
const mode = payoutMode == null ? undefined : normalizePayoutMode(payoutMode);
const poolSummaryCacheKey = mode == null ? this.poolSummaryCacheKey : `${this.poolSummaryCacheKey}:${mode}`;
const summary = await this.withPoolRollupOverlay(await this.getSummary({ payoutMode: mode }), mode);
await this.redisMessagingService
?.setJsonCache(this.poolSummaryCacheKey, summary, 10 * 60 * 1000)
?.setJsonCache(poolSummaryCacheKey, summary, 10 * 60 * 1000)
.catch(error => {
console.error(`Pool accounting summary cache write failed: ${error.message}`);
});
return summary;
}
private async withPoolRollupOverlay(summary: ShareAccountingSummary): Promise<ShareAccountingSummary> {
private async withPoolRollupOverlay(summary: ShareAccountingSummary, payoutMode?: PayoutMode): Promise<ShareAccountingSummary> {
if (process.env.API_ONLY === 'true') {
return summary;
}
@@ -364,11 +388,13 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
WITH latest_found_block AS (
SELECT COALESCE(MAX("height"), 0) AS "height"
FROM "blocks_entity"
${payoutMode == null ? '' : 'WHERE "payoutMode" = $1'}
),
filtered_rows AS (
SELECT "accepted_share_block_10m".*
FROM "accepted_share_block_10m", latest_found_block
WHERE "blockHeight" > latest_found_block."height"
${payoutMode == null ? '' : 'AND "payoutMode" = $1'}
),
best_share AS (
SELECT
@@ -384,17 +410,26 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
COALESCE(MAX("networkDifficulty"), 0)::float AS "currentRoundNetworkDifficulty",
COALESCE((SELECT "bestSubmissionDifficulty" FROM best_share), 0)::float AS "bestSubmissionDifficulty",
(SELECT "bucket" FROM best_share) AS "bestSubmissionDifficultyAt"
FROM filtered_rows
`);
FROM filtered_rows
`, payoutMode == null ? [] : [payoutMode]);
const currentRoundNetworkDifficulty = this.toNumber(currentRoundRow?.currentRoundNetworkDifficulty);
const workSinceLastBlock = this.toNumber(currentRoundRow?.workSinceLastBlock);
const liveBestSubmissionDifficulty = this.toNumber(currentRoundRow?.bestSubmissionDifficulty);
const liveBestSubmissionDifficultyAt = currentRoundRow?.bestSubmissionDifficultyAt == null
? null
: new Date(currentRoundRow.bestSubmissionDifficultyAt).toISOString();
const retainedBest = await this.getRetainedBestSubmissionDifficulty(payoutMode);
const retainedBestSubmissionDifficulty = this.toNumber(retainedBest?.bestSubmissionDifficulty);
const useRetainedBest = retainedBestSubmissionDifficulty > liveBestSubmissionDifficulty;
return {
...summary,
bestSubmissionDifficulty: this.toNumber(currentRoundRow?.bestSubmissionDifficulty),
bestSubmissionDifficultyAt: currentRoundRow?.bestSubmissionDifficultyAt == null
? null
: new Date(currentRoundRow.bestSubmissionDifficultyAt).toISOString(),
bestSubmissionDifficulty: useRetainedBest
? retainedBestSubmissionDifficulty
: liveBestSubmissionDifficulty,
bestSubmissionDifficultyAt: useRetainedBest
? retainedBest?.bestSubmissionDifficultyAt ?? null
: liveBestSubmissionDifficultyAt,
workSinceLastBlock,
currentRoundAcceptedShares: this.toNumber(currentRoundRow?.currentRoundAcceptedShares),
currentRoundNetworkDifficulty,
@@ -404,6 +439,40 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
};
}
private async getRetainedBestSubmissionDifficulty(payoutMode?: PayoutMode): Promise<{
bestSubmissionDifficulty: number;
bestSubmissionDifficultyAt: string | null;
} | null> {
try {
const [row] = await this.acceptedShareRepository.query(`
SELECT
"submissionDifficulty"::float AS "bestSubmissionDifficulty",
"acceptedAt" AS "bestSubmissionDifficultyAt"
FROM "accepted_share_high_score"
WHERE "scope" = 'all_time'
AND "payoutMode" = $1
ORDER BY "submissionDifficulty" DESC, "acceptedAt" DESC
LIMIT 1
`, [payoutMode ?? 'all']);
if (row == null) {
return null;
}
return {
bestSubmissionDifficulty: this.toNumber(row.bestSubmissionDifficulty),
bestSubmissionDifficultyAt: row.bestSubmissionDifficultyAt == null
? null
: new Date(row.bestSubmissionDifficultyAt).toISOString(),
};
} catch (error) {
if (error?.code === '42P01') {
return null;
}
throw error;
}
}
public emptySummary(): ShareAccountingSummary {
return {
totalAcceptedShares: 0,
@@ -428,16 +497,16 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
};
}
public async getAddressSummary(address: string): Promise<ShareAccountingSummary> {
return this.getSummary({ address });
public async getAddressSummary(address: string, payoutMode?: PayoutMode): Promise<ShareAccountingSummary> {
return this.getSummary({ address, payoutMode });
}
public async getWorkerGroupSummary(address: string, clientName: string): Promise<ShareAccountingSummary> {
return this.getSummary({ address, clientName });
public async getWorkerGroupSummary(address: string, clientName: string, payoutMode?: PayoutMode): Promise<ShareAccountingSummary> {
return this.getSummary({ address, clientName, payoutMode });
}
public async getSessionSummary(clientId: string): Promise<ShareAccountingSummary> {
return this.getSummary({ clientId });
public async getSessionSummary(clientId: string, payoutMode?: PayoutMode): Promise<ShareAccountingSummary> {
return this.getSummary({ clientId, payoutMode });
}
public async getSessionSummaries(clientIds: string[]): Promise<Map<string, SessionShareSummary>> {
@@ -705,6 +774,10 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
params.push(filter.clientId);
where.push(`"clientId" = $${params.length}`);
}
if (filter.payoutMode != null) {
params.push(filter.payoutMode);
where.push(`"payoutMode" = $${params.length}`);
}
return {
whereSql: where.length > 0 ? `WHERE ${where.join(' AND ')}` : '',
@@ -726,6 +799,7 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
address: filter.address ?? null,
clientName: filter.clientName ?? null,
clientId: filter.clientId ?? null,
payoutMode: filter.payoutMode ?? null,
});
}
@@ -0,0 +1,285 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { PayoutMode } from '../../types/payout-mode';
type HighScorePayoutMode = PayoutMode | 'all';
interface HighScoreCandidate {
bucketDate: string;
bucket: Date;
blockHeight: string;
bestSubmissionDifficulty: string;
}
interface ExactHighScore {
submissionDifficulty: string;
acceptedAt: Date | null;
blockHeight: string | null;
address: string | null;
clientName: string | null;
protocol: string | null;
}
interface HighScoreRecord {
scope: 'all_time' | 'daily';
payoutMode: HighScorePayoutMode;
bucketDate: string;
submissionDifficulty: string;
acceptedAt: Date | null;
bucket: Date | null;
blockHeight: string | null;
address: string | null;
clientName: string | null;
protocol: string | null;
}
export interface HighScoreRefreshResult {
processed: boolean;
reason?: 'locked';
updatedRows: number;
}
const DEFAULT_HIGH_SCORE_REFRESH_INTERVAL_MS = 60 * 60 * 1000;
const DEFAULT_HIGH_SCORE_LOOKBACK_DAYS = 7;
const HIGH_SCORE_ADVISORY_LOCK = '1781309000';
const ALL_TIME_BUCKET_DATE = '1970-01-01';
@Injectable()
export class ShareHighScoreService implements OnModuleInit, OnModuleDestroy {
private timer: NodeJS.Timeout | null = null;
private activeRefresh: Promise<HighScoreRefreshResult> | null = null;
private readonly refreshIntervalMs = this.readPositiveInt(
'SHARE_HIGH_SCORE_REFRESH_INTERVAL_MS',
DEFAULT_HIGH_SCORE_REFRESH_INTERVAL_MS,
);
private readonly lookbackDays = this.readPositiveInt(
'SHARE_HIGH_SCORE_LOOKBACK_DAYS',
DEFAULT_HIGH_SCORE_LOOKBACK_DAYS,
);
constructor(private readonly dataSource: DataSource) { }
public onModuleInit(): void {
if (process.env.MASTER !== 'true' || process.env.API_ONLY === 'true') {
return;
}
this.timer = setInterval(() => {
if (this.activeRefresh != null) {
return;
}
this.activeRefresh = this.refreshHighScores()
.catch(error => {
console.error(`Accepted share high-score refresh failed: ${error.message}`);
return { processed: false, updatedRows: 0 };
})
.finally(() => {
this.activeRefresh = null;
});
}, this.refreshIntervalMs);
this.timer.unref?.();
setTimeout(() => {
if (this.activeRefresh == null) {
this.activeRefresh = this.refreshHighScores()
.catch(error => {
console.error(`Accepted share high-score refresh failed: ${error.message}`);
return { processed: false, updatedRows: 0 };
})
.finally(() => {
this.activeRefresh = null;
});
}
}, 30_000).unref?.();
}
public async onModuleDestroy(): Promise<void> {
if (this.timer != null) {
clearInterval(this.timer);
this.timer = null;
}
if (this.activeRefresh != null) {
await this.activeRefresh;
}
}
public async refreshHighScores(): Promise<HighScoreRefreshResult> {
return this.dataSource.transaction(async manager => {
const [lockRow] = await manager.query(`
SELECT pg_try_advisory_xact_lock($1::bigint) AS "locked"
`, [HIGH_SCORE_ADVISORY_LOCK]);
if (lockRow?.locked !== true) {
return { processed: false, reason: 'locked', updatedRows: 0 };
}
let updatedRows = 0;
for (const payoutMode of ['all', 'solo', 'pplns'] as HighScorePayoutMode[]) {
updatedRows += await this.refreshForPayoutMode(manager, payoutMode);
}
return { processed: true, updatedRows };
});
}
private async refreshForPayoutMode(manager: EntityManager, payoutMode: HighScorePayoutMode): Promise<number> {
const candidates = await this.loadDailyCandidates(manager, payoutMode);
let updatedRows = 0;
for (const candidate of candidates) {
const exact = await this.loadExactHighScore(manager, candidate, payoutMode);
const record = this.toRecord('daily', payoutMode, candidate.bucketDate, candidate, exact);
updatedRows += await this.upsertHighScore(manager, record);
updatedRows += await this.upsertHighScore(manager, {
...record,
scope: 'all_time',
bucketDate: ALL_TIME_BUCKET_DATE,
});
}
return updatedRows;
}
private async loadDailyCandidates(manager: EntityManager, payoutMode: HighScorePayoutMode): Promise<HighScoreCandidate[]> {
const params: unknown[] = [this.lookbackDays];
const modeFilter = payoutMode === 'all'
? ''
: `AND "payoutMode" = $${params.push(payoutMode)}`;
return manager.query(`
WITH candidate_rows AS (
SELECT
date_trunc('day', "bucket")::date AS "bucketDate",
"bucket",
"blockHeight",
"bestSubmissionDifficulty"
FROM "accepted_share_block_10m"
WHERE "bucket" >= NOW() - ($1::int * INTERVAL '1 day')
AND "bestSubmissionDifficulty" IS NOT NULL
AND "bestSubmissionDifficulty" > 0
${modeFilter}
)
SELECT DISTINCT ON ("bucketDate")
"bucketDate"::text AS "bucketDate",
"bucket",
"blockHeight"::text AS "blockHeight",
"bestSubmissionDifficulty"::text AS "bestSubmissionDifficulty"
FROM candidate_rows
ORDER BY "bucketDate", "bestSubmissionDifficulty" DESC, "bucket" DESC
`, params);
}
private async loadExactHighScore(
manager: EntityManager,
candidate: HighScoreCandidate,
payoutMode: HighScorePayoutMode,
): Promise<ExactHighScore | null> {
const params: unknown[] = [candidate.bucket, candidate.blockHeight];
const modeFilter = payoutMode === 'all'
? ''
: `AND "payoutMode" = $${params.push(payoutMode)}`;
const rows = await manager.query(`
SELECT
"submissionDifficulty"::text AS "submissionDifficulty",
"acceptedAt",
"blockHeight"::text AS "blockHeight",
"address",
"clientName",
"protocol"
FROM "accepted_share_entity"
WHERE "acceptedAt" >= $1::timestamptz
AND "acceptedAt" < $1::timestamptz + INTERVAL '10 minutes'
AND "blockHeight" = $2::bigint
${modeFilter}
ORDER BY "submissionDifficulty" DESC, "acceptedAt" DESC
LIMIT 1
`, params);
return rows[0] ?? null;
}
private toRecord(
scope: HighScoreRecord['scope'],
payoutMode: HighScorePayoutMode,
bucketDate: string,
candidate: HighScoreCandidate,
exact: ExactHighScore | null,
): HighScoreRecord {
return {
scope,
payoutMode,
bucketDate,
submissionDifficulty: exact?.submissionDifficulty ?? candidate.bestSubmissionDifficulty,
acceptedAt: exact?.acceptedAt ?? candidate.bucket,
bucket: candidate.bucket,
blockHeight: exact?.blockHeight ?? candidate.blockHeight,
address: exact?.address ?? null,
clientName: exact?.clientName ?? null,
protocol: exact?.protocol ?? null,
};
}
private async upsertHighScore(manager: EntityManager, record: HighScoreRecord): Promise<number> {
const rows = await manager.query(`
INSERT INTO "accepted_share_high_score" (
"scope",
"payoutMode",
"bucketDate",
"submissionDifficulty",
"acceptedAt",
"bucket",
"blockHeight",
"address",
"clientName",
"protocol"
) VALUES (
$1,
$2,
$3::date,
$4::numeric,
$5::timestamptz,
$6::timestamptz,
$7::bigint,
$8,
$9,
$10
)
ON CONFLICT ("scope", "payoutMode", "bucketDate")
DO UPDATE SET
"submissionDifficulty" = EXCLUDED."submissionDifficulty",
"acceptedAt" = EXCLUDED."acceptedAt",
"bucket" = EXCLUDED."bucket",
"blockHeight" = EXCLUDED."blockHeight",
"address" = EXCLUDED."address",
"clientName" = EXCLUDED."clientName",
"protocol" = EXCLUDED."protocol",
"updatedAt" = NOW()
WHERE EXCLUDED."submissionDifficulty" > "accepted_share_high_score"."submissionDifficulty"
OR (
EXCLUDED."submissionDifficulty" = "accepted_share_high_score"."submissionDifficulty"
AND EXCLUDED."acceptedAt" > COALESCE("accepted_share_high_score"."acceptedAt", '-infinity'::timestamptz)
)
RETURNING 1
`, [
record.scope,
record.payoutMode,
record.bucketDate,
record.submissionDifficulty,
record.acceptedAt,
record.bucket,
record.blockHeight,
record.address,
record.clientName,
record.protocol,
]);
return rows.length;
}
private readPositiveInt(name: string, defaultValue: number): number {
const value = Number(process.env[name]);
return Number.isInteger(value) && value > 0 ? value : defaultValue;
}
}
+130
View File
@@ -0,0 +1,130 @@
import { AppController } from './app.controller';
describe('AppController', () => {
const createController = (overrides: {
cacheManager?: any;
blocksService?: any;
addressSettingsService?: any;
userAgentReportService?: any;
stratumV2Service?: any;
redisMessagingService?: any;
} = {}) => {
return new AppController(
overrides.cacheManager ?? {
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(undefined),
},
{} as any,
{} as any,
overrides.blocksService ?? {
getFoundBlocks: jest.fn().mockResolvedValue([]),
},
{} as any,
overrides.addressSettingsService ?? {
getHighScores: jest.fn().mockResolvedValue([]),
},
overrides.userAgentReportService ?? {
getReport: jest.fn().mockResolvedValue([]),
},
overrides.stratumV2Service ?? {
getPoolAuthorityPublicKey: jest.fn().mockResolvedValue({
publicKey: '',
configured: false,
}),
},
{} as any,
overrides.redisMessagingService ?? {
getJsonCache: jest.fn().mockResolvedValue(null),
setJsonCache: jest.fn().mockResolvedValue(undefined),
},
);
};
it('refreshes found blocks even when site info is served from cache', async () => {
const cachedInfo = {
blockData: [{ height: 1 }],
userAgents: [],
highScores: [],
sv2: {
poolAuthorityPublicKey: '',
authorityKeyConfigured: false,
},
uptime: new Date('2026-01-01T00:00:00.000Z'),
};
const freshBlocks = [{ height: 2 }];
const cacheManager = {
get: jest.fn().mockResolvedValue(cachedInfo),
set: jest.fn().mockResolvedValue(undefined),
};
const blocksService = {
getFoundBlocks: jest.fn().mockResolvedValue(freshBlocks),
};
const controller = createController({
cacheManager,
blocksService,
});
await expect(controller.info()).resolves.toEqual({
...cachedInfo,
blockData: freshBlocks,
});
expect(blocksService.getFoundBlocks).toHaveBeenCalledTimes(1);
});
it('keeps small user-agent reports expanded so local protocol workers are visible', async () => {
const controller = createController({
userAgentReportService: {
getReport: jest.fn().mockResolvedValue([
{
userAgent: 'unknown/sv2',
count: '1',
bestDifficulty: 100,
totalHashRate: '0',
},
{
userAgent: 'v0.4.1-beta/datum',
count: '1',
bestDifficulty: 0,
totalHashRate: '0',
},
]),
},
});
await expect(controller.info()).resolves.toMatchObject({
userAgents: [
{
userAgent: 'unknown/sv2',
count: '1',
bestDifficulty: 100,
totalHashRate: '0',
},
{
userAgent: 'v0.4.1-beta/datum',
count: '1',
bestDifficulty: 0,
totalHashRate: '0',
},
],
});
});
it('groups small user-agent rows only when the report is large', () => {
const controller = createController() as any;
const report = Array.from({ length: 21 }, (_, index) => ({
userAgent: `small-${index}`,
count: '1',
bestDifficulty: index,
totalHashRate: '0',
}));
expect(controller.groupSmallUserAgents(report)).toEqual([
{
userAgent: 'Other',
count: '21',
bestDifficulty: 20,
totalHashRate: '0',
},
]);
});
});
+84 -33
View File
@@ -1,5 +1,5 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Controller, Get, Inject } from '@nestjs/common';
import { Controller, Get, Inject, Query } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { firstValueFrom } from 'rxjs';
@@ -13,12 +13,14 @@ import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-r
import { StratumV2Service } from './services/stratum-v2.service';
import { ShareAccountingService } from './ORM/share-accounting/share-accounting.service';
import { RedisMessagingService } from './services/redis-messaging.service';
import { normalizePayoutMode } from './types/payout-mode';
@Controller()
export class AppController {
private uptime = new Date();
private siteInfoRefreshPromise: Promise<SiteInfoResponse> | null = null;
private readonly userAgentOtherGroupThreshold = 20;
constructor(
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
@@ -37,16 +39,16 @@ export class AppController {
public async info() {
const CACHE_KEY = 'SITE_INFO';
const STALE_CACHE_KEY = 'SITE_INFO_STALE';
const cachedResult = await this.getCached(CACHE_KEY, 5 * 60 * 1000);
const cachedResult = await this.getCached<SiteInfoResponse>(CACHE_KEY, 5 * 60 * 1000);
if (cachedResult != null) {
return cachedResult;
return await this.withFreshFoundBlocks(cachedResult);
}
const staleResult = await this.getCached<SiteInfoResponse>(STALE_CACHE_KEY, 60 * 60 * 1000);
if (staleResult != null) {
void this.refreshSiteInfo(staleResult);
return staleResult;
return await this.withFreshFoundBlocks(staleResult);
}
return await this.refreshSiteInfo(null);
@@ -83,32 +85,7 @@ export class AppController {
withInfoTimeout<UserAgentReportView[]>('user agent report', this.userAgentReportService.getReport(), staleInfo?.userAgents ?? []),
]);
const other: {
count: number,
bestDifficulty: number,
totalHashRate: number;
} = {
count: 0,
bestDifficulty: 0,
totalHashRate: 0
};
const userAgents: UserAgentReportView[] = userAgentReport.reduce((pre, cur, idx, arr) => {
// If less than 10Th/s and less than 100 devices, add to 'other'
if (parseInt(cur.totalHashRate) < 10000000000000 && parseInt(cur.count) < 200) {
other.totalHashRate += parseFloat(cur.totalHashRate);
other.count += parseInt(cur.count);
if (other.bestDifficulty < cur.bestDifficulty) {
other.bestDifficulty = cur.bestDifficulty;
}
} else {
pre.push(cur);
}
return pre;
}, []);
if (other.count > 0) {
userAgents.push({ userAgent: 'Other', count: other.count.toString(), bestDifficulty: other.bestDifficulty, totalHashRate: other.totalHashRate.toString() })
}
const userAgents = this.groupSmallUserAgents(userAgentReport);
const data: SiteInfoResponse = {
blockData,
@@ -129,16 +106,71 @@ export class AppController {
return data;
}
private groupSmallUserAgents(userAgentReport: UserAgentReportView[]): UserAgentReportView[] {
if (userAgentReport.length <= this.userAgentOtherGroupThreshold) {
return userAgentReport;
}
const other: {
count: number,
bestDifficulty: number,
totalHashRate: number;
} = {
count: 0,
bestDifficulty: 0,
totalHashRate: 0
};
const userAgents: UserAgentReportView[] = userAgentReport.reduce((pre, cur) => {
// If less than 10Th/s and less than 100 devices, add to 'other'
if (parseInt(cur.totalHashRate) < 10000000000000 && parseInt(cur.count) < 200) {
other.totalHashRate += parseFloat(cur.totalHashRate);
other.count += parseInt(cur.count);
if (other.bestDifficulty < cur.bestDifficulty) {
other.bestDifficulty = cur.bestDifficulty;
}
} else {
pre.push(cur);
}
return pre;
}, []);
if (other.count > 0) {
userAgents.push({ userAgent: 'Other', count: other.count.toString(), bestDifficulty: other.bestDifficulty, totalHashRate: other.totalHashRate.toString() })
}
return userAgents;
}
private async withFreshFoundBlocks(info: SiteInfoResponse): Promise<SiteInfoResponse> {
const blockData = await this.withTimeout(
'found blocks cache refresh',
this.blocksService.getFoundBlocks(),
info.blockData,
() => undefined,
500
);
if (blockData === info.blockData) {
return info;
}
return {
...info,
blockData
};
}
@Get('info/accounting')
public async infoAccounting() {
const CACHE_KEY = 'SITE_ACCOUNTING';
public async infoAccounting(@Query('payoutMode') payoutMode?: string) {
const mode = normalizePayoutMode(payoutMode ?? 'pplns');
const CACHE_KEY = `SITE_ACCOUNTING:${mode}`;
const cachedResult = await this.getCached(CACHE_KEY, 15 * 1000);
if (cachedResult != null) {
return cachedResult;
}
const data = await this.shareAccountingService.getPoolSummary();
const data = await this.shareAccountingService.getPoolSummary(mode);
//15 sec
await this.setCached(CACHE_KEY, data, 15 * 1000);
@@ -200,6 +232,25 @@ export class AppController {
}
@Get('info/chart/payout-modes')
public async infoChartByPayoutMode(@Query('payoutMode') payoutMode?: string) {
const mode = payoutMode == null || payoutMode === 'all'
? undefined
: normalizePayoutMode(payoutMode);
const CACHE_KEY = `SITE_HASHRATE_GRAPH_BY_PAYOUT_MODE:${mode ?? 'all'}`;
const cachedResult = await this.getCached(CACHE_KEY, 10 * 60 * 1000);
if (cachedResult != null) {
return cachedResult;
}
const chartData = await this.clientStatisticsService.getChartDataForSiteByPayoutMode(mode);
await this.setCached(CACHE_KEY, chartData, 10 * 60 * 1000);
return chartData;
}
private async getCached<T>(key: string, localTtlMs: number): Promise<T | null> {
const local = await this.cacheManager.get<T>(key);
if (local != null) {
@@ -3,12 +3,14 @@ import { Test, TestingModule } from '@nestjs/testing';
import { AddressSettingsService } from '../../ORM/address-settings/address-settings.service';
import { ClientStatisticsService } from '../../ORM/client-statistics/client-statistics.service';
import { ClientService } from '../../ORM/client/client.service';
import { PayoutSnapshotService } from '../../ORM/payout-snapshot/payout-snapshot.service';
import { ShareAccountingService } from '../../ORM/share-accounting/share-accounting.service';
import { RedisMessagingService } from '../../services/redis-messaging.service';
import { ClientController } from './client.controller';
describe('ClientController', () => {
let controller: ClientController;
let clientService: { getBySessionId: jest.Mock; getActiveIds: jest.Mock };
let addressSettingsService: { getSettings: jest.Mock };
let shareAccountingService: {
getAddressSummary: jest.Mock;
@@ -16,7 +18,8 @@ describe('ClientController', () => {
getSessionSummary: jest.Mock;
getSessionSummaries: jest.Mock;
};
let redisMessagingService: { getClientPresenceByAddress: jest.Mock };
let payoutSnapshotService: { getLatestExpectedPayoutForAddress: jest.Mock };
let redisMessagingService: { getClientPresenceByAddress: jest.Mock; removeClientPresence: jest.Mock };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -28,6 +31,7 @@ describe('ClientController', () => {
getByAddress: jest.fn(),
getByName: jest.fn(),
getBySessionId: jest.fn(),
getActiveIds: jest.fn(async (ids: string[]) => new Set(ids)),
},
},
{
@@ -53,10 +57,17 @@ describe('ClientController', () => {
getSessionSummaries: jest.fn().mockResolvedValue(new Map()),
},
},
{
provide: PayoutSnapshotService,
useValue: {
getLatestExpectedPayoutForAddress: jest.fn().mockResolvedValue(null),
},
},
{
provide: RedisMessagingService,
useValue: {
getClientPresenceByAddress: jest.fn().mockResolvedValue([]),
removeClientPresence: jest.fn().mockResolvedValue(undefined),
},
},
],
@@ -64,8 +75,10 @@ describe('ClientController', () => {
}).compile();
controller = module.get<ClientController>(ClientController);
clientService = module.get(ClientService);
addressSettingsService = module.get(AddressSettingsService);
shareAccountingService = module.get(ShareAccountingService);
payoutSnapshotService = module.get(PayoutSnapshotService);
redisMessagingService = module.get(RedisMessagingService);
});
@@ -83,6 +96,7 @@ describe('ClientController', () => {
hashRate: 1024,
startTime: '2026-06-08T12:00:00.000Z',
lastSeen: '2026-06-08T12:10:00.000Z',
address: 'bc1qtest',
},
]);
addressSettingsService.getSettings.mockResolvedValue({ bestDifficulty: 4096 });
@@ -100,4 +114,88 @@ describe('ClientController', () => {
}),
}));
});
it('should expose the latest current PPLNS expected payout for an address', async () => {
addressSettingsService.getSettings.mockResolvedValue(null);
shareAccountingService.getAddressSummary.mockResolvedValue({
totalAcceptedShares: 10,
totalCreditedDifficulty: 100,
bestSubmissionDifficulty: 0,
});
payoutSnapshotService.getLatestExpectedPayoutForAddress.mockResolvedValue({
snapshotId: '19',
blockHeight: 900001,
payoutMode: 'pplns',
payoutSats: 1234,
creditedDifficulty: 50,
percent: 12.34,
});
await expect(controller.getClientInfo('bc1qtest')).resolves.toEqual(expect.objectContaining({
expectedPayout: expect.objectContaining({
snapshotId: '19',
payoutSats: 1234,
}),
}));
expect(payoutSnapshotService.getLatestExpectedPayoutForAddress).toHaveBeenCalledWith('bc1qtest');
});
it('should not query expected PPLNS payout for solo-only address requests', async () => {
addressSettingsService.getSettings.mockResolvedValue(null);
shareAccountingService.getAddressSummary.mockResolvedValue({
totalAcceptedShares: 10,
totalCreditedDifficulty: 100,
bestSubmissionDifficulty: 0,
});
await expect(controller.getClientInfo('bc1qtest', 'solo')).resolves.toEqual(expect.objectContaining({
expectedPayout: null,
}));
expect(payoutSnapshotService.getLatestExpectedPayoutForAddress).not.toHaveBeenCalled();
});
it('should hide stale Redis workers that are no longer active in the database', async () => {
redisMessagingService.getClientPresenceByAddress.mockResolvedValue([
{
clientId: 'active-client',
address: 'bc1qtest',
sessionId: 'active1',
clientName: 'active-worker',
bestDifficulty: 64,
hashRate: 1024,
startTime: '2026-06-08T12:00:00.000Z',
lastSeen: '2026-06-08T12:10:00.000Z',
},
{
clientId: 'stale-client',
address: 'bc1qtest',
sessionId: 'stale1',
clientName: 'stale-worker',
bestDifficulty: 128,
hashRate: 2048,
startTime: '2026-06-08T12:00:00.000Z',
lastSeen: '2026-06-08T12:10:00.000Z',
},
]);
clientService.getActiveIds.mockResolvedValue(new Set(['active-client']));
addressSettingsService.getSettings.mockResolvedValue(null);
shareAccountingService.getSessionSummaries.mockResolvedValue(new Map());
shareAccountingService.getAddressSummary.mockResolvedValue({
totalAcceptedShares: 0,
totalCreditedDifficulty: 0,
bestSubmissionDifficulty: 0,
});
await expect(controller.getClientInfo('bc1qtest')).resolves.toMatchObject({
workersCount: 1,
workers: [
{
sessionId: 'active1',
name: 'active-worker',
},
],
});
expect(redisMessagingService.removeClientPresence)
.toHaveBeenCalledWith('stale-client', 'bc1qtest');
});
});
+61 -16
View File
@@ -1,10 +1,12 @@
import { Controller, Get, NotFoundException, Param } from '@nestjs/common';
import { Controller, Get, NotFoundException, Param, Query } from '@nestjs/common';
import { AddressSettingsService } from '../../ORM/address-settings/address-settings.service';
import { ClientStatisticsService } from '../../ORM/client-statistics/client-statistics.service';
import { ClientService } from '../../ORM/client/client.service';
import { PayoutSnapshotService } from '../../ORM/payout-snapshot/payout-snapshot.service';
import { ShareAccountingService } from '../../ORM/share-accounting/share-accounting.service';
import { RedisMessagingService } from '../../services/redis-messaging.service';
import { normalizePayoutMode, PayoutMode } from '../../types/payout-mode';
@Controller('client')
@@ -15,13 +17,15 @@ export class ClientController {
private readonly clientStatisticsService: ClientStatisticsService,
private readonly addressSettingsService: AddressSettingsService,
private readonly shareAccountingService: ShareAccountingService,
private readonly payoutSnapshotService: PayoutSnapshotService,
private readonly redisMessagingService: RedisMessagingService
) { }
@Get(':address')
async getClientInfo(@Param('address') address: string) {
const workers = await this.redisMessagingService.getClientPresenceByAddress(address);
async getClientInfo(@Param('address') address: string, @Query('payoutMode') payoutMode?: string) {
const mode = this.getRequestedPayoutMode(payoutMode);
const workers = await this.getActiveAddressWorkers(address, mode);
const sessionSummaries = await this.shareAccountingService.getSessionSummaries(workers.map(worker => worker.clientId));
const addressSettings = process.env.API_ONLY === 'true'
@@ -30,15 +34,19 @@ export class ClientController {
const bestDifficulty = addressSettings?.bestDifficulty ?? workers.reduce((best, worker) => {
return Math.max(best, Number(worker.bestDifficulty ?? 0));
}, 0);
const accounting = this.withBestSubmissionDifficulty(
await this.shareAccountingService.getAddressSummary(address),
bestDifficulty,
);
const accountingSummary = mode == null
? await this.shareAccountingService.getAddressSummary(address)
: await this.shareAccountingService.getAddressSummary(address, mode);
const accounting = this.withBestSubmissionDifficulty(accountingSummary, bestDifficulty);
const expectedPayout = mode === 'solo'
? null
: await this.payoutSnapshotService.getLatestExpectedPayoutForAddress(address);
const response = {
bestDifficulty,
workersCount: workers.length,
accounting,
expectedPayout,
workers: await Promise.all(
workers.map(async (worker) => {
const sessionSummary = sessionSummaries.get(worker.clientId);
@@ -49,6 +57,7 @@ export class ClientController {
return {
sessionId: worker.sessionId,
name: worker.clientName,
payoutMode: worker.payoutMode,
bestDifficulty: bestDifficulty.toFixed(2),
hashRate: sessionSummary?.hashRateLast10Minutes ?? worker.hashRate,
startTime: worker.startTime,
@@ -65,9 +74,16 @@ export class ClientController {
return await this.clientStatisticsService.getChartDataForAddress(address);
}
@Get(':address/chart/payout-modes')
async getClientInfoChartByPayoutMode(@Param('address') address: string, @Query('payoutMode') payoutMode?: string) {
const mode = this.getRequestedPayoutMode(payoutMode);
return await this.clientStatisticsService.getChartDataForAddressByPayoutMode(address, mode);
}
@Get(':address/:workerName')
async getWorkerGroupInfo(@Param('address') address: string, @Param('workerName') workerName: string) {
const addressWorkers = await this.redisMessagingService.getClientPresenceByAddress(address);
async getWorkerGroupInfo(@Param('address') address: string, @Param('workerName') workerName: string, @Query('payoutMode') payoutMode?: string) {
const mode = this.getRequestedPayoutMode(payoutMode);
const addressWorkers = await this.getActiveAddressWorkers(address, mode);
const workers = addressWorkers
.filter(worker => worker.clientName === workerName);
@@ -79,24 +95,28 @@ export class ClientController {
}, 0);
const chartData = await this.clientStatisticsService.getChartDataForGroup(address, workerName);
const accounting = this.withBestSubmissionDifficulty(
await this.shareAccountingService.getWorkerGroupSummary(address, workerName),
bestDifficulty,
);
const chartDataByPayoutMode = await this.clientStatisticsService.getChartDataForGroupByPayoutMode(address, workerName, mode);
const accountingSummary = mode == null
? await this.shareAccountingService.getWorkerGroupSummary(address, workerName)
: await this.shareAccountingService.getWorkerGroupSummary(address, workerName, mode);
const accounting = this.withBestSubmissionDifficulty(accountingSummary, bestDifficulty);
const response = {
name: workerName,
bestDifficulty: Math.floor(bestDifficulty),
payoutModes: [...new Set(workers.map(worker => worker.payoutMode))],
accounting,
chartData: chartData,
chartDataByPayoutMode,
}
return response;
}
@Get(':address/:workerName/:sessionId')
async getWorkerInfo(@Param('address') address: string, @Param('workerName') workerName: string, @Param('sessionId') sessionId: string) {
const addressWorkers = await this.redisMessagingService.getClientPresenceByAddress(address);
async getWorkerInfo(@Param('address') address: string, @Param('workerName') workerName: string, @Param('sessionId') sessionId: string, @Query('payoutMode') payoutMode?: string) {
const mode = this.getRequestedPayoutMode(payoutMode);
const addressWorkers = await this.getActiveAddressWorkers(address, mode);
const presenceWorker = addressWorkers
.find(worker => worker.clientName === workerName && worker.sessionId === sessionId);
const worker = presenceWorker == null
@@ -106,14 +126,18 @@ export class ClientController {
sessionId: presenceWorker.sessionId,
clientName: presenceWorker.clientName,
bestDifficulty: presenceWorker.bestDifficulty,
payoutMode: presenceWorker.payoutMode,
startTime: presenceWorker.startTime,
};
if (worker == null) {
return new NotFoundException();
}
const chartData = await this.clientStatisticsService.getChartDataForSession(worker.id);
const chartDataByPayoutMode = await this.clientStatisticsService.getChartDataForSessionByPayoutMode(worker.id, mode);
const accounting = this.withBestSubmissionDifficulty(
await this.shareAccountingService.getSessionSummary(worker.id),
mode == null
? await this.shareAccountingService.getSessionSummary(worker.id)
: await this.shareAccountingService.getSessionSummary(worker.id, mode),
worker.bestDifficulty,
);
@@ -121,8 +145,10 @@ export class ClientController {
sessionId: worker.sessionId,
name: worker.clientName,
bestDifficulty: Math.floor(worker.bestDifficulty),
payoutMode: worker.payoutMode,
accounting,
chartData: chartData,
chartDataByPayoutMode,
startTime: worker.startTime
}
return response;
@@ -144,4 +170,23 @@ export class ClientController {
bestSubmissionDifficulty: fallback,
};
}
private getRequestedPayoutMode(payoutMode?: string): PayoutMode | undefined {
if (payoutMode == null || payoutMode === 'all') {
return undefined;
}
return normalizePayoutMode(payoutMode);
}
private async getActiveAddressWorkers(address: string, payoutMode?: PayoutMode) {
const workers = await this.redisMessagingService.getClientPresenceByAddress(address);
const activeIds = await this.clientService.getActiveIds(workers.map(worker => worker.clientId));
const staleWorkers = workers.filter(worker => !activeIds.has(worker.clientId));
void Promise.all(staleWorkers.map(worker => {
return this.redisMessagingService.removeClientPresence(worker.clientId, worker.address)
.catch(() => undefined);
}));
return workers.filter(worker => activeIds.has(worker.clientId) && (payoutMode == null || worker.payoutMode === payoutMode));
}
}
+6
View File
@@ -14,6 +14,9 @@ import { AcceptedShareRetentionCompression1780966200000 } from './ORM/_migration
import { PayoutSnapshots1780969800000 } from './ORM/_migrations/PayoutSnapshots1780969800000';
import { AcceptedShareProtocolMetadata1781130600000 } from './ORM/_migrations/AcceptedShareProtocolMetadata1781130600000';
import { BlocksSubmissionMetadata1781220000000 } from './ORM/_migrations/BlocksSubmissionMetadata1781220000000';
import { PayoutModes1781300000000 } from './ORM/_migrations/PayoutModes1781300000000';
import { ShareRollupStoragePolicy1781305000000 } from './ORM/_migrations/ShareRollupStoragePolicy1781305000000';
import { AcceptedShareHighScores1781309000000 } from './ORM/_migrations/AcceptedShareHighScores1781309000000';
import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
import { AcceptedShareEntity } from './ORM/accepted-share/accepted-share.entity';
import { AddressSettingsEntity } from './ORM/address-settings/address-settings.entity';
@@ -54,6 +57,9 @@ export const databaseMigrations = [
PayoutSnapshots1780969800000,
AcceptedShareProtocolMetadata1781130600000,
BlocksSubmissionMetadata1781220000000,
PayoutModes1781300000000,
ShareRollupStoragePolicy1781305000000,
AcceptedShareHighScores1781309000000,
];
export function createDatabaseOptions(env: NodeJS.ProcessEnv): TypeOrmModuleOptions & DataSourceOptions {
+7 -3
View File
@@ -547,8 +547,9 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job not found",""]}\n`);
expect(await clientService.connectedClientCount()).toBe(0);
expect(await clientService.connectedClientCount()).toBe(1);
expect(hashSpy).not.toHaveBeenCalled();
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
});
it('should reject submissions when the job template has expired', async () => {
@@ -578,7 +579,8 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[23,"Difficulty too low",""]}\n`);
expect(await clientService.connectedClientCount()).toBe(0);
expect(await clientService.connectedClientCount()).toBe(1);
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
});
it('should reject submissions with short extranonce2 values', async () => {
@@ -670,7 +672,9 @@ describe('StratumV1Client', () => {
minerAddress: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
worker: 'bitaxe3',
sessionId: MockRecording1.EXTRA_NONCE,
blockData: expect.any(String)
blockData: expect.any(String),
payoutSnapshotId: null,
payoutMode: 'solo',
}));
expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalled();
expect(addressSettings.resetBestDifficultyAndShares).toHaveBeenCalled();
+19 -7
View File
@@ -19,6 +19,7 @@ import { RedisMessagingService } from '../services/redis-messaging.service';
import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { DifficultyUtils } from '../utils/difficulty.utils';
import { hash256 } from '../utils/hash.utils';
import { PayoutMode } from '../types/payout-mode';
import { eRequestMethod } from './enums/eRequestMethod';
import { eResponseMethod } from './enums/eResponseMethod';
import { eStratumErrorCode } from './enums/eStratumErrorCode';
@@ -83,6 +84,7 @@ export class StratumV1Client {
private readonly redisMessagingService?: RedisMessagingService,
private readonly payoutSnapshotService?: PayoutSnapshotService,
private readonly accountingProtocol: 'sv1' | 'sv1_tls' = 'sv1',
private readonly payoutMode: PayoutMode = 'solo',
) {
this.socketDataHandler = (data: Buffer) => {
@@ -447,6 +449,7 @@ export class StratumV1Client {
}
private async sendNewMiningJob(jobTemplate: IJobTemplate) {
await this.ensureClientEntity();
let payoutInformation = this.getPayoutInformation(jobTemplate, this.clientAuthorization.address);
// const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS');
@@ -519,6 +522,7 @@ export class StratumV1Client {
clientName: this.clientAuthorization.worker,
userAgent: this.clientSubscription.userAgent,
startTime: new Date(),
payoutMode: this.payoutMode,
bestDifficulty: 0
});
await this.updateClientPresence(new Date());
@@ -539,6 +543,7 @@ export class StratumV1Client {
address: this.clientEntity.address,
clientName: this.clientEntity.clientName,
sessionId: this.clientEntity.sessionId,
payoutMode: this.payoutMode,
userAgent: this.clientEntity.userAgent,
startTime: new Date(this.clientEntity.startTime).toISOString(),
lastSeen: lastSeen.toISOString(),
@@ -651,13 +656,19 @@ export class StratumV1Client {
sessionId: this.extraNonceAndSessionId,
blockData: blockHex,
blockSubmissionResult,
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId ?? null,
});
await this.payoutSnapshotService?.finalizeSnapshotForBlock({
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId,
blockHeight: jobTemplate.blockData.height,
blockSubmissionResult,
payoutSnapshotId: this.payoutMode === 'pplns'
? jobTemplate.blockData.payoutSnapshotId ?? null
: null,
payoutMode: this.payoutMode,
});
if (this.payoutMode === 'pplns') {
await this.payoutSnapshotService?.finalizeSnapshotForBlock({
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId,
blockHeight: jobTemplate.blockData.height,
blockSubmissionResult,
payoutMode: this.payoutMode,
});
}
await this.notificationService.notifySubscribersBlockFound(this.clientAuthorization.address, jobTemplate.blockData.height, updatedJobBlock, blockSubmissionResult);
//success
@@ -669,6 +680,7 @@ export class StratumV1Client {
try {
await this.shareAccountingService?.recordAcceptedShare({
protocol: this.accountingProtocol,
payoutMode: this.payoutMode,
address: this.clientAuthorization.address,
clientName: this.clientAuthorization.worker,
sessionId: this.extraNonceAndSessionId,
@@ -886,7 +898,7 @@ export class StratumV1Client {
}
private getPayoutInformation(jobTemplate: IJobTemplate, fallbackAddress: string): AddressObject[] {
if (this.configService.get('PAYOUT_COINBASE_MODE') === 'snapshot' && jobTemplate.blockData.payoutOutputs?.length > 0) {
if (this.payoutMode === 'pplns' && jobTemplate.blockData.payoutOutputs?.length > 0) {
return jobTemplate.blockData.payoutOutputs;
}
+122 -1
View File
@@ -7,6 +7,7 @@ import { MockRecording1 } from '../../test/models/MockRecording1';
import { ClientEntity } from '../ORM/client/client.entity';
import { CustomWorkService } from '../services/custom-work.service';
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { hash256 } from '../utils/hash.utils';
import { BufferReader } from './sv2/sv2-binary-codec';
import { SV2_CHANNEL_MSG_FLAG, Sv2MiningSetupFlags, Sv2MsgType, Sv2Protocol } from './sv2/sv2-constants';
import {
@@ -115,6 +116,50 @@ describe('StratumV2Client extended channels', () => {
expect(error.errorCode).toBe('invalid-extranonce-size');
});
it('reconstructs extended-channel block candidates with a matching merkle root', async () => {
const { client, sentFrames } = await createClient();
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
requestId: 1,
userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker',
nominalHashRate: 0,
maxTarget: Buffer.alloc(32, 0xff),
minExtranonceSize: 8,
}));
const jobFrame = sentFrames
.filter(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB)
.at(-1);
const job = deserializeNewExtendedMiningJob(new BufferReader(jobFrame.payload));
const channel = (client as any).channels.get(1);
const extendedJob = channel.extendedJobs.get(job.jobId);
const extranonce = Buffer.alloc(channel.extranonceSize, 0x42);
const coinbaseTxBytes = Buffer.concat([
extendedJob.coinbasePrefix,
channel.extranoncePrefix,
extranonce,
extendedJob.coinbaseSuffix,
]);
let merkleRoot = hash256(coinbaseTxBytes);
for (const sibling of extendedJob.merklePath) {
merkleRoot = hash256(Buffer.concat([merkleRoot, sibling]));
}
const block = (client as any).reconstructExtendedBlock(
extendedJob,
{
jobId: job.jobId,
nonce: 123,
ntime: parseInt(MockRecording1.TIME, 16),
version: job.version,
extranonce,
},
merkleRoot,
channel.extranoncePrefix,
);
expect(block.merkleRoot.equals(bitcoinjs.Block.calculateMerkleRoot(block.transactions, false))).toBe(true);
expect(block.transactions[0].toBuffer()).toEqual(coinbaseTxBytes);
});
it('accepts SV2 work-selection setup and SetCustomMiningJob without sending pool work', async () => {
const { client, sentFrames } = await createClient();
await (client as any).handleSetupConnection(serializeSetupConnection({
@@ -151,7 +196,16 @@ describe('StratumV2Client extended channels', () => {
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.from('51', 'hex'),
coinbaseTxInputNSequence: 0xffffffff,
coinbaseTxOutputs: Buffer.from('010000000000000000016a', 'hex'),
coinbaseTxOutputs: serializeCoinbaseOutputs([
{
address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2',
amountSats: 0,
},
{
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
amountSats: MockRecording1.BLOCK_TEMPLATE.coinbasevalue,
},
]),
coinbaseTxLocktime: 0,
merklePath: [],
}));
@@ -163,6 +217,55 @@ describe('StratumV2Client extended channels', () => {
expect((client as any).channels.get(1).extendedJobs.get(success.jobId).workProtocol).toBe('sv2_jdp');
});
it('rejects SV2 work-selection custom jobs that do not pay the pool coinbase outputs', async () => {
const { client, sentFrames } = await createClient();
await (client as any).handleSetupConnection(serializeSetupConnection({
protocol: Sv2Protocol.MINING,
minVersion: 2,
maxVersion: 2,
flags: Sv2MiningSetupFlags.REQUIRES_WORK_SELECTION | Sv2MiningSetupFlags.REQUIRES_VERSION_ROLLING,
endpoint_host: 'localhost',
endpoint_port: 3333,
vendor: 'jd-client',
hardwareVersion: '',
firmwareVersion: '',
deviceId: '',
}));
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
requestId: 1,
userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker',
nominalHashRate: 0,
maxTarget: Buffer.alloc(32, 0xff),
minExtranonceSize: 8,
}));
sentFrames.length = 0;
await (client as any).handleSetCustomMiningJob(serializeSetCustomMiningJob({
channelId: 1,
requestId: 100,
token: Buffer.from('aa', 'hex'),
version: MockRecording1.BLOCK_TEMPLATE.version,
prevHash: Buffer.alloc(32, 1),
minNtime: parseInt(MockRecording1.TIME, 16),
nBits: parseInt(MockRecording1.BLOCK_TEMPLATE.bits, 16),
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.from('51', 'hex'),
coinbaseTxInputNSequence: 0xffffffff,
coinbaseTxOutputs: serializeCoinbaseOutputs([{
address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2',
amountSats: MockRecording1.BLOCK_TEMPLATE.coinbasevalue,
}]),
coinbaseTxLocktime: 0,
merklePath: [],
}));
const errorFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR);
const error = deserializeSetCustomMiningJobError(new BufferReader(errorFrame.payload));
expect(error.channelId).toBe(1);
expect(error.requestId).toBe(100);
expect(error.errorCode).toBe('invalid-coinbase-payout-outputs');
});
it('rejects SetCustomMiningJob when work selection was not negotiated', async () => {
const { client, sentFrames } = await createClient();
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
@@ -308,6 +411,8 @@ describe('StratumV2Client extended channels', () => {
worker: 'worker',
sessionId: 'sv2-session',
blockData: expect.any(String),
payoutSnapshotId: null,
payoutMode: 'solo',
}));
expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalled();
expect(addressSettingsService.resetBestDifficultyAndShares).toHaveBeenCalled();
@@ -452,3 +557,19 @@ describe('StratumV2Client extended channels', () => {
};
}
});
function serializeCoinbaseOutputs(outputs: { address: string; amountSats: number }[]): Buffer {
return Buffer.concat([
Buffer.from([outputs.length]),
...outputs.map(output => {
const script = bitcoinjs.address.toOutputScript(output.address, bitcoinjs.networks.testnet);
const value = Buffer.alloc(8);
value.writeBigUInt64LE(BigInt(output.amountSats), 0);
return Buffer.concat([
value,
Buffer.from([script.length]),
script,
]);
}),
]);
}
+107 -12
View File
@@ -23,6 +23,7 @@ import { DifficultyUtils } from '../utils/difficulty.utils';
import { hash256 } from '../utils/hash.utils';
import { AddressObject, MiningJob } from './MiningJob';
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
import { PayoutMode } from '../types/payout-mode';
import { BufferReader } from './sv2/sv2-binary-codec';
import {
SV2_CHANNEL_MSG_FLAG,
@@ -149,6 +150,7 @@ export class StratumV2Client {
private readonly shareAccountingService?: ShareAccountingService,
private readonly redisMessagingService?: RedisMessagingService,
private readonly payoutSnapshotService?: PayoutSnapshotService,
private readonly payoutMode: PayoutMode = 'solo',
) {
this.firstChunkSummary = this.describeChunk(firstChunk);
this.noiseSession = new Sv2NoiseSession(this.stratumV2Service.getNoiseConfig());
@@ -766,6 +768,26 @@ export class StratumV2Client {
msg,
channel.extranoncePrefix.length + channel.extranonceSize,
);
const payoutValidation = this.validateCoinbasePayoutOutputs(
Buffer.concat([
split.coinbasePrefix,
Buffer.alloc(channel.extranoncePrefix.length + channel.extranonceSize),
split.coinbaseSuffix,
]),
latestTemplate,
this.getPayoutInformation(latestTemplate, this.address),
);
if (!payoutValidation.valid) {
await this.sendFrame(
Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR,
serializeSetCustomMiningJobError({
channelId: msg.channelId,
requestId: msg.requestId,
errorCode: payoutValidation.errorCode,
}),
);
return;
}
const placeholderJob = new MiningJob(
this.network,
jobIdHex,
@@ -862,13 +884,19 @@ export class StratumV2Client {
sessionId: this.sessionId,
blockData: blockHex,
blockSubmissionResult,
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId ?? null,
});
await this.payoutSnapshotService?.finalizeSnapshotForBlock({
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId,
blockHeight: jobTemplate.blockData.height,
blockSubmissionResult,
payoutSnapshotId: this.payoutMode === 'pplns'
? jobTemplate.blockData.payoutSnapshotId ?? null
: null,
payoutMode: this.payoutMode,
});
if (this.payoutMode === 'pplns') {
await this.payoutSnapshotService?.finalizeSnapshotForBlock({
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId,
blockHeight: jobTemplate.blockData.height,
blockSubmissionResult,
payoutMode: this.payoutMode,
});
}
await this.notificationService.notifySubscribersBlockFound(
this.address,
jobTemplate.blockData.height,
@@ -882,6 +910,7 @@ export class StratumV2Client {
await this.shareAccountingService?.recordAcceptedShare({
protocol: metadata.workProtocol === 'sv2_jdp' ? 'sv2_jdp' : 'sv2',
payoutMode: this.payoutMode,
workSource: metadata.workProtocol === 'sv2_jdp' ? 'miner_template' : 'pool_template',
workProtocol: metadata.workProtocol ?? 'pool',
address: this.address,
@@ -929,13 +958,12 @@ export class StratumV2Client {
const testBlock = Object.assign(new bitcoinjs.Block(), jobTemplate.block);
testBlock.transactions = jobTemplate.block.transactions.map(tx => Object.assign(new bitcoinjs.Transaction(), tx));
const coinbaseTx = extendedJob.miningJob.cloneCoinbaseTransaction();
const script = coinbaseTx.ins[0].script;
coinbaseTx.ins[0].script = Buffer.concat([
script.subarray(0, script.length - (extranoncePrefix.length + submission.extranonce.length)),
const coinbaseTx = bitcoinjs.Transaction.fromBuffer(Buffer.concat([
extendedJob.coinbasePrefix,
extranoncePrefix,
submission.extranonce,
]);
extendedJob.coinbaseSuffix,
]));
testBlock.transactions[0] = coinbaseTx;
testBlock.version = submission.version;
testBlock.nonce = submission.nonce;
@@ -1282,6 +1310,7 @@ export class StratumV2Client {
clientName: this.workerName,
userAgent: this.userAgent,
startTime: new Date(),
payoutMode: this.payoutMode,
bestDifficulty: 0,
});
await this.updateClientPresence(new Date());
@@ -1302,6 +1331,7 @@ export class StratumV2Client {
address: this.clientEntity.address,
clientName: this.clientEntity.clientName,
sessionId: this.clientEntity.sessionId,
payoutMode: this.payoutMode,
userAgent: this.clientEntity.userAgent,
startTime: new Date(this.clientEntity.startTime).toISOString(),
lastSeen: lastSeen.toISOString(),
@@ -1394,13 +1424,78 @@ export class StratumV2Client {
}
private getPayoutInformation(jobTemplate: IJobTemplate, fallbackAddress: string): AddressObject[] {
if (this.configService.get('PAYOUT_COINBASE_MODE') === 'snapshot' && jobTemplate.blockData.payoutOutputs?.length > 0) {
if (this.payoutMode === 'pplns' && jobTemplate.blockData.payoutOutputs?.length > 0) {
return jobTemplate.blockData.payoutOutputs;
}
return [{ address: fallbackAddress, percent: 100 }];
}
private validateCoinbasePayoutOutputs(
coinbaseTxBytes: Buffer,
jobTemplate: IJobTemplate,
payoutInformation: AddressObject[],
): { valid: boolean; errorCode?: string } {
let transaction: bitcoinjs.Transaction;
try {
transaction = bitcoinjs.Transaction.fromBuffer(coinbaseTxBytes);
} catch {
return { valid: false, errorCode: 'invalid-coinbase-transaction' };
}
const expectedOutputs = this.buildExpectedPayoutOutputs(payoutInformation, jobTemplate.blockData.coinbasevalue);
const submittedOutputs = transaction.outs
.filter(output => !this.isWitnessCommitmentOutput(output.script))
.filter(output => BigInt(output.value) !== 0n)
.map(output => ({
value: BigInt(output.value),
scriptPubKey: Buffer.from(output.script),
}));
if (submittedOutputs.length !== expectedOutputs.length) {
return { valid: false, errorCode: 'invalid-coinbase-payout-outputs' };
}
for (let i = 0; i < expectedOutputs.length; i++) {
const expected = expectedOutputs[i];
const submitted = submittedOutputs[i];
if (submitted.value !== expected.value || !submitted.scriptPubKey.equals(expected.scriptPubKey)) {
return { valid: false, errorCode: 'invalid-coinbase-payout-outputs' };
}
}
return { valid: true };
}
private buildExpectedPayoutOutputs(
payoutInformation: AddressObject[],
coinbaseValue: number,
): { value: bigint; scriptPubKey: Buffer }[] {
let rewardBalance = BigInt(Math.max(0, Math.floor(coinbaseValue)));
const outputs = payoutInformation.map(recipientAddress => {
const value = recipientAddress.amountSats == null
? BigInt(Math.floor(((recipientAddress.percent ?? 0) / 100) * coinbaseValue))
: BigInt(recipientAddress.amountSats);
rewardBalance -= value;
return {
value,
scriptPubKey: bitcoinjs.address.toOutputScript(recipientAddress.address, this.network),
};
});
if (outputs.length > 0 && rewardBalance !== 0n) {
outputs[0] = {
...outputs[0],
value: outputs[0].value + rewardBalance,
};
}
return outputs;
}
private isWitnessCommitmentOutput(script: Buffer): boolean {
return script.length === 38
&& script[0] === bitcoinjs.opcodes.OP_RETURN
&& script[1] === 0x24
&& script.subarray(2, 6).equals(Buffer.from('aa21a9ed', 'hex'));
}
private isSuccessfulBlockSubmission(result?: string | null): boolean {
return result == null || result === 'SUCCESS!';
}
+41 -1
View File
@@ -80,11 +80,15 @@ describe('datum-codec', () => {
it('serializes coinbaser fetch responses', () => {
const response = serializeDatumCoinbaserFetchResponse(50n, [
{ value: 50n, scriptPubKey: Buffer.from('6a', 'hex') },
]);
], 7);
const mining = deserializeDatumMiningCommand(response);
expect(mining.command).toBe(DatumMiningCommand.FETCH_COINBASER_RESPONSE);
expect(mining.body.readBigUInt64LE(0)).toBe(50n);
expect(mining.body.readUInt32LE(8)).toBe(11);
expect(mining.body[12]).toBe(7);
expect(mining.body.readBigUInt64LE(13)).toBe(50n);
expect(mining.body[21]).toBe(1);
expect(mining.body.subarray(22).toString('hex')).toBe('6a');
});
it('deserializes coinbaser fetch requests', () => {
@@ -178,6 +182,42 @@ describe('datum-codec', () => {
expect(parsed.coinbasePairs.get(2)?.coinb2.toString('hex')).toBe('010203');
});
it('classifies DATUM coinbase sections by section type, not the top-level coinbase id', () => {
const username = Buffer.from('bc1ptest.worker\0', 'utf8');
const extranonce = Buffer.from('000102030405060708090a0b', 'hex');
const base = Buffer.alloc(17 + DATUM_EXTRANONCE_SIZE);
base[0] = 3;
base[1] = 0xff;
base[2] = 0x02;
base[3] = 0x12;
base.writeUInt32LE(100, 4);
base.writeUInt32LE(200, 8);
base.writeUInt32LE(0x20000000, 12);
base[16] = DATUM_EXTRANONCE_SIZE;
extranonce.copy(base, 17);
const coinbaseSection = Buffer.concat([
Buffer.from([0x02, 2]),
Buffer.from([0x02, 0x00]),
Buffer.from([0x03, 0x00]),
Buffer.from('abcd', 'hex'),
Buffer.from('010203', 'hex'),
]);
const parsed = deserializeDatumPowSubmit(Buffer.concat([
base,
username,
Buffer.alloc(4),
coinbaseSection,
Buffer.from([0xfe]),
]));
expect(parsed.subsidyOnly).toBe(true);
expect(parsed.subsidyOnlyCoinbase).toBeUndefined();
expect(parsed.coinbasePairs.get(2)?.coinb1.toString('hex')).toBe('abcd');
expect(parsed.coinbasePairs.get(2)?.coinb2.toString('hex')).toBe('010203');
});
it('rejects DATUM POW submissions with non-standard extranonce sizes', () => {
const payload = Buffer.alloc(18);
payload[16] = 1;
+7 -7
View File
@@ -332,21 +332,21 @@ export function deserializeDatumCoinbaserFetch(payload: Buffer): DatumCoinbaserF
return { rewardValue: payload.readBigUInt64LE(0) };
}
export function serializeDatumCoinbaserFetchResponse(rewardValue: bigint, outputs: DatumPayoutOutput[]): Buffer {
export function serializeDatumCoinbaserFetchResponse(rewardValue: bigint, outputs: DatumPayoutOutput[], coinbaserId = 1): Buffer {
const w = new BufferWriter();
w.writeU64(rewardValue);
const outputsBuffer = serializeDatumPayoutOutputs(outputs);
const outputsBuffer = serializeDatumPayoutOutputs(outputs, coinbaserId);
w.writeU32(outputsBuffer.length);
w.writeBytes(outputsBuffer);
return serializeDatumMiningCommand(DatumMiningCommand.FETCH_COINBASER_RESPONSE, Buffer.from(w.toBuffer()));
}
export function serializeDatumPayoutOutputs(outputs: DatumPayoutOutput[]): Buffer {
if (outputs.length > 255) {
throw new RangeError('DATUM payout output count must fit in one byte');
export function serializeDatumPayoutOutputs(outputs: DatumPayoutOutput[], coinbaserId = 1): Buffer {
if (!Number.isInteger(coinbaserId) || coinbaserId < 0 || coinbaserId > 255) {
throw new RangeError('DATUM coinbaser id must fit in one byte');
}
const w = new BufferWriter();
w.writeU8(outputs.length);
w.writeU8(coinbaserId);
for (const output of outputs) {
if (output.scriptPubKey.length > 255) {
throw new RangeError('DATUM scriptPubKey length must fit in one byte');
@@ -422,7 +422,7 @@ export function deserializeDatumPowSubmit(payload: Buffer): DatumPowSubmit {
coinb1: reader.readBytes(coinb1Len),
coinb2: reader.readBytes(coinb2Len),
};
if (coinbaseType === 255 || coinbaseId === 255) {
if (coinbaseType === 255) {
result.subsidyOnlyCoinbase = coinbase;
} else {
coinbasePairs.set(coinbaseType, coinbase);
+2
View File
@@ -84,6 +84,8 @@ export class AppService implements OnModuleInit {
this.refreshingPoolSummary = true;
try {
await this.shareAccountingService.refreshPoolSummary();
await this.shareAccountingService.refreshPoolSummary('pplns');
await this.shareAccountingService.refreshPoolSummary('solo');
} catch (error) {
console.error(`Failed refreshing pool accounting summary: ${error.message}`);
} finally {
+315 -4
View File
@@ -1,9 +1,26 @@
import { Socket } from 'net';
import * as bitcoinjs from 'bitcoinjs-lib';
import { DatumProtocolCommand } from '../models/datum/datum-codec';
import { DatumService } from './datum.service';
import { TemplateProviderService } from './template-provider.service';
import { StratumV1ClientStatistics } from '../models/StratumV1ClientStatistics';
function createService(): DatumService {
function createService(overrides: {
configService?: any;
clientService?: any;
redisMessagingService?: any;
} = {}): DatumService {
const configService = overrides.configService ?? {
get: jest.fn((key: string) => {
if (key === 'NETWORK') {
return 'testnet';
}
if (key === 'PAYOUT_COINBASE_MODE') {
return 'snapshot';
}
return undefined;
}),
};
const templateProvider = {
validateTransactionData: jest.fn(({ transactionList, expectedCount, maxTotalBytes }) => {
if (expectedCount != null && transactionList.length !== expectedCount) {
@@ -17,15 +34,15 @@ function createService(): DatumService {
}),
};
return new DatumService(
configService as any,
{} as any,
{} as any,
{} as any,
{} as any,
overrides.clientService ?? {} as any,
{} as any,
{} as any,
{} as any,
templateProvider as unknown as TemplateProviderService,
undefined,
overrides.redisMessagingService,
);
}
@@ -48,6 +65,10 @@ function createState(cache: any): any {
}
describe('DatumService job validation', () => {
afterEach(() => {
jest.useRealTimers();
});
it('requests short transaction IDs once a DATUM job advertises transactions', async () => {
const service = createService() as any;
const socket = createSocket();
@@ -153,4 +174,294 @@ describe('DatumService job validation', () => {
});
expect(socket.write).not.toHaveBeenCalled();
});
it('publishes DATUM client presence with smoothed hashrate after accepted shares', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-06-13T15:00:00.000Z'));
const clientService = {
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
};
const redisMessagingService = {
setClientPresence: jest.fn().mockResolvedValue(undefined),
};
const service = createService({ clientService, redisMessagingService }) as any;
const state = {
sessionId: 'datum-session',
userAgent: 'datum/test',
clientEntity: {
id: '3db0db03-3a62-4e3b-91bc-243adff4b542',
address: 'tb1qdatum',
clientName: 'datum-worker',
sessionId: 'datum-session',
startTime: new Date('2026-06-13T14:59:00.000Z'),
bestDifficulty: 0,
hashRate: 0,
},
statistics: new StratumV1ClientStatistics(1),
};
await service.updateAcceptedSharePresence(state, 'tb1qdatum', 'datum-worker', 10, 1);
jest.setSystemTime(new Date('2026-06-13T15:00:31.000Z'));
await service.updateAcceptedSharePresence(state, 'tb1qdatum', 'datum-worker', 20, 1);
jest.setSystemTime(new Date('2026-06-13T15:01:02.000Z'));
await service.updateAcceptedSharePresence(state, 'tb1qdatum', 'datum-worker', 30, 1);
expect(clientService.updateBestDifficultyIfHigher).toHaveBeenLastCalledWith(
'3db0db03-3a62-4e3b-91bc-243adff4b542',
30,
);
expect(redisMessagingService.setClientPresence).toHaveBeenLastCalledWith(expect.objectContaining({
clientId: '3db0db03-3a62-4e3b-91bc-243adff4b542',
address: 'tb1qdatum',
clientName: 'datum-worker',
sessionId: 'datum-session',
hashRate: expect.any(Number),
bestDifficulty: 30,
}));
const lastPresence = redisMessagingService.setClientPresence.mock.calls.at(-1)[0];
expect(lastPresence.hashRate).toBeGreaterThan(0);
expect(state.clientEntity.hashRate).toBe(lastPresence.hashRate);
});
it('derives submitted share difficulty from DATUM target byte', () => {
const service = createService() as any;
expect(service.getDatumSubmittedShareDifficulty({ targetByte: 0x00 })).toBe(1);
expect(service.getDatumSubmittedShareDifficulty({ targetByte: 0x0e })).toBe(16_384);
expect(service.getDatumSubmittedShareDifficulty({ targetByte: 0x14 })).toBe(1_048_576);
});
it('falls back to configured DATUM share difficulty for invalid target bytes', () => {
const service = createService({
configService: {
get: jest.fn((key: string) => {
if (key === 'DATUM_SHARE_DIFFICULTY') {
return '4096';
}
if (key === 'NETWORK') {
return 'testnet';
}
return undefined;
}),
},
}) as any;
expect(service.getDatumSubmittedShareDifficulty({ targetByte: 0xff })).toBe(4096);
});
it('accepts DATUM coinbases that exactly match snapshot payout outputs', () => {
const service = createService() as any;
const extranonce = Buffer.alloc(12, 1);
const expectedOutputs = [
{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 421 },
{ address: 'tb1q9r8gvnx3j4d6jvl0fqjrmy3dar4k4l3052af7q', amountSats: 133 },
{ address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2', amountSats: 42 },
];
const coinbase = createDatumCoinbaseSplit(expectedOutputs, extranonce);
const latestTemplate = {
blockData: {
coinbasevalue: 596,
payoutOutputs: expectedOutputs,
},
};
expect(service.validateDatumCoinbasePayouts(
coinbase,
{ extranonce, targetByte: 0 },
latestTemplate,
596n,
undefined,
'pplns',
).valid).toBe(true);
});
it('accepts DATUM coinbases with zero-value OP_RETURN metadata plus matching snapshot payouts', () => {
const service = createService() as any;
const extranonce = Buffer.alloc(12, 1);
const expectedOutputs = [
{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 421 },
{ address: 'tb1q9r8gvnx3j4d6jvl0fqjrmy3dar4k4l3052af7q', amountSats: 133 },
{ address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2', amountSats: 42 },
];
const coinbase = createDatumCoinbaseSplit(expectedOutputs, extranonce, true);
const latestTemplate = {
blockData: {
coinbasevalue: 596,
payoutOutputs: expectedOutputs,
},
};
const validation = service.validateDatumCoinbasePayouts(
coinbase,
{ extranonce, targetByte: 0 },
latestTemplate,
596n,
undefined,
'pplns',
);
expect(validation.valid).toBe(true);
expect(validation.submittedOutputs).toHaveLength(3);
});
it('rejects DATUM coinbases with extra spendable outputs beyond snapshot payouts', () => {
const service = createService() as any;
const extranonce = Buffer.alloc(12, 1);
const expectedOutputs = [
{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 421 },
{ address: 'tb1q9r8gvnx3j4d6jvl0fqjrmy3dar4k4l3052af7q', amountSats: 133 },
{ address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2', amountSats: 42 },
];
const coinbase = createDatumCoinbaseSplit([
...expectedOutputs,
{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', amountSats: 1 },
], extranonce);
const latestTemplate = {
blockData: {
coinbasevalue: 596,
payoutOutputs: expectedOutputs,
},
};
const validation = service.validateDatumCoinbasePayouts(
coinbase,
{ extranonce, targetByte: 0 },
latestTemplate,
596n,
undefined,
'pplns',
);
expect(validation.valid).toBe(false);
expect(validation.error).toBe('output-count-mismatch');
});
it('rejects DATUM coinbases that do not pay the pool snapshot outputs', () => {
const service = createService() as any;
const extranonce = Buffer.alloc(12, 1);
const expectedOutputs = [
{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 421 },
{ address: 'tb1q9r8gvnx3j4d6jvl0fqjrmy3dar4k4l3052af7q', amountSats: 133 },
{ address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2', amountSats: 42 },
];
const maliciousCoinbase = createDatumCoinbaseSplit([
{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', amountSats: 596 },
], extranonce);
const latestTemplate = {
blockData: {
coinbasevalue: 596,
payoutOutputs: expectedOutputs,
},
};
expect(service.validateDatumCoinbasePayouts(
maliciousCoinbase,
{ extranonce, targetByte: 0 },
latestTemplate,
596n,
undefined,
'pplns',
).valid).toBe(false);
});
it('validates DATUM coinbases against the coinbaser-id payout outputs instead of the latest template', () => {
const service = createService() as any;
const extranonce = Buffer.alloc(12, 1);
const fetchedOutputs = [
{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 596 },
];
const laterTemplateOutputs = [
{ address: 'tb1q9r8gvnx3j4d6jvl0fqjrmy3dar4k4l3052af7q', amountSats: 596 },
];
const coinbase = createDatumCoinbaseSplit(fetchedOutputs, extranonce);
const latestTemplate = {
blockData: {
coinbasevalue: 596,
payoutOutputs: laterTemplateOutputs,
},
};
const expectedPayoutOutputs = (service as any).getDatumPayoutOutputs({
blockData: {
payoutOutputs: fetchedOutputs,
},
}, 596, 'pplns');
expect(service.validateDatumCoinbasePayouts(
coinbase,
{ extranonce, targetByte: 0 },
latestTemplate,
596n,
expectedPayoutOutputs,
'pplns',
).valid).toBe(true);
});
it('carries the coinbaser payout snapshot id into the DATUM job cache', () => {
const service = createService() as any;
const payoutOutputs = [{
value: 596n,
scriptPubKey: bitcoinjs.address.toOutputScript(
'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh',
bitcoinjs.networks.testnet,
),
}];
const state = {
datumJobs: new Map(),
coinbaserPayoutContexts: new Map([[9, {
payoutOutputs,
payoutSnapshotId: '95',
blockHeight: 4991366,
}]]),
};
const cache = service.updateDatumJobCache(state, {
jobId: 7,
coinbaserId: 9,
coinbasePairs: new Map(),
});
expect(cache.coinbaserId).toBe(9);
expect(cache.expectedPayoutOutputs).toBe(payoutOutputs);
expect(cache.payoutSnapshotId).toBe('95');
});
});
function createDatumCoinbaseSplit(
outputs: { address: string; amountSats: number }[],
extranonce: Buffer,
includeDatumMetadata = false,
): { coinb1: Buffer; coinb2: Buffer } {
const tx = new bitcoinjs.Transaction();
tx.version = 2;
tx.addInput(Buffer.alloc(32), 0xffffffff, 0xffffffff);
tx.ins[0].script = Buffer.concat([
Buffer.from([0x03, 0x51, 0x27, 0x4c]),
extranonce,
]);
tx.ins[0].witness = [Buffer.alloc(32)];
for (const output of outputs) {
tx.addOutput(
bitcoinjs.address.toOutputScript(output.address, bitcoinjs.networks.testnet),
output.amountSats,
);
}
if (includeDatumMetadata) {
tx.addOutput(bitcoinjs.script.compile([bitcoinjs.opcodes.OP_RETURN, Buffer.from([0])]), 0);
}
tx.addOutput(
bitcoinjs.script.compile([
bitcoinjs.opcodes.OP_RETURN,
Buffer.concat([Buffer.from('aa21a9ed', 'hex'), Buffer.alloc(32, 2)]),
]),
0,
);
const serialized = tx.toBuffer();
const index = serialized.indexOf(extranonce);
if (index < 0) {
throw new Error('test coinbase split missing extranonce');
}
return {
coinb1: serialized.subarray(0, index),
coinb2: serialized.subarray(index + extranonce.length),
};
}
+293 -29
View File
@@ -19,6 +19,7 @@ import {
DatumPowSubmit,
DatumJobValidationStatus,
DatumJobValidationResponse,
DatumPayoutOutput,
deserializeDatumCoinbaserFetch,
deserializeDatumJobValidationResponse,
deserializeDatumMiningCommand,
@@ -33,9 +34,13 @@ import { DatumCryptoSession } from '../models/datum/datum-crypto';
import { BitcoinRpcService } from './bitcoin-rpc.service';
import { CustomWorkService } from './custom-work.service';
import { RedisMessagingService } from './redis-messaging.service';
import { StratumV1JobsService } from './stratum-v1-jobs.service';
import { IJobTemplate, StratumV1JobsService } from './stratum-v1-jobs.service';
import { DatumKeyPair } from '../models/datum/datum-crypto';
import { TemplateProviderService } from './template-provider.service';
import { StratumV1ClientStatistics } from '../models/StratumV1ClientStatistics';
import type { AddressObject } from '../models/MiningJob';
import { PayoutSnapshotService } from '../ORM/payout-snapshot/payout-snapshot.service';
import { parsePayoutModePorts, PayoutMode } from '../types/payout-mode';
const DEFAULT_DATUM_SHARE_DIFFICULTY = 1;
const DEFAULT_DATUM_PING_INTERVAL_MS = 30_000;
@@ -55,6 +60,7 @@ export class DatumService implements OnModuleInit {
private readonly customWorkService: CustomWorkService,
private readonly templateProvider: TemplateProviderService,
private readonly redisMessagingService?: RedisMessagingService,
private readonly payoutSnapshotService?: PayoutSnapshotService,
) {}
public async onModuleInit(): Promise<void> {
@@ -70,25 +76,25 @@ export class DatumService implements OnModuleInit {
this.identityKeys = this.getIdentityKeys();
console.log(`DATUM server public key: ${Buffer.concat([this.identityKeys.edPublicKey, this.identityKeys.xPublicKey]).toString('hex')}`);
for (const port of ports) {
await this.startServer(port);
for (const { port, payoutMode } of ports) {
await this.startServer(port, payoutMode);
}
}
private async startServer(port: number): Promise<void> {
private async startServer(port: number, payoutMode: PayoutMode): Promise<void> {
const server = new Server(socket => {
void this.handleSocket(socket);
void this.handleSocket(socket, payoutMode);
});
server.on('error', error => {
console.error(`DATUM server error on port ${port}: ${error.message}`);
});
server.listen(port, () => {
console.log(`DATUM server is listening on port ${port}`);
console.log(`DATUM ${payoutMode} server is listening on port ${port}`);
});
this.servers.push(server);
}
private async handleSocket(socket: Socket): Promise<void> {
private async handleSocket(socket: Socket, payoutMode: PayoutMode): Promise<void> {
socket.setKeepAlive(true, 60_000);
socket.setNoDelay(true);
@@ -96,6 +102,7 @@ export class DatumService implements OnModuleInit {
const reader = new DatumFrameReader(DATUM_INITIAL_HEADER_KEY);
const state: DatumClientState = {
sessionId: Math.random().toString(16).slice(2, 10),
payoutMode,
session,
clientEntity: null,
address: null,
@@ -103,6 +110,9 @@ export class DatumService implements OnModuleInit {
userAgent: 'datum/unknown',
pingTimer: null,
datumJobs: new Map(),
coinbaserPayoutContexts: new Map(),
nextCoinbaserId: 1,
statistics: new StratumV1ClientStatistics(this.getConfiguredDatumShareDifficulty()),
};
console.log(`[DATUM ${state.sessionId}] connection accepted from ${socket.remoteAddress}:${socket.remotePort}`);
@@ -188,7 +198,7 @@ export class DatumService implements OnModuleInit {
throw new Error(`DATUM coinbase tag is too long: ${coinbaseTag.length}`);
}
const minDifficulty = Math.max(1, Math.floor(this.getDatumShareDifficulty()));
const minDifficulty = Math.max(1, Math.floor(this.getConfiguredDatumShareDifficulty()));
const vardiffMin = 1n << BigInt(Math.ceil(Math.log2(minDifficulty)));
const vardiffMinBuffer = Buffer.alloc(8);
vardiffMinBuffer.writeBigUInt64LE(vardiffMin, 0);
@@ -208,16 +218,27 @@ export class DatumService implements OnModuleInit {
private async handleCoinbaserFetch(socket: Socket, state: DatumClientState, payload: Buffer): Promise<void> {
const fetch = deserializeDatumCoinbaserFetch(payload);
const payoutAddress = this.getDatumPoolPayoutAddress();
if (!payoutAddress) {
const latestTemplate = await firstValueFrom(this.jobsService.newMiningJob$);
const payoutOutputs = this.getDatumPayoutOutputs(latestTemplate, Number(fetch.rewardValue), state.payoutMode);
if (payoutOutputs.length === 0) {
throw new Error('DATUM_POOL_PAYOUT_ADDRESS or DEV_FEE_ADDRESS must be set before DATUM coinbaser fetches can be served');
}
const scriptPubKey = bitcoinjs.address.toOutputScript(payoutAddress, this.getNetwork());
const response = serializeDatumCoinbaserFetchResponse(fetch.rewardValue, [{
value: fetch.rewardValue,
scriptPubKey,
}]);
const coinbaserId = this.nextDatumCoinbaserId(state);
state.coinbaserPayoutContexts.set(coinbaserId, {
payoutOutputs,
payoutSnapshotId: latestTemplate.blockData.payoutSnapshotId ?? null,
blockHeight: latestTemplate.blockData.height,
payoutMode: state.payoutMode,
});
if (state.coinbaserPayoutContexts.size > 512) {
const oldestCoinbaserId = state.coinbaserPayoutContexts.keys().next().value;
if (oldestCoinbaserId != null) {
state.coinbaserPayoutContexts.delete(oldestCoinbaserId);
}
}
const response = serializeDatumCoinbaserFetchResponse(fetch.rewardValue, payoutOutputs, coinbaserId);
await this.writeRaw(socket, state.session.encryptChannelFrame(DatumProtocolCommand.MINING, response));
}
@@ -273,8 +294,14 @@ export class DatumService implements OnModuleInit {
await this.sendShareResponse(socket, state, DatumShareResponseStatus.REJECTED, DatumRejectReason.OTHER, pow.nonce, pow.targetByte, pow.jobId);
return;
}
const payoutValidation = this.validateDatumCoinbasePayouts(coinbase, pow, latestTemplate, datumJob.coinbaseValue, datumJob.expectedPayoutOutputs, state.payoutMode);
if (!payoutValidation.valid) {
this.logDatumCoinbaseMismatchOnce(state, payoutValidation);
await this.sendShareResponse(socket, state, DatumShareResponseStatus.REJECTED, DatumRejectReason.BAD_COINBASER_ID, pow.nonce, pow.targetByte, pow.jobId);
return;
}
const shareDifficulty = this.getDatumShareDifficulty();
const shareDifficulty = this.getDatumSubmittedShareDifficulty(pow);
const nBits = datumJob.nBits.readUInt32LE(0);
const validation = this.customWorkService.validateShare({
coinbasePrefix: coinbase.coinb1,
@@ -310,11 +337,23 @@ export class DatumService implements OnModuleInit {
sessionId: state.sessionId,
blockData: validation.header.toString('hex'),
blockSubmissionResult,
payoutSnapshotId: null,
payoutSnapshotId: state.payoutMode === 'pplns'
? datumJob.payoutSnapshotId ?? null
: null,
payoutMode: state.payoutMode,
});
if (state.payoutMode === 'pplns') {
await this.payoutSnapshotService?.finalizeSnapshotForBlock({
payoutSnapshotId: datumJob.payoutSnapshotId,
blockHeight: datumJob.height ?? latestTemplate.blockData.height,
blockSubmissionResult,
payoutMode: state.payoutMode,
});
}
}
await this.shareAccountingService.recordAcceptedShare({
protocol: 'datum',
payoutMode: state.payoutMode,
workSource: 'miner_template',
workProtocol: 'datum',
address,
@@ -334,16 +373,41 @@ export class DatumService implements OnModuleInit {
isBlockCandidate,
blockSubmissionResult,
});
await this.updateAcceptedSharePresence(state, address, workerName, validation.submissionDifficulty, shareDifficulty);
}
private async updateAcceptedSharePresence(
state: DatumClientState,
address: string,
workerName: string,
submissionDifficulty: number,
creditedDifficulty: number,
): Promise<void> {
if (state.clientEntity == null) {
return;
}
await state.statistics.addShares(state.clientEntity, creditedDifficulty);
state.clientEntity.hashRate = state.statistics.hashRate;
if (submissionDifficulty > Number(state.clientEntity.bestDifficulty ?? 0)) {
await this.clientService.updateBestDifficultyIfHigher(state.clientEntity.id, submissionDifficulty);
state.clientEntity.bestDifficulty = submissionDifficulty;
}
await this.redisMessagingService?.setClientPresence({
clientId: state.clientEntity.id,
address,
clientName: workerName,
sessionId: state.sessionId,
payoutMode: state.payoutMode,
userAgent: state.userAgent,
startTime: new Date(state.clientEntity.startTime).toISOString(),
lastSeen: new Date().toISOString(),
hashRate: 0,
bestDifficulty: validation.submissionDifficulty,
hashRate: state.statistics.hashRate,
bestDifficulty: Math.max(
Number(state.clientEntity.bestDifficulty ?? 0),
submissionDifficulty,
),
});
}
@@ -367,6 +431,11 @@ export class DatumService implements OnModuleInit {
}
if (pow.coinbaserId != null) {
cache.coinbaserId = pow.coinbaserId;
const payoutContext = state.coinbaserPayoutContexts.get(pow.coinbaserId);
if (payoutContext != null) {
cache.expectedPayoutOutputs = payoutContext.payoutOutputs;
cache.payoutSnapshotId = payoutContext.payoutSnapshotId;
}
}
if (pow.height != null) {
cache.height = pow.height;
@@ -507,6 +576,152 @@ export class DatumService implements OnModuleInit {
return cache.coinbasePairs.get(pow.coinbaseId) ?? cache.subsidyOnlyCoinbase;
}
private validateDatumCoinbasePayouts(
coinbase: { coinb1: Buffer; coinb2: Buffer },
pow: Pick<DatumPowSubmit, 'extranonce' | 'targetByteIndex' | 'targetByte'>,
latestTemplate: IJobTemplate,
coinbaseValue?: bigint,
expectedPayoutOutputs?: DatumPayoutOutput[],
payoutMode?: PayoutMode,
): DatumCoinbasePayoutValidation {
const rewardValue = coinbaseValue == null
? latestTemplate.blockData.coinbasevalue
: Number(coinbaseValue);
const expectedOutputs = expectedPayoutOutputs ?? this.getDatumPayoutOutputs(latestTemplate, rewardValue, payoutMode);
if (expectedOutputs.length === 0) {
return { valid: false, expectedOutputs, submittedOutputs: [], error: 'missing-expected-outputs' };
}
let coinbaseTx = Buffer.concat([
coinbase.coinb1,
pow.extranonce,
coinbase.coinb2,
]);
coinbaseTx = Buffer.from(coinbaseTx);
if (pow.targetByteIndex != null) {
if (pow.targetByteIndex < 0 || pow.targetByteIndex >= coinbaseTx.length) {
return { valid: false, expectedOutputs, submittedOutputs: [], error: 'target-byte-out-of-range' };
}
coinbaseTx[pow.targetByteIndex] = pow.targetByte & 0xff;
}
let transaction: bitcoinjs.Transaction;
try {
transaction = bitcoinjs.Transaction.fromBuffer(coinbaseTx);
} catch {
return { valid: false, expectedOutputs, submittedOutputs: [], error: 'invalid-coinbase-transaction' };
}
const submittedOutputs = transaction.outs
.filter(output => !this.isIgnoredCoinbaseMetadataOutput(output))
.map(output => ({
value: BigInt(output.value),
scriptPubKey: Buffer.from(output.script),
}));
if (submittedOutputs.length !== expectedOutputs.length) {
return { valid: false, expectedOutputs, submittedOutputs, error: 'output-count-mismatch' };
}
const valid = expectedOutputs.every((expected, index) => {
const submitted = submittedOutputs[index];
return submitted.value === expected.value
&& submitted.scriptPubKey.equals(expected.scriptPubKey);
});
return {
valid,
expectedOutputs,
submittedOutputs,
error: valid ? undefined : 'output-mismatch',
};
}
private getDatumPayoutOutputs(latestTemplate: { blockData?: { payoutOutputs?: AddressObject[] } }, rewardValue: number, payoutMode: PayoutMode = 'solo'): DatumPayoutOutput[] {
const configuredOutputs = latestTemplate?.blockData?.payoutOutputs;
const payoutAddresses = payoutMode === 'pplns' && configuredOutputs?.length > 0
? configuredOutputs
: this.getDatumFallbackPayoutOutputs(rewardValue);
let rewardBalance = Math.max(0, Math.floor(rewardValue));
const outputs = payoutAddresses.map((recipientAddress, index) => {
const amount = recipientAddress.amountSats == null
? Math.floor(((recipientAddress.percent ?? 0) / 100) * rewardValue)
: recipientAddress.amountSats;
rewardBalance -= amount;
return {
value: BigInt(amount),
scriptPubKey: bitcoinjs.address.toOutputScript(recipientAddress.address, this.getNetwork()),
};
});
if (outputs.length > 0 && rewardBalance !== 0) {
outputs[0] = {
...outputs[0],
value: outputs[0].value + BigInt(rewardBalance),
};
}
return outputs;
}
private nextDatumCoinbaserId(state: DatumClientState): number {
const id = state.nextCoinbaserId;
state.nextCoinbaserId = state.nextCoinbaserId >= 254 ? 1 : state.nextCoinbaserId + 1;
return id;
}
private getDatumFallbackPayoutOutputs(rewardValue: number): AddressObject[] {
const payoutAddress = this.getDatumPoolPayoutAddress();
if (!payoutAddress) {
return [];
}
return [{ address: payoutAddress, amountSats: Math.max(0, Math.floor(rewardValue)) }];
}
private isIgnoredCoinbaseMetadataOutput(output: { value: bigint | number; script: Buffer }): boolean {
if (this.isWitnessCommitmentOutput(output.script)) {
return true;
}
return BigInt(output.value) === 0n
&& output.script.length > 0
&& output.script[0] === bitcoinjs.opcodes.OP_RETURN;
}
private isWitnessCommitmentOutput(script: Buffer): boolean {
return script.length === 38
&& script[0] === bitcoinjs.opcodes.OP_RETURN
&& script[1] === 0x24
&& script.subarray(2, 6).equals(Buffer.from('aa21a9ed', 'hex'));
}
private logDatumCoinbaseMismatchOnce(
state: DatumClientState,
validation: DatumCoinbasePayoutValidation,
): void {
if (state.coinbaseMismatchLogged) {
return;
}
state.coinbaseMismatchLogged = true;
console.warn(`[DATUM ${state.sessionId}] Coinbase payout mismatch (${validation.error}); expected=${this.describeDatumOutputs(validation.expectedOutputs)} submitted=${this.describeDatumOutputs(validation.submittedOutputs)}`);
}
private describeDatumOutputs(outputs: DatumPayoutOutput[]): string {
return outputs
.map(output => {
const address = this.tryOutputAddress(output.scriptPubKey);
return `${output.value.toString()}:${address ?? output.scriptPubKey.toString('hex')}`;
})
.join(',');
}
private tryOutputAddress(scriptPubKey: Buffer): string | null {
try {
return bitcoinjs.address.fromOutputScript(scriptPubKey, this.getNetwork());
} catch {
return null;
}
}
private async sendShareResponse(
socket: Socket,
state: DatumClientState,
@@ -530,8 +745,33 @@ export class DatumService implements OnModuleInit {
clientName: state.workerName,
userAgent: state.userAgent,
startTime: new Date(),
payoutMode: state.payoutMode,
bestDifficulty: 0,
});
await this.updateClientPresence(state, new Date());
}
private async updateClientPresence(state: DatumClientState, lastSeen: Date): Promise<void> {
if (state.clientEntity == null) {
return;
}
try {
await this.redisMessagingService?.setClientPresence({
clientId: state.clientEntity.id,
address: state.clientEntity.address,
clientName: state.clientEntity.clientName,
sessionId: state.clientEntity.sessionId,
payoutMode: state.payoutMode,
userAgent: state.clientEntity.userAgent,
startTime: new Date(state.clientEntity.startTime).toISOString(),
lastSeen: lastSeen.toISOString(),
hashRate: state.statistics.hashRate,
bestDifficulty: Number(state.clientEntity.bestDifficulty ?? 0),
});
} catch (error) {
console.error(`Failed to update DATUM client presence: ${error.message}`);
}
}
private async destroyClient(state: DatumClientState): Promise<void> {
@@ -612,11 +852,18 @@ export class DatumService implements OnModuleInit {
|| '';
}
private getDatumShareDifficulty(): number {
private getConfiguredDatumShareDifficulty(): number {
const configured = parseFloat(this.configService.get<string>('DATUM_SHARE_DIFFICULTY') ?? '');
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_DATUM_SHARE_DIFFICULTY;
}
private getDatumSubmittedShareDifficulty(pow: Pick<DatumPowSubmit, 'targetByte'>): number {
if (!Number.isInteger(pow.targetByte) || pow.targetByte < 0 || pow.targetByte > 63) {
return this.getConfiguredDatumShareDifficulty();
}
return Math.pow(2, pow.targetByte);
}
private mapDatumTemplateRejectReason(errorCode?: string): DatumRejectReason {
switch (errorCode) {
case 'prevhash-mismatch':
@@ -655,15 +902,11 @@ export class DatumService implements OnModuleInit {
return DatumCryptoSession.generateKeyPairFromSeed(Buffer.from(seedHex, 'hex'));
}
private getPorts(): number[] {
const configured = this.configService.get<string>('DATUM_PORTS');
if (!configured?.trim()) {
return [];
}
return Array.from(new Set(configured
.split(',')
.map(port => parseInt(port.trim(), 10))
.filter(port => Number.isInteger(port) && port > 0 && port <= 65535)));
private getPorts(): { port: number; payoutMode: PayoutMode }[] {
return parsePayoutModePorts(
this.configService.get<string>('DATUM_PORTS'),
this.configService.get<string>('PPLNS_DATUM_PORTS'),
);
}
private getNetwork(): bitcoinjs.networks.Network {
@@ -683,6 +926,7 @@ export class DatumService implements OnModuleInit {
interface DatumClientState {
sessionId: string;
payoutMode: PayoutMode;
session: DatumCryptoSession;
clientEntity: ClientEntity | null;
address: string | null;
@@ -690,6 +934,17 @@ interface DatumClientState {
userAgent: string;
pingTimer: NodeJS.Timeout | null;
datumJobs: Map<number, DatumJobCache>;
coinbaserPayoutContexts: Map<number, DatumCoinbaserPayoutContext>;
nextCoinbaserId: number;
statistics: StratumV1ClientStatistics;
coinbaseMismatchLogged?: boolean;
}
interface DatumCoinbaserPayoutContext {
payoutOutputs: DatumPayoutOutput[];
payoutSnapshotId?: string | null;
blockHeight?: number;
payoutMode?: PayoutMode;
}
interface DatumJobCache {
@@ -713,6 +968,15 @@ interface DatumJobCache {
validationShortTxCrosscheck?: Buffer;
validationTransactions?: Buffer[];
validationError?: string;
expectedPayoutOutputs?: DatumPayoutOutput[];
payoutSnapshotId?: string | null;
}
interface DatumCoinbasePayoutValidation {
valid: boolean;
expectedOutputs: DatumPayoutOutput[];
submittedOutputs: DatumPayoutOutput[];
error?: string;
}
function uint32le(value: number): Buffer {
@@ -113,6 +113,28 @@ describe('RedisMessagingService', () => {
expect([...store.keys()].filter(key => key.startsWith('client-presence'))).toHaveLength(0);
expect([...sets.keys()].filter(key => key.startsWith('client-presence'))).toHaveLength(0);
});
it('should not return address presence that is no longer in the active global set', async () => {
await service.connect();
await service.setClientPresence({
clientId: '00000000-0000-4000-8000-000000000001',
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'worker',
sessionId: '57a6f098',
userAgent: 'bitaxe',
startTime: '2026-06-07T12:00:00.000Z',
lastSeen: '2026-06-07T12:01:00.000Z',
hashRate: 100,
bestDifficulty: 200,
});
sets.get('client-presence:all')?.delete('00000000-0000-4000-8000-000000000001');
expect(await service.getClientPresenceByAddress('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4'))
.toHaveLength(0);
expect(sets.get('client-presence:address:tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4'))
?.not.toContain('00000000-0000-4000-8000-000000000001');
});
});
const store = new Map<string, string>();
+13 -1
View File
@@ -4,6 +4,7 @@ import { createClient, RedisClientType } from 'redis';
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
import { normalizePayoutMode, PayoutMode } from '../types/payout-mode';
const MINING_INFO_CHANNEL = 'mining-info.updated';
const MINING_INFO_KEY = 'mining-info:latest';
@@ -20,6 +21,7 @@ export interface ClientPresence {
address: string;
clientName: string;
sessionId: string;
payoutMode?: PayoutMode;
userAgent?: string | null;
startTime: string;
lastSeen: string;
@@ -143,6 +145,7 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
const serialized = JSON.stringify({
...presence,
payoutMode: normalizePayoutMode(presence.payoutMode),
userAgent: presence.userAgent ?? null,
hashRate: Number.isFinite(Number(presence.hashRate)) ? Number(presence.hashRate) : 0,
bestDifficulty: Number.isFinite(Number(presence.bestDifficulty)) ? Number(presence.bestDifficulty) : 0,
@@ -267,6 +270,9 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
if (clientIds.length === 0) {
return [];
}
const activeClientIds = setKey === CLIENT_PRESENCE_ALL_KEY
? null
: new Set(await this.publisher.sMembers(CLIENT_PRESENCE_ALL_KEY));
const presences: ClientPresence[] = [];
const staleClientIds: string[] = [];
@@ -274,9 +280,14 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
const chunk = clientIds.slice(i, i + 1000);
const values = await this.publisher.mGet(chunk.map(clientPresenceKey));
values.forEach((value, index) => {
const clientId = chunk[index];
if (activeClientIds != null && !activeClientIds.has(clientId)) {
staleClientIds.push(clientId);
return;
}
const presence = this.parseClientPresence(value);
if (presence == null) {
staleClientIds.push(chunk[index]);
staleClientIds.push(clientId);
return;
}
presences.push(presence);
@@ -312,6 +323,7 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
address: parsed.address,
clientName: parsed.clientName ?? 'default',
sessionId: parsed.sessionId ?? parsed.clientId,
payoutMode: normalizePayoutMode(parsed.payoutMode),
userAgent: parsed.userAgent ?? null,
startTime: parsed.startTime,
lastSeen: parsed.lastSeen,
+8 -3
View File
@@ -85,8 +85,10 @@ describe('StratumV1Service', () => {
it('should start Stratum listeners in worker processes', async () => {
process.env.MASTER = 'false';
process.env.STRATUM_PORTS = '3333,3334';
process.env.PPLNS_STRATUM_PORTS = '13333';
process.env.STRATUM_SECURE = 'true';
process.env.SECURE_STRATUM_PORTS = '4333';
process.env.PPLNS_SECURE_STRATUM_PORTS = '14333';
const startSocketServerSpy = jest.spyOn(service as any, 'startSocketServer').mockImplementation(() => undefined);
const startSecureSocketServerSpy = jest.spyOn(service as any, 'startSecureSocketServer').mockImplementation(() => undefined);
@@ -95,9 +97,11 @@ describe('StratumV1Service', () => {
expect(clientService.deleteAll).not.toHaveBeenCalled();
expect(userAgentReportService.refreshReport).not.toHaveBeenCalled();
expect(startSocketServerSpy).toHaveBeenCalledWith(3333);
expect(startSocketServerSpy).toHaveBeenCalledWith(3334);
expect(startSecureSocketServerSpy).toHaveBeenCalledWith(4333);
expect(startSocketServerSpy).toHaveBeenCalledWith(3333, 'solo');
expect(startSocketServerSpy).toHaveBeenCalledWith(3334, 'solo');
expect(startSocketServerSpy).toHaveBeenCalledWith(13333, 'pplns');
expect(startSecureSocketServerSpy).toHaveBeenCalledWith(4333, 'solo');
expect(startSecureSocketServerSpy).toHaveBeenCalledWith(14333, 'pplns');
});
it('should pause listeners when worker backpressure is high', () => {
@@ -105,6 +109,7 @@ describe('StratumV1Service', () => {
(service as any).listeners.push({
port: 3333,
secure: false,
payoutMode: 'solo',
server: { close },
paused: false
});
+25 -14
View File
@@ -16,6 +16,7 @@ import { NotificationService } from './notification.service';
import { RedisMessagingService } from './redis-messaging.service';
import { StratumV1JobsService } from './stratum-v1-jobs.service';
import { StratumV2Service } from './stratum-v2.service';
import { parsePayoutModePorts, PayoutMode } from '../types/payout-mode';
import { readFileSync } from 'fs';
import { TlsOptions, TLSSocket, createServer } from 'tls';
@@ -24,6 +25,7 @@ import * as path from 'path';
interface StratumListenerState {
port: number;
secure: boolean;
payoutMode: PayoutMode;
server: Server | null;
paused: boolean;
}
@@ -87,12 +89,18 @@ export class StratumV1Service implements OnModuleInit {
// wait for all the other processes to init for an even connection distribution
setTimeout(() => {
process.env.STRATUM_PORTS.split(',').forEach(port => {
this.startSocketServer(parseInt(port));
parsePayoutModePorts(
process.env.STRATUM_PORTS,
process.env.PPLNS_STRATUM_PORTS,
).forEach(({ port, payoutMode }) => {
this.startSocketServer(port, payoutMode);
});
if (process.env.STRATUM_SECURE?.toLowerCase() === 'true') {
process.env.SECURE_STRATUM_PORTS.split(',').forEach(port => {
this.startSecureSocketServer(parseInt(port));
parsePayoutModePorts(
process.env.SECURE_STRATUM_PORTS,
process.env.PPLNS_SECURE_STRATUM_PORTS,
).forEach(({ port, payoutMode }) => {
this.startSecureSocketServer(port, payoutMode);
});
}
}, (10000));
@@ -109,10 +117,11 @@ export class StratumV1Service implements OnModuleInit {
}
private startSocketServer(port: number) {
private startSocketServer(port: number, payoutMode: PayoutMode) {
const listener: StratumListenerState = {
port,
secure: false,
payoutMode,
server: null,
paused: false
};
@@ -120,7 +129,7 @@ export class StratumV1Service implements OnModuleInit {
this.listen(listener);
}
private createSocketServer(): Server {
private createSocketServer(payoutMode: PayoutMode): Server {
const server = new Server(async (socket: Socket) => {
socket.setTimeout(this.getSocketTimeoutMs());
socket.setKeepAlive(true, this.getTcpKeepAliveInitialDelayMs());
@@ -188,14 +197,14 @@ export class StratumV1Service implements OnModuleInit {
try {
protocol = this.detectProtocol(firstChunk);
if (protocol === 'v1') {
client = this.createV1Client(socket, 'sv1');
client = this.createV1Client(socket, 'sv1', payoutMode);
socket.emit('data', firstChunk);
return;
}
if (protocol === 'v2') {
await this.stratumV2Service.ensureInitialized();
client = this.stratumV2Service.createClient(socket, firstChunk);
client = this.stratumV2Service.createClient(socket, firstChunk, payoutMode);
return;
}
@@ -220,7 +229,7 @@ export class StratumV1Service implements OnModuleInit {
return server;
}
private createV1Client(socket: Socket, accountingProtocol: 'sv1' | 'sv1_tls'): StratumV1Client {
private createV1Client(socket: Socket, accountingProtocol: 'sv1' | 'sv1_tls', payoutMode: PayoutMode): StratumV1Client {
return new StratumV1Client(
socket,
this.stratumV1JobsService,
@@ -234,13 +243,15 @@ export class StratumV1Service implements OnModuleInit {
this.redisMessagingService,
this.payoutSnapshotService,
accountingProtocol,
payoutMode,
);
}
private startSecureSocketServer(port: number) {
private startSecureSocketServer(port: number, payoutMode: PayoutMode) {
const listener: StratumListenerState = {
port,
secure: true,
payoutMode,
server: null,
paused: false
};
@@ -248,7 +259,7 @@ export class StratumV1Service implements OnModuleInit {
this.listen(listener);
}
private createSecureSocketServer(): Server {
private createSecureSocketServer(payoutMode: PayoutMode): Server {
const currentDirectory = process.cwd();
const keyPath = path.join(currentDirectory, 'secrets', 'key.pem');
@@ -264,7 +275,7 @@ export class StratumV1Service implements OnModuleInit {
socket.setTimeout(this.getSocketTimeoutMs());
socket.setKeepAlive(true, this.getTcpKeepAliveInitialDelayMs());
const client = this.createV1Client(socket, 'sv1_tls');
const client = this.createV1Client(socket, 'sv1_tls', payoutMode);
let cleanedUp = false;
const cleanup = async (reason: string) => {
@@ -329,12 +340,12 @@ export class StratumV1Service implements OnModuleInit {
return;
}
const server = listener.secure ? this.createSecureSocketServer() : this.createSocketServer();
const server = listener.secure ? this.createSecureSocketServer(listener.payoutMode) : this.createSocketServer(listener.payoutMode);
listener.server = server;
listener.paused = false;
server.listen(listener.port, () => {
console.log(`${listener.secure ? 'Stratum TLS' : 'Stratum'} server is listening on port ${listener.port}`);
console.log(`${listener.secure ? 'Stratum TLS' : 'Stratum'} ${listener.payoutMode} server is listening on port ${listener.port}`);
});
}
+12 -17
View File
@@ -24,6 +24,7 @@ import { NotificationService } from './notification.service';
import { RedisMessagingService } from './redis-messaging.service';
import { StratumV1JobsService } from './stratum-v1-jobs.service';
import { Sv2JobDeclarationRegistryService } from './sv2-job-declaration-registry.service';
import { parsePayoutModePorts, PayoutMode } from '../types/payout-mode';
const DEFAULT_SOCKET_TIMEOUT_MS = 1000 * 60 * 60;
const DEFAULT_TCP_KEEPALIVE_INITIAL_DELAY_MS = 1000 * 60;
@@ -70,7 +71,7 @@ export class StratumV2Service implements OnModuleInit {
}
await this.ensureInitialized();
ports.forEach(port => this.startSocketServer(port));
ports.forEach(({ port, payoutMode }) => this.startSocketServer(port, payoutMode));
}
public async ensureInitialized(): Promise<void> {
@@ -81,7 +82,7 @@ export class StratumV2Service implements OnModuleInit {
await this.initializeNoiseConfig();
}
public createClient(socket: Socket, firstChunk: Buffer): StratumV2Client {
public createClient(socket: Socket, firstChunk: Buffer, payoutMode: PayoutMode = 'solo'): StratumV2Client {
if (this.noiseConfig == null) {
throw new Error('Stratum V2 service is not initialized');
}
@@ -102,6 +103,7 @@ export class StratumV2Service implements OnModuleInit {
this.shareAccountingService,
this.redisMessagingService,
this.payoutSnapshotService,
payoutMode,
);
}
@@ -170,21 +172,14 @@ export class StratumV2Service implements OnModuleInit {
};
}
private getPorts(): number[] {
const configuredPorts = this.configService.get<string>('STRATUM_V2_PORTS');
if (!configuredPorts?.trim()) {
return [];
}
const ports = configuredPorts
.split(',')
.map(port => parseInt(port.trim(), 10))
.filter(port => Number.isInteger(port) && port > 0 && port <= 65535);
return Array.from(new Set(ports));
private getPorts(): { port: number; payoutMode: PayoutMode }[] {
return parsePayoutModePorts(
this.configService.get<string>('STRATUM_V2_PORTS'),
this.configService.get<string>('PPLNS_STRATUM_V2_PORTS'),
);
}
private startSocketServer(port: number): void {
private startSocketServer(port: number, payoutMode: PayoutMode): void {
const server = new Server((socket: Socket) => {
socket.setTimeout(this.getSocketTimeoutMs());
socket.setKeepAlive(true, this.getTcpKeepAliveInitialDelayMs());
@@ -202,7 +197,7 @@ export class StratumV2Service implements OnModuleInit {
};
socket.once('data', (firstChunk: Buffer) => {
client = this.createClient(socket, firstChunk);
client = this.createClient(socket, firstChunk, payoutMode);
});
socket.on('timeout', closeSocket);
@@ -224,7 +219,7 @@ export class StratumV2Service implements OnModuleInit {
});
server.listen(port, () => {
console.log(`Stratum V2 server is listening on port ${port}`);
console.log(`Stratum V2 ${payoutMode} server is listening on port ${port}`);
});
this.servers.push(server);
}
@@ -3,9 +3,11 @@ import * as bitcoinjs from 'bitcoinjs-lib';
import { BufferReader } from '../models/sv2/sv2-binary-codec';
import { Sv2JdpSetupFlags, Sv2MsgType, Sv2Protocol } from '../models/sv2/sv2-constants';
import {
deserializeAllocateMiningJobTokenSuccess,
deserializeDeclareMiningJobError,
deserializeDeclareMiningJobSuccess,
deserializeProvideMissingTransactions,
serializeAllocateMiningJobToken,
serializeDeclareMiningJob,
serializeProvideMissingTransactionsSuccess,
serializePushSolution,
@@ -60,6 +62,43 @@ describe('Sv2JobDeclarationConnection compliance', () => {
expect(registry.declareJob).not.toHaveBeenCalled();
});
it('allocates a zero-value JDP token output while fixed payouts remain in the template', async () => {
const registry = new Sv2JobDeclarationRegistryService();
const payoutOutputs = [
{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 391 },
{ address: 'tb1q9r8gvnx3j4d6jvl0fqjrmy3dar4k4l3052af7q', amountSats: 110 },
{ address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2', amountSats: 95 },
];
const { connection, sentFrames } = createConnection({
registry,
templateProvider: {
getLatestTemplate: jest.fn().mockReturnValue({
jobTemplate: {
blockData: {
coinbasevalue: 596,
payoutOutputs,
},
},
}),
},
});
await (connection as any).handleAllocateMiningJobToken(serializeAllocateMiningJobToken({
requestId: 7,
userIdentifier: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2.sri-jdc',
}));
const successFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.JDP_ALLOCATE_MINING_JOB_TOKEN_SUCCESS);
const success = deserializeAllocateMiningJobTokenSuccess(new BufferReader(successFrame.payload));
expect(success.requestId).toBe(7);
const tokenOutputs = readSerializedTxOutputs(success.coinbaseOutputs);
expect(tokenOutputs).toHaveLength(1);
expect(tokenOutputs[0].value).toBe(0);
expect(bitcoinjs.address.fromOutputScript(tokenOutputs[0].script, bitcoinjs.networks.testnet))
.toBe('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4');
});
it('accepts DeclareMiningJob when all wtxids are known to the template provider', async () => {
const registry = new Sv2JobDeclarationRegistryService();
const allocated = registry.allocateToken('bc1ptest.worker', Buffer.alloc(0));
@@ -208,6 +247,7 @@ describe('Sv2JobDeclarationConnection compliance', () => {
blocksService,
payoutSnapshotService,
notificationService,
payoutMode: 'pplns',
});
(connection as any).latestDeclaredJob = {
token: Buffer.from('0123456789abcdef0123456789abcdef', 'hex'),
@@ -241,6 +281,7 @@ describe('Sv2JobDeclarationConnection compliance', () => {
payoutSnapshotId: '17',
blockHeight: 4990255,
blockSubmissionResult: null,
payoutMode: 'pplns',
});
expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalledWith(
'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
@@ -283,6 +324,7 @@ describe('Sv2JobDeclarationConnection compliance', () => {
blocksService,
payoutSnapshotService,
notificationService,
payoutMode: 'pplns',
});
(connection as any).latestDeclaredJob = {
token: Buffer.from('0123456789abcdef0123456789abcdef', 'hex'),
@@ -409,6 +451,7 @@ describe('Sv2JobDeclarationConnection compliance', () => {
blocksService?: any;
payoutSnapshotService?: any;
notificationService?: any;
payoutMode?: 'solo' | 'pplns';
} = {}): {
connection: Sv2JobDeclarationConnection;
sentFrames: any[];
@@ -428,6 +471,24 @@ describe('Sv2JobDeclarationConnection compliance', () => {
declareJob: jest.fn(),
};
const templateProvider = {
getLatestTemplate: jest.fn().mockReturnValue(undefined),
buildCoinbasePayoutOutputs: jest.fn((payoutInformation, coinbaseValue, network) => {
let balance = BigInt(coinbaseValue);
const outputs = payoutInformation.map(recipient => {
const value = recipient.amountSats == null
? BigInt(Math.floor(((recipient.percent ?? 0) / 100) * Number(coinbaseValue)))
: BigInt(recipient.amountSats);
balance -= value;
return {
value,
scriptPubKey: bitcoinjs.address.toOutputScript(recipient.address, network),
};
});
if (outputs.length > 0 && balance !== 0n) {
outputs[0] = { ...outputs[0], value: outputs[0].value + balance };
}
return outputs;
}),
validateDeclaredWtxids: jest.fn().mockReturnValue({ valid: false, errorCode: 'template-not-found' }),
validateProvidedTransactions: jest.fn().mockReturnValue({ valid: false, errorCode: 'template-not-found' }),
validateCoinbaseTransactionHeight: jest.fn().mockReturnValue({ valid: true }),
@@ -450,7 +511,9 @@ describe('Sv2JobDeclarationConnection compliance', () => {
}),
} as any,
registry as any,
{} as any,
{
encodeBitcoinVarInt: encodeBitcoinVarIntForTest,
} as any,
templateProvider,
overrides.bitcoinRpcService ?? {
SUBMIT_BLOCK: jest.fn().mockResolvedValue(null),
@@ -466,6 +529,7 @@ describe('Sv2JobDeclarationConnection compliance', () => {
},
'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
bitcoinjs.networks.testnet,
overrides.payoutMode ?? 'solo',
);
const sentFrames: any[] = [];
(connection as any).sendFrame = jest.fn((msgType: number, payload: Buffer) => {
@@ -500,4 +564,28 @@ describe('Sv2JobDeclarationConnection compliance', () => {
}],
};
}
function readSerializedTxOutputs(outputs: Buffer): bitcoinjs.TxOutput[] {
const tx = bitcoinjs.Transaction.fromBuffer(Buffer.concat([
Buffer.from('0200000001', 'hex'),
Buffer.alloc(32),
Buffer.from('ffffffff00ffffffff', 'hex'),
outputs,
Buffer.from('00000000', 'hex'),
]));
return tx.outs;
}
function encodeBitcoinVarIntForTest(value: number): Buffer {
if (value < 0xfd) {
return Buffer.from([value]);
}
if (value <= 0xffff) {
const result = Buffer.alloc(3);
result[0] = 0xfd;
result.writeUInt16LE(value, 1);
return result;
}
throw new RangeError(`Unsupported test varint ${value}`);
}
});
+49 -26
View File
@@ -31,6 +31,7 @@ import { NotificationService } from './notification.service';
import { StratumV2Service } from './stratum-v2.service';
import { Sv2DeclaredMiningJob, Sv2JobDeclarationRegistryService } from './sv2-job-declaration-registry.service';
import { TemplateProviderService } from './template-provider.service';
import { parsePayoutModePorts, PayoutMode } from '../types/payout-mode';
@Injectable()
export class Sv2JobDeclarationService implements OnModuleInit {
@@ -59,12 +60,12 @@ export class Sv2JobDeclarationService implements OnModuleInit {
}
await this.stratumV2Service.ensureInitialized();
for (const port of ports) {
this.startServer(port);
for (const { port, payoutMode } of ports) {
this.startServer(port, payoutMode);
}
}
private startServer(port: number): void {
private startServer(port: number, payoutMode: PayoutMode): void {
const server = new Server(socket => {
void new Sv2JobDeclarationConnection(
socket,
@@ -78,10 +79,11 @@ export class Sv2JobDeclarationService implements OnModuleInit {
this.notificationService,
this.getPoolPayoutAddress(),
this.getNetwork(),
payoutMode,
).start();
});
server.on('error', error => console.error(`SV2 JDP server error on port ${port}: ${error.message}`));
server.listen(port, () => console.log(`SV2 Job Declaration server is listening on port ${port}`));
server.listen(port, () => console.log(`SV2 Job Declaration ${payoutMode} server is listening on port ${port}`));
this.servers.push(server);
}
@@ -93,15 +95,11 @@ export class Sv2JobDeclarationService implements OnModuleInit {
|| '';
}
private getPorts(): number[] {
const configured = this.configService.get<string>('SV2_JDP_PORTS');
if (!configured?.trim()) {
return [];
}
return Array.from(new Set(configured
.split(',')
.map(port => parseInt(port.trim(), 10))
.filter(port => Number.isInteger(port) && port > 0 && port <= 65535)));
private getPorts(): { port: number; payoutMode: PayoutMode }[] {
return parsePayoutModePorts(
this.configService.get<string>('SV2_JDP_PORTS'),
this.configService.get<string>('PPLNS_SV2_JDP_PORTS'),
);
}
private getNetwork(): bitcoinjs.networks.Network {
@@ -148,6 +146,7 @@ export class Sv2JobDeclarationConnection {
private readonly notificationService: NotificationService,
private readonly poolPayoutAddress: string,
private readonly network: bitcoinjs.networks.Network,
private readonly payoutMode: PayoutMode,
) {
this.noiseSession = new Sv2NoiseSession(stratumV2Service.getNoiseConfig());
}
@@ -239,8 +238,8 @@ export class Sv2JobDeclarationConnection {
private async handleAllocateMiningJobToken(payload: Buffer): Promise<void> {
const request = deserializeAllocateMiningJobToken(new BufferReader(payload));
const token = this.registry.allocateToken(
request.userIdentifier,
const token = this.registry.allocateToken(
request.userIdentifier,
this.buildPoolCoinbaseOutputs(request.userIdentifier),
);
await this.sendFrame(Sv2MsgType.JDP_ALLOCATE_MINING_JOB_TOKEN_SUCCESS, serializeAllocateMiningJobTokenSuccess({
@@ -413,13 +412,19 @@ export class Sv2JobDeclarationConnection {
sessionId: declared.token.toString('hex').slice(0, 8),
blockData: blockHex,
blockSubmissionResult: result,
payoutSnapshotId: template.jobTemplate.blockData.payoutSnapshotId ?? null,
});
await this.payoutSnapshotService.finalizeSnapshotForBlock({
payoutSnapshotId: template.jobTemplate.blockData.payoutSnapshotId,
blockHeight: template.height,
blockSubmissionResult: result,
payoutSnapshotId: this.payoutMode === 'pplns'
? template.jobTemplate.blockData.payoutSnapshotId ?? null
: null,
payoutMode: this.payoutMode,
});
if (this.payoutMode === 'pplns') {
await this.payoutSnapshotService.finalizeSnapshotForBlock({
payoutSnapshotId: template.jobTemplate.blockData.payoutSnapshotId,
blockHeight: template.height,
blockSubmissionResult: result,
payoutMode: this.payoutMode,
});
}
await this.notificationService.notifySubscribersBlockFound(address, template.height, block, result);
console.log(`[SV2 JDP] PushSolution submitted block at height ${template.height}: ${result ?? 'accepted'}`);
} catch (error) {
@@ -428,14 +433,32 @@ export class Sv2JobDeclarationConnection {
}
private buildPoolCoinbaseOutputs(userIdentifier: string): Buffer {
const snapshotOutputs = this.payoutMode === 'pplns'
? this.templateProvider.getLatestTemplate()?.jobTemplate.blockData.payoutOutputs ?? []
: [];
if (snapshotOutputs.length > 0) {
return this.serializeCoinbaseOutputs(snapshotOutputs.map(output => ({
value: 0n,
scriptPubKey: bitcoinjs.address.toOutputScript(output.address, this.network),
})));
}
const address = this.poolPayoutAddress || userIdentifier.split('.')[0];
const script = bitcoinjs.address.toOutputScript(address, this.network);
const value = Buffer.alloc(8);
return this.serializeCoinbaseOutputs([{ value: 0n, scriptPubKey: script }]);
}
private serializeCoinbaseOutputs(outputs: Array<{ value: bigint; scriptPubKey: Buffer }>): Buffer {
return Buffer.concat([
this.customWorkService.encodeBitcoinVarInt(1),
value,
this.customWorkService.encodeBitcoinVarInt(script.length),
script,
this.customWorkService.encodeBitcoinVarInt(outputs.length),
...outputs.flatMap(output => {
const value = Buffer.alloc(8);
value.writeBigUInt64LE(output.value, 0);
return [
value,
this.customWorkService.encodeBitcoinVarInt(output.scriptPubKey.length),
output.scriptPubKey,
];
}),
]);
}
@@ -3,7 +3,6 @@ import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { BufferReader } from '../models/sv2/sv2-binary-codec';
import { MiningJob } from '../models/MiningJob';
import { Sv2MsgType, Sv2Protocol } from '../models/sv2/sv2-constants';
import {
deserializeSetupConnectionSuccess,
@@ -56,20 +55,23 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
expect(sentFrames.some(frame => frame.msgType === Sv2MsgType.TDP_SET_NEW_PREV_HASH)).toBe(true);
const templateFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.TDP_NEW_TEMPLATE);
const template = deserializeTdpNewTemplate(new BufferReader(templateFrame.payload));
const expectedJob = new MiningJob(
const heightPrefix = templateProvider.buildCoinbaseHeightPrefix(MockRecording1.BLOCK_TEMPLATE.height);
expect(template.coinbasePrefix).toEqual(heightPrefix);
expect(template.coinbasePrefix.subarray(0, 4)).not.toEqual(Buffer.from('02000000', 'hex'));
expect(template.coinbaseTxVersion).toBe(2);
expect(template.coinbaseTxInputSequence).toBe(0xffffffff);
expect(template.coinbaseTxValueRemaining).toBe(0n);
expect(template.coinbaseTxOutputsCount).toBe(2);
expect(template.coinbaseTxOutputs).toEqual((connection as any).serializeFixedCoinbaseOutputs(jobTemplate).serialized);
expect(template.coinbaseTxOutputs.includes(bitcoinjs.address.toOutputScript(
'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
bitcoinjs.networks.testnet,
jobTemplate.blockData.id,
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
jobTemplate,
);
const expectedCoinbaseTx = expectedJob.cloneCoinbaseTransaction();
expect(template.coinbasePrefix).toEqual(expectedJob.getCoinbasePrefixBuffer());
expect(template.coinbasePrefix.includes(templateProvider.buildCoinbaseHeightPrefix(MockRecording1.BLOCK_TEMPLATE.height))).toBe(true);
expect(template.coinbaseTxVersion).toBe(expectedCoinbaseTx.version);
expect(template.coinbaseTxInputSequence).toBe(expectedCoinbaseTx.ins[0].sequence);
expect(template.coinbaseTxOutputsCount).toBe(expectedCoinbaseTx.outs.length);
expect(template.coinbaseTxOutputs).toEqual((connection as any).serializeCoinbaseOutputs(expectedCoinbaseTx));
expect(template.coinbaseTxLocktime).toBe(expectedCoinbaseTx.locktime);
))).toBe(true);
expect(template.coinbaseTxOutputs.includes(Buffer.concat([
Buffer.from('aa21a9ed', 'hex'),
jobTemplate.block.witnessCommit,
]))).toBe(true);
expect(template.coinbaseTxLocktime).toBe(0);
});
it('serves transaction data for known templates', async () => {
@@ -88,7 +90,7 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
});
it('submits reconstructed blocks from SubmitSolution', async () => {
const { connection, templateProvider, jobTemplate, bitcoinRpcService, blocksService, payoutSnapshotService, notificationService } = await createConnection();
const { connection, templateProvider, jobTemplate, bitcoinRpcService, blocksService, payoutSnapshotService, notificationService } = await createConnection('pplns');
const template = templateProvider.upsert(jobTemplate);
const callOrder: string[] = [];
bitcoinRpcService.SUBMIT_BLOCK.mockImplementation(async () => {
@@ -98,7 +100,10 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
blocksService.save.mockImplementation(async () => {
callOrder.push('save');
});
const coinbaseTx = createCoinbaseTransaction(templateProvider.buildCoinbaseHeightPrefix(template.height));
const coinbaseTx = createPayoutCoinbaseTransaction(
templateProvider.buildCoinbaseHeightPrefix(template.height),
jobTemplate.blockData.coinbasevalue,
);
await (connection as any).handleFrame(
Sv2MsgType.TDP_SUBMIT_SOLUTION,
@@ -123,6 +128,7 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId,
blockHeight: MockRecording1.BLOCK_TEMPLATE.height,
blockSubmissionResult: null,
payoutMode: 'pplns',
});
expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalledWith(
'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
@@ -135,7 +141,10 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
it('does not resubmit duplicate SubmitSolution messages', async () => {
const { connection, templateProvider, jobTemplate, bitcoinRpcService } = await createConnection();
const template = templateProvider.upsert(jobTemplate);
const coinbaseTx = createCoinbaseTransaction(templateProvider.buildCoinbaseHeightPrefix(template.height));
const coinbaseTx = createPayoutCoinbaseTransaction(
templateProvider.buildCoinbaseHeightPrefix(template.height),
jobTemplate.blockData.coinbasevalue,
);
const payload = serializeTdpSubmitSolution({
templateId: template.templateId,
version: template.version,
@@ -173,13 +182,44 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
warn.mockRestore();
});
it('rejects SubmitSolution locally when the coinbase does not pay the pool outputs', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
const { connection, templateProvider, jobTemplate, bitcoinRpcService, blocksService } = await createConnection();
const template = templateProvider.upsert(jobTemplate);
const coinbaseTx = createPayoutCoinbaseTransaction(
templateProvider.buildCoinbaseHeightPrefix(template.height),
jobTemplate.blockData.coinbasevalue,
'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2',
);
await (connection as any).handleFrame(
Sv2MsgType.TDP_SUBMIT_SOLUTION,
serializeTdpSubmitSolution({
templateId: template.templateId,
version: template.version,
headerTimestamp: template.minNtime,
headerNonce: 123,
coinbaseTx: coinbaseTx.toBuffer(),
}),
);
expect(bitcoinRpcService.SUBMIT_BLOCK).not.toHaveBeenCalled();
expect(blocksService.save).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('coinbase-payout-output-mismatch'));
warn.mockRestore();
});
it('submits using a served template snapshot after provider cache cleanup', async () => {
const { connection, sentFrames, templateProvider, jobTemplate, bitcoinRpcService, blocksService } = await createConnection();
await (connection as any).sendTemplate(jobTemplate);
const templateId = BigInt(parseInt(jobTemplate.blockData.id, 16));
jest.spyOn(templateProvider, 'getTemplate').mockReturnValue(undefined);
sentFrames.length = 0;
const coinbaseTx = createCoinbaseTransaction(templateProvider.buildCoinbaseHeightPrefix(jobTemplate.blockData.height));
const coinbaseTx = createPayoutCoinbaseTransaction(
templateProvider.buildCoinbaseHeightPrefix(jobTemplate.blockData.height),
jobTemplate.blockData.coinbasevalue,
);
await (connection as any).handleFrame(
Sv2MsgType.TDP_SUBMIT_SOLUTION,
@@ -200,7 +240,7 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
expect(sentFrames.some(frame => frame.msgType === Sv2MsgType.TDP_REQUEST_TRANSACTION_DATA_ERROR)).toBe(false);
});
async function createConnection(): Promise<{
async function createConnection(payoutMode: 'solo' | 'pplns' = 'solo'): Promise<{
connection: Sv2TemplateDistributionConnection;
sentFrames: any[];
templateProvider: TemplateProviderService;
@@ -262,6 +302,7 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
notificationService as any,
'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
bitcoinjs.networks.testnet,
payoutMode,
);
const sentFrames: any[] = [];
(connection as any).sendFrame = jest.fn((msgType: number, payload: Buffer) => {
@@ -287,4 +328,19 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
tx.addOutput(Buffer.from('6a', 'hex'), 0);
return tx;
}
function createPayoutCoinbaseTransaction(
script: Buffer,
coinbaseValue: number,
address = 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
): bitcoinjs.Transaction {
const tx = new bitcoinjs.Transaction();
tx.version = 2;
tx.addInput(Buffer.alloc(32), 0xffffffff, 0xffffffff, script);
tx.addOutput(
bitcoinjs.address.toOutputScript(address, bitcoinjs.networks.testnet),
coinbaseValue,
);
return tx;
}
});
@@ -14,7 +14,6 @@ import {
serializeSetupConnectionError,
serializeSetupConnectionSuccess,
} from '../models/sv2/sv2-messages';
import { AddressObject, MiningJob } from '../models/MiningJob';
import { Sv2NoiseSession } from '../models/sv2/sv2-noise';
import {
deserializeTdpCoinbaseOutputConstraints,
@@ -30,6 +29,8 @@ import { NotificationService } from './notification.service';
import { IJobTemplate, StratumV1JobsService } from './stratum-v1-jobs.service';
import { StratumV2Service } from './stratum-v2.service';
import { TemplateProviderService, TemplateProviderTemplate } from './template-provider.service';
import { AddressObject } from '../models/MiningJob';
import { parsePayoutModePorts, PayoutMode } from '../types/payout-mode';
interface TdpCoinbaseTemplateFields {
coinbaseTxVersion: number;
@@ -67,12 +68,12 @@ export class Sv2TemplateDistributionService implements OnModuleInit {
}
await this.stratumV2Service.ensureInitialized();
for (const port of ports) {
this.startServer(port);
for (const { port, payoutMode } of ports) {
this.startServer(port, payoutMode);
}
}
private startServer(port: number): void {
private startServer(port: number, payoutMode: PayoutMode): void {
const server = new Server(socket => {
void new Sv2TemplateDistributionConnection(
socket,
@@ -85,22 +86,19 @@ export class Sv2TemplateDistributionService implements OnModuleInit {
this.notificationService,
this.getPoolPayoutAddress(),
this.getNetwork(),
payoutMode,
).start();
});
server.on('error', error => console.error(`SV2 TDP server error on port ${port}: ${error.message}`));
server.listen(port, () => console.log(`SV2 Template Distribution server is listening on port ${port}`));
server.listen(port, () => console.log(`SV2 Template Distribution ${payoutMode} server is listening on port ${port}`));
this.servers.push(server);
}
private getPorts(): number[] {
const configured = this.configService.get<string>('SV2_TDP_PORTS');
if (!configured?.trim()) {
return [];
}
return Array.from(new Set(configured
.split(',')
.map(port => parseInt(port.trim(), 10))
.filter(port => Number.isInteger(port) && port > 0 && port <= 65535)));
private getPorts(): { port: number; payoutMode: PayoutMode }[] {
return parsePayoutModePorts(
this.configService.get<string>('SV2_TDP_PORTS'),
this.configService.get<string>('PPLNS_SV2_TDP_PORTS'),
);
}
private getPoolPayoutAddress(): string {
@@ -149,6 +147,7 @@ export class Sv2TemplateDistributionConnection {
private readonly notificationService: NotificationService,
private readonly poolPayoutAddress: string,
private readonly network: bitcoinjs.networks.Network,
private readonly payoutMode: PayoutMode,
) {
this.noiseSession = new Sv2NoiseSession(stratumV2Service.getNoiseConfig());
}
@@ -272,43 +271,65 @@ export class Sv2TemplateDistributionConnection {
}
private buildCoinbaseTemplateFields(template: IJobTemplate): TdpCoinbaseTemplateFields {
const job = new MiningJob(
this.network,
template.blockData.id,
this.getPayoutInformation(template),
template,
);
const coinbaseTx = job.cloneCoinbaseTransaction();
const coinbaseTxOutputs = this.serializeFixedCoinbaseOutputs(template);
const spendableValue = coinbaseTxOutputs.spendableValue;
return {
coinbaseTxVersion: coinbaseTx.version,
coinbasePrefix: job.getCoinbasePrefixBuffer(),
coinbaseTxInputSequence: coinbaseTx.ins[0]?.sequence ?? 0xffffffff,
coinbaseTxValueRemaining: BigInt(template.blockData.coinbasevalue),
coinbaseTxOutputsCount: coinbaseTx.outs.length,
coinbaseTxOutputs: this.serializeCoinbaseOutputs(coinbaseTx),
coinbaseTxLocktime: coinbaseTx.locktime,
coinbaseTxVersion: 2,
coinbasePrefix: this.templateProvider.buildCoinbaseHeightPrefix(template.blockData.height),
coinbaseTxInputSequence: 0xffffffff,
coinbaseTxValueRemaining: BigInt(template.blockData.coinbasevalue) - spendableValue,
coinbaseTxOutputsCount: coinbaseTxOutputs.count,
coinbaseTxOutputs: coinbaseTxOutputs.serialized,
coinbaseTxLocktime: 0,
};
}
private serializeFixedCoinbaseOutputs(template: IJobTemplate): { count: number; serialized: Buffer; spendableValue: bigint } {
const payoutOutputs = this.templateProvider.buildCoinbasePayoutOutputs(
this.getPayoutInformation(template),
template.blockData.coinbasevalue,
this.network,
);
const outputs: { value: bigint; script: Buffer }[] = payoutOutputs.map(output => ({
value: output.value,
script: output.scriptPubKey,
}));
const spendableValue = payoutOutputs.reduce((sum, output) => sum + output.value, 0n);
if (template.block.witnessCommit != null) {
const segwitMagicBits = Buffer.from('aa21a9ed', 'hex');
outputs.push({
value: 0n,
script: bitcoinjs.script.compile([
bitcoinjs.opcodes.OP_RETURN,
Buffer.concat([segwitMagicBits, template.block.witnessCommit]),
]),
});
}
return {
count: outputs.length,
serialized: Buffer.concat(outputs.map(output => this.serializeCoinbaseOutput(output.value, output.script))),
spendableValue,
};
}
private getPayoutInformation(template: IJobTemplate): AddressObject[] {
if (template.blockData.payoutOutputs?.length > 0) {
if (this.payoutMode === 'pplns' && template.blockData.payoutOutputs?.length > 0) {
return template.blockData.payoutOutputs;
}
return [{ address: this.poolPayoutAddress, percent: 100 }];
}
private serializeCoinbaseOutputs(coinbaseTx: bitcoinjs.Transaction): Buffer {
return Buffer.concat(coinbaseTx.outs.map(output => {
const value = Buffer.alloc(8);
value.writeBigUInt64LE(BigInt(output.value), 0);
return Buffer.concat([
value,
this.encodeBitcoinVarInt(output.script.length),
Buffer.from(output.script),
]);
}));
private serializeCoinbaseOutput(valueSats: bigint, script: Buffer): Buffer {
const value = Buffer.alloc(8);
value.writeBigUInt64LE(valueSats, 0);
return Buffer.concat([
value,
this.encodeBitcoinVarInt(script.length),
Buffer.from(script),
]);
}
private encodeBitcoinVarInt(value: number): Buffer {
@@ -369,6 +390,16 @@ export class Sv2TemplateDistributionConnection {
console.warn(`[SV2 TDP] SubmitSolution rejected locally: ${coinbaseValidation.errorCode}`);
return;
}
const payoutValidation = this.templateProvider.validateCoinbaseTransactionPayoutOutputs({
coinbaseTx: solution.coinbaseTx,
payoutInformation: this.getPayoutInformation(template.jobTemplate),
coinbaseValue: template.coinbaseValue,
network: this.network,
});
if (!payoutValidation.valid) {
console.warn(`[SV2 TDP] SubmitSolution rejected locally: ${payoutValidation.errorCode}`);
return;
}
const block = this.templateProvider.buildBlockFromSolution({
template,
coinbaseTx: solution.coinbaseTx,
@@ -390,13 +421,19 @@ export class Sv2TemplateDistributionConnection {
sessionId: solution.templateId.toString(16).slice(-8).padStart(8, '0'),
blockData: blockHex,
blockSubmissionResult: result,
payoutSnapshotId: template.jobTemplate.blockData.payoutSnapshotId ?? null,
});
await this.payoutSnapshotService.finalizeSnapshotForBlock({
payoutSnapshotId: template.jobTemplate.blockData.payoutSnapshotId,
blockHeight: template.height,
blockSubmissionResult: result,
payoutSnapshotId: this.payoutMode === 'pplns'
? template.jobTemplate.blockData.payoutSnapshotId ?? null
: null,
payoutMode: this.payoutMode,
});
if (this.payoutMode === 'pplns') {
await this.payoutSnapshotService.finalizeSnapshotForBlock({
payoutSnapshotId: template.jobTemplate.blockData.payoutSnapshotId,
blockHeight: template.height,
blockSubmissionResult: result,
payoutMode: this.payoutMode,
});
}
await this.notificationService.notifySubscribersBlockFound(
this.poolPayoutAddress || 'sv2-tdp',
template.height,
@@ -114,6 +114,49 @@ describe('TemplateProviderService', () => {
expect(validation.template).toBe(template);
});
it('accepts declared SV2 JDP script-only coinbase prefixes that start with the required BIP34 height', async () => {
const { provider, jobTemplate } = await createProvider();
const template = provider.upsert(jobTemplate);
const scriptOnlyPrefix = Buffer.concat([
provider.buildCoinbaseHeightPrefix(template.height),
Buffer.from('/public-pool-sri-jdc-e2e/', 'utf8'),
]);
const validation = provider.validateDeclaredWtxids({
version: MockRecording1.BLOCK_TEMPLATE.version,
coinbaseTxPrefix: scriptOnlyPrefix,
wtxidList: [],
});
expect(validation.valid).toBe(true);
expect(validation.template).toBe(template);
});
it('ignores zero-value extra outputs when validating coinbase payouts', async () => {
const { provider, jobTemplate } = await createProvider();
const coinbaseTx = createCoinbaseTransaction(provider.buildCoinbaseHeightPrefix(jobTemplate.blockData.height));
coinbaseTx.addOutput(
bitcoinjs.address.toOutputScript('tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2', bitcoinjs.networks.testnet),
0,
);
coinbaseTx.addOutput(
bitcoinjs.address.toOutputScript('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', bitcoinjs.networks.testnet),
jobTemplate.blockData.coinbasevalue,
);
const validation = provider.validateCoinbaseTransactionPayoutOutputs({
coinbaseTx: coinbaseTx.toBuffer(),
payoutInformation: [{
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
amountSats: jobTemplate.blockData.coinbasevalue,
}],
coinbaseValue: jobTemplate.blockData.coinbasevalue,
network: bitcoinjs.networks.testnet,
});
expect(validation.valid).toBe(true);
});
it('accepts DATUM template metadata matching the latest template fast path', async () => {
const { provider, jobTemplate } = await createProvider();
const template = provider.upsert(jobTemplate);
+109 -1
View File
@@ -3,6 +3,7 @@ import * as bitcoinjs from 'bitcoinjs-lib';
import { Subscription } from 'rxjs';
import { IBlockTemplateTx } from '../models/bitcoin-rpc/IBlockTemplate';
import { AddressObject } from '../models/MiningJob';
import { Sv2DeclareMiningJob, Sv2PushSolution } from '../models/sv2/sv2-jdp-messages';
import { hash256 } from '../utils/hash.utils';
import { BitcoinRpcService } from './bitcoin-rpc.service';
@@ -76,6 +77,18 @@ export interface CoinbaseHeightValidationResult {
errorCode?: string;
}
export interface CoinbasePayoutOutput {
value: bigint;
scriptPubKey: Buffer;
}
export interface CoinbasePayoutValidationResult {
valid: boolean;
errorCode?: string;
expectedOutputs?: CoinbasePayoutOutput[];
submittedOutputs?: CoinbasePayoutOutput[];
}
const VERSION_ROLLING_MASK = 0x1fffe000;
@Injectable()
@@ -211,10 +224,98 @@ export class TemplateProviderService implements OnModuleInit {
return this.validateCoinbaseScriptHeight(tx.ins[0].script, height);
}
public buildCoinbasePayoutOutputs(
payoutInformation: AddressObject[],
coinbaseValue: number | bigint,
network: bitcoinjs.networks.Network,
): CoinbasePayoutOutput[] {
const rewardValue = typeof coinbaseValue === 'bigint' ? coinbaseValue : BigInt(Math.max(0, Math.floor(coinbaseValue)));
let rewardBalance = rewardValue;
const outputs = payoutInformation.map(recipientAddress => {
const value = recipientAddress.amountSats == null
? BigInt(Math.floor(((recipientAddress.percent ?? 0) / 100) * Number(rewardValue)))
: BigInt(recipientAddress.amountSats);
rewardBalance -= value;
return {
value,
scriptPubKey: bitcoinjs.address.toOutputScript(recipientAddress.address, network),
};
});
if (outputs.length > 0 && rewardBalance !== 0n) {
outputs[0] = {
...outputs[0],
value: outputs[0].value + rewardBalance,
};
}
return outputs;
}
public validateCoinbaseTransactionPayoutOutputs(input: {
coinbaseTx: Buffer;
payoutInformation: AddressObject[];
coinbaseValue: number | bigint;
network: bitcoinjs.networks.Network;
}): CoinbasePayoutValidationResult {
const expectedOutputs = this.buildCoinbasePayoutOutputs(
input.payoutInformation,
input.coinbaseValue,
input.network,
);
if (expectedOutputs.length === 0) {
return { valid: false, errorCode: 'missing-expected-coinbase-payouts', expectedOutputs, submittedOutputs: [] };
}
let tx: bitcoinjs.Transaction;
try {
tx = bitcoinjs.Transaction.fromBuffer(input.coinbaseTx);
} catch (error) {
return {
valid: false,
errorCode: `invalid-coinbase-transaction:${error instanceof Error ? error.message : String(error)}`,
expectedOutputs,
submittedOutputs: [],
};
}
const submittedOutputs = tx.outs
.filter(output => !this.isWitnessCommitmentOutput(output.script))
.filter(output => BigInt(output.value) !== 0n)
.map(output => ({
value: BigInt(output.value),
scriptPubKey: Buffer.from(output.script),
}));
if (submittedOutputs.length !== expectedOutputs.length) {
return {
valid: false,
errorCode: `coinbase-payout-output-count-mismatch:${expectedOutputs.length}:${submittedOutputs.length}`,
expectedOutputs,
submittedOutputs,
};
}
for (let i = 0; i < expectedOutputs.length; i++) {
const expected = expectedOutputs[i];
const submitted = submittedOutputs[i];
if (expected.value !== submitted.value || !expected.scriptPubKey.equals(submitted.scriptPubKey)) {
return {
valid: false,
errorCode: `coinbase-payout-output-mismatch:${i}`,
expectedOutputs,
submittedOutputs,
};
}
}
return { valid: true, expectedOutputs, submittedOutputs };
}
public validateDeclaredCoinbasePrefixHeight(coinbaseTxPrefix: Buffer, height: number): CoinbaseHeightValidationResult {
const scriptStart = this.getCoinbaseInputScriptStart(coinbaseTxPrefix);
if (scriptStart == null) {
return { valid: false, errorCode: 'invalid-job-param-value-coinbase_tx_prefix' };
return this.validateCoinbaseScriptHeight(coinbaseTxPrefix, height);
}
const expected = this.buildCoinbaseHeightPrefix(height);
if (coinbaseTxPrefix.length < scriptStart + expected.length) {
@@ -482,6 +583,13 @@ export class TemplateProviderService implements OnModuleInit {
return { valid: true };
}
private isWitnessCommitmentOutput(script: Buffer): boolean {
return script.length === 38
&& script[0] === bitcoinjs.opcodes.OP_RETURN
&& script[1] === 0x24
&& script.subarray(2, 6).equals(Buffer.from('aa21a9ed', 'hex'));
}
private getCoinbaseInputScriptStart(txPrefix: Buffer): number | null {
let cursor = 4;
if (txPrefix.length < cursor + 1) {
+22
View File
@@ -0,0 +1,22 @@
import { normalizePayoutMode, parsePayoutModePorts } from './payout-mode';
describe('payout mode helpers', () => {
it('maps legacy ports to solo and PPLNS ports to pplns', () => {
expect(parsePayoutModePorts('3333,3332', '13333')).toEqual([
{ port: 3333, payoutMode: 'solo' },
{ port: 3332, payoutMode: 'solo' },
{ port: 13333, payoutMode: 'pplns' },
]);
});
it('rejects a port configured for both payout modes', () => {
expect(() => parsePayoutModePorts('3333', '3333'))
.toThrow('Port 3333 is configured for both solo and pplns payout modes');
});
it('normalizes unknown mode values to solo', () => {
expect(normalizePayoutMode('pplns')).toBe('pplns');
expect(normalizePayoutMode('pool')).toBe('solo');
expect(normalizePayoutMode(undefined)).toBe('solo');
});
});
+44
View File
@@ -0,0 +1,44 @@
export type PayoutMode = 'solo' | 'pplns';
export interface PayoutModePort {
port: number;
payoutMode: PayoutMode;
}
export const DEFAULT_PAYOUT_MODE: PayoutMode = 'solo';
export function normalizePayoutMode(value: unknown): PayoutMode {
return value === 'pplns' ? 'pplns' : 'solo';
}
export function parsePortList(configured?: string | null): number[] {
if (!configured?.trim()) {
return [];
}
return configured
.split(',')
.map(port => parseInt(port.trim(), 10))
.filter(port => Number.isInteger(port) && port > 0 && port <= 65535);
}
export function parsePayoutModePorts(
soloPorts?: string | null,
pplnsPorts?: string | null,
): PayoutModePort[] {
const byPort = new Map<number, PayoutMode>();
const addPorts = (configured: string | null | undefined, payoutMode: PayoutMode) => {
for (const port of parsePortList(configured)) {
const existingMode = byPort.get(port);
if (existingMode != null && existingMode !== payoutMode) {
throw new Error(`Port ${port} is configured for both ${existingMode} and ${payoutMode} payout modes`);
}
byPort.set(port, payoutMode);
}
};
addPorts(soloPorts, 'solo');
addPorts(pplnsPorts, 'pplns');
return Array.from(byPort.entries()).map(([port, payoutMode]) => ({ port, payoutMode }));
}