mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 17:15:03 -07:00
Add payout modes and production switchover schema
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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!';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
Reference in New Issue
Block a user