mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 09:05:06 -07:00
Speed up template publication and share validation
This commit is contained in:
@@ -10,6 +10,12 @@ BITCOIN_RPC_PASSWORD=
|
||||
BITCOIN_RPC_PORT=8332
|
||||
BITCOIN_RPC_TIMEOUT=10000
|
||||
|
||||
# Keep an authoritative getblocktemplate longpoll outstanding so new-tip work
|
||||
# does not wait for the ZMQ -> RPC round trip. The normal ZMQ feed remains a watchdog.
|
||||
BLOCK_TEMPLATE_LONGPOLL_TIMEOUT_MS=600000
|
||||
BLOCK_TEMPLATE_RETRY_INITIAL_MS=25
|
||||
BLOCK_TEMPLATE_RETRY_MAX_MS=1000
|
||||
|
||||
# Enable in bitcoin.conf with:
|
||||
# zmqpubrawblock=tcp://0.0.0.0:3000
|
||||
BITCOIN_ZMQ_HOST=tcp://192.168.1.100:3000
|
||||
@@ -32,9 +38,28 @@ STRATUM_PORTS=3333,3332,3331,3330
|
||||
#PPLNS_STRATUM_PORTS=
|
||||
STRATUM_WORKERS=2
|
||||
STRATUM_WORKER_MAX_MEMORY_RESTART=4096M
|
||||
# High-byte base for pool-assigned SV2 extranonce prefixes. PM2 reserves two
|
||||
# values per NODE_APP_INSTANCE so an old/new reload pair cannot overlap. Keep
|
||||
# base + (2 * worker index) + reload parity <= 255. Separate pool containers
|
||||
# sharing work must use non-overlapping ranges of 2 * STRATUM_WORKERS values.
|
||||
SV2_EXTRANONCE_NAMESPACE_BASE=0
|
||||
STRATUM_MIN_DIFFICULTY=1
|
||||
STRATUM_SOCKET_TIMEOUT_MS=3600000
|
||||
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS=60000
|
||||
# Disconnect slow/non-reading SV1 clients before their per-socket write queue
|
||||
# can grow without bound. Includes bytes already buffered by Node.js.
|
||||
STRATUM_MAX_SOCKET_BUFFER_BYTES=262144
|
||||
# Immediately publish a consensus-valid, subsidy-only solo job before the full
|
||||
# transaction template. SV2 uses the same activation to select its pre-staged
|
||||
# future job; the full job then follows on the active tip.
|
||||
SV1_SUBSIDY_BRIDGE_ENABLED=true
|
||||
# Comma-separated: solo,pplns. PPLNS requires a fresh precomputed snapshot;
|
||||
# solo remains the safe default and is always published first when both are set.
|
||||
SV1_SUBSIDY_BRIDGE_PAYOUT_MODES=solo
|
||||
SV1_SUBSIDY_BRIDGE_PPLNS_SEED_MAX_AGE_MS=300000
|
||||
STRATUM_JOB_RETENTION_MS=300000
|
||||
STRATUM_SUBMISSION_DEDUP_TTL_MS=300000
|
||||
STRATUM_SUBMISSION_DEDUP_MAX_ENTRIES=10000
|
||||
|
||||
# Optional additional SV2-only mining ports. Usually unnecessary when STRATUM_PORTS are exposed.
|
||||
#STRATUM_V2_PORTS=
|
||||
@@ -43,6 +68,13 @@ STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS=60000
|
||||
#SV2_TARGET_SHARES_PER_MINUTE=2
|
||||
#SV2_DIFFICULTY_CHECK_INTERVAL_MS=60000
|
||||
#STRATUM_V2_SOCKET_TIMEOUT_MS=3600000
|
||||
# Per-client lifecycle bounds. Obsolete queued jobs are coalesced; clients are
|
||||
# disconnected instead of dropping still-recoverable candidates or buffering forever.
|
||||
SV2_JOB_RETENTION_MS=300000
|
||||
SV2_MAX_RETAINED_JOBS_PER_CHANNEL=16
|
||||
SV2_MAX_QUEUED_JOB_OPERATIONS=4
|
||||
SV2_MAX_SOCKET_BUFFER_BYTES=262144
|
||||
SV2_SOCKET_WRITE_TIMEOUT_MS=2000
|
||||
#SV2_AUTHORITY_PRIVKEY=
|
||||
#SV2_JDP_PORTS=
|
||||
#PPLNS_SV2_JDP_PORTS=
|
||||
|
||||
@@ -63,6 +63,61 @@ Cluster-mode connection dropping requires Node.js `22.12.0` or newer.
|
||||
Size it using the busiest port: `worker count * limit`. For example, 28 workers
|
||||
with the default limit of `10000` allow up to `280000` connections on one port.
|
||||
|
||||
SV2 pool-assigned extranonces reserve the first of their four prefix bytes for
|
||||
a process namespace and share the remaining 24-bit allocation space across
|
||||
standard and extended channels. Under PM2, the namespace uses
|
||||
`NODE_APP_INSTANCE` (falling back to `pm_id`) plus `restart_time` parity, which
|
||||
keeps workers and overlapping zero-downtime reload generations disjoint without
|
||||
changing the advertised prefix or total extranonce sizes. The effective value is
|
||||
`SV2_EXTRANONCE_NAMESPACE_BASE + 2 * worker + (restart_time % 2)` and must be at
|
||||
most `255` (up to 128 worker lanes at base zero). Startup fails if a PM2 worker
|
||||
identity is missing or the namespace cannot fit. Independent containers or PM2
|
||||
worker apps that share mining work must be assigned non-overlapping base ranges;
|
||||
reserve two namespace values per configured Stratum worker.
|
||||
|
||||
### New-block notification path
|
||||
|
||||
The master keeps an authoritative Bitcoin Core `getblocktemplate` longpoll open;
|
||||
rawblock ZMQ remains a watchdog and duplicate results are discarded. On a new tip,
|
||||
the master publishes a compact subsidy-only SV1 job before serializing the full
|
||||
transaction template. Workers fan that job out through one process-level socket
|
||||
broadcaster, then issue the full fee-paying job as a second clean switch. Payout
|
||||
snapshot creation and Postgres persistence run after the immediate solo publish.
|
||||
|
||||
SV2 solo channels pre-stage a native subsidy-only future job for the next height.
|
||||
When the authoritative header arrives, the pool activates that job with only
|
||||
`SetNewPrevHash`; the same-tip full job follows without a second prevhash switch.
|
||||
Standard and extended candidates retain exact header/body reconstruction, and
|
||||
late network-target candidates remain recoverable without crediting stale shares.
|
||||
Pending SV2 canonical jobs are coalesced per client, while a new-tip activation
|
||||
is moved ahead of any not-yet-started canonical work for that tip. The finite
|
||||
defaults are 16 retained jobs per channel, four queued operations, 256 KiB of
|
||||
outstanding socket writes, and a two-second write-callback deadline. A client is
|
||||
disconnected if `SV2_MAX_RETAINED_JOBS_PER_CHANNEL`,
|
||||
`SV2_MAX_QUEUED_JOB_OPERATIONS`, `SV2_MAX_SOCKET_BUFFER_BYTES`, or
|
||||
`SV2_SOCKET_WRITE_TIMEOUT_MS` is exceeded; `SV2_JOB_RETENTION_MS` controls how
|
||||
long stale network candidates remain reconstructable.
|
||||
|
||||
`SV1_SUBSIDY_BRIDGE_ENABLED=true` enables the solo bridge (the default). PPLNS is
|
||||
never allowed to fall back to a miner-address coinbase; optional PPLNS bridge
|
||||
support requires a precomputed subsidy-valued payout snapshot. Retained jobs are
|
||||
kept for `STRATUM_JOB_RETENTION_MS` so a late network-target candidate can still
|
||||
be reconstructed and submitted, while ordinary old-tip shares are rejected.
|
||||
PPLNS seeds use a non-active snapshot status and are skipped if the next
|
||||
authoritative `nBits` differs from their preparation basis.
|
||||
|
||||
The Redis protocol remains rolling-deploy compatible: new workers retain the
|
||||
legacy mining-info reload path, while the master writes the historical latest
|
||||
key as JSON only after a PPLNS-safe compatibility template is ready. Deploying
|
||||
workers before the master is still the preferred rollout order. When any PPLNS
|
||||
listener is configured and snapshot preparation fails, legacy workers are held
|
||||
on their prior job instead of being woken with a miner-address fallback job.
|
||||
|
||||
Two structured log events expose the end-to-end timing:
|
||||
|
||||
- `block_notification_trace` reports Core, bridge, Redis, PPLNS, and persistence stages, separated by payout mode and job type.
|
||||
- `stratum_job_fanout` reports client count, bytes, backpressure, and p50/p95/p99/last enqueue time, correlated by `eventId`.
|
||||
|
||||
## Docker
|
||||
|
||||
The default compose stack includes Public Pool, TimescaleDB, and Redis. TimescaleDB
|
||||
@@ -163,3 +218,11 @@ Integration tests against real TimescaleDB and Redis:
|
||||
```bash
|
||||
$ docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
|
||||
```
|
||||
|
||||
With the full-setup regtest Bitcoin Core running, validate reconstructed empty
|
||||
and full blocks through BIP23 proposal mode:
|
||||
|
||||
```bash
|
||||
$ RUN_BITCOIN_REGTEST_INTEGRATION=true \
|
||||
npm run test:integration -- bitcoin-regtest-proposal
|
||||
```
|
||||
|
||||
@@ -17,7 +17,7 @@ export class PayoutSnapshotEntity {
|
||||
payoutMode: PayoutMode;
|
||||
|
||||
@Column({ length: 16, type: 'varchar', default: 'finalized' })
|
||||
status: 'finalized';
|
||||
status: 'finalized' | 'bridge_seed';
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
blockHeight: number;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
|
||||
import type { PayoutDistributionEntry } from './payout-distribution';
|
||||
import { PayoutSnapshotService } from './payout-snapshot.service';
|
||||
|
||||
const ADDRESS_A = 'bc1qs29kyaqqc0fkvj897ke9e5xa9utljjey0y5jjn';
|
||||
@@ -52,7 +53,6 @@ describe('PayoutSnapshotService', () => {
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ id: '55' }])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{
|
||||
id: '55',
|
||||
method: 'pplns',
|
||||
@@ -91,8 +91,106 @@ describe('PayoutSnapshotService', () => {
|
||||
{ address: ADDRESS_A, amountSats: 600, percent: 60 },
|
||||
{ address: ADDRESS_B, amountSats: 400, percent: 40 },
|
||||
]);
|
||||
expect(manager.query).toHaveBeenCalledTimes(9);
|
||||
expect(manager.query).toHaveBeenCalledTimes(8);
|
||||
expect(manager.query.mock.calls[0][1]).toEqual([100, 'pplns']);
|
||||
|
||||
const entryInsertCalls = manager.query.mock.calls.filter(([sql]) => (
|
||||
sql.includes('INSERT INTO "payout_snapshot_entry"')
|
||||
));
|
||||
expect(entryInsertCalls).toHaveLength(1);
|
||||
expect(entryInsertCalls[0][0]).toContain('FROM UNNEST(');
|
||||
expect(entryInsertCalls[0][0]).toContain('WITH ORDINALITY');
|
||||
expect(entryInsertCalls[0][0]).toContain('ORDER BY entry."ordinality"');
|
||||
expect(entryInsertCalls[0][1]).toEqual([
|
||||
'55',
|
||||
'pplns',
|
||||
[ADDRESS_A, ADDRESS_B],
|
||||
[60, 40],
|
||||
[3, 2],
|
||||
[0.6, 0.4],
|
||||
[600, 400],
|
||||
[600, 400],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[true, true],
|
||||
['coinbase', 'coinbase'],
|
||||
[1, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should bound and chunk set-based snapshot entry inserts without changing values', async () => {
|
||||
const entries: PayoutDistributionEntry[] = Array.from({ length: 1_001 }, (_, index) => {
|
||||
const includedInCoinbase = index % 3 !== 0;
|
||||
const balanceBeforeSats = 200 + index;
|
||||
const grossPayoutSats = 10_000 + index;
|
||||
return {
|
||||
address: `test-address-${index}`,
|
||||
creditedDifficulty: index + 0.5,
|
||||
acceptedShareCount: index + 1,
|
||||
payoutWeight: (index + 1) / 1_001,
|
||||
grossPayoutSats,
|
||||
payoutSats: includedInCoinbase ? 1_000 + index : 0,
|
||||
balanceBeforeSats,
|
||||
balanceAfterSats: includedInCoinbase
|
||||
? balanceBeforeSats
|
||||
: balanceBeforeSats + grossPayoutSats,
|
||||
includedInCoinbase,
|
||||
rank: 1_001 - index,
|
||||
};
|
||||
});
|
||||
const insertSnapshotEntries = Reflect.get(service, 'insertSnapshotEntries') as (
|
||||
manager: EntityManager,
|
||||
snapshotId: string,
|
||||
entries: PayoutDistributionEntry[],
|
||||
) => Promise<void>;
|
||||
let resolveFirstInsert: () => void = () => {
|
||||
throw new Error('First insert did not start');
|
||||
};
|
||||
manager.query
|
||||
.mockImplementationOnce(() => new Promise<void>(resolve => {
|
||||
resolveFirstInsert = () => resolve();
|
||||
}))
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const insertion = insertSnapshotEntries.call(
|
||||
service,
|
||||
manager as unknown as EntityManager,
|
||||
'77',
|
||||
entries,
|
||||
);
|
||||
|
||||
expect(manager.query).toHaveBeenCalledTimes(1);
|
||||
resolveFirstInsert();
|
||||
await insertion;
|
||||
expect(manager.query).toHaveBeenCalledTimes(2);
|
||||
const calls = manager.query.mock.calls;
|
||||
expect(calls.map(([, parameters]) => parameters.length)).toEqual([13, 13]);
|
||||
expect(calls.map(([, parameters]) => parameters[2].length)).toEqual([1_000, 1]);
|
||||
for (const [sql, parameters] of calls) {
|
||||
expect(sql).toContain('FROM UNNEST(');
|
||||
expect(sql).toContain('ORDER BY entry."ordinality"');
|
||||
for (const values of parameters.slice(2)) {
|
||||
expect(values.length).toBeLessThanOrEqual(1_000);
|
||||
}
|
||||
}
|
||||
|
||||
const expectedParameters = (batch: PayoutDistributionEntry[]) => [
|
||||
'77',
|
||||
'pplns',
|
||||
batch.map(entry => entry.address),
|
||||
batch.map(entry => entry.creditedDifficulty),
|
||||
batch.map(entry => entry.acceptedShareCount),
|
||||
batch.map(entry => entry.payoutWeight),
|
||||
batch.map(entry => entry.grossPayoutSats),
|
||||
batch.map(entry => entry.payoutSats),
|
||||
batch.map(entry => entry.balanceBeforeSats),
|
||||
batch.map(entry => entry.balanceAfterSats),
|
||||
batch.map(entry => entry.includedInCoinbase),
|
||||
batch.map(entry => entry.includedInCoinbase ? 'coinbase' : 'pending'),
|
||||
batch.map(entry => entry.rank),
|
||||
];
|
||||
expect(calls[0][1]).toEqual(expectedParameters(entries.slice(0, 1_000)));
|
||||
expect(calls[1][1]).toEqual(expectedParameters(entries.slice(1_000)));
|
||||
});
|
||||
|
||||
it('should bootstrap the PPLNS window from paid block count up to the configured factor', async () => {
|
||||
@@ -116,7 +214,6 @@ describe('PayoutSnapshotService', () => {
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ id: '56' }])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{
|
||||
id: '56',
|
||||
method: 'pplns',
|
||||
@@ -213,6 +310,56 @@ describe('PayoutSnapshotService', () => {
|
||||
expect(manager.query).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('stores bridge seeds with a non-active status excluded from latest payout queries', async () => {
|
||||
manager.query
|
||||
.mockResolvedValueOnce([{
|
||||
startBatchId: '10',
|
||||
endBatchId: '12',
|
||||
windowStartShareIndex: '1000',
|
||||
windowEndShareIndex: '2000',
|
||||
totalCreditedDifficulty: '100',
|
||||
totalAcceptedShareCount: '5',
|
||||
}])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{ address: ADDRESS_A, creditedDifficulty: 100, acceptedShareCount: 5 },
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ id: 'seed-57' }])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{
|
||||
id: 'seed-57',
|
||||
method: 'pplns',
|
||||
payoutMode: 'pplns',
|
||||
blockHeight: 900002,
|
||||
coinbaseValueSats: '1000',
|
||||
windowStartShareIndex: '1000',
|
||||
windowEndShareIndex: '2000',
|
||||
totalCreditedDifficulty: 100,
|
||||
totalAcceptedShareCount: '5',
|
||||
eligibleAddressCount: 1,
|
||||
includedOutputCount: 1,
|
||||
distributedSats: '1000',
|
||||
unallocatedRemainderSats: '0',
|
||||
}])
|
||||
.mockResolvedValueOnce([{ address: ADDRESS_A, payoutSats: '1000' }]);
|
||||
|
||||
await expect(service.createSnapshotForTemplate({
|
||||
blockHeight: 900002,
|
||||
coinbaseValueSats: 1000,
|
||||
networkDifficulty: 25,
|
||||
visibility: 'bridge_seed',
|
||||
})).resolves.toEqual(expect.objectContaining({ id: 'seed-57' }));
|
||||
|
||||
const existingLookupSql = manager.query.mock.calls[1][0] as string;
|
||||
const insertSql = manager.query.mock.calls[4][0] as string;
|
||||
expect(existingLookupSql).toContain(`"status" = 'bridge_seed'`);
|
||||
expect(insertSql).toContain(`'bridge_seed'`);
|
||||
dataSource.query.mockResolvedValueOnce([]);
|
||||
await expect(service.getLatestSnapshot()).resolves.toBeNull();
|
||||
expect(dataSource.query.mock.calls[0][0]).toContain(`"status" = 'finalized'`);
|
||||
});
|
||||
|
||||
it('should finalize snapshot balances and payout history for a found block', async () => {
|
||||
manager.query
|
||||
.mockResolvedValueOnce([{ id: '55', coinbaseValueSats: '1000' }])
|
||||
|
||||
@@ -58,6 +58,9 @@ const DEFAULT_PAYOUT_WINDOW_FACTOR = 4;
|
||||
const DEFAULT_COINBASE_WEIGHT_BUDGET = 26_000;
|
||||
const DEFAULT_PAYOUT_BOOTSTRAP_WINDOW = true;
|
||||
const PPLNS_PAYOUT_MODE: PayoutMode = 'pplns';
|
||||
// UNNEST keeps each statement at 13 bind parameters; this cap bounds array payload size.
|
||||
const PAYOUT_SNAPSHOT_ENTRY_INSERT_BATCH_SIZE = 1_000;
|
||||
type PayoutSnapshotStatus = 'finalized' | 'bridge_seed';
|
||||
|
||||
@Injectable()
|
||||
export class PayoutSnapshotService {
|
||||
@@ -79,12 +82,17 @@ export class PayoutSnapshotService {
|
||||
blockHeight: number;
|
||||
coinbaseValueSats: number;
|
||||
networkDifficulty: number;
|
||||
/** Bridge seeds are frozen/reconstructable but excluded from active payout APIs. */
|
||||
visibility?: 'active' | 'bridge_seed';
|
||||
}): Promise<PayoutSnapshotForTemplate | null> {
|
||||
if (!this.snapshotsEnabled || input.coinbaseValueSats <= 0 || input.networkDifficulty <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async manager => {
|
||||
const snapshotStatus: PayoutSnapshotStatus = input.visibility === 'bridge_seed'
|
||||
? 'bridge_seed'
|
||||
: 'finalized';
|
||||
const effectiveWindowFactor = await this.getEffectiveWindowFactor(manager);
|
||||
const windowTargetDifficulty = input.networkDifficulty * effectiveWindowFactor;
|
||||
const window = await this.getPplnsWindow(manager, windowTargetDifficulty);
|
||||
@@ -96,6 +104,7 @@ export class PayoutSnapshotService {
|
||||
blockHeight: input.blockHeight,
|
||||
coinbaseValueSats: input.coinbaseValueSats,
|
||||
windowEndShareIndex: window.windowEndShareIndex,
|
||||
status: snapshotStatus,
|
||||
});
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
@@ -153,7 +162,7 @@ export class PayoutSnapshotService {
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
'finalized',
|
||||
'${snapshotStatus}',
|
||||
$3::bigint,
|
||||
$4::bigint,
|
||||
$5::numeric,
|
||||
@@ -199,9 +208,7 @@ export class PayoutSnapshotService {
|
||||
unallocatedRemainderSats.toString(),
|
||||
]);
|
||||
|
||||
for (const entry of entries) {
|
||||
await this.insertSnapshotEntry(manager, snapshotRow.id, entry);
|
||||
}
|
||||
await this.insertSnapshotEntries(manager, snapshotRow.id, entries);
|
||||
|
||||
return this.getSnapshotById(manager, snapshotRow.id);
|
||||
});
|
||||
@@ -554,14 +561,19 @@ export class PayoutSnapshotService {
|
||||
|
||||
private async getExistingSnapshot(
|
||||
manager: EntityManager,
|
||||
input: { blockHeight: number; coinbaseValueSats: number; windowEndShareIndex: string },
|
||||
input: {
|
||||
blockHeight: number;
|
||||
coinbaseValueSats: number;
|
||||
windowEndShareIndex: string;
|
||||
status: PayoutSnapshotStatus;
|
||||
},
|
||||
): Promise<PayoutSnapshotForTemplate | null> {
|
||||
const [snapshot] = await manager.query(`
|
||||
SELECT "id"::text AS "id"
|
||||
FROM "payout_snapshot"
|
||||
WHERE "method" = $1
|
||||
AND "payoutMode" = $5
|
||||
AND "status" = 'finalized'
|
||||
AND "status" = '${input.status}'
|
||||
AND "blockHeight" = $2::bigint
|
||||
AND "coinbaseValueSats" = $3::bigint
|
||||
AND "windowEndShareIndex" = $4::bigint
|
||||
@@ -641,52 +653,86 @@ export class PayoutSnapshotService {
|
||||
};
|
||||
}
|
||||
|
||||
private async insertSnapshotEntry(manager: EntityManager, snapshotId: string, entry: PayoutDistributionEntry): Promise<void> {
|
||||
await manager.query(`
|
||||
INSERT INTO "payout_snapshot_entry" (
|
||||
"snapshotId",
|
||||
"payoutMode",
|
||||
"address",
|
||||
"creditedDifficulty",
|
||||
"acceptedShareCount",
|
||||
"payoutWeight",
|
||||
"grossPayoutSats",
|
||||
"payoutSats",
|
||||
"balanceBeforeSats",
|
||||
"balanceAfterSats",
|
||||
"includedInCoinbase",
|
||||
"rowType",
|
||||
"rank"
|
||||
) VALUES (
|
||||
$1::bigint,
|
||||
$2,
|
||||
$3,
|
||||
$4::numeric,
|
||||
$5::bigint,
|
||||
$6::numeric,
|
||||
$7::bigint,
|
||||
$8::bigint,
|
||||
$9::bigint,
|
||||
$10::bigint,
|
||||
$11::boolean,
|
||||
$12,
|
||||
$13::int
|
||||
)
|
||||
`, [
|
||||
snapshotId,
|
||||
PPLNS_PAYOUT_MODE,
|
||||
entry.address,
|
||||
entry.creditedDifficulty,
|
||||
entry.acceptedShareCount,
|
||||
entry.payoutWeight,
|
||||
entry.grossPayoutSats,
|
||||
entry.payoutSats,
|
||||
entry.balanceBeforeSats,
|
||||
entry.balanceAfterSats,
|
||||
entry.includedInCoinbase,
|
||||
entry.includedInCoinbase ? 'coinbase' : 'pending',
|
||||
entry.rank,
|
||||
]);
|
||||
private async insertSnapshotEntries(
|
||||
manager: EntityManager,
|
||||
snapshotId: string,
|
||||
entries: PayoutDistributionEntry[],
|
||||
): Promise<void> {
|
||||
for (let offset = 0; offset < entries.length; offset += PAYOUT_SNAPSHOT_ENTRY_INSERT_BATCH_SIZE) {
|
||||
const batch = entries.slice(offset, offset + PAYOUT_SNAPSHOT_ENTRY_INSERT_BATCH_SIZE);
|
||||
await manager.query(`
|
||||
INSERT INTO "payout_snapshot_entry" (
|
||||
"snapshotId",
|
||||
"payoutMode",
|
||||
"address",
|
||||
"creditedDifficulty",
|
||||
"acceptedShareCount",
|
||||
"payoutWeight",
|
||||
"grossPayoutSats",
|
||||
"payoutSats",
|
||||
"balanceBeforeSats",
|
||||
"balanceAfterSats",
|
||||
"includedInCoinbase",
|
||||
"rowType",
|
||||
"rank"
|
||||
)
|
||||
SELECT
|
||||
$1::bigint,
|
||||
$2,
|
||||
entry."address",
|
||||
entry."creditedDifficulty",
|
||||
entry."acceptedShareCount",
|
||||
entry."payoutWeight",
|
||||
entry."grossPayoutSats",
|
||||
entry."payoutSats",
|
||||
entry."balanceBeforeSats",
|
||||
entry."balanceAfterSats",
|
||||
entry."includedInCoinbase",
|
||||
entry."rowType",
|
||||
entry."rank"
|
||||
FROM UNNEST(
|
||||
$3::varchar[],
|
||||
$4::numeric[],
|
||||
$5::bigint[],
|
||||
$6::numeric[],
|
||||
$7::bigint[],
|
||||
$8::bigint[],
|
||||
$9::bigint[],
|
||||
$10::bigint[],
|
||||
$11::boolean[],
|
||||
$12::varchar[],
|
||||
$13::int[]
|
||||
) WITH ORDINALITY AS entry(
|
||||
"address",
|
||||
"creditedDifficulty",
|
||||
"acceptedShareCount",
|
||||
"payoutWeight",
|
||||
"grossPayoutSats",
|
||||
"payoutSats",
|
||||
"balanceBeforeSats",
|
||||
"balanceAfterSats",
|
||||
"includedInCoinbase",
|
||||
"rowType",
|
||||
"rank",
|
||||
"ordinality"
|
||||
)
|
||||
ORDER BY entry."ordinality"
|
||||
`, [
|
||||
snapshotId,
|
||||
PPLNS_PAYOUT_MODE,
|
||||
batch.map(entry => entry.address),
|
||||
batch.map(entry => entry.creditedDifficulty),
|
||||
batch.map(entry => entry.acceptedShareCount),
|
||||
batch.map(entry => entry.payoutWeight),
|
||||
batch.map(entry => entry.grossPayoutSats),
|
||||
batch.map(entry => entry.payoutSats),
|
||||
batch.map(entry => entry.balanceBeforeSats),
|
||||
batch.map(entry => entry.balanceAfterSats),
|
||||
batch.map(entry => entry.includedInCoinbase),
|
||||
batch.map(entry => entry.includedInCoinbase ? 'coinbase' : 'pending'),
|
||||
batch.map(entry => entry.rank),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private limitCoinbaseOutputs(entries: PayoutDistributionEntry[]): PayoutDistributionEntry[] {
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
||||
|
||||
import { MockRecording1 } from '../../test/models/MockRecording1';
|
||||
import { createSubsidyOnlyBlockTemplate } from '../services/subsidy-only-template.factory';
|
||||
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
|
||||
import { MiningJob } from './MiningJob';
|
||||
|
||||
@@ -48,6 +49,34 @@ describe('MiningJob', () => {
|
||||
expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ height: 1, expectedPrefix: '51' },
|
||||
{ height: 16, expectedPrefix: '60' },
|
||||
{ height: 17, expectedPrefix: '0111' },
|
||||
])('minimally encodes BIP34 coinbase height $height', ({ height, expectedPrefix }) => {
|
||||
const heightTemplate = {
|
||||
...jobTemplate,
|
||||
blockData: { ...jobTemplate.blockData, height },
|
||||
};
|
||||
const heightJob = new MiningJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
`height-${height}`,
|
||||
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
|
||||
heightTemplate,
|
||||
);
|
||||
const extraNonce1 = '57a6f098';
|
||||
const extraNonce2 = 'c708000000000000';
|
||||
const coinbase = bitcoinjs.Transaction.fromHex([
|
||||
heightJob.getCoinbasePrefixBuffer().toString('hex'),
|
||||
extraNonce1,
|
||||
extraNonce2,
|
||||
heightJob.getCoinbaseSuffixBuffer().toString('hex'),
|
||||
].join(''));
|
||||
|
||||
expect(coinbase.ins[0].script.toString('hex').startsWith(expectedPrefix)).toBe(true);
|
||||
expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose coinbase prefix and suffix buffers for SV2 extended jobs', () => {
|
||||
const notify = JSON.parse(job.response(jobTemplate));
|
||||
const extraNonce1 = '57a6f098';
|
||||
@@ -105,6 +134,44 @@ describe('MiningJob', () => {
|
||||
expect(updatedBlock.merkleRoot.equals(originalMerkleRoot)).toBe(false);
|
||||
});
|
||||
|
||||
it('replaces negotiated BIP310 bits without toggling bits already set in the job version', () => {
|
||||
const versionRollingMask = 0x1fffe000;
|
||||
const versionBits = 0x00002000;
|
||||
const baseVersion = 0xa0002000 | 0;
|
||||
const versionedTemplate = {
|
||||
...jobTemplate,
|
||||
block: Object.assign(new bitcoinjs.Block(), jobTemplate.block, {
|
||||
version: baseVersion,
|
||||
}),
|
||||
};
|
||||
const expectedVersion = 0xa0002000 >>> 0;
|
||||
|
||||
expect(MiningJob.applyVersionRolling(baseVersion, versionBits, versionRollingMask))
|
||||
.toBe(expectedVersion);
|
||||
const updatedBlock = job.copyAndUpdateBlock(
|
||||
versionedTemplate,
|
||||
versionBits,
|
||||
parseInt('ed460d91', 16),
|
||||
'57a6f098',
|
||||
'c708000000000000',
|
||||
parseInt(MockRecording1.TIME, 16),
|
||||
versionRollingMask,
|
||||
);
|
||||
const header = job.buildHeaderBuffer(
|
||||
versionedTemplate,
|
||||
versionBits,
|
||||
parseInt('ed460d91', 16),
|
||||
'57a6f098',
|
||||
'c708000000000000',
|
||||
parseInt(MockRecording1.TIME, 16),
|
||||
versionRollingMask,
|
||||
);
|
||||
|
||||
expect(updatedBlock.version >>> 0).toBe(expectedVersion);
|
||||
expect(header.readUInt32LE(0)).toBe(expectedVersion);
|
||||
expect(header).toEqual(updatedBlock.toBuffer(true));
|
||||
});
|
||||
|
||||
it('should leave block version unchanged without a version mask', () => {
|
||||
const updatedBlock = job.copyAndUpdateBlock(
|
||||
jobTemplate,
|
||||
@@ -144,4 +211,118 @@ describe('MiningJob', () => {
|
||||
|
||||
expect(fastHeader.equals(updatedBlock.toBuffer(true))).toBe(true);
|
||||
});
|
||||
|
||||
it('hydrates the raw transaction body only when reconstructing a full candidate', () => {
|
||||
const versionMask = parseInt('00002000', 16);
|
||||
const nonce = parseInt('ed460d91', 16);
|
||||
const extraNonce1 = '57a6f098';
|
||||
const extraNonce2 = 'c708000000000000';
|
||||
const timestamp = parseInt(MockRecording1.TIME, 16);
|
||||
const fromHexSpy = jest.spyOn(bitcoinjs.Transaction, 'fromHex');
|
||||
|
||||
const merkleRoot = job.buildCoinbaseMerkleRoot(extraNonce1, extraNonce2);
|
||||
job.buildHeaderBuffer(
|
||||
jobTemplate,
|
||||
versionMask,
|
||||
nonce,
|
||||
extraNonce1,
|
||||
extraNonce2,
|
||||
timestamp,
|
||||
);
|
||||
expect(fromHexSpy).not.toHaveBeenCalled();
|
||||
|
||||
const candidate = job.copyAndUpdateBlock(
|
||||
jobTemplate,
|
||||
versionMask,
|
||||
nonce,
|
||||
extraNonce1,
|
||||
extraNonce2,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
expect(fromHexSpy).toHaveBeenCalledTimes(jobTemplate.blockData.transactions.length);
|
||||
expect(candidate.transactions).toHaveLength(jobTemplate.blockData.transactions.length + 1);
|
||||
expect(candidate.transactions.slice(1).map(transaction => transaction.toHex()))
|
||||
.toEqual(jobTemplate.blockData.transactions.map(transaction => transaction.data));
|
||||
expect(candidate.transactions.slice(1).map(transaction => transaction.getId()))
|
||||
.toEqual(jobTemplate.blockData.transactions.map(transaction => transaction.txid));
|
||||
expect(candidate.merkleRoot).toEqual(merkleRoot);
|
||||
expect(bitcoinjs.Block.calculateMerkleRoot(candidate.transactions, false))
|
||||
.toEqual(candidate.merkleRoot);
|
||||
expect(candidate.checkTxRoots()).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a lazily hydrated candidate body whose bytes do not match the GBT txid', () => {
|
||||
const corruptedTemplate = {
|
||||
...jobTemplate,
|
||||
blockData: {
|
||||
...jobTemplate.blockData,
|
||||
transactions: jobTemplate.blockData.transactions.map((transaction, index) => ({
|
||||
...transaction,
|
||||
txid: index === 0 ? '00'.repeat(32) : transaction.txid,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => job.copyAndUpdateBlock(
|
||||
corruptedTemplate,
|
||||
0,
|
||||
1,
|
||||
'57a6f098',
|
||||
'c708000000000000',
|
||||
parseInt(MockRecording1.TIME, 16),
|
||||
)).toThrow('data does not match its txid');
|
||||
});
|
||||
|
||||
it('builds a consensus-shaped subsidy bridge with one coinbase and a clean notify', async () => {
|
||||
const authoritative = {
|
||||
...MockRecording1.BLOCK_TEMPLATE,
|
||||
height: 840_000,
|
||||
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(tx => ({ ...tx })),
|
||||
};
|
||||
authoritative.coinbasevalue = 312_500_000
|
||||
+ authoritative.transactions.reduce((sum, transaction) => sum + transaction.fee, 0);
|
||||
const emptyTemplate = createSubsidyOnlyBlockTemplate({
|
||||
authoritativeTemplate: authoritative,
|
||||
network: 'mainnet',
|
||||
payoutMode: 'solo',
|
||||
});
|
||||
const emptyTemplate$ = new BehaviorSubject(emptyTemplate);
|
||||
const jobsService = new StratumV1JobsService({
|
||||
newBlockTemplate$: emptyTemplate$.asObservable(),
|
||||
miningInfo: { blocks: emptyTemplate.height - 1 },
|
||||
} as any);
|
||||
const emptyJobTemplate = await firstValueFrom(jobsService.newMiningJob$);
|
||||
const emptyJob = new MiningJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
'bridge',
|
||||
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
|
||||
emptyJobTemplate,
|
||||
);
|
||||
|
||||
const notify = JSON.parse(emptyJob.response(emptyJobTemplate));
|
||||
const fromHexSpy = jest.spyOn(bitcoinjs.Transaction, 'fromHex');
|
||||
const reconstructed = emptyJob.copyAndUpdateBlock(
|
||||
emptyJobTemplate,
|
||||
0,
|
||||
1,
|
||||
'57a6f098',
|
||||
'c708000000000000',
|
||||
emptyJobTemplate.block.timestamp,
|
||||
);
|
||||
const coinbaseScript = reconstructed.transactions[0].ins[0].script;
|
||||
const encodedHeight = coinbaseScript.subarray(1, 1 + coinbaseScript[0]);
|
||||
|
||||
expect(emptyJobTemplate.merkle_branch).toEqual([]);
|
||||
expect(notify.params[8]).toBe(true);
|
||||
expect(fromHexSpy).not.toHaveBeenCalled();
|
||||
expect(reconstructed.transactions).toHaveLength(1);
|
||||
expect(reconstructed.transactions[0].outs.reduce((sum, output) => sum + output.value, 0))
|
||||
.toBe(312_500_000);
|
||||
expect(bitcoinjs.script.number.decode(encodedHeight)).toBe(840_000);
|
||||
expect(reconstructed.transactions[0].outs[1].script.toString('hex'))
|
||||
.toBe(emptyTemplate.default_witness_commitment);
|
||||
expect(bitcoinjs.Block.calculateMerkleRoot(reconstructed.transactions, false))
|
||||
.toEqual(reconstructed.merkleRoot);
|
||||
});
|
||||
});
|
||||
|
||||
+163
-53
@@ -2,6 +2,7 @@ import { AddressType, getAddressInfo } from 'bitcoin-address-validation';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
|
||||
import { IJobTemplate } from '../services/stratum-v1-jobs.service';
|
||||
import { PayoutMode } from '../types/payout-mode';
|
||||
import { hash256 } from '../utils/hash.utils';
|
||||
import { eResponseMethod } from './enums/eResponseMethod';
|
||||
import { IMiningNotify } from './stratum-messages/IMiningNotify';
|
||||
@@ -13,16 +14,28 @@ export interface AddressObject {
|
||||
percent?: number;
|
||||
amountSats?: number;
|
||||
}
|
||||
|
||||
export interface MiningJobOwnership {
|
||||
payoutMode: PayoutMode;
|
||||
payoutIdentity: string;
|
||||
}
|
||||
|
||||
export class MiningJob {
|
||||
|
||||
private static readonly paymentScriptCache = new Map<string, Buffer>();
|
||||
private static readonly paymentScriptCacheMaxEntries = 250_000;
|
||||
|
||||
private coinbaseTransaction: bitcoinjs.Transaction;
|
||||
private coinbasePart1: string;
|
||||
private coinbasePart2: string;
|
||||
private coinbasePart1Buffer: Buffer;
|
||||
private coinbasePart2Buffer: Buffer;
|
||||
private merkleBranchBuffers: Buffer[];
|
||||
private miningNotifyResponse: string;
|
||||
private miningNotifyResponseBuffer: Buffer;
|
||||
|
||||
public jobTemplateId: string;
|
||||
public tipKey: string;
|
||||
public networkDifficulty: number;
|
||||
public creation: number;
|
||||
|
||||
@@ -30,11 +43,13 @@ export class MiningJob {
|
||||
private network: bitcoinjs.networks.Network,
|
||||
public jobId: string,
|
||||
payoutInformation: AddressObject[],
|
||||
jobTemplate: IJobTemplate
|
||||
jobTemplate: IJobTemplate,
|
||||
public readonly ownership?: MiningJobOwnership,
|
||||
) {
|
||||
|
||||
this.creation = new Date().getTime();
|
||||
this.jobTemplateId = jobTemplate.blockData.id;
|
||||
this.tipKey = jobTemplate.blockData.tipKey;
|
||||
this.merkleBranchBuffers = jobTemplate.merkle_branch.map(branch => Buffer.from(branch, 'hex'));
|
||||
|
||||
this.coinbaseTransaction = this.createCoinbaseTransaction(payoutInformation, jobTemplate.blockData.coinbasevalue);
|
||||
@@ -49,18 +64,15 @@ export class MiningJob {
|
||||
// 39th byte onwards: Optional data with no consensus meaning
|
||||
const extra = Buffer.from('Public-Pool');
|
||||
|
||||
// Encode the block height
|
||||
// https://github.com/bitcoin/bips/blob/master/bip-0034.mediawiki
|
||||
const blockHeightEncoded = bitcoinjs.script.number.encode(jobTemplate.blockData.height);
|
||||
// BIP34 uses Bitcoin Script's minimally encoded integer push. Heights
|
||||
// 1..16 are OP_1..OP_16, not a one-byte PUSHDATA operation. Keeping the
|
||||
// extranonce padding independent of the height width also prevents a
|
||||
// future four-byte height from consuming part of the pool tag.
|
||||
const blockHeightPrefix = this.encodeCoinbaseHeight(jobTemplate.blockData.height);
|
||||
const padding = Buffer.alloc(TOTAL_EXTRANONCE_SIZE_BYTES, 0);
|
||||
|
||||
// Get the length of the block height encoding
|
||||
const blockHeightLengthByte = Buffer.from([blockHeightEncoded.length]);
|
||||
|
||||
// generate padding and take length of encode blockHeight into account
|
||||
const padding = Buffer.alloc(TOTAL_EXTRANONCE_SIZE_BYTES + (3 - blockHeightEncoded.length), 0)
|
||||
|
||||
// build the script
|
||||
this.coinbaseTransaction.ins[0].script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, extra, padding])
|
||||
// Build the script with the extranonce placeholder at the very end.
|
||||
this.coinbaseTransaction.ins[0].script = Buffer.concat([blockHeightPrefix, extra, padding]);
|
||||
|
||||
this.coinbaseTransaction.addOutput(bitcoinjs.script.compile([bitcoinjs.opcodes.OP_RETURN, Buffer.concat([segwitMagicBits, jobTemplate.block.witnessCommit])]), 0);
|
||||
|
||||
@@ -92,22 +104,61 @@ export class MiningJob {
|
||||
return Buffer.from(this.coinbasePart2Buffer);
|
||||
}
|
||||
|
||||
public buildHeaderBuffer(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): Buffer {
|
||||
public buildCoinbaseMerkleRoot(extraNonce: string, extraNonce2: string): Buffer {
|
||||
const coinbaseBuffer = Buffer.concat([
|
||||
this.coinbasePart1Buffer,
|
||||
Buffer.from(`${extraNonce}${extraNonce2}`, 'hex'),
|
||||
this.coinbasePart2Buffer,
|
||||
]);
|
||||
const coinbaseHash = hash256(coinbaseBuffer);
|
||||
const merkleRoot = this.calculateMerkleRootHash(coinbaseHash, this.merkleBranchBuffers);
|
||||
return MiningJob.calculateMerkleRootFromCoinbaseHash(
|
||||
hash256(coinbaseBuffer),
|
||||
this.merkleBranchBuffers,
|
||||
);
|
||||
}
|
||||
|
||||
let version = jobTemplate.block.version;
|
||||
if (versionMask !== undefined && versionMask != 0) {
|
||||
version = version ^ versionMask;
|
||||
public static calculateMerkleRootFromCoinbaseHash(
|
||||
coinbaseHash: Buffer,
|
||||
merkleBranches: readonly Buffer[],
|
||||
): Buffer {
|
||||
let merkleRoot = coinbaseHash;
|
||||
const bothMerkles = Buffer.alloc(64);
|
||||
for (const merkleBranch of merkleBranches) {
|
||||
bothMerkles.set(merkleRoot, 0);
|
||||
bothMerkles.set(merkleBranch, 32);
|
||||
merkleRoot = hash256(bothMerkles);
|
||||
}
|
||||
return merkleRoot;
|
||||
}
|
||||
|
||||
public static applyVersionRolling(jobVersion: number, versionBits: number, mask: number): number {
|
||||
const unsignedJobVersion = jobVersion >>> 0;
|
||||
const unsignedVersionBits = versionBits >>> 0;
|
||||
const unsignedMask = mask >>> 0;
|
||||
return (
|
||||
(unsignedJobVersion & (~unsignedMask >>> 0))
|
||||
| (unsignedVersionBits & unsignedMask)
|
||||
) >>> 0;
|
||||
}
|
||||
|
||||
public buildHeaderBuffer(
|
||||
jobTemplate: IJobTemplate,
|
||||
versionBits: number,
|
||||
nonce: number,
|
||||
extraNonce: string,
|
||||
extraNonce2: string,
|
||||
timestamp: number,
|
||||
versionRollingMask?: number,
|
||||
): Buffer {
|
||||
const merkleRoot = this.buildCoinbaseMerkleRoot(extraNonce, extraNonce2);
|
||||
|
||||
const version = versionRollingMask == null
|
||||
// SV2 standard-channel reconstruction passes an XOR delta because
|
||||
// its submit message contains the complete version field.
|
||||
? (jobTemplate.block.version ^ versionBits) >>> 0
|
||||
: MiningJob.applyVersionRolling(jobTemplate.block.version, versionBits, versionRollingMask);
|
||||
|
||||
const header = Buffer.alloc(80);
|
||||
header.writeInt32LE(version, 0);
|
||||
header.writeUInt32LE(version, 0);
|
||||
jobTemplate.block.prevHash.copy(header, 4);
|
||||
merkleRoot.copy(header, 36);
|
||||
header.writeUInt32LE(timestamp, 68);
|
||||
@@ -117,21 +168,40 @@ export class MiningJob {
|
||||
return header;
|
||||
}
|
||||
|
||||
public copyAndUpdateBlock(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): bitcoinjs.Block {
|
||||
public copyAndUpdateBlock(
|
||||
jobTemplate: IJobTemplate,
|
||||
versionBits: number,
|
||||
nonce: number,
|
||||
extraNonce: string,
|
||||
extraNonce2: string,
|
||||
timestamp: number,
|
||||
versionRollingMask?: number,
|
||||
): bitcoinjs.Block {
|
||||
|
||||
const testBlock = Object.assign(new bitcoinjs.Block(), jobTemplate.block);
|
||||
testBlock.transactions = jobTemplate.block.transactions.map(tx => {
|
||||
return Object.assign(new bitcoinjs.Transaction(), tx);
|
||||
});
|
||||
|
||||
testBlock.transactions[0] = this.cloneCoinbaseTransaction();
|
||||
const rawTransactions = jobTemplate.blockData.transactions;
|
||||
testBlock.transactions = [
|
||||
this.cloneCoinbaseTransaction(),
|
||||
...(rawTransactions == null
|
||||
? jobTemplate.block.transactions.slice(1).map(tx =>
|
||||
Object.assign(new bitcoinjs.Transaction(), tx))
|
||||
: rawTransactions.map((rawTransaction, index) => {
|
||||
const transaction = bitcoinjs.Transaction.fromHex(rawTransaction.data);
|
||||
if (transaction.getId().toLowerCase() !== rawTransaction.txid.toLowerCase()) {
|
||||
throw new Error(`transactions[${index}] data does not match its txid`);
|
||||
}
|
||||
return transaction;
|
||||
})),
|
||||
];
|
||||
|
||||
testBlock.nonce = nonce;
|
||||
|
||||
// recompute version mask
|
||||
if (versionMask !== undefined && versionMask != 0) {
|
||||
testBlock.version = (testBlock.version ^ versionMask);
|
||||
}
|
||||
const version = versionRollingMask == null
|
||||
? (testBlock.version ^ versionBits) >>> 0
|
||||
: MiningJob.applyVersionRolling(testBlock.version, versionBits, versionRollingMask);
|
||||
// bitcoinjs-lib serializes Block.version as a signed int32, while the
|
||||
// protocol and bit-mask arithmetic use the corresponding uint32 bits.
|
||||
testBlock.version = version | 0;
|
||||
|
||||
// set the nonces
|
||||
const nonceScript = testBlock.transactions[0].ins[0].script.toString('hex');
|
||||
@@ -139,7 +209,10 @@ export class MiningJob {
|
||||
testBlock.transactions[0].ins[0].script = Buffer.from(`${nonceScript.substring(0, nonceScript.length - (TOTAL_EXTRANONCE_SIZE_BYTES * 2))}${extraNonce}${extraNonce2}`, 'hex');
|
||||
|
||||
//recompute the root since we updated the coinbase script with the nonces
|
||||
testBlock.merkleRoot = this.calculateMerkleRootHash(testBlock.transactions[0].getHash(false), this.merkleBranchBuffers);
|
||||
testBlock.merkleRoot = MiningJob.calculateMerkleRootFromCoinbaseHash(
|
||||
testBlock.transactions[0].getHash(false),
|
||||
this.merkleBranchBuffers,
|
||||
);
|
||||
|
||||
|
||||
testBlock.timestamp = timestamp;
|
||||
@@ -148,22 +221,6 @@ export class MiningJob {
|
||||
}
|
||||
|
||||
|
||||
private calculateMerkleRootHash(newRoot: Buffer, merkleBranches: Buffer[]): Buffer {
|
||||
|
||||
const bothMerkles = Buffer.alloc(64);
|
||||
|
||||
bothMerkles.set(newRoot);
|
||||
|
||||
for (let i = 0; i < merkleBranches.length; i++) {
|
||||
bothMerkles.set(merkleBranches[i], 32);
|
||||
newRoot = hash256(bothMerkles);
|
||||
bothMerkles.set(newRoot);
|
||||
}
|
||||
|
||||
return bothMerkles.subarray(0, 32)
|
||||
}
|
||||
|
||||
|
||||
private createCoinbaseTransaction(addresses: AddressObject[], reward: number): bitcoinjs.Transaction {
|
||||
// Part 1
|
||||
const coinbaseTransaction = new bitcoinjs.Transaction();
|
||||
@@ -196,32 +253,76 @@ export class MiningJob {
|
||||
return coinbaseTransaction;
|
||||
}
|
||||
|
||||
private encodeCoinbaseHeight(height: number): Buffer {
|
||||
if (!Number.isSafeInteger(height) || height < 0) {
|
||||
throw new Error('Coinbase height must be a non-negative safe integer');
|
||||
}
|
||||
if (height === 0) {
|
||||
return Buffer.from([bitcoinjs.opcodes.OP_0]);
|
||||
}
|
||||
if (height <= 16) {
|
||||
return Buffer.from([bitcoinjs.opcodes.OP_1 + height - 1]);
|
||||
}
|
||||
|
||||
const encoded = bitcoinjs.script.number.encode(height);
|
||||
if (encoded.length >= bitcoinjs.opcodes.OP_PUSHDATA1) {
|
||||
throw new Error('Coinbase height encoding is unexpectedly large');
|
||||
}
|
||||
return Buffer.concat([Buffer.from([encoded.length]), encoded]);
|
||||
}
|
||||
|
||||
private getPaymentScript(address: string): Buffer {
|
||||
const cacheKey = `${this.network.bech32}:${this.network.pubKeyHash}:${this.network.scriptHash}:${address}`;
|
||||
const cached = MiningJob.paymentScriptCache.get(cacheKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const addressInfo = getAddressInfo(address);
|
||||
let paymentScript: Buffer;
|
||||
switch (addressInfo.type) {
|
||||
case AddressType.p2wpkh: {
|
||||
return bitcoinjs.payments.p2wpkh({ address, network: this.network }).output;
|
||||
paymentScript = bitcoinjs.payments.p2wpkh({ address, network: this.network }).output;
|
||||
break;
|
||||
}
|
||||
case AddressType.p2pkh: {
|
||||
return bitcoinjs.payments.p2pkh({ address, network: this.network }).output;
|
||||
paymentScript = bitcoinjs.payments.p2pkh({ address, network: this.network }).output;
|
||||
break;
|
||||
}
|
||||
case AddressType.p2sh: {
|
||||
return bitcoinjs.payments.p2sh({ address, network: this.network }).output;
|
||||
paymentScript = bitcoinjs.payments.p2sh({ address, network: this.network }).output;
|
||||
break;
|
||||
}
|
||||
case AddressType.p2tr: {
|
||||
return bitcoinjs.payments.p2tr({ address, network: this.network }).output;
|
||||
paymentScript = bitcoinjs.payments.p2tr({ address, network: this.network }).output;
|
||||
break;
|
||||
}
|
||||
case AddressType.p2wsh: {
|
||||
return bitcoinjs.payments.p2wsh({ address, network: this.network }).output;
|
||||
paymentScript = bitcoinjs.payments.p2wsh({ address, network: this.network }).output;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return Buffer.alloc(0);
|
||||
paymentScript = Buffer.alloc(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
MiningJob.paymentScriptCache.set(cacheKey, paymentScript);
|
||||
if (MiningJob.paymentScriptCache.size > MiningJob.paymentScriptCacheMaxEntries) {
|
||||
const oldest = MiningJob.paymentScriptCache.keys().next().value;
|
||||
if (oldest != null) {
|
||||
MiningJob.paymentScriptCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
return paymentScript;
|
||||
}
|
||||
|
||||
public response(jobTemplate: IJobTemplate): string {
|
||||
|
||||
if (this.miningNotifyResponse != null) {
|
||||
return this.miningNotifyResponse;
|
||||
}
|
||||
|
||||
const job: IMiningNotify = {
|
||||
id: null,
|
||||
method: eResponseMethod.MINING_NOTIFY,
|
||||
@@ -238,7 +339,16 @@ export class MiningJob {
|
||||
]
|
||||
};
|
||||
|
||||
return JSON.stringify(job) + '\n';
|
||||
this.miningNotifyResponse = JSON.stringify(job) + '\n';
|
||||
this.miningNotifyResponseBuffer = Buffer.from(this.miningNotifyResponse);
|
||||
return this.miningNotifyResponse;
|
||||
}
|
||||
|
||||
public responseBuffer(jobTemplate: IJobTemplate): Buffer {
|
||||
if (this.miningNotifyResponseBuffer == null) {
|
||||
this.response(jobTemplate);
|
||||
}
|
||||
return this.miningNotifyResponseBuffer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { Socket } from 'net';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
||||
|
||||
import { MockRecording1 } from '../../test/models/MockRecording1';
|
||||
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
|
||||
@@ -51,7 +52,7 @@ describe('StratumV1Client', () => {
|
||||
let consoleErrorSpy: jest.SpyInstance;
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
|
||||
let newBlockEmitter: BehaviorSubject<IBlockTemplate> = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
|
||||
let newBlockEmitter: BehaviorSubject<IBlockTemplate>;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -60,6 +61,7 @@ describe('StratumV1Client', () => {
|
||||
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
newBlockEmitter = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
|
||||
|
||||
const clients = new Map<string, any>();
|
||||
let nextClientId = 1;
|
||||
@@ -168,7 +170,6 @@ describe('StratumV1Client', () => {
|
||||
});
|
||||
|
||||
it('should clean up socket state only once when destroyed repeatedly', async () => {
|
||||
const unsubscribe = jest.fn();
|
||||
const timer = setInterval(() => undefined, 1000);
|
||||
const removeListenerSpy = jest.spyOn(socket, 'removeListener');
|
||||
|
||||
@@ -176,16 +177,14 @@ describe('StratumV1Client', () => {
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
address: 'tb1qcleanup',
|
||||
};
|
||||
(client as any).stratumSubscription = { unsubscribe };
|
||||
(client as any).backgroundWork = [timer];
|
||||
(client as any).miningSubmissionHashes.add('submitted-share');
|
||||
(client as any).miningSubmissionHashes.set('submitted-share', Date.now() + 1000);
|
||||
(client as any).buffer = 'partial-message';
|
||||
|
||||
await Promise.all([client.destroy(), client.destroy()]);
|
||||
|
||||
expect(clientService.delete).toHaveBeenCalledTimes(1);
|
||||
expect(clientService.delete).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000001');
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(removeListenerSpy).toHaveBeenCalledWith('data', expect.any(Function));
|
||||
expect((client as any).backgroundWork).toEqual([]);
|
||||
expect((client as any).miningSubmissionHashes.size).toBe(0);
|
||||
@@ -221,6 +220,99 @@ describe('StratumV1Client', () => {
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('disconnects a buffered client before building another mining job', async () => {
|
||||
jest.spyOn(client as any, 'write').mockResolvedValue(true);
|
||||
jest.spyOn(socket, 'write').mockImplementation(() => true);
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
(client as any).maxSocketBufferBytes = 256;
|
||||
const boundedSocket = {
|
||||
destroyed: false,
|
||||
writableEnded: false,
|
||||
writableLength: 256,
|
||||
write: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
};
|
||||
(client as any).socket = boundedSocket;
|
||||
const buildJob = jest.spyOn(stratumV1JobsService, 'getOrCreateJob');
|
||||
|
||||
const result = client.broadcastMiningJob(
|
||||
stratumV1JobsService.getLatestJobTemplate('solo'),
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: 'closed', bytes: 0, bufferedBytes: 256 });
|
||||
expect(buildJob).not.toHaveBeenCalled();
|
||||
expect(boundedSocket.write).not.toHaveBeenCalled();
|
||||
expect(boundedSocket.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disconnects without writing when a mining job would reach the socket buffer limit', async () => {
|
||||
jest.spyOn(client as any, 'write').mockResolvedValue(true);
|
||||
jest.spyOn(socket, 'write').mockImplementation(() => true);
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
(client as any).maxSocketBufferBytes = 1;
|
||||
const boundedSocket = {
|
||||
destroyed: false,
|
||||
writableEnded: false,
|
||||
writableLength: 0,
|
||||
write: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
};
|
||||
(client as any).socket = boundedSocket;
|
||||
const buildJob = jest.spyOn(stratumV1JobsService, 'getOrCreateJob');
|
||||
|
||||
const result = client.broadcastMiningJob(
|
||||
stratumV1JobsService.getLatestJobTemplate('solo'),
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: 'closed', bytes: 0, bufferedBytes: 0 });
|
||||
expect(buildJob).toHaveBeenCalledTimes(1);
|
||||
expect(boundedSocket.write).not.toHaveBeenCalled();
|
||||
expect(boundedSocket.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports bytes already queued when a write crosses the socket buffer limit', async () => {
|
||||
jest.spyOn(client as any, 'write').mockResolvedValue(true);
|
||||
jest.spyOn(socket, 'write').mockImplementation(() => true);
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
(client as any).maxSocketBufferBytes = 4096;
|
||||
let writableLengthReads = 0;
|
||||
const boundedSocket = {
|
||||
destroyed: false,
|
||||
writableEnded: false,
|
||||
get writableLength() {
|
||||
return writableLengthReads++ < 2 ? 0 : 4096;
|
||||
},
|
||||
write: jest.fn(() => true),
|
||||
destroy: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
};
|
||||
(client as any).socket = boundedSocket;
|
||||
|
||||
const result = client.broadcastMiningJob(
|
||||
stratumV1JobsService.getLatestJobTemplate('solo'),
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result.status).toBe('closed');
|
||||
expect(result.bytes).toBeGreaterThan(0);
|
||||
expect(result.bufferedBytes).toBe(4096);
|
||||
expect(boundedSocket.write).toHaveBeenCalledTimes(1);
|
||||
expect(boundedSocket.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block non-compliant user agents on subscribe without allocating a session', async () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string) => {
|
||||
switch (key) {
|
||||
@@ -348,6 +440,44 @@ describe('StratumV1Client', () => {
|
||||
|
||||
});
|
||||
|
||||
it('should pair a solo difficulty change with clean solo work when merged latest is PPLNS', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
jest.spyOn(socket, 'write').mockImplementation(() => true);
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
newBlockEmitter.next({
|
||||
...MockRecording1.BLOCK_TEMPLATE,
|
||||
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.slice(0, -1),
|
||||
payoutMode: 'solo',
|
||||
});
|
||||
newBlockEmitter.next({
|
||||
...MockRecording1.BLOCK_TEMPLATE,
|
||||
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.slice(0, -2),
|
||||
payoutMode: 'pplns',
|
||||
payoutSnapshotId: 'opposite-mode-snapshot',
|
||||
payoutOutputs: [{
|
||||
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
||||
amountSats: MockRecording1.BLOCK_TEMPLATE.coinbasevalue,
|
||||
}],
|
||||
});
|
||||
expect((await firstValueFrom(stratumV1JobsService.sv1MiningJob$)).blockData.payoutMode)
|
||||
.toBe('pplns');
|
||||
|
||||
jest.spyOn((client as any).statistics, 'getSuggestedDifficulty').mockReturnValue(200000);
|
||||
const broadcastSpy = jest.spyOn(client, 'broadcastMiningJob');
|
||||
await (client as any).checkDifficulty();
|
||||
|
||||
const [refreshedTemplate, force] = broadcastSpy.mock.calls.at(-1);
|
||||
expect(refreshedTemplate.blockData).toEqual(expect.objectContaining({
|
||||
payoutMode: 'solo',
|
||||
clearJobs: true,
|
||||
}));
|
||||
expect(force).toBe(true);
|
||||
});
|
||||
|
||||
it('should save client', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
|
||||
@@ -376,6 +506,7 @@ describe('StratumV1Client', () => {
|
||||
jest.setSystemTime(date);
|
||||
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
const socketWriteSpy = jest.spyOn(socket, 'write').mockImplementation(() => true);
|
||||
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
@@ -389,7 +520,7 @@ describe('StratumV1Client', () => {
|
||||
|
||||
|
||||
|
||||
expect((client as any).write).lastCalledWith(`{"id":null,"method":"mining.notify","params":["1","171592f223740e92d223f6e68bff25279af7ac4f2246451e0000000200000000","02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff1b03c943255075626c69632d506f6f6c","ffffffff02b59f250000000000160014e6f22ca44dc800e9d049621a3b9a42c509f1c4bc0000000000000000266a24aa21a9edbd3d1d916aa0b57326a2d88ebe1b68a1d7c48585f26d8335fe6a94b62755f64c00000000",["175335649d5e8746982969ec88f52e85ac9917106fba5468e699c8879ab974a1","d5644ab3e708c54cd68dc5aedc92b8d3037449687f92ec41ed6e37673d969d4a","5c9ec187517edc0698556cca5ce27e54c96acb014770599ed9df4d4937fbf2b0"],"20000000","192495f8","${MockRecording1.TIME}",true]}\n`);
|
||||
expect(socketWriteSpy).toHaveBeenCalledWith(Buffer.from(`{"id":null,"method":"mining.notify","params":["1","171592f223740e92d223f6e68bff25279af7ac4f2246451e0000000200000000","02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff1b03c943255075626c69632d506f6f6c","ffffffff02b59f250000000000160014e6f22ca44dc800e9d049621a3b9a42c509f1c4bc0000000000000000266a24aa21a9edbd3d1d916aa0b57326a2d88ebe1b68a1d7c48585f26d8335fe6a94b62755f64c00000000",["175335649d5e8746982969ec88f52e85ac9917106fba5468e699c8879ab974a1","d5644ab3e708c54cd68dc5aedc92b8d3037449687f92ec41ed6e37673d969d4a","5c9ec187517edc0698556cca5ce27e54c96acb014770599ed9df4d4937fbf2b0"],"20000000","192495f8","${MockRecording1.TIME}",true]}\n`));
|
||||
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||
@@ -504,6 +635,38 @@ describe('StratumV1Client', () => {
|
||||
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should submit a network-target candidate even when it misses the harder session target', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
const jobTemplate = stratumV1JobsService.getLatestJobTemplate('solo');
|
||||
const networkTarget = DifficultyUtils.compactToTarget(jobTemplate.block.bits);
|
||||
expect(networkTarget).not.toBeNull();
|
||||
(client as any).sessionDifficultyTarget = Buffer.alloc(32);
|
||||
const addSharesSpy = jest.spyOn((client as any).statistics, 'addShares');
|
||||
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
|
||||
submissionDifficulty: 1234,
|
||||
submissionHash: 'network-block-below-session-target',
|
||||
hashBuffer: networkTarget,
|
||||
});
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||
jest.useRealTimers();
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledWith(expect.any(String));
|
||||
expect((client as any).write).toHaveBeenCalledWith(`{"id":5,"error":null,"result":true}\n`);
|
||||
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
|
||||
isBlockCandidate: true,
|
||||
creditedDifficulty: 1234,
|
||||
submissionDifficulty: 1234,
|
||||
}));
|
||||
expect(addSharesSpy).toHaveBeenCalledWith(expect.any(Object), 1234);
|
||||
});
|
||||
|
||||
it('should reject duplicate submissions', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
|
||||
@@ -520,6 +683,257 @@ describe('StratumV1Client', () => {
|
||||
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[22,"Duplicate share",""]}\n`);
|
||||
});
|
||||
|
||||
it('should accept a five-field submission without clearing advertised version bits', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
const baseVersion = (MockRecording1.BLOCK_TEMPLATE.version | 0x00002000) | 0;
|
||||
newBlockEmitter.next({
|
||||
...MockRecording1.BLOCK_TEMPLATE,
|
||||
version: baseVersion,
|
||||
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(transaction => ({ ...transaction })),
|
||||
});
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
const submission = JSON.parse(MockRecording1.MINING_SUBMIT);
|
||||
submission.params.pop();
|
||||
emitMessage(JSON.stringify(submission));
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect((client as any).write).lastCalledWith(`{"id":5,"error":null,"result":true}\n`);
|
||||
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
|
||||
version: (baseVersion >>> 0).toString(16),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should reject case-variant encodings of the same submission as duplicates', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const variant = JSON.parse(MockRecording1.MINING_SUBMIT);
|
||||
for (const index of [2, 3, 4, 5]) {
|
||||
variant.params[index] = variant.params[index].toUpperCase();
|
||||
}
|
||||
emitMessage(JSON.stringify(variant));
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[22,"Duplicate share",""]}\n`);
|
||||
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ payoutMode: 'pplns' as const, payoutIdentity: 'pplns\0foreign-snapshot' },
|
||||
{ payoutMode: 'solo' as const, payoutIdentity: 'solo\0foreign-address' },
|
||||
])('should reject a $payoutMode job not owned by the connection', async ({ payoutMode, payoutIdentity }) => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
const calculateDifficultySpy = jest.spyOn(client as any, 'calculateDifficulty');
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
const template = stratumV1JobsService.getLatestJobTemplate('solo');
|
||||
const foreignJob = stratumV1JobsService.getOrCreateJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
|
||||
template,
|
||||
payoutIdentity,
|
||||
payoutMode,
|
||||
);
|
||||
const foreignSubmission = JSON.parse(MockRecording1.MINING_SUBMIT);
|
||||
foreignSubmission.params[1] = foreignJob.jobId;
|
||||
emitMessage(JSON.stringify(foreignSubmission));
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job not found",""]}\n`);
|
||||
expect(calculateDifficultySpy).not.toHaveBeenCalled();
|
||||
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
|
||||
expect(bitcoinRpcService.SUBMIT_BLOCK).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept a legacy PPLNS template with explicit outputs and no snapshot id', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
(client as any).payoutMode = 'pplns';
|
||||
newBlockEmitter.next({
|
||||
...MockRecording1.BLOCK_TEMPLATE,
|
||||
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(transaction => ({ ...transaction })),
|
||||
payoutMode: 'all',
|
||||
payoutSnapshotId: undefined,
|
||||
payoutOutputs: [{
|
||||
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
||||
amountSats: MockRecording1.BLOCK_TEMPLATE.coinbasevalue,
|
||||
}],
|
||||
});
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
const ownedJob = stratumV1JobsService.getJobById('1');
|
||||
expect(ownedJob.ownership).toEqual(expect.objectContaining({
|
||||
payoutMode: 'pplns',
|
||||
payoutIdentity: expect.stringMatching(/^pplns\0outputs\0/),
|
||||
}));
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect((client as any).write).lastCalledWith(`{"id":5,"error":null,"result":true}\n`);
|
||||
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payoutMode: 'pplns',
|
||||
jobId: '1',
|
||||
}));
|
||||
});
|
||||
|
||||
it('should retain duplicate detection across a clean tip switch', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
jest.spyOn(socket, 'write').mockImplementation(() => true);
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect((client as any).isDuplicateSubmission('old-tip-share')).toBe(false);
|
||||
const nextTip = {
|
||||
...MockRecording1.BLOCK_TEMPLATE,
|
||||
previousblockhash: '11'.repeat(32),
|
||||
height: MockRecording1.BLOCK_TEMPLATE.height + 1,
|
||||
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(tx => ({ ...tx })),
|
||||
};
|
||||
newBlockEmitter.next(nextTip);
|
||||
client.broadcastMiningJob(stratumV1JobsService.getLatestJobTemplate('solo'));
|
||||
|
||||
expect((client as any).isDuplicateSubmission('old-tip-share')).toBe(true);
|
||||
});
|
||||
|
||||
it('should bound duplicate tracking and expire entries by TTL', () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string) => {
|
||||
if (key === 'STRATUM_SUBMISSION_DEDUP_TTL_MS') return '1000';
|
||||
if (key === 'STRATUM_SUBMISSION_DEDUP_MAX_ENTRIES') return '2';
|
||||
if (key === 'NETWORK') return 'testnet';
|
||||
return null;
|
||||
});
|
||||
|
||||
expect((client as any).isDuplicateSubmission('one')).toBe(false);
|
||||
expect((client as any).isDuplicateSubmission('two')).toBe(false);
|
||||
expect((client as any).isDuplicateSubmission('three')).toBe(false);
|
||||
expect([...(client as any).miningSubmissionHashes.keys()]).toEqual(['two', 'three']);
|
||||
|
||||
jest.advanceTimersByTime(1001);
|
||||
expect((client as any).isDuplicateSubmission('two')).toBe(false);
|
||||
expect([...(client as any).miningSubmissionHashes.keys()]).toEqual(['two']);
|
||||
});
|
||||
|
||||
it('should hash and reject stale non-block shares without accounting them', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
jest.spyOn(socket, 'write').mockImplementation(() => true);
|
||||
const calculateDifficultySpy = jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
|
||||
submissionDifficulty: 1,
|
||||
submissionHash: 'stale-share',
|
||||
hashBuffer: Buffer.alloc(32, 0xff),
|
||||
});
|
||||
const buildHeaderSpy = jest.spyOn(MiningJob.prototype, 'buildHeaderBuffer');
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
newBlockEmitter.next({
|
||||
...MockRecording1.BLOCK_TEMPLATE,
|
||||
previousblockhash: '22'.repeat(32),
|
||||
height: MockRecording1.BLOCK_TEMPLATE.height + 1,
|
||||
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(tx => ({ ...tx })),
|
||||
});
|
||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect(buildHeaderSpy).toHaveBeenCalled();
|
||||
expect(calculateDifficultySpy).toHaveBeenCalled();
|
||||
expect(bitcoinRpcService.SUBMIT_BLOCK).not.toHaveBeenCalled();
|
||||
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
|
||||
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Stale share",""]}\n`);
|
||||
});
|
||||
|
||||
it('should reconstruct and submit an exact-target candidate from a stale tip', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
jest.spyOn(socket, 'write').mockImplementation(() => true);
|
||||
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
|
||||
submissionDifficulty: Number.POSITIVE_INFINITY,
|
||||
submissionHash: 'stale-block',
|
||||
hashBuffer: Buffer.alloc(32),
|
||||
});
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
newBlockEmitter.next({
|
||||
...MockRecording1.BLOCK_TEMPLATE,
|
||||
previousblockhash: '33'.repeat(32),
|
||||
height: MockRecording1.BLOCK_TEMPLATE.height + 1,
|
||||
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(tx => ({ ...tx })),
|
||||
});
|
||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledWith(expect.any(String));
|
||||
expect(blocksService.save).toHaveBeenCalledWith(expect.objectContaining({
|
||||
height: MockRecording1.BLOCK_TEMPLATE.height,
|
||||
blockSubmissionResult: null,
|
||||
}));
|
||||
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
|
||||
jobId: '1',
|
||||
isBlockCandidate: true,
|
||||
blockSubmissionResult: null,
|
||||
}));
|
||||
expect((client as any).write).lastCalledWith(`{"id":5,"error":null,"result":true}\n`);
|
||||
});
|
||||
|
||||
it('should not credit a stale exact-target candidate rejected by Core', async () => {
|
||||
(bitcoinRpcService.SUBMIT_BLOCK as jest.Mock).mockResolvedValue('stale-prevblk');
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
jest.spyOn(socket, 'write').mockImplementation(() => true);
|
||||
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
|
||||
submissionDifficulty: Number.POSITIVE_INFINITY,
|
||||
submissionHash: 'rejected-stale-block',
|
||||
hashBuffer: Buffer.alloc(32),
|
||||
});
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
newBlockEmitter.next({
|
||||
...MockRecording1.BLOCK_TEMPLATE,
|
||||
previousblockhash: '44'.repeat(32),
|
||||
height: MockRecording1.BLOCK_TEMPLATE.height + 1,
|
||||
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(tx => ({ ...tx })),
|
||||
});
|
||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledWith(expect.any(String));
|
||||
expect(blocksService.save).toHaveBeenCalledWith(expect.objectContaining({
|
||||
blockSubmissionResult: 'stale-prevblk',
|
||||
}));
|
||||
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
|
||||
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Stale share",""]}\n`);
|
||||
});
|
||||
|
||||
it('should reject submissions for unknown jobs', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
const hashSpy = jest.spyOn(MiningSubmitMessage.prototype, 'hash');
|
||||
@@ -550,7 +964,7 @@ describe('StratumV1Client', () => {
|
||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job Template not found",""]}\n`);
|
||||
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job not found",""]}\n`);
|
||||
});
|
||||
|
||||
it('should reject low difficulty shares', async () => {
|
||||
@@ -569,6 +983,62 @@ describe('StratumV1Client', () => {
|
||||
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects version rolling masks outside the negotiated BIP320 range', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation(() => Promise.resolve(true));
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const invalid = JSON.parse(MockRecording1.MINING_SUBMIT);
|
||||
invalid.params[5] = '20000000';
|
||||
emitMessage(JSON.stringify(invalid));
|
||||
jest.useRealTimers();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect((client as any).write).lastCalledWith(
|
||||
`{"id":5,"result":null,"error":[20,"Invalid version mask",""]}\n`,
|
||||
);
|
||||
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reconstructs BIP310 replacement bits when the job version already has the bit set', async () => {
|
||||
const versionBit = 0x00002000;
|
||||
const baseVersion = (MockRecording1.BLOCK_TEMPLATE.version | versionBit) | 0;
|
||||
newBlockEmitter.next({
|
||||
...MockRecording1.BLOCK_TEMPLATE,
|
||||
version: baseVersion,
|
||||
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(transaction => ({ ...transaction })),
|
||||
});
|
||||
jest.spyOn(client as any, 'write').mockResolvedValue(true);
|
||||
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
|
||||
submissionDifficulty: Number.POSITIVE_INFINITY,
|
||||
submissionHash: 'bip310-candidate',
|
||||
hashBuffer: Buffer.alloc(32),
|
||||
});
|
||||
const buildHeader = jest.spyOn(MiningJob.prototype, 'buildHeaderBuffer');
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const submission = JSON.parse(MockRecording1.MINING_SUBMIT);
|
||||
submission.params[5] = versionBit.toString(16).padStart(8, '0');
|
||||
emitMessage(JSON.stringify(submission));
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const expectedVersion = baseVersion >>> 0;
|
||||
const header = buildHeader.mock.results.at(-1).value as Buffer;
|
||||
const submittedBlockHex = (bitcoinRpcService.SUBMIT_BLOCK as jest.Mock).mock.calls.at(-1)[0] as string;
|
||||
expect(header.readUInt32LE(0)).toBe(expectedVersion);
|
||||
expect(Buffer.from(submittedBlockHex.slice(0, 160), 'hex').readUInt32LE(0)).toBe(expectedVersion);
|
||||
expect(submittedBlockHex.slice(0, 160)).toBe(header.toString('hex'));
|
||||
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
|
||||
version: expectedVersion.toString(16),
|
||||
isBlockCandidate: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should reject submissions with short extranonce2 values', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
|
||||
|
||||
+393
-146
@@ -4,7 +4,7 @@ import { plainToInstance } from 'class-transformer';
|
||||
import { validate, ValidationError, ValidatorOptions } from 'class-validator';
|
||||
import * as crypto from 'crypto';
|
||||
import { Socket } from 'net';
|
||||
import { firstValueFrom, Subscription } from 'rxjs';
|
||||
import { filter, firstValueFrom } from 'rxjs';
|
||||
import { clearInterval } from 'timers';
|
||||
|
||||
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
|
||||
@@ -16,7 +16,12 @@ import { ShareAccountingService } from '../ORM/share-accounting/share-accounting
|
||||
import { BitcoinRpcService } from '../services/bitcoin-rpc.service';
|
||||
import { NotificationService } from '../services/notification.service';
|
||||
import { RedisMessagingService } from '../services/redis-messaging.service';
|
||||
import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service';
|
||||
import {
|
||||
createPayoutOutputIdentity,
|
||||
IJobSubmissionContext,
|
||||
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';
|
||||
@@ -38,6 +43,16 @@ const BLOCKED_USER_AGENT_LOG_INTERVAL_MS = 60 * 1000;
|
||||
const VALIDATION_ERROR_LOG_INTERVAL_MS = 60 * 1000;
|
||||
const DEFAULT_MIN_DIFFICULTY = 0.001;
|
||||
const DEFAULT_CLIENT_HASHRATE_PERSIST_INTERVAL_MS = 60 * 1000;
|
||||
const DEFAULT_SUBMISSION_DEDUP_TTL_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_SUBMISSION_DEDUP_MAX_ENTRIES = 10_000;
|
||||
const DEFAULT_MAX_SOCKET_BUFFER_BYTES = 256 * 1024;
|
||||
const VERSION_ROLLING_MASK = 0x1fffe000;
|
||||
|
||||
export interface MiningJobBroadcastResult {
|
||||
status: 'written' | 'backpressured' | 'skipped' | 'closed' | 'error';
|
||||
bytes: number;
|
||||
bufferedBytes: number;
|
||||
}
|
||||
|
||||
export class StratumV1Client {
|
||||
private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>();
|
||||
@@ -47,7 +62,6 @@ export class StratumV1Client {
|
||||
private clientConfiguration: ConfigurationMessage;
|
||||
private clientAuthorization: AuthorizationMessage;
|
||||
private clientSuggestedDifficulty: SuggestDifficulty;
|
||||
private stratumSubscription: Subscription;
|
||||
private backgroundWork: NodeJS.Timeout[] = [];
|
||||
private readonly socketDataHandler: (data: Buffer) => void;
|
||||
private destroyPromise: Promise<void> | null = null;
|
||||
@@ -69,9 +83,12 @@ export class StratumV1Client {
|
||||
private buffer: string = '';
|
||||
private connectionClosed = false;
|
||||
private lastSentMiningJobTimestamp: number = null;
|
||||
private lastSentMiningJobSignature: string = null;
|
||||
private lastHashRatePersistedAt = 0;
|
||||
private readonly network: bitcoinjs.Network;
|
||||
private readonly maxSocketBufferBytes: number;
|
||||
|
||||
private miningSubmissionHashes = new Set<string>()
|
||||
private miningSubmissionHashes = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
public readonly socket: Socket,
|
||||
@@ -93,6 +110,8 @@ export class StratumV1Client {
|
||||
void this.handleSocketData(data);
|
||||
};
|
||||
this.socket.on('data', this.socketDataHandler);
|
||||
this.network = this.getNetwork();
|
||||
this.maxSocketBufferBytes = this.readMaxSocketBufferBytes();
|
||||
|
||||
|
||||
}
|
||||
@@ -111,11 +130,6 @@ export class StratumV1Client {
|
||||
this.socket.removeListener('data', this.socketDataHandler);
|
||||
this.buffer = '';
|
||||
|
||||
if (this.stratumSubscription != null) {
|
||||
this.stratumSubscription.unsubscribe();
|
||||
this.stratumSubscription = null;
|
||||
}
|
||||
|
||||
for (const work of this.backgroundWork) {
|
||||
clearInterval(work);
|
||||
}
|
||||
@@ -405,7 +419,6 @@ export class StratumV1Client {
|
||||
}
|
||||
|
||||
private async initStratum() {
|
||||
this.stratumInitialized = true;
|
||||
this.socket.setTimeout(0);
|
||||
|
||||
if (this.isBlockedUserAgent(this.clientSubscription.userAgent)) {
|
||||
@@ -423,17 +436,10 @@ export class StratumV1Client {
|
||||
}
|
||||
}
|
||||
|
||||
this.stratumSubscription = this.stratumV1JobsService.newMiningJob$.subscribe(async (jobTemplate) => {
|
||||
try {
|
||||
if(jobTemplate.blockData.clearJobs){
|
||||
this.miningSubmissionHashes.clear();
|
||||
}
|
||||
await this.sendNewMiningJob(jobTemplate);
|
||||
} catch (e) {
|
||||
await this.socket.end();
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
await this.ensureClientEntity();
|
||||
this.stratumInitialized = true;
|
||||
const latestJobTemplate = await this.getLatestPayoutJobTemplate();
|
||||
this.broadcastMiningJob(latestJobTemplate);
|
||||
|
||||
this.backgroundWork.push(
|
||||
setInterval(async () => {
|
||||
@@ -448,10 +454,42 @@ export class StratumV1Client {
|
||||
// );
|
||||
}
|
||||
|
||||
private async sendNewMiningJob(jobTemplate: IJobTemplate) {
|
||||
await this.ensureClientEntity();
|
||||
public isReadyForMiningJobs(): boolean {
|
||||
return this.stratumInitialized
|
||||
&& !this.connectionClosed
|
||||
&& !this.socket.destroyed
|
||||
&& !this.socket.writableEnded;
|
||||
}
|
||||
|
||||
let payoutInformation = this.getPayoutInformation(jobTemplate, this.clientAuthorization.address);
|
||||
public broadcastMiningJob(jobTemplate: IJobTemplate, force = false): MiningJobBroadcastResult {
|
||||
if (!this.isReadyForMiningJobs()) {
|
||||
return { status: 'skipped', bytes: 0, bufferedBytes: this.socket.writableLength ?? 0 };
|
||||
}
|
||||
if (jobTemplate.blockData.payoutMode !== 'all'
|
||||
&& jobTemplate.blockData.payoutMode !== this.payoutMode) {
|
||||
return { status: 'skipped', bytes: 0, bufferedBytes: this.socket.writableLength ?? 0 };
|
||||
}
|
||||
|
||||
const signature = [
|
||||
jobTemplate.blockData.id,
|
||||
jobTemplate.block.timestamp,
|
||||
jobTemplate.blockData.clearJobs,
|
||||
].join(':');
|
||||
if (!force && signature === this.lastSentMiningJobSignature) {
|
||||
return { status: 'skipped', bytes: 0, bufferedBytes: this.socket.writableLength ?? 0 };
|
||||
}
|
||||
|
||||
const maximumBufferedBytes = this.maxSocketBufferBytes;
|
||||
const bufferedBeforeBuild = this.socket.writableLength ?? 0;
|
||||
if (bufferedBeforeBuild >= maximumBufferedBytes) {
|
||||
this.closeSocket();
|
||||
return { status: 'closed', bytes: 0, bufferedBytes: bufferedBeforeBuild };
|
||||
}
|
||||
|
||||
const payoutInformation = this.getPayoutInformation(jobTemplate, this.clientAuthorization.address);
|
||||
if (payoutInformation == null) {
|
||||
return { status: 'skipped', bytes: 0, bufferedBytes: this.socket.writableLength ?? 0 };
|
||||
}
|
||||
// const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS');
|
||||
// //50Th/s
|
||||
// this.noFee = false;
|
||||
@@ -474,38 +512,46 @@ export class StratumV1Client {
|
||||
// ];
|
||||
// }
|
||||
|
||||
const networkConfig = this.configService.get('NETWORK');
|
||||
let network;
|
||||
|
||||
if (networkConfig === 'mainnet') {
|
||||
network = bitcoinjs.networks.bitcoin;
|
||||
} else if (networkConfig === 'testnet') {
|
||||
network = bitcoinjs.networks.testnet;
|
||||
} else if (networkConfig === 'regtest') {
|
||||
network = bitcoinjs.networks.regtest;
|
||||
} else {
|
||||
throw new Error('Invalid network configuration');
|
||||
}
|
||||
|
||||
const job = new MiningJob(
|
||||
network,
|
||||
this.stratumV1JobsService.getNextId(),
|
||||
const job = this.stratumV1JobsService.getOrCreateJob(
|
||||
this.network,
|
||||
payoutInformation,
|
||||
jobTemplate
|
||||
jobTemplate,
|
||||
this.getPayoutIdentity(jobTemplate),
|
||||
this.payoutMode,
|
||||
);
|
||||
|
||||
this.stratumV1JobsService.addJob(job);
|
||||
|
||||
|
||||
const success = await this.write(job.response(jobTemplate));
|
||||
if (!success) {
|
||||
return;
|
||||
const payload = job.responseBuffer(jobTemplate);
|
||||
const bufferedBeforeWrite = this.socket.writableLength ?? 0;
|
||||
if (bufferedBeforeWrite >= maximumBufferedBytes
|
||||
|| payload.length >= maximumBufferedBytes - bufferedBeforeWrite) {
|
||||
this.closeSocket();
|
||||
return { status: 'closed', bytes: 0, bufferedBytes: bufferedBeforeWrite };
|
||||
}
|
||||
try {
|
||||
const accepted = this.socket.write(payload);
|
||||
this.lastSentMiningJobTimestamp = jobTemplate.block.timestamp;
|
||||
this.lastSentMiningJobSignature = signature;
|
||||
const bufferedAfterWrite = this.socket.writableLength ?? 0;
|
||||
if (bufferedAfterWrite >= maximumBufferedBytes) {
|
||||
this.closeSocket();
|
||||
return {
|
||||
status: 'closed',
|
||||
bytes: payload.length,
|
||||
bufferedBytes: bufferedAfterWrite,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: accepted ? 'written' : 'backpressured',
|
||||
bytes: payload.length,
|
||||
bufferedBytes: bufferedAfterWrite,
|
||||
};
|
||||
} catch (error) {
|
||||
this.closeSocket();
|
||||
return {
|
||||
status: 'error',
|
||||
bytes: 0,
|
||||
bufferedBytes: this.socket.writableLength ?? 0,
|
||||
};
|
||||
}
|
||||
this.lastSentMiningJobTimestamp = jobTemplate.block.timestamp;
|
||||
|
||||
|
||||
//console.log(`Sent new job to ${this.clientAuthorization.worker}.${this.extraNonceAndSessionId}. (clearJobs: ${jobTemplate.blockData.clearJobs}, fee?: ${!this.noFee})`)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -533,10 +579,10 @@ export class StratumV1Client {
|
||||
|
||||
private async handleMiningSubmission(submission: MiningSubmitMessage) {
|
||||
|
||||
const job = this.stratumV1JobsService.getJobById(submission.jobId);
|
||||
const submissionContext = this.stratumV1JobsService.getSubmissionContext(submission.jobId);
|
||||
|
||||
// a miner may submit a job that doesn't exist anymore if it was removed by a new block notification (or expired, 5 min)
|
||||
if (job == null) {
|
||||
if (submissionContext == null || !this.isOwnedSubmissionContext(submissionContext)) {
|
||||
const err = new StratumErrorMessage(
|
||||
submission.id,
|
||||
eStratumErrorCode.JobNotFound,
|
||||
@@ -549,29 +595,60 @@ export class StratumV1Client {
|
||||
return false;
|
||||
}
|
||||
|
||||
const jobTemplate = this.stratumV1JobsService.getJobTemplateById(job.jobTemplateId);
|
||||
const { job, jobTemplate, status } = submissionContext;
|
||||
|
||||
if (jobTemplate == null) {
|
||||
const err = new StratumErrorMessage(
|
||||
submission.id,
|
||||
eStratumErrorCode.JobNotFound,
|
||||
'Job Template not found').response();
|
||||
//console.log(err);
|
||||
const success = await this.write(err);
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
const versionBits = this.parseUint32Hex(submission.versionMask);
|
||||
const nonce = this.parseUint32Hex(submission.nonce);
|
||||
const timestamp = this.parseUint32Hex(submission.ntime);
|
||||
if (versionBits == null || nonce == null || timestamp == null) {
|
||||
await this.writeSubmissionError(
|
||||
submission,
|
||||
eStratumErrorCode.OtherUnknown,
|
||||
'Invalid mining submit hexadecimal field',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const unsignedVersionBits = versionBits >>> 0;
|
||||
if (!Number.isInteger(versionBits)
|
||||
|| versionBits < 0
|
||||
|| versionBits > 0xffffffff
|
||||
|| (unsignedVersionBits & (~VERSION_ROLLING_MASK >>> 0)) !== 0) {
|
||||
await this.writeSubmissionError(
|
||||
submission,
|
||||
eStratumErrorCode.OtherUnknown,
|
||||
'Invalid version mask',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// The optional BIP310 field contains replacement bits, not an XOR
|
||||
// delta. A legacy five-field submission leaves the advertised version
|
||||
// unchanged, including any bits already set inside the rolling mask.
|
||||
const effectiveVersionBits = submission.params.length >= 6
|
||||
? unsignedVersionBits
|
||||
: (jobTemplate.block.version >>> 0) & (VERSION_ROLLING_MASK >>> 0);
|
||||
const submittedVersion = MiningJob.applyVersionRolling(
|
||||
jobTemplate.block.version,
|
||||
effectiveVersionBits,
|
||||
VERSION_ROLLING_MASK,
|
||||
);
|
||||
const requiredVersionBits = jobTemplate.blockData.requiredVersionBits ?? 0;
|
||||
if ((submittedVersion & requiredVersionBits) !== requiredVersionBits) {
|
||||
await this.writeSubmissionError(
|
||||
submission,
|
||||
eStratumErrorCode.OtherUnknown,
|
||||
'Invalid version mask',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const submissionHash = [
|
||||
submission.jobId,
|
||||
submission.extraNonce2,
|
||||
submission.ntime,
|
||||
submission.nonce,
|
||||
submission.versionMask ?? ''
|
||||
job.jobId,
|
||||
submission.extraNonce2.toLowerCase(),
|
||||
timestamp.toString(16).padStart(8, '0'),
|
||||
nonce.toString(16).padStart(8, '0'),
|
||||
submittedVersion.toString(16).padStart(8, '0'),
|
||||
].join(':');
|
||||
if (this.miningSubmissionHashes.has(submissionHash)) {
|
||||
if (this.isDuplicateSubmission(submissionHash)) {
|
||||
const err = new StratumErrorMessage(
|
||||
submission.id,
|
||||
eStratumErrorCode.DuplicateShare,
|
||||
@@ -581,76 +658,82 @@ export class StratumV1Client {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
this.miningSubmissionHashes.add(submissionHash);
|
||||
}
|
||||
|
||||
const versionMask = parseInt(submission.versionMask, 16);
|
||||
const nonce = parseInt(submission.nonce, 16);
|
||||
const timestamp = parseInt(submission.ntime, 16);
|
||||
|
||||
const header = job.buildHeaderBuffer(
|
||||
jobTemplate,
|
||||
versionMask,
|
||||
effectiveVersionBits,
|
||||
nonce,
|
||||
this.extraNonceAndSessionId,
|
||||
submission.extraNonce2,
|
||||
timestamp
|
||||
timestamp,
|
||||
VERSION_ROLLING_MASK,
|
||||
);
|
||||
const { submissionDifficulty, hashBuffer } = this.calculateDifficulty(header);
|
||||
|
||||
//console.log(`DIFF: ${submissionDifficulty} of ${this.sessionDifficulty} from ${this.clientAuthorization.worker + '.' + this.extraNonceAndSessionId}`);
|
||||
|
||||
const isBlockCandidate = DifficultyUtils.meetsCompactTarget(
|
||||
hashBuffer,
|
||||
jobTemplate.block.bits,
|
||||
);
|
||||
if (status === 'stale' && !isBlockCandidate) {
|
||||
await this.writeSubmissionError(
|
||||
submission,
|
||||
eStratumErrorCode.JobNotFound,
|
||||
'Stale share',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (DifficultyUtils.meetsTarget(hashBuffer, this.sessionDifficultyTarget)) {
|
||||
const meetsSessionTarget = DifficultyUtils.meetsTarget(hashBuffer, this.sessionDifficultyTarget);
|
||||
if (status === 'current' && !isBlockCandidate && !meetsSessionTarget) {
|
||||
await this.writeSubmissionError(
|
||||
submission,
|
||||
eStratumErrorCode.LowDifficultyShare,
|
||||
'Difficulty too low',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const creditedDifficulty = isBlockCandidate && !meetsSessionTarget
|
||||
? Math.min(this.sessionDifficulty, submissionDifficulty)
|
||||
: this.sessionDifficulty;
|
||||
|
||||
let blockSubmissionResult: string = null;
|
||||
if (status === 'stale') {
|
||||
blockSubmissionResult = await this.submitBlockCandidate(
|
||||
job,
|
||||
jobTemplate,
|
||||
submission,
|
||||
effectiveVersionBits,
|
||||
nonce,
|
||||
timestamp,
|
||||
);
|
||||
if (!this.isSuccessfulBlockSubmission(blockSubmissionResult)) {
|
||||
await this.writeSubmissionError(
|
||||
submission,
|
||||
eStratumErrorCode.JobNotFound,
|
||||
'Stale share',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const success = await this.write(JSON.stringify(submission.response()) + '\n');
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let blockSubmissionResult: string = null;
|
||||
const isBlockCandidate = DifficultyUtils.meetsTarget(
|
||||
hashBuffer,
|
||||
DifficultyUtils.difficultyToTarget(jobTemplate.blockData.networkDifficulty),
|
||||
);
|
||||
if (isBlockCandidate) {
|
||||
console.log('!!! BLOCK FOUND !!!');
|
||||
const updatedJobBlock = job.copyAndUpdateBlock(
|
||||
if (status === 'current' && isBlockCandidate) {
|
||||
blockSubmissionResult = await this.submitBlockCandidate(
|
||||
job,
|
||||
jobTemplate,
|
||||
versionMask,
|
||||
submission,
|
||||
effectiveVersionBits,
|
||||
nonce,
|
||||
this.extraNonceAndSessionId,
|
||||
submission.extraNonce2,
|
||||
timestamp
|
||||
timestamp,
|
||||
);
|
||||
const blockHex = updatedJobBlock.toHex(false);
|
||||
blockSubmissionResult = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
|
||||
await this.blocksService.save({
|
||||
height: jobTemplate.blockData.height,
|
||||
minerAddress: this.clientAuthorization.address,
|
||||
worker: this.clientAuthorization.worker,
|
||||
sessionId: this.extraNonceAndSessionId,
|
||||
blockData: blockHex,
|
||||
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
|
||||
if (this.isSuccessfulBlockSubmission(blockSubmissionResult)) {
|
||||
await this.addressSettingsService.resetBestDifficultyAndShares();
|
||||
}
|
||||
}
|
||||
await this.ensureClientEntity();
|
||||
try {
|
||||
@@ -664,19 +747,17 @@ export class StratumV1Client {
|
||||
jobId: job.jobId,
|
||||
jobTemplateId: job.jobTemplateId,
|
||||
blockHeight: jobTemplate.blockData.height,
|
||||
creditedDifficulty: this.sessionDifficulty,
|
||||
creditedDifficulty,
|
||||
submissionDifficulty,
|
||||
networkDifficulty: jobTemplate.blockData.networkDifficulty,
|
||||
nonce: submission.nonce,
|
||||
ntime: submission.ntime,
|
||||
version: Number.isFinite(versionMask)
|
||||
? (jobTemplate.block.version ^ versionMask).toString(16)
|
||||
: jobTemplate.block.version.toString(16),
|
||||
version: submittedVersion.toString(16),
|
||||
extraNonce2: submission.extraNonce2,
|
||||
isBlockCandidate,
|
||||
blockSubmissionResult,
|
||||
});
|
||||
await this.statistics.addShares(this.clientEntity, this.sessionDifficulty);
|
||||
await this.statistics.addShares(this.clientEntity, creditedDifficulty);
|
||||
const now = new Date();
|
||||
this.clientEntity.updatedAt = now;
|
||||
this.clientEntity.hashRate = this.statistics.hashRate;
|
||||
@@ -691,21 +772,6 @@ export class StratumV1Client {
|
||||
this.clientEntity.bestDifficulty = submissionDifficulty;
|
||||
await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.clientEntity.userAgent);
|
||||
}
|
||||
|
||||
|
||||
|
||||
} else {
|
||||
const err = new StratumErrorMessage(
|
||||
submission.id,
|
||||
eStratumErrorCode.LowDifficultyShare,
|
||||
'Difficulty too low').response();
|
||||
|
||||
const success = await this.write(err);
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
//await this.checkDifficulty();
|
||||
@@ -713,6 +779,71 @@ export class StratumV1Client {
|
||||
|
||||
}
|
||||
|
||||
private async submitBlockCandidate(
|
||||
job: MiningJob,
|
||||
jobTemplate: IJobTemplate,
|
||||
submission: MiningSubmitMessage,
|
||||
versionBits: number,
|
||||
nonce: number,
|
||||
timestamp: number,
|
||||
): Promise<string> {
|
||||
console.log('!!! BLOCK FOUND !!!');
|
||||
const updatedJobBlock = job.copyAndUpdateBlock(
|
||||
jobTemplate,
|
||||
versionBits,
|
||||
nonce,
|
||||
this.extraNonceAndSessionId,
|
||||
submission.extraNonce2,
|
||||
timestamp,
|
||||
VERSION_ROLLING_MASK,
|
||||
);
|
||||
const blockHex = updatedJobBlock.toHex(false);
|
||||
const blockSubmissionResult = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
|
||||
await this.blocksService.save({
|
||||
height: jobTemplate.blockData.height,
|
||||
minerAddress: this.clientAuthorization.address,
|
||||
worker: this.clientAuthorization.worker,
|
||||
sessionId: this.extraNonceAndSessionId,
|
||||
blockData: blockHex,
|
||||
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,
|
||||
);
|
||||
if (this.isSuccessfulBlockSubmission(blockSubmissionResult)) {
|
||||
await this.addressSettingsService.resetBestDifficultyAndShares();
|
||||
}
|
||||
return blockSubmissionResult;
|
||||
}
|
||||
|
||||
private async writeSubmissionError(
|
||||
submission: MiningSubmitMessage,
|
||||
code: eStratumErrorCode,
|
||||
message: string,
|
||||
): Promise<boolean> {
|
||||
return this.write(new StratumErrorMessage(
|
||||
submission.id,
|
||||
code,
|
||||
message,
|
||||
).response());
|
||||
}
|
||||
|
||||
private async checkDifficulty() {
|
||||
const targetDiff = this.clampDifficulty(this.statistics.getSuggestedDifficulty(this.sessionDifficulty));
|
||||
if (targetDiff == null) {
|
||||
@@ -733,7 +864,7 @@ export class StratumV1Client {
|
||||
|
||||
await this.socket.write(data);
|
||||
|
||||
const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$);
|
||||
const jobTemplate = await this.getLatestPayoutJobTemplate();
|
||||
const nextTimestamp = Math.max(
|
||||
jobTemplate.block.timestamp,
|
||||
Math.floor(Date.now() / 1000),
|
||||
@@ -748,7 +879,7 @@ export class StratumV1Client {
|
||||
}),
|
||||
blockData: { ...jobTemplate.blockData, clearJobs: true }
|
||||
};
|
||||
await this.sendNewMiningJob(refreshedJobTemplate);
|
||||
this.broadcastMiningJob(refreshedJobTemplate, true);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -860,6 +991,67 @@ export class StratumV1Client {
|
||||
return DEFAULT_CLIENT_HASHRATE_PERSIST_INTERVAL_MS;
|
||||
}
|
||||
|
||||
private isDuplicateSubmission(submissionHash: string): boolean {
|
||||
const now = Date.now();
|
||||
const existingExpiry = this.miningSubmissionHashes.get(submissionHash);
|
||||
if (existingExpiry != null && existingExpiry > now) {
|
||||
return true;
|
||||
}
|
||||
if (existingExpiry != null) {
|
||||
this.miningSubmissionHashes.delete(submissionHash);
|
||||
}
|
||||
|
||||
for (const [hash, expiresAt] of this.miningSubmissionHashes) {
|
||||
if (expiresAt <= now) {
|
||||
this.miningSubmissionHashes.delete(hash);
|
||||
}
|
||||
}
|
||||
|
||||
this.miningSubmissionHashes.set(
|
||||
submissionHash,
|
||||
now + this.getSubmissionDedupTtlMs(),
|
||||
);
|
||||
const maxEntries = this.getSubmissionDedupMaxEntries();
|
||||
while (this.miningSubmissionHashes.size > maxEntries) {
|
||||
const oldestHash = this.miningSubmissionHashes.keys().next().value;
|
||||
if (oldestHash == null) {
|
||||
break;
|
||||
}
|
||||
this.miningSubmissionHashes.delete(oldestHash);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private getSubmissionDedupTtlMs(): number {
|
||||
const configured = Number(
|
||||
this.configService.get<string>('STRATUM_SUBMISSION_DEDUP_TTL_MS')
|
||||
?? process.env.STRATUM_SUBMISSION_DEDUP_TTL_MS,
|
||||
);
|
||||
return Number.isInteger(configured) && configured > 0
|
||||
? configured
|
||||
: DEFAULT_SUBMISSION_DEDUP_TTL_MS;
|
||||
}
|
||||
|
||||
private getSubmissionDedupMaxEntries(): number {
|
||||
const configured = Number(
|
||||
this.configService.get<string>('STRATUM_SUBMISSION_DEDUP_MAX_ENTRIES')
|
||||
?? process.env.STRATUM_SUBMISSION_DEDUP_MAX_ENTRIES,
|
||||
);
|
||||
return Number.isInteger(configured) && configured > 0
|
||||
? configured
|
||||
: DEFAULT_SUBMISSION_DEDUP_MAX_ENTRIES;
|
||||
}
|
||||
|
||||
private readMaxSocketBufferBytes(): number {
|
||||
const configured = Number(
|
||||
this.configService.get<string>('STRATUM_MAX_SOCKET_BUFFER_BYTES')
|
||||
?? process.env.STRATUM_MAX_SOCKET_BUFFER_BYTES,
|
||||
);
|
||||
return Number.isSafeInteger(configured) && configured > 0
|
||||
? configured
|
||||
: DEFAULT_MAX_SOCKET_BUFFER_BYTES;
|
||||
}
|
||||
|
||||
private getValidationErrorSignature(errors: ValidationError[]): string {
|
||||
if (errors.length === 0) {
|
||||
return 'unknown';
|
||||
@@ -905,14 +1097,69 @@ export class StratumV1Client {
|
||||
return Number.isFinite(configured) && configured > 0 ? configured : null;
|
||||
}
|
||||
|
||||
private getPayoutInformation(jobTemplate: IJobTemplate, fallbackAddress: string): AddressObject[] {
|
||||
if (this.payoutMode === 'pplns' && jobTemplate.blockData.payoutOutputs?.length > 0) {
|
||||
return jobTemplate.blockData.payoutOutputs;
|
||||
private getPayoutInformation(jobTemplate: IJobTemplate, fallbackAddress: string): AddressObject[] | null {
|
||||
if (this.payoutMode === 'pplns') {
|
||||
return jobTemplate.blockData.payoutOutputs?.length > 0
|
||||
? jobTemplate.blockData.payoutOutputs
|
||||
: null;
|
||||
}
|
||||
|
||||
return [{ address: fallbackAddress, percent: 100 }];
|
||||
}
|
||||
|
||||
private async getLatestPayoutJobTemplate(): Promise<IJobTemplate> {
|
||||
return this.stratumV1JobsService.getLatestJobTemplate(this.payoutMode)
|
||||
?? firstValueFrom((
|
||||
this.stratumV1JobsService.sv1MiningJob$
|
||||
?? this.stratumV1JobsService.newMiningJob$
|
||||
).pipe(
|
||||
filter(template => template.blockData.payoutMode === 'all'
|
||||
|| template.blockData.payoutMode === this.payoutMode),
|
||||
));
|
||||
}
|
||||
|
||||
private getPayoutIdentity(jobTemplate: IJobTemplate): string | undefined {
|
||||
if (this.payoutMode === 'solo') {
|
||||
return `solo\0${this.clientAuthorization.address}`;
|
||||
}
|
||||
if (jobTemplate.blockData.payoutSnapshotId != null) {
|
||||
return `pplns\0${jobTemplate.blockData.payoutSnapshotId}`;
|
||||
}
|
||||
return jobTemplate.blockData.payoutOutputs?.length > 0
|
||||
? createPayoutOutputIdentity('pplns', jobTemplate.blockData.payoutOutputs)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private isOwnedSubmissionContext(context: IJobSubmissionContext): boolean {
|
||||
const templatePayoutMode = context.jobTemplate.blockData.payoutMode;
|
||||
const expectedPayoutIdentity = this.getPayoutIdentity(context.jobTemplate);
|
||||
return (templatePayoutMode === 'all' || templatePayoutMode === this.payoutMode)
|
||||
&& expectedPayoutIdentity != null
|
||||
&& context.job.ownership?.payoutMode === this.payoutMode
|
||||
&& context.job.ownership.payoutIdentity === expectedPayoutIdentity;
|
||||
}
|
||||
|
||||
private parseUint32Hex(value: string | null | undefined): number | null {
|
||||
if (typeof value !== 'string' || !/^[0-9a-fA-F]{8}$/.test(value)) {
|
||||
return null;
|
||||
}
|
||||
return Number.parseInt(value, 16);
|
||||
}
|
||||
|
||||
private getNetwork(): bitcoinjs.Network {
|
||||
const networkConfig = this.configService.get('NETWORK');
|
||||
if (networkConfig === 'mainnet') {
|
||||
return bitcoinjs.networks.bitcoin;
|
||||
}
|
||||
if (networkConfig === 'testnet') {
|
||||
return bitcoinjs.networks.testnet;
|
||||
}
|
||||
if (networkConfig === 'regtest') {
|
||||
return bitcoinjs.networks.regtest;
|
||||
}
|
||||
throw new Error('Invalid network configuration');
|
||||
}
|
||||
|
||||
private isSuccessfulBlockSubmission(result?: string | null): boolean {
|
||||
return result == null || result === 'SUCCESS!';
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1301
-254
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
import type { PayoutMode } from '../../types/payout-mode';
|
||||
|
||||
export interface IBlockTemplateTx {
|
||||
data: string; //'hex', // (string) transaction data encoded in hexadecimal (byte-for-byte)
|
||||
txid: string; //'hex', // (string) transaction id encoded in little-endian hexadecimal
|
||||
@@ -44,5 +46,16 @@ export interface IBlockTemplate {
|
||||
capabilities: string[]
|
||||
payoutSnapshotId?: string;
|
||||
payoutOutputs?: IBlockTemplatePayoutOutput[];
|
||||
/** Force miners to switch even when the previous block hash is unchanged. */
|
||||
forceCleanJobs?: boolean;
|
||||
/** Distinguishes normal fee-paying templates from the subsidy-only bridge. */
|
||||
jobType?: 'full' | 'empty';
|
||||
/** Limits this template to the payout path whose coinbase data is ready. */
|
||||
payoutMode?: PayoutMode | 'all';
|
||||
/** Correlates master detection, Redis delivery, and socket fan-out traces. */
|
||||
notificationEventId?: string;
|
||||
notificationPublishedAtMs?: number;
|
||||
/** Timestamp of the rolling PPLNS snapshot seed used by an empty bridge. */
|
||||
payoutBridgeSeedCreatedAtMs?: number;
|
||||
|
||||
}
|
||||
|
||||
@@ -62,6 +62,30 @@ describe('MiningSubmitMessage', () => {
|
||||
|
||||
expect(errors.some(error => error.property === 'extraNonce2')).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ field: 'extraNonce2', index: 2, value: '990200000000000g' },
|
||||
{ field: 'ntime', index: 3, value: '64b1f10ftrailing' },
|
||||
{ field: 'nonce', index: 4, value: '2402812g' },
|
||||
{ field: 'versionMask', index: 5, value: '00006000trailing' },
|
||||
])('should reject a non-canonical hexadecimal $field', async ({ field, index, value }) => {
|
||||
const parsed = JSON.parse(MINING_SUBMIT_MESSAGE);
|
||||
parsed.params[index] = value;
|
||||
const invalidMessage = plainToInstance(MiningSubmitMessage, parsed);
|
||||
|
||||
const errors = await validate(invalidMessage);
|
||||
|
||||
expect(errors.some(error => error.property === field)).toBe(true);
|
||||
});
|
||||
|
||||
it('should normalize an omitted version mask to eight zeroes', async () => {
|
||||
const parsed = JSON.parse(MINING_SUBMIT_MESSAGE);
|
||||
parsed.params.pop();
|
||||
const noVersionMask = plainToInstance(MiningSubmitMessage, parsed);
|
||||
|
||||
expect(noVersionMask.versionMask).toBe('00000000');
|
||||
await expect(validate(noVersionMask)).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Expose, Transform } from 'class-transformer';
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, Length } from 'class-validator';
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
import { eRequestMethod } from '../enums/eRequestMethod';
|
||||
import { EXTRANONCE2_SIZE_BYTES } from '../stratum.constants';
|
||||
@@ -29,18 +29,23 @@ export class MiningSubmitMessage extends StratumBaseMessage {
|
||||
@Expose()
|
||||
@IsString()
|
||||
@Length(EXTRANONCE2_SIZE_BYTES * 2, EXTRANONCE2_SIZE_BYTES * 2)
|
||||
@Matches(/^[0-9a-fA-F]+$/)
|
||||
@Transform(({ value, key, obj, type }) => {
|
||||
return obj.params[2];
|
||||
})
|
||||
public extraNonce2: string;
|
||||
@Expose()
|
||||
@IsString()
|
||||
@Length(8, 8)
|
||||
@Matches(/^[0-9a-fA-F]{8}$/)
|
||||
@Transform(({ value, key, obj, type }) => {
|
||||
return obj.params[3];
|
||||
})
|
||||
public ntime: string;
|
||||
@Expose()
|
||||
@IsString()
|
||||
@Length(8, 8)
|
||||
@Matches(/^[0-9a-fA-F]{8}$/)
|
||||
@Transform(({ value, key, obj, type }) => {
|
||||
return obj.params[4];
|
||||
})
|
||||
@@ -48,8 +53,10 @@ export class MiningSubmitMessage extends StratumBaseMessage {
|
||||
|
||||
@Expose()
|
||||
@IsString()
|
||||
@Length(8, 8)
|
||||
@Matches(/^[0-9a-fA-F]{8}$/)
|
||||
@Transform(({ value, key, obj, type }) => {
|
||||
return obj.params[5] == null ? '0' : obj.params[5];
|
||||
return obj.params[5] == null ? '00000000' : obj.params[5];
|
||||
})
|
||||
public versionMask?: string | null;
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Sv2ExtranonceManager } from './sv2-extranonce-manager';
|
||||
import {
|
||||
resolveSv2ProcessNamespace,
|
||||
Sv2ExtranonceManager,
|
||||
} from './sv2-extranonce-manager';
|
||||
|
||||
|
||||
describe('Sv2ExtranonceManager', () => {
|
||||
@@ -114,4 +117,68 @@ describe('Sv2ExtranonceManager', () => {
|
||||
expect(mgr.allocatedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('partitions prefixes deterministically across PM2 workers and reload generations', () => {
|
||||
const worker0 = resolveSv2ProcessNamespace({
|
||||
NODE_APP_INSTANCE: '0',
|
||||
restart_time: '0',
|
||||
});
|
||||
const worker1 = resolveSv2ProcessNamespace({
|
||||
NODE_APP_INSTANCE: '1',
|
||||
restart_time: '0',
|
||||
});
|
||||
const worker0Reload = resolveSv2ProcessNamespace({
|
||||
NODE_APP_INSTANCE: '0',
|
||||
restart_time: '1',
|
||||
});
|
||||
const prefixes = [worker0, worker1, worker0Reload].map(namespace => (
|
||||
new Sv2ExtranonceManager(4, 14, namespace).allocate(1).toString('hex')
|
||||
));
|
||||
|
||||
expect([worker0, worker1, worker0Reload]).toEqual([0, 2, 1]);
|
||||
expect(new Set(prefixes).size).toBe(3);
|
||||
expect(prefixes).toEqual(['00000001', '02000001', '01000001']);
|
||||
});
|
||||
|
||||
it('offsets PM2 worker lanes with an explicit deployment namespace base', () => {
|
||||
expect(resolveSv2ProcessNamespace({
|
||||
SV2_EXTRANONCE_NAMESPACE_BASE: '32',
|
||||
NODE_APP_INSTANCE: '3',
|
||||
restart_time: '5',
|
||||
})).toBe(39);
|
||||
});
|
||||
|
||||
it('falls back to pm_id when NODE_APP_INSTANCE is unavailable', () => {
|
||||
expect(resolveSv2ProcessNamespace({
|
||||
pm_id: '7',
|
||||
restart_time: '2',
|
||||
})).toBe(14);
|
||||
});
|
||||
|
||||
it('fails closed for managed workers without a unique process identifier', () => {
|
||||
expect(() => resolveSv2ProcessNamespace({ PM2_ENABLED: 'true' }))
|
||||
.toThrow('cannot allocate collision-free extranonces');
|
||||
});
|
||||
|
||||
it('fails when the worker namespace cannot fit in the four-byte prefix', () => {
|
||||
expect(() => resolveSv2ProcessNamespace({
|
||||
NODE_APP_INSTANCE: '128',
|
||||
restart_time: '0',
|
||||
})).toThrow('does not fit in one byte');
|
||||
expect(() => new Sv2ExtranonceManager(4, 14, 256))
|
||||
.toThrow('must fit in one byte');
|
||||
});
|
||||
|
||||
it('rejects prefix and total sizes that cannot preserve the namespace', () => {
|
||||
expect(() => new Sv2ExtranonceManager(1, 14, 0))
|
||||
.toThrow('between 2 and 4 bytes');
|
||||
expect(() => new Sv2ExtranonceManager(4, 3, 0))
|
||||
.toThrow('must include the pool prefix');
|
||||
});
|
||||
|
||||
it('rejects channel identifiers that cannot be represented by SV2', () => {
|
||||
const mgr = new Sv2ExtranonceManager();
|
||||
expect(() => mgr.allocate(0)).toThrow('unsigned non-zero 32-bit integer');
|
||||
expect(() => mgr.allocate(0x1_0000_0000)).toThrow('unsigned non-zero 32-bit integer');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -6,12 +6,12 @@ import { SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES } from '../stratum.constants';
|
||||
|
||||
export class Sv2ExtranonceManager {
|
||||
private nextPrefix: number;
|
||||
private readonly workerOffset: number;
|
||||
private readonly maxPrefix: number;
|
||||
private allocatedPrefixes = new Map<number, number>(); // channelId → prefix
|
||||
private usedPrefixes = new Set<number>();
|
||||
private readonly prefixSize: number;
|
||||
private readonly totalExtranonceSize: number;
|
||||
private readonly processNamespace: number;
|
||||
|
||||
/**
|
||||
* @param prefixSize Bytes used for pool-assigned prefix (default 4)
|
||||
@@ -19,13 +19,25 @@ export class Sv2ExtranonceManager {
|
||||
* SV2 extended jobs patch the coinbase script length when the
|
||||
* negotiated total differs from the SV1-compatible template slot.
|
||||
*/
|
||||
constructor(prefixSize = 4, totalExtranonceSize = SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES) {
|
||||
constructor(
|
||||
prefixSize = 4,
|
||||
totalExtranonceSize = SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES,
|
||||
processNamespace = 0,
|
||||
) {
|
||||
if (!Number.isInteger(prefixSize) || prefixSize < 2 || prefixSize > 4) {
|
||||
throw new Error('SV2 extranonce prefix size must be between 2 and 4 bytes');
|
||||
}
|
||||
if (!Number.isInteger(totalExtranonceSize) || totalExtranonceSize < prefixSize) {
|
||||
throw new Error('SV2 total extranonce size must include the pool prefix');
|
||||
}
|
||||
if (!Number.isInteger(processNamespace) || processNamespace < 0 || processNamespace > 0xff) {
|
||||
throw new Error('SV2 process namespace must fit in one byte');
|
||||
}
|
||||
this.prefixSize = prefixSize;
|
||||
this.totalExtranonceSize = totalExtranonceSize;
|
||||
this.processNamespace = processNamespace;
|
||||
|
||||
const workerId = 0;
|
||||
const bitsPerWorker = (prefixSize - 1) * 8;
|
||||
this.workerOffset = workerId * Math.pow(2, bitsPerWorker);
|
||||
this.maxPrefix = Math.pow(2, bitsPerWorker) - 1; // max within this worker's slice
|
||||
this.nextPrefix = 1;
|
||||
}
|
||||
@@ -43,6 +55,9 @@ export class Sv2ExtranonceManager {
|
||||
* Returns a Buffer of `prefixSize` bytes.
|
||||
*/
|
||||
allocate(channelId: number): Buffer {
|
||||
if (!Number.isInteger(channelId) || channelId < 1 || channelId > 0xffffffff) {
|
||||
throw new Error('SV2 channel ID must be an unsigned non-zero 32-bit integer');
|
||||
}
|
||||
// Check if already allocated
|
||||
if (this.allocatedPrefixes.has(channelId)) {
|
||||
const existing = this.allocatedPrefixes.get(channelId)!;
|
||||
@@ -52,19 +67,17 @@ export class Sv2ExtranonceManager {
|
||||
// Find next available prefix within this worker's partition
|
||||
let localPrefix = this.nextPrefix;
|
||||
let attempts = 0;
|
||||
const globalPrefix = () => this.workerOffset + localPrefix;
|
||||
|
||||
while (this.usedPrefixes.has(globalPrefix()) && attempts <= this.maxPrefix) {
|
||||
while (this.usedPrefixes.has(localPrefix) && attempts <= this.maxPrefix) {
|
||||
localPrefix = localPrefix + 1;
|
||||
if (localPrefix > this.maxPrefix) localPrefix = 1; // Wrap within partition, skip 0
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (this.usedPrefixes.has(globalPrefix())) {
|
||||
if (this.usedPrefixes.has(localPrefix)) {
|
||||
throw new Error('Extranonce prefix space exhausted');
|
||||
}
|
||||
|
||||
const prefix = globalPrefix();
|
||||
const prefix = localPrefix;
|
||||
this.allocatedPrefixes.set(channelId, prefix);
|
||||
this.usedPrefixes.add(prefix);
|
||||
this.nextPrefix = localPrefix + 1;
|
||||
@@ -99,19 +112,90 @@ export class Sv2ExtranonceManager {
|
||||
|
||||
private prefixToBuffer(prefix: number): Buffer {
|
||||
const buf = Buffer.alloc(this.prefixSize);
|
||||
// Write as big-endian so prefix 1 = 0x00000001
|
||||
if (this.prefixSize === 4) {
|
||||
buf.writeUInt32BE(prefix, 0);
|
||||
} else if (this.prefixSize === 2) {
|
||||
buf.writeUInt16BE(prefix, 0);
|
||||
} else {
|
||||
// Generic: write big-endian
|
||||
let val = prefix;
|
||||
for (let i = this.prefixSize - 1; i >= 0; i--) {
|
||||
buf[i] = val & 0xff;
|
||||
val = val >>> 8;
|
||||
}
|
||||
buf[0] = this.processNamespace;
|
||||
// The remaining bytes are a process-local, big-endian allocation. Prefix
|
||||
// zero stays reserved so an all-zero pool prefix is never issued.
|
||||
let val = prefix;
|
||||
for (let i = this.prefixSize - 1; i >= 1; i--) {
|
||||
buf[i] = val % 0x100;
|
||||
val = Math.floor(val / 0x100);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
|
||||
export const SV2_MAX_PROCESS_NAMESPACE = 0xff;
|
||||
|
||||
/**
|
||||
* Resolve the byte that namespaces every pool-assigned SV2 extranonce prefix.
|
||||
* PM2 can overlap old and new workers during reload, so each worker receives
|
||||
* two lanes selected by restart-generation parity.
|
||||
*/
|
||||
export function resolveSv2ProcessNamespace(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
clusterWorkerIndex?: number,
|
||||
): number {
|
||||
const base = parseNamespaceInteger(
|
||||
'SV2_EXTRANONCE_NAMESPACE_BASE',
|
||||
env.SV2_EXTRANONCE_NAMESPACE_BASE,
|
||||
true,
|
||||
) ?? 0;
|
||||
const nodeAppInstance = parseNamespaceInteger(
|
||||
'NODE_APP_INSTANCE',
|
||||
env.NODE_APP_INSTANCE,
|
||||
true,
|
||||
);
|
||||
const pmId = parseNamespaceInteger('pm_id', env.pm_id, true);
|
||||
const workerIndex = nodeAppInstance ?? pmId ?? clusterWorkerIndex;
|
||||
|
||||
if (workerIndex == null) {
|
||||
if (env.PM2_ENABLED?.toLowerCase() === 'true') {
|
||||
throw new Error(
|
||||
'PM2 SV2 worker has no NODE_APP_INSTANCE or pm_id; cannot allocate collision-free extranonces',
|
||||
);
|
||||
}
|
||||
assertNamespaceFits(base);
|
||||
return base;
|
||||
}
|
||||
if (!Number.isInteger(workerIndex) || workerIndex < 0) {
|
||||
throw new Error('SV2 worker index must be a non-negative integer');
|
||||
}
|
||||
|
||||
const restartGeneration = parseNamespaceInteger(
|
||||
'restart_time',
|
||||
env.restart_time,
|
||||
true,
|
||||
) ?? 0;
|
||||
const namespace = base + (workerIndex * 2) + (restartGeneration % 2);
|
||||
assertNamespaceFits(namespace);
|
||||
return namespace;
|
||||
}
|
||||
|
||||
function parseNamespaceInteger(
|
||||
name: string,
|
||||
value: string | undefined,
|
||||
optional: boolean,
|
||||
): number | null {
|
||||
if (value == null || value.trim().length === 0) {
|
||||
if (optional) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
if (!/^\d+$/.test(value.trim())) {
|
||||
throw new Error(`${name} must be a non-negative integer`);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new Error(`${name} must be a safe integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function assertNamespaceFits(namespace: number): void {
|
||||
if (!Number.isInteger(namespace) || namespace < 0 || namespace > SV2_MAX_PROCESS_NAMESPACE) {
|
||||
throw new Error(
|
||||
`SV2 extranonce namespace ${namespace} does not fit in one byte; reduce worker count or namespace base`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1271
-44
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,292 @@
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import * as crypto from 'crypto';
|
||||
import * as merkle from 'merkle-lib';
|
||||
import * as merkleProof from 'merkle-lib/proof';
|
||||
|
||||
import { MockRecording1 } from '../../test/models/MockRecording1';
|
||||
import type { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
|
||||
import { hash256 } from '../utils/hash.utils';
|
||||
import {
|
||||
createPreparedMiningJob,
|
||||
PREPARED_JOB_BODY_CHECKSUM_ALGORITHM,
|
||||
} from './prepared-mining-job.factory';
|
||||
|
||||
describe('prepared mining job factory', () => {
|
||||
it('prepares a compact full job from authoritative GBT fields', () => {
|
||||
const template = cloneTemplate();
|
||||
const prepared = createPreparedMiningJob(template);
|
||||
const parsedTransactions = template.transactions.map((transaction) =>
|
||||
bitcoinjs.Transaction.fromHex(transaction.data),
|
||||
);
|
||||
const expectedBody = checksumTemplateTransactions(template.transactions);
|
||||
|
||||
expect(prepared).toEqual(
|
||||
expect.objectContaining({
|
||||
tipKey: `${template.height}:${template.previousblockhash}`,
|
||||
height: template.height,
|
||||
jobType: 'full',
|
||||
payoutMode: 'all',
|
||||
forceCleanJobs: false,
|
||||
}),
|
||||
);
|
||||
expect(prepared.header).toEqual({
|
||||
previousBlockHash: template.previousblockhash,
|
||||
previousBlockHashLE: Buffer.from(template.previousblockhash, 'hex')
|
||||
.reverse()
|
||||
.toString('hex'),
|
||||
version: template.version,
|
||||
bits: parseInt(template.bits, 16),
|
||||
bitsHex: template.bits,
|
||||
minTime: template.mintime,
|
||||
currentTime: template.curtime,
|
||||
});
|
||||
expect(prepared.coinbase).toEqual({
|
||||
valueSats: template.coinbasevalue,
|
||||
merkleBranch: expect.any(Array),
|
||||
witnessCommitmentHash: template.default_witness_commitment.slice(12, 76),
|
||||
payoutSnapshotId: undefined,
|
||||
payoutOutputs: undefined,
|
||||
});
|
||||
expect(prepared.body).toEqual({
|
||||
reference: `sha256:${expectedBody.checksum}`,
|
||||
checksum: expectedBody.checksum,
|
||||
checksumAlgorithm: PREPARED_JOB_BODY_CHECKSUM_ALGORITHM,
|
||||
transactionCount: parsedTransactions.length,
|
||||
byteLength: expectedBody.byteLength,
|
||||
});
|
||||
expect(prepared).not.toHaveProperty('transactions');
|
||||
});
|
||||
|
||||
it('matches a bitcoinjs/merkle-lib branch and root for the full fixture', () => {
|
||||
const template = cloneTemplate();
|
||||
const prepared = createPreparedMiningJob(template);
|
||||
const coinbase = createPlaceholderCoinbase();
|
||||
const parsedTransactions = template.transactions.map((transaction) =>
|
||||
bitcoinjs.Transaction.fromHex(transaction.data),
|
||||
);
|
||||
const expected = buildBitcoinJsMerkleProof(coinbase, parsedTransactions);
|
||||
|
||||
expect(prepared.coinbase.merkleBranch).toEqual(expected.branch);
|
||||
expect(
|
||||
applyCoinbaseMerkleBranch(
|
||||
coinbase.getHash(false),
|
||||
prepared.coinbase.merkleBranch,
|
||||
),
|
||||
).toEqual(expected.root);
|
||||
expect(expected.root).toEqual(
|
||||
bitcoinjs.Block.calculateMerkleRoot(
|
||||
[coinbase, ...parsedTransactions],
|
||||
false,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
template.transactions.map((transaction) => transaction.txid),
|
||||
).toEqual(parsedTransactions.map((transaction) => transaction.getId()));
|
||||
});
|
||||
|
||||
it('prepares an empty template with an empty branch and coinbase-only root', () => {
|
||||
const witnessCommitmentHash = hash256(Buffer.alloc(64)).toString('hex');
|
||||
const template = cloneTemplate({
|
||||
transactions: [],
|
||||
coinbasevalue: 312_500_000,
|
||||
default_witness_commitment: `6a24aa21a9ed${witnessCommitmentHash}`,
|
||||
forceCleanJobs: true,
|
||||
jobType: 'empty',
|
||||
payoutMode: 'solo',
|
||||
});
|
||||
const prepared = createPreparedMiningJob(template);
|
||||
const coinbase = createPlaceholderCoinbase();
|
||||
const emptyChecksum = crypto.createHash('sha256').digest('hex');
|
||||
|
||||
expect(prepared.coinbase.merkleBranch).toEqual([]);
|
||||
expect(prepared.coinbase.witnessCommitmentHash).toBe(witnessCommitmentHash);
|
||||
expect(prepared.body).toEqual({
|
||||
reference: `sha256:${emptyChecksum}`,
|
||||
checksum: emptyChecksum,
|
||||
checksumAlgorithm: PREPARED_JOB_BODY_CHECKSUM_ALGORITHM,
|
||||
transactionCount: 0,
|
||||
byteLength: 0,
|
||||
});
|
||||
expect(prepared.jobType).toBe('empty');
|
||||
expect(prepared.payoutMode).toBe('solo');
|
||||
expect(prepared.forceCleanJobs).toBe(true);
|
||||
expect(
|
||||
applyCoinbaseMerkleBranch(
|
||||
coinbase.getHash(false),
|
||||
prepared.coinbase.merkleBranch,
|
||||
),
|
||||
).toEqual(bitcoinjs.Block.calculateMerkleRoot([coinbase], false));
|
||||
});
|
||||
|
||||
it('builds the branch from txid even when data is not a parseable transaction', () => {
|
||||
const txid = '0123456789abcdef'.repeat(4);
|
||||
const template = cloneTemplate({
|
||||
transactions: [
|
||||
{
|
||||
...MockRecording1.BLOCK_TEMPLATE.transactions[0],
|
||||
data: '00',
|
||||
txid,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(() => bitcoinjs.Transaction.fromHex('00')).toThrow();
|
||||
expect(createPreparedMiningJob(template).coinbase.merkleBranch).toEqual([
|
||||
Buffer.from(txid, 'hex').reverse().toString('hex'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves payout and job modes while copying payout output objects', () => {
|
||||
const payoutOutputs = [
|
||||
{ address: 'bc1qprepared-a', amountSats: 200_000_000 },
|
||||
{ address: 'bc1qprepared-b', amountSats: 112_500_000 },
|
||||
];
|
||||
const template = cloneTemplate({
|
||||
payoutMode: 'pplns',
|
||||
payoutSnapshotId: 'snapshot-42',
|
||||
payoutOutputs,
|
||||
jobType: 'empty',
|
||||
forceCleanJobs: true,
|
||||
});
|
||||
const prepared = createPreparedMiningJob(template);
|
||||
|
||||
expect(prepared.payoutMode).toBe('pplns');
|
||||
expect(prepared.jobType).toBe('empty');
|
||||
expect(prepared.coinbase.payoutSnapshotId).toBe('snapshot-42');
|
||||
expect(prepared.coinbase.payoutOutputs).toEqual(payoutOutputs);
|
||||
expect(prepared.coinbase.payoutOutputs).not.toBe(payoutOutputs);
|
||||
expect(prepared.coinbase.payoutOutputs?.[0]).not.toBe(payoutOutputs[0]);
|
||||
});
|
||||
|
||||
it('rejects malformed or incorrectly sized authoritative txids', () => {
|
||||
const original = MockRecording1.BLOCK_TEMPLATE.transactions[0];
|
||||
for (const txid of ['11'.repeat(31), '11'.repeat(33), 'zz'.repeat(32)]) {
|
||||
const template = cloneTemplate({
|
||||
transactions: [{ ...original, txid }],
|
||||
});
|
||||
expect(() => createPreparedMiningJob(template)).toThrow(
|
||||
/transactions\[0\]\.txid/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates and parses the BIP141 witness commitment length and prefix', () => {
|
||||
const commitmentHash = 'ab'.repeat(32);
|
||||
const commitment = `6a24aa21a9ed${commitmentHash}`;
|
||||
|
||||
expect(
|
||||
createPreparedMiningJob(
|
||||
cloneTemplate({
|
||||
default_witness_commitment: `${commitment}cafe`,
|
||||
}),
|
||||
).coinbase.witnessCommitmentHash,
|
||||
).toBe(commitmentHash);
|
||||
|
||||
for (const defaultWitnessCommitment of [
|
||||
`6a24aa21a9ed${'ab'.repeat(31)}`,
|
||||
`6a24aa21a9ee${commitmentHash}`,
|
||||
'zz'.repeat(38),
|
||||
]) {
|
||||
expect(() =>
|
||||
createPreparedMiningJob(
|
||||
cloneTemplate({
|
||||
default_witness_commitment: defaultWitnessCommitment,
|
||||
}),
|
||||
),
|
||||
).toThrow(/default_witness_commitment/);
|
||||
}
|
||||
});
|
||||
|
||||
it('defers raw body hex parsing while still requiring a non-empty byte-aligned body', () => {
|
||||
const original = MockRecording1.BLOCK_TEMPLATE.transactions[0];
|
||||
for (const data of ['', '0']) {
|
||||
expect(() =>
|
||||
createPreparedMiningJob(
|
||||
cloneTemplate({
|
||||
transactions: [{ ...original, data }],
|
||||
}),
|
||||
),
|
||||
).toThrow(/transactions\[0\]\.data/);
|
||||
}
|
||||
expect(() => createPreparedMiningJob(cloneTemplate({
|
||||
transactions: [{ ...original, data: 'zz' }],
|
||||
}))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
function cloneTemplate(
|
||||
overrides: Partial<IBlockTemplate> = {},
|
||||
): IBlockTemplate {
|
||||
const template = MockRecording1.BLOCK_TEMPLATE;
|
||||
return {
|
||||
...template,
|
||||
...overrides,
|
||||
rules: [...(overrides.rules ?? template.rules)],
|
||||
vbavailable: { ...(overrides.vbavailable ?? template.vbavailable) },
|
||||
coinbaseaux: { ...(overrides.coinbaseaux ?? template.coinbaseaux) },
|
||||
mutable: [...(overrides.mutable ?? template.mutable)],
|
||||
capabilities: [...(overrides.capabilities ?? template.capabilities)],
|
||||
transactions: (overrides.transactions ?? template.transactions).map(
|
||||
(transaction) => ({
|
||||
...transaction,
|
||||
depends: [...transaction.depends],
|
||||
}),
|
||||
),
|
||||
payoutOutputs: (overrides.payoutOutputs ?? template.payoutOutputs)?.map(
|
||||
(output) => ({ ...output }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function createPlaceholderCoinbase(): bitcoinjs.Transaction {
|
||||
const coinbase = new bitcoinjs.Transaction();
|
||||
coinbase.version = 2;
|
||||
coinbase.addInput(Buffer.alloc(32), 0xffffffff, 0xffffffff);
|
||||
coinbase.ins[0].witness = [Buffer.alloc(32)];
|
||||
return coinbase;
|
||||
}
|
||||
|
||||
function buildBitcoinJsMerkleProof(
|
||||
coinbase: bitcoinjs.Transaction,
|
||||
transactions: bitcoinjs.Transaction[],
|
||||
): { branch: string[]; root: Buffer } {
|
||||
const hashes = [
|
||||
coinbase.getHash(false),
|
||||
...transactions.map((transaction) => transaction.getHash(false)),
|
||||
];
|
||||
const tree = merkle(hashes, hash256);
|
||||
const proof = merkleProof(tree, hashes[0]).filter(
|
||||
(hash): hash is Buffer => hash != null,
|
||||
);
|
||||
|
||||
return {
|
||||
branch: proof.slice(1, -1).map((hash) => hash.toString('hex')),
|
||||
root: proof[proof.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
function applyCoinbaseMerkleBranch(
|
||||
coinbaseHash: Buffer,
|
||||
branch: readonly string[],
|
||||
): Buffer {
|
||||
return branch.reduce(
|
||||
(root, sibling) =>
|
||||
hash256(Buffer.concat([root, Buffer.from(sibling, 'hex')])),
|
||||
coinbaseHash,
|
||||
);
|
||||
}
|
||||
|
||||
function checksumTemplateTransactions(
|
||||
transactions: IBlockTemplate['transactions'],
|
||||
): {
|
||||
checksum: string;
|
||||
byteLength: number;
|
||||
} {
|
||||
const checksum = crypto.createHash('sha256');
|
||||
let byteLength = 0;
|
||||
for (const transaction of transactions) {
|
||||
checksum.update(Buffer.from(transaction.txid, 'hex'));
|
||||
byteLength += transaction.data.length / 2;
|
||||
}
|
||||
return { checksum: checksum.digest('hex'), byteLength };
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
import type {
|
||||
IBlockTemplate,
|
||||
IBlockTemplatePayoutOutput,
|
||||
IBlockTemplateTx,
|
||||
} from '../models/bitcoin-rpc/IBlockTemplate';
|
||||
import type { PayoutMode } from '../types/payout-mode';
|
||||
import { hash256 } from '../utils/hash.utils';
|
||||
|
||||
const WITNESS_COMMITMENT_PREFIX = '6a24aa21a9ed';
|
||||
const HASH_BYTES = 32;
|
||||
const UINT32_MAX = 0xffffffff;
|
||||
const INT32_MIN = -0x80000000;
|
||||
const INT32_MAX = 0x7fffffff;
|
||||
|
||||
export const PREPARED_JOB_BODY_CHECKSUM_ALGORITHM =
|
||||
'sha256-txids-v1' as const;
|
||||
|
||||
export interface PreparedMiningJobHeader {
|
||||
/** Display/RPC byte order, normalized to lowercase hex. */
|
||||
previousBlockHash: string;
|
||||
/** Internal header byte order, normalized to lowercase hex. */
|
||||
previousBlockHashLE: string;
|
||||
version: number;
|
||||
bits: number;
|
||||
bitsHex: string;
|
||||
minTime: number;
|
||||
currentTime: number;
|
||||
}
|
||||
|
||||
export interface PreparedMiningJobCoinbase {
|
||||
valueSats: number;
|
||||
/** Internal-byte-order hashes, ready for the Stratum coinbase Merkle path. */
|
||||
merkleBranch: readonly string[];
|
||||
/** The 32-byte hash following the BIP141 commitment header. */
|
||||
witnessCommitmentHash: string;
|
||||
payoutSnapshotId?: string;
|
||||
payoutOutputs?: ReadonlyArray<Readonly<IBlockTemplatePayoutOutput>>;
|
||||
}
|
||||
|
||||
export interface PreparedMiningJobBodyReference {
|
||||
reference: string;
|
||||
checksum: string;
|
||||
checksumAlgorithm: typeof PREPARED_JOB_BODY_CHECKSUM_ALGORITHM;
|
||||
transactionCount: number;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
export interface PreparedMiningJob {
|
||||
tipKey: string;
|
||||
height: number;
|
||||
header: Readonly<PreparedMiningJobHeader>;
|
||||
coinbase: Readonly<PreparedMiningJobCoinbase>;
|
||||
body: Readonly<PreparedMiningJobBodyReference>;
|
||||
jobType: 'full' | 'empty';
|
||||
payoutMode: PayoutMode | 'all';
|
||||
forceCleanJobs: boolean;
|
||||
}
|
||||
|
||||
export function createPreparedMiningJob(
|
||||
template: Readonly<IBlockTemplate>,
|
||||
): PreparedMiningJob {
|
||||
validateTemplateNumbers(template);
|
||||
|
||||
const previousBlockHash = parseHex(
|
||||
template.previousblockhash,
|
||||
'previousblockhash',
|
||||
HASH_BYTES,
|
||||
);
|
||||
const bits = parseHex(template.bits, 'bits', 4);
|
||||
const witnessCommitmentHash = parseWitnessCommitment(
|
||||
template.default_witness_commitment,
|
||||
);
|
||||
const body = prepareBodyReference(template.transactions);
|
||||
const merkleBranch = buildCoinbaseMerkleBranch(template.transactions);
|
||||
const normalizedPreviousBlockHash = previousBlockHash.toString('hex');
|
||||
|
||||
return {
|
||||
tipKey: `${template.height}:${normalizedPreviousBlockHash}`,
|
||||
height: template.height,
|
||||
header: {
|
||||
previousBlockHash: normalizedPreviousBlockHash,
|
||||
previousBlockHashLE: Buffer.from(previousBlockHash)
|
||||
.reverse()
|
||||
.toString('hex'),
|
||||
version: template.version,
|
||||
bits: bits.readUInt32BE(0),
|
||||
bitsHex: bits.toString('hex'),
|
||||
minTime: template.mintime,
|
||||
currentTime: template.curtime,
|
||||
},
|
||||
coinbase: {
|
||||
valueSats: template.coinbasevalue,
|
||||
merkleBranch,
|
||||
witnessCommitmentHash,
|
||||
payoutSnapshotId: template.payoutSnapshotId,
|
||||
payoutOutputs: template.payoutOutputs?.map((output) => ({ ...output })),
|
||||
},
|
||||
body,
|
||||
jobType: template.jobType ?? 'full',
|
||||
payoutMode: template.payoutMode ?? 'all',
|
||||
forceCleanJobs: template.forceCleanJobs === true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCoinbaseMerkleBranch(
|
||||
transactions: readonly IBlockTemplateTx[],
|
||||
): string[] {
|
||||
let level: Buffer[] = [
|
||||
Buffer.alloc(HASH_BYTES),
|
||||
...transactions.map((transaction, index) =>
|
||||
Buffer.from(
|
||||
parseHex(transaction.txid, `transactions[${index}].txid`, HASH_BYTES),
|
||||
).reverse(),
|
||||
),
|
||||
];
|
||||
const branch: string[] = [];
|
||||
|
||||
while (level.length > 1) {
|
||||
if (level.length % 2 !== 0) {
|
||||
level.push(Buffer.from(level[level.length - 1]));
|
||||
}
|
||||
|
||||
branch.push(level[1].toString('hex'));
|
||||
const nextLevel: Buffer[] = [];
|
||||
for (let index = 0; index < level.length; index += 2) {
|
||||
nextLevel.push(hash256(Buffer.concat([level[index], level[index + 1]])));
|
||||
}
|
||||
level = nextLevel;
|
||||
}
|
||||
|
||||
return branch;
|
||||
}
|
||||
|
||||
function parseWitnessCommitment(commitment: string): string {
|
||||
const script = parseHex(commitment, 'default_witness_commitment');
|
||||
const minimumBytes = WITNESS_COMMITMENT_PREFIX.length / 2 + HASH_BYTES;
|
||||
if (script.length < minimumBytes) {
|
||||
throw new Error(
|
||||
`default_witness_commitment must be at least ${minimumBytes} bytes`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!script
|
||||
.subarray(0, WITNESS_COMMITMENT_PREFIX.length / 2)
|
||||
.equals(Buffer.from(WITNESS_COMMITMENT_PREFIX, 'hex'))
|
||||
) {
|
||||
throw new Error('default_witness_commitment has an invalid BIP141 prefix');
|
||||
}
|
||||
|
||||
return script
|
||||
.subarray(WITNESS_COMMITMENT_PREFIX.length / 2, minimumBytes)
|
||||
.toString('hex');
|
||||
}
|
||||
|
||||
function prepareBodyReference(
|
||||
transactions: readonly IBlockTemplateTx[],
|
||||
): PreparedMiningJobBodyReference {
|
||||
const checksum = crypto.createHash('sha256');
|
||||
let byteLength = 0;
|
||||
|
||||
transactions.forEach((transaction, index) => {
|
||||
const txid = parseHex(
|
||||
transaction.txid,
|
||||
`transactions[${index}].txid`,
|
||||
HASH_BYTES,
|
||||
);
|
||||
if (
|
||||
typeof transaction.data !== 'string' ||
|
||||
transaction.data.length === 0 ||
|
||||
transaction.data.length % 2 !== 0
|
||||
) {
|
||||
throw new Error(`transactions[${index}].data must not be empty`);
|
||||
}
|
||||
const transactionBytes = transaction.data.length / 2;
|
||||
if (transactionBytes > UINT32_MAX) {
|
||||
throw new Error(`transactions[${index}].data exceeds the checksum frame`);
|
||||
}
|
||||
checksum.update(txid);
|
||||
byteLength += transactionBytes;
|
||||
});
|
||||
|
||||
const checksumHex = checksum.digest('hex');
|
||||
return {
|
||||
reference: `sha256:${checksumHex}`,
|
||||
checksum: checksumHex,
|
||||
checksumAlgorithm: PREPARED_JOB_BODY_CHECKSUM_ALGORITHM,
|
||||
transactionCount: transactions.length,
|
||||
byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
function parseHex(value: string, field: string, exactBytes?: number): Buffer {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length % 2 !== 0 ||
|
||||
!/^[0-9a-f]*$/i.test(value)
|
||||
) {
|
||||
throw new Error(`${field} must be valid hexadecimal`);
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(value, 'hex');
|
||||
if (exactBytes != null && bytes.length !== exactBytes) {
|
||||
throw new Error(`${field} must be exactly ${exactBytes} bytes`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function validateTemplateNumbers(template: Readonly<IBlockTemplate>): void {
|
||||
if (
|
||||
!Number.isInteger(template.version) ||
|
||||
template.version < INT32_MIN ||
|
||||
template.version > INT32_MAX
|
||||
) {
|
||||
throw new Error('version must be a signed 32-bit integer');
|
||||
}
|
||||
validateUnsignedInteger(template.height, 'height', Number.MAX_SAFE_INTEGER);
|
||||
validateUnsignedInteger(
|
||||
template.coinbasevalue,
|
||||
'coinbasevalue',
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
validateUnsignedInteger(template.mintime, 'mintime', UINT32_MAX);
|
||||
validateUnsignedInteger(template.curtime, 'curtime', UINT32_MAX);
|
||||
}
|
||||
|
||||
function validateUnsignedInteger(
|
||||
value: number,
|
||||
field: string,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
|
||||
throw new Error(
|
||||
`${field} must be an unsigned integer no greater than ${maximum}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ describe('RedisMessagingService', () => {
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
sets.clear();
|
||||
subscriptions.clear();
|
||||
clientsByRole.publisher = null;
|
||||
clientsByRole.subscriber = null;
|
||||
clients = [createRedisClient(), createRedisClient()];
|
||||
@@ -31,11 +32,62 @@ describe('RedisMessagingService', () => {
|
||||
await service.connect();
|
||||
|
||||
await service.setLatestMiningInfo({ blocks: 900000 } as any);
|
||||
await service.setBlockTemplate(900000, { height: 900000, transactions: [] } as any);
|
||||
await service.setBlockTemplate(900000, {
|
||||
height: 900001,
|
||||
previousblockhash: 'aa'.repeat(32),
|
||||
payoutMode: 'solo',
|
||||
transactions: [],
|
||||
} as any);
|
||||
|
||||
expect(await service.getLatestMiningInfo()).toEqual({ blocks: 900000 });
|
||||
expect(await service.getBlockTemplate(900000)).toEqual({ height: 900000, transactions: [] });
|
||||
expect(await service.getLatestBlockTemplate()).toEqual({ height: 900000, transactions: [] });
|
||||
expect(await service.getBlockTemplate(900000)).toEqual(expect.objectContaining({
|
||||
height: 900001,
|
||||
payoutMode: 'solo',
|
||||
}));
|
||||
expect(await service.getLatestBlockTemplate()).toEqual(expect.objectContaining({
|
||||
height: 900001,
|
||||
payoutMode: 'solo',
|
||||
}));
|
||||
});
|
||||
|
||||
it('keeps the legacy latest key as JSON and writes it only when explicitly ready', async () => {
|
||||
await service.connect();
|
||||
const template = {
|
||||
height: 900001,
|
||||
previousblockhash: 'ab'.repeat(32),
|
||||
payoutMode: 'pplns',
|
||||
payoutSnapshotId: 'safe-snapshot',
|
||||
transactions: [],
|
||||
} as any;
|
||||
|
||||
await service.setBlockTemplate(900000, template);
|
||||
expect(store.has('block-template:latest')).toBe(false);
|
||||
expect(store.has('block-template:900000')).toBe(false);
|
||||
|
||||
await service.setLegacyBlockTemplate(900000, template);
|
||||
|
||||
expect(JSON.parse(store.get('block-template:latest')!)).toEqual(template);
|
||||
expect(JSON.parse(store.get('block-template:900000')!)).toEqual(template);
|
||||
});
|
||||
|
||||
it('never uses a tagged legacy template for the wrong payout mode', async () => {
|
||||
await service.connect();
|
||||
const solo = {
|
||||
height: 900001,
|
||||
previousblockhash: 'ac'.repeat(32),
|
||||
payoutMode: 'solo',
|
||||
transactions: [],
|
||||
} as any;
|
||||
await service.setLegacyBlockTemplate(900000, solo);
|
||||
|
||||
expect(await service.getBlockTemplate(900000, 'solo')).toEqual(solo);
|
||||
expect(await service.getBlockTemplate(900000, 'pplns')).toBeNull();
|
||||
|
||||
const oldCombined = { ...solo };
|
||||
delete oldCombined.payoutMode;
|
||||
await service.setLegacyBlockTemplate(900000, oldCombined);
|
||||
expect(await service.getBlockTemplate(900000, 'solo')).toEqual(oldCombined);
|
||||
expect(await service.getBlockTemplate(900000, 'pplns')).toEqual(oldCombined);
|
||||
});
|
||||
|
||||
it('should publish and subscribe to mining info updates', async () => {
|
||||
@@ -54,18 +106,201 @@ describe('RedisMessagingService', () => {
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await service.subscribeMiningInfoUpdates(handler);
|
||||
await clientsByRole.subscriber.callback('{bad json');
|
||||
await subscriptions.get('mining-info.updated')!('{bad json');
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid Redis mining info update'));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('stores payout variants and same-height reorg templates under distinct tip keys', async () => {
|
||||
await service.connect();
|
||||
const firstHash = '11'.repeat(32);
|
||||
const reorgHash = '22'.repeat(32);
|
||||
|
||||
await service.setBlockTemplate(900000, {
|
||||
height: 900001,
|
||||
previousblockhash: firstHash,
|
||||
payoutMode: 'solo',
|
||||
transactions: [],
|
||||
} as any);
|
||||
await service.setBlockTemplate(900000, {
|
||||
height: 900001,
|
||||
previousblockhash: firstHash,
|
||||
payoutMode: 'pplns',
|
||||
payoutSnapshotId: '7',
|
||||
transactions: [],
|
||||
} as any);
|
||||
await service.setBlockTemplate(900000, {
|
||||
height: 900001,
|
||||
previousblockhash: reorgHash,
|
||||
payoutMode: 'solo',
|
||||
transactions: [],
|
||||
} as any);
|
||||
|
||||
expect((await service.getBlockTemplate(900000, 'solo', firstHash))?.previousblockhash).toBe(firstHash);
|
||||
expect((await service.getBlockTemplate(900000, 'solo'))?.previousblockhash).toBe(reorgHash);
|
||||
expect((await service.getBlockTemplate(900000, 'pplns', firstHash))?.payoutSnapshotId).toBe('7');
|
||||
expect((await service.getLatestBlockTemplate('pplns'))?.payoutMode).toBe('pplns');
|
||||
});
|
||||
|
||||
it('publishes validated block template update envelopes', async () => {
|
||||
await service.connect();
|
||||
const handler = jest.fn().mockResolvedValue(undefined);
|
||||
const update = {
|
||||
schemaVersion: 1 as const,
|
||||
eventId: 'new-block:1',
|
||||
height: 900001,
|
||||
previousBlockHash: '33'.repeat(32),
|
||||
payoutMode: 'solo' as const,
|
||||
publishedAtMs: 123,
|
||||
};
|
||||
|
||||
await service.subscribeBlockTemplateUpdates(handler);
|
||||
await service.publishBlockTemplateUpdate(update);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(update);
|
||||
});
|
||||
|
||||
it('ignores malformed block template update envelopes', async () => {
|
||||
await service.connect();
|
||||
const handler = jest.fn();
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
await service.subscribeBlockTemplateUpdates(handler);
|
||||
|
||||
await subscriptions.get('block-template.updated')!(JSON.stringify({ schemaVersion: 2 }));
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid Redis block template update'));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('stores, publishes, and replays compact SV1 bridge updates', async () => {
|
||||
await service.connect();
|
||||
const handler = jest.fn().mockResolvedValue(undefined);
|
||||
const update = {
|
||||
schemaVersion: 1 as const,
|
||||
type: 'subsidy-bridge' as const,
|
||||
eventId: 'bridge:900001:hash',
|
||||
publishedAtMs: 123,
|
||||
template: {
|
||||
height: 900001,
|
||||
previousblockhash: '44'.repeat(32),
|
||||
payoutMode: 'solo' as const,
|
||||
jobType: 'empty' as const,
|
||||
transactions: [],
|
||||
} as any,
|
||||
};
|
||||
|
||||
await service.subscribeSv1BridgeUpdates(handler);
|
||||
await service.publishSv1BridgeUpdate(update);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(update);
|
||||
expect(await service.getLatestSv1BridgeUpdate()).toEqual(update);
|
||||
});
|
||||
|
||||
it('stores and replays the latest SV1 bridge independently by payout mode', async () => {
|
||||
await service.connect();
|
||||
const solo = createBridgeUpdate('solo', 'solo-bridge');
|
||||
const pplns = createBridgeUpdate('pplns', 'pplns-bridge');
|
||||
|
||||
await service.publishSv1BridgeUpdate(solo);
|
||||
await service.publishSv1BridgeUpdate(pplns);
|
||||
|
||||
expect(await service.getLatestSv1BridgeUpdate('solo')).toEqual(solo);
|
||||
expect(await service.getLatestSv1BridgeUpdate('pplns')).toEqual(pplns);
|
||||
expect(store.get('sv1-bridge:latest:solo')).toBe(JSON.stringify(solo));
|
||||
expect(store.get('sv1-bridge:latest:pplns')).toBe(JSON.stringify(pplns));
|
||||
});
|
||||
|
||||
it('rejects PPLNS bridges without an explicit snapshot and fixed-value outputs', async () => {
|
||||
await service.connect();
|
||||
const valid = createBridgeUpdate('pplns', 'pplns-valid');
|
||||
const invalidUpdates = [
|
||||
{
|
||||
...valid,
|
||||
template: { ...valid.template, payoutSnapshotId: undefined },
|
||||
},
|
||||
{
|
||||
...valid,
|
||||
template: { ...valid.template, payoutOutputs: [] },
|
||||
},
|
||||
{
|
||||
...valid,
|
||||
template: {
|
||||
...valid.template,
|
||||
payoutOutputs: [{ address: 'bc1qpercentage', percent: 100 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
...valid,
|
||||
template: {
|
||||
...valid.template,
|
||||
payoutOutputs: [{ address: 'bc1qpartial', amountSats: 1 }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const update of invalidUpdates) {
|
||||
await expect(service.publishSv1BridgeUpdate(update as any)).rejects.toThrow(
|
||||
'unsupported SV1 bridge update',
|
||||
);
|
||||
}
|
||||
expect(await service.getLatestSv1BridgeUpdate('pplns')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects non-empty bridge templates from the shared bridge channel', async () => {
|
||||
await service.connect();
|
||||
const handler = jest.fn();
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
await service.subscribeSv1BridgeUpdates(handler);
|
||||
|
||||
await subscriptions.get('sv1-bridge.updated')!(JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
type: 'subsidy-bridge',
|
||||
eventId: 'bad',
|
||||
template: {
|
||||
height: 1,
|
||||
previousblockhash: '00'.repeat(32),
|
||||
payoutMode: 'pplns',
|
||||
jobType: 'empty',
|
||||
transactions: [{ data: '00' }],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid Redis SV1 bridge update'));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
function createBridgeUpdate(payoutMode: 'solo' | 'pplns', eventId: string) {
|
||||
const isPplns = payoutMode === 'pplns';
|
||||
return {
|
||||
schemaVersion: 1 as const,
|
||||
type: 'subsidy-bridge' as const,
|
||||
eventId,
|
||||
publishedAtMs: 123,
|
||||
template: {
|
||||
height: 900001,
|
||||
previousblockhash: '55'.repeat(32),
|
||||
payoutMode,
|
||||
jobType: 'empty' as const,
|
||||
transactions: [],
|
||||
coinbasevalue: 312_500_000,
|
||||
payoutSnapshotId: isPplns ? 'snapshot-55' : undefined,
|
||||
payoutOutputs: isPplns
|
||||
? [{ address: 'bc1qpplns', amountSats: 312_500_000 }]
|
||||
: undefined,
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
|
||||
const store = new Map<string, string>();
|
||||
const sets = new Map<string, Set<string>>();
|
||||
const clientsByRole: { publisher?: any; subscriber?: any } = {};
|
||||
const subscriptions = new Map<string, (message: string) => Promise<void>>();
|
||||
|
||||
function createRedisClient() {
|
||||
const client = {
|
||||
@@ -116,11 +351,11 @@ function createRedisClient() {
|
||||
}
|
||||
}),
|
||||
publish: jest.fn((channel: string, value: string) => {
|
||||
void clientsByRole.subscriber?.callback(value);
|
||||
void subscriptions.get(channel)?.(value);
|
||||
return Promise.resolve(1);
|
||||
}),
|
||||
subscribe: jest.fn((channel: string, callback: (message: string) => Promise<void>) => {
|
||||
clientsByRole.subscriber.callback = callback;
|
||||
subscriptions.set(channel, callback);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -4,13 +4,46 @@ import { createClient, RedisClientType } from 'redis';
|
||||
|
||||
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
|
||||
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
|
||||
import { PayoutMode } from '../types/payout-mode';
|
||||
|
||||
const MINING_INFO_CHANNEL = 'mining-info.updated';
|
||||
const BLOCK_TEMPLATE_CHANNEL = 'block-template.updated';
|
||||
const SV1_BRIDGE_CHANNEL = 'sv1-bridge.updated';
|
||||
const MINING_INFO_KEY = 'mining-info:latest';
|
||||
const BLOCK_TEMPLATE_LATEST_KEY = 'block-template:latest';
|
||||
const blockTemplateKey = (height: number) => `block-template:${height}`;
|
||||
const SV1_BRIDGE_LATEST_KEY = 'sv1-bridge:latest';
|
||||
const BLOCK_TEMPLATE_CACHE_TTL_SECONDS = 60 * 60;
|
||||
const sv1BridgeLatestKey = (payoutMode: PayoutMode) =>
|
||||
`${SV1_BRIDGE_LATEST_KEY}:${payoutMode}`;
|
||||
const blockTemplateLegacyKey = (height: number) => `block-template:${height}`;
|
||||
const blockTemplateKey = (
|
||||
height: number,
|
||||
previousBlockHash: string,
|
||||
payoutMode: PayoutMode,
|
||||
) => `block-template:${height}:${previousBlockHash}:${payoutMode}`;
|
||||
const blockTemplateHeightPointerKey = (height: number, payoutMode: PayoutMode) =>
|
||||
`block-template:${height}:${payoutMode}:latest`;
|
||||
const blockTemplateLatestPointerKey = (payoutMode: PayoutMode) =>
|
||||
`${BLOCK_TEMPLATE_LATEST_KEY}:${payoutMode}`;
|
||||
const jsonCacheKey = (key: string) => `json-cache:${key}`;
|
||||
|
||||
export interface BlockTemplateUpdate {
|
||||
schemaVersion: 1;
|
||||
eventId: string;
|
||||
height: number;
|
||||
previousBlockHash: string;
|
||||
payoutMode: PayoutMode;
|
||||
publishedAtMs: number;
|
||||
}
|
||||
|
||||
export interface Sv1BridgeUpdate {
|
||||
schemaVersion: 1;
|
||||
type: 'subsidy-bridge';
|
||||
eventId: string;
|
||||
template: IBlockTemplate;
|
||||
publishedAtMs: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
||||
private publisher: RedisClientType;
|
||||
@@ -75,6 +108,99 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
public async publishBlockTemplateUpdate(update: BlockTemplateUpdate): Promise<void> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
await this.publisher.publish(BLOCK_TEMPLATE_CHANNEL, JSON.stringify(update));
|
||||
}
|
||||
|
||||
public async subscribeBlockTemplateUpdates(handler: (update: BlockTemplateUpdate) => Promise<void>): Promise<void> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
await this.subscriber.subscribe(BLOCK_TEMPLATE_CHANNEL, async message => {
|
||||
try {
|
||||
const update = JSON.parse(message) as Partial<BlockTemplateUpdate>;
|
||||
if (update.schemaVersion !== 1
|
||||
|| !Number.isInteger(update.height)
|
||||
|| typeof update.eventId !== 'string'
|
||||
|| typeof update.previousBlockHash !== 'string'
|
||||
|| (update.payoutMode !== 'solo' && update.payoutMode !== 'pplns')) {
|
||||
throw new Error('unsupported block template update');
|
||||
}
|
||||
await handler(update as BlockTemplateUpdate);
|
||||
} catch (error) {
|
||||
console.error(`Invalid Redis block template update: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async publishSv1BridgeUpdate(update: Sv1BridgeUpdate): Promise<void> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
const serialized = JSON.stringify(update);
|
||||
const validated = this.parseSv1BridgeUpdate(serialized);
|
||||
const payoutMode = validated.template.payoutMode === 'pplns' ? 'pplns' : 'solo';
|
||||
// The bridge envelope is self-contained. Deliver it before spending a
|
||||
// second Redis round trip on best-effort replay metadata.
|
||||
await this.publisher.publish(SV1_BRIDGE_CHANNEL, serialized);
|
||||
void Promise.all([
|
||||
this.publisher.setEx(
|
||||
sv1BridgeLatestKey(payoutMode),
|
||||
BLOCK_TEMPLATE_CACHE_TTL_SECONDS,
|
||||
serialized,
|
||||
),
|
||||
...(payoutMode === 'solo'
|
||||
? [this.publisher.setEx(
|
||||
SV1_BRIDGE_LATEST_KEY,
|
||||
BLOCK_TEMPLATE_CACHE_TTL_SECONDS,
|
||||
serialized,
|
||||
)]
|
||||
: []),
|
||||
]).catch(error => {
|
||||
console.error(`Unable to cache latest SV1 bridge: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
public async subscribeSv1BridgeUpdates(handler: (update: Sv1BridgeUpdate) => Promise<void>): Promise<void> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
await this.subscriber.subscribe(SV1_BRIDGE_CHANNEL, async message => {
|
||||
try {
|
||||
await handler(this.parseSv1BridgeUpdate(message));
|
||||
} catch (error) {
|
||||
console.error(`Invalid Redis SV1 bridge update: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async getLatestSv1BridgeUpdate(
|
||||
payoutMode: PayoutMode = 'solo',
|
||||
): Promise<Sv1BridgeUpdate | null> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return null;
|
||||
}
|
||||
let value = await this.publisher.get(sv1BridgeLatestKey(payoutMode)) as string | null;
|
||||
if (value == null && payoutMode === 'solo') {
|
||||
// Backward-compatible replay for solo bridges published before
|
||||
// latest pointers were split by payout mode.
|
||||
value = await this.publisher.get(SV1_BRIDGE_LATEST_KEY) as string | null;
|
||||
}
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const update = this.parseSv1BridgeUpdate(value);
|
||||
return update.template.payoutMode === payoutMode ? update : null;
|
||||
} catch (error) {
|
||||
console.error(`Invalid Redis latest SV1 bridge update: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async setLatestMiningInfo(miningInfo: IMiningInfo) {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
@@ -90,31 +216,100 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
||||
return value == null ? null : JSON.parse(value as string);
|
||||
}
|
||||
|
||||
public async setBlockTemplate(height: number, blockTemplate: IBlockTemplate) {
|
||||
public async setBlockTemplate(height: number, blockTemplate: IBlockTemplate): Promise<string | null> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return null;
|
||||
}
|
||||
const payoutMode = blockTemplate.payoutMode === 'pplns' ? 'pplns' : 'solo';
|
||||
const key = blockTemplateKey(height, blockTemplate.previousblockhash, payoutMode);
|
||||
const serialized = JSON.stringify(blockTemplate);
|
||||
await Promise.all([
|
||||
this.publisher.setEx(key, BLOCK_TEMPLATE_CACHE_TTL_SECONDS, serialized),
|
||||
this.publisher.setEx(
|
||||
blockTemplateHeightPointerKey(height, payoutMode),
|
||||
BLOCK_TEMPLATE_CACHE_TTL_SECONDS,
|
||||
key,
|
||||
),
|
||||
this.publisher.set(blockTemplateLatestPointerKey(payoutMode), key),
|
||||
]);
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the payload shape understood by pre-channel workers. Call this only
|
||||
* once the PPLNS-safe compatibility template is ready, then publish the
|
||||
* mining-info notification that wakes those workers.
|
||||
*/
|
||||
public async setLegacyBlockTemplate(height: number, blockTemplate: IBlockTemplate): Promise<void> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
const serialized = JSON.stringify(blockTemplate);
|
||||
await Promise.all([
|
||||
this.publisher.set(blockTemplateKey(height), serialized),
|
||||
this.publisher.setEx(blockTemplateLegacyKey(height), 15 * 60, serialized),
|
||||
// Old workers JSON.parse this key directly, so it must remain a
|
||||
// template payload rather than one of the new pointer values.
|
||||
this.publisher.set(BLOCK_TEMPLATE_LATEST_KEY, serialized),
|
||||
]);
|
||||
}
|
||||
|
||||
public async getBlockTemplate(height: number): Promise<IBlockTemplate | null> {
|
||||
public async getBlockTemplate(
|
||||
height: number,
|
||||
payoutMode: PayoutMode = 'solo',
|
||||
previousBlockHash?: string,
|
||||
): Promise<IBlockTemplate | null> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return null;
|
||||
}
|
||||
const value = await this.publisher.get(blockTemplateKey(height));
|
||||
return value == null ? null : JSON.parse(value as string);
|
||||
if (previousBlockHash != null) {
|
||||
return this.readBlockTemplateValue(
|
||||
await this.publisher.get(blockTemplateKey(height, previousBlockHash, payoutMode)) as string | null,
|
||||
);
|
||||
}
|
||||
|
||||
const pointer = await this.publisher.get(blockTemplateHeightPointerKey(height, payoutMode)) as string | null;
|
||||
const pointedTemplate = await this.readBlockTemplatePointer(pointer);
|
||||
if (pointedTemplate != null) {
|
||||
return pointedTemplate;
|
||||
}
|
||||
|
||||
// Backward-compatible replay for templates written before tip-specific keys.
|
||||
const legacyTemplate = this.readBlockTemplateValue(
|
||||
await this.publisher.get(blockTemplateLegacyKey(height)) as string | null,
|
||||
);
|
||||
return this.matchesPayoutMode(legacyTemplate, payoutMode)
|
||||
? legacyTemplate
|
||||
: null;
|
||||
}
|
||||
|
||||
public async getLatestBlockTemplate(): Promise<IBlockTemplate | null> {
|
||||
public async getLatestBlockTemplate(payoutMode: PayoutMode = 'solo'): Promise<IBlockTemplate | null> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return null;
|
||||
}
|
||||
const value = await this.publisher.get(BLOCK_TEMPLATE_LATEST_KEY);
|
||||
return value == null ? null : JSON.parse(value as string);
|
||||
const modeValue = await this.publisher.get(blockTemplateLatestPointerKey(payoutMode)) as string | null;
|
||||
if (modeValue != null) {
|
||||
return this.readBlockTemplatePointer(modeValue);
|
||||
}
|
||||
|
||||
const value = await this.publisher.get(BLOCK_TEMPLATE_LATEST_KEY) as string | null;
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (/^\d+$/.test(value as string)) {
|
||||
return this.getBlockTemplate(Number(value), payoutMode);
|
||||
}
|
||||
const legacyTemplate = await this.readBlockTemplatePointer(value);
|
||||
return this.matchesPayoutMode(legacyTemplate, payoutMode)
|
||||
? legacyTemplate
|
||||
: null;
|
||||
}
|
||||
|
||||
public async getLatestBlockTemplates(): Promise<IBlockTemplate[]> {
|
||||
const [solo, pplns] = await Promise.all([
|
||||
this.getLatestBlockTemplate('solo'),
|
||||
this.getLatestBlockTemplate('pplns'),
|
||||
]);
|
||||
return [solo, pplns].filter((template): template is IBlockTemplate => template != null);
|
||||
}
|
||||
|
||||
public async getJsonCache<T>(key: string): Promise<T | null> {
|
||||
@@ -148,6 +343,74 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
private async readBlockTemplatePointer(value: string | null): Promise<IBlockTemplate | null> {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value.startsWith('block-template:')) {
|
||||
return this.readBlockTemplateValue(await this.publisher.get(value) as string | null);
|
||||
}
|
||||
return this.readBlockTemplateValue(value);
|
||||
}
|
||||
|
||||
private readBlockTemplateValue(value: string | null): IBlockTemplate | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as IBlockTemplate;
|
||||
return parsed != null && typeof parsed === 'object' ? parsed : null;
|
||||
} catch (error) {
|
||||
console.error(`Invalid Redis block template: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private matchesPayoutMode(
|
||||
template: IBlockTemplate | null,
|
||||
payoutMode: PayoutMode,
|
||||
): template is IBlockTemplate {
|
||||
return template != null
|
||||
&& (template.payoutMode == null
|
||||
|| template.payoutMode === 'all'
|
||||
|| template.payoutMode === payoutMode);
|
||||
}
|
||||
|
||||
private parseSv1BridgeUpdate(message: string): Sv1BridgeUpdate {
|
||||
const update = JSON.parse(message) as Partial<Sv1BridgeUpdate>;
|
||||
const template = update.template as Partial<IBlockTemplate> | undefined;
|
||||
const payoutMode = template?.payoutMode;
|
||||
const payoutOutputs = template?.payoutOutputs;
|
||||
const hasExplicitPplnsPayout = payoutMode !== 'pplns' || (
|
||||
typeof template?.payoutSnapshotId === 'string'
|
||||
&& template.payoutSnapshotId.trim().length > 0
|
||||
&& Array.isArray(payoutOutputs)
|
||||
&& payoutOutputs.length > 0
|
||||
&& Number.isSafeInteger(template.coinbasevalue)
|
||||
&& payoutOutputs.every(output => (
|
||||
typeof output.address === 'string'
|
||||
&& output.address.trim().length > 0
|
||||
&& Number.isSafeInteger(output.amountSats)
|
||||
&& output.amountSats >= 0
|
||||
))
|
||||
&& payoutOutputs.reduce((sum, output) => sum + output.amountSats, 0)
|
||||
=== template.coinbasevalue
|
||||
);
|
||||
if (update.schemaVersion !== 1
|
||||
|| update.type !== 'subsidy-bridge'
|
||||
|| typeof update.eventId !== 'string'
|
||||
|| template?.jobType !== 'empty'
|
||||
|| (payoutMode !== 'solo' && payoutMode !== 'pplns')
|
||||
|| !Array.isArray(template?.transactions)
|
||||
|| template.transactions.length !== 0
|
||||
|| typeof template.previousblockhash !== 'string'
|
||||
|| !Number.isInteger(template.height)
|
||||
|| !hasExplicitPplnsPayout) {
|
||||
throw new Error('unsupported SV1 bridge update');
|
||||
}
|
||||
return update as Sv1BridgeUpdate;
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<boolean> {
|
||||
if (!this.connected) {
|
||||
try {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { BehaviorSubject, firstValueFrom, skip } from 'rxjs';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { BehaviorSubject, firstValueFrom, skip, Subject } from 'rxjs';
|
||||
|
||||
import { MockRecording1 } from '../../test/models/MockRecording1';
|
||||
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
|
||||
import { createPreparedMiningJob } from './prepared-mining-job.factory';
|
||||
import { StratumV1JobsService } from './stratum-v1-jobs.service';
|
||||
|
||||
describe('StratumV1JobsService', () => {
|
||||
let blockTemplate$: BehaviorSubject<IBlockTemplate>;
|
||||
let bitcoinRpcService: { newBlockTemplate$: any, miningInfo: { blocks: number } };
|
||||
let bridgeTemplate$: Subject<IBlockTemplate>;
|
||||
let bitcoinRpcService: { newBlockTemplate$: any, newSv1BridgeTemplate$: any, miningInfo: { blocks: number } };
|
||||
let service: StratumV1JobsService;
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
|
||||
@@ -22,8 +25,10 @@ describe('StratumV1JobsService', () => {
|
||||
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
|
||||
blockTemplate$ = new BehaviorSubject(createTemplate());
|
||||
bridgeTemplate$ = new Subject<IBlockTemplate>();
|
||||
bitcoinRpcService = {
|
||||
newBlockTemplate$: blockTemplate$.asObservable(),
|
||||
newSv1BridgeTemplate$: bridgeTemplate$.asObservable(),
|
||||
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height }
|
||||
};
|
||||
service = new StratumV1JobsService(bitcoinRpcService as any);
|
||||
@@ -36,33 +41,289 @@ describe('StratumV1JobsService', () => {
|
||||
|
||||
it('should create job templates from block templates', async () => {
|
||||
const jobTemplate = await firstValueFrom(service.newMiningJob$);
|
||||
const sourceTemplate = createTemplate();
|
||||
const prepared = createPreparedMiningJob(sourceTemplate);
|
||||
|
||||
expect(jobTemplate.blockData).toEqual(expect.objectContaining({
|
||||
id: '1',
|
||||
height: MockRecording1.BLOCK_TEMPLATE.height,
|
||||
clearJobs: true,
|
||||
coinbasevalue: MockRecording1.BLOCK_TEMPLATE.coinbasevalue
|
||||
coinbasevalue: MockRecording1.BLOCK_TEMPLATE.coinbasevalue,
|
||||
transactions: sourceTemplate.transactions,
|
||||
bodyReference: prepared.body,
|
||||
}));
|
||||
expect(jobTemplate.merkle_branch.length).toBeGreaterThan(0);
|
||||
expect(jobTemplate.block.transactions).toHaveLength(1);
|
||||
expect(jobTemplate.merkle_branch).toEqual(prepared.coinbase.merkleBranch);
|
||||
expect(jobTemplate.block.witnessCommit.toString('hex'))
|
||||
.toBe(prepared.coinbase.witnessCommitmentHash);
|
||||
expect(jobTemplate.block.transactions[0].ins[0].witness[0]).toHaveLength(32);
|
||||
expect(service.getJobTemplateById('1')).toBe(jobTemplate);
|
||||
});
|
||||
|
||||
it('should clear jobs when the block height changes', async () => {
|
||||
it('does not parse transaction bodies while preparing a mining job', async () => {
|
||||
const fromHexSpy = jest.spyOn(bitcoinjs.Transaction, 'fromHex');
|
||||
const reorderedTemplate = createTemplate();
|
||||
reorderedTemplate.transactions = reorderedTemplate.transactions.slice().reverse();
|
||||
|
||||
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
|
||||
blockTemplate$.next(reorderedTemplate);
|
||||
const jobTemplate = await nextTemplate;
|
||||
|
||||
expect(fromHexSpy).not.toHaveBeenCalled();
|
||||
expect(jobTemplate.block.transactions).toHaveLength(1);
|
||||
expect(jobTemplate.blockData.transactions).toHaveLength(reorderedTemplate.transactions.length);
|
||||
expect(jobTemplate.merkle_branch)
|
||||
.toEqual(createPreparedMiningJob(reorderedTemplate).coinbase.merkleBranch);
|
||||
});
|
||||
|
||||
it('should mark a new previous block hash clean while retaining old jobs for late submissions', async () => {
|
||||
const firstTemplate = await firstValueFrom(service.newMiningJob$);
|
||||
service.addJob({ jobId: 'old-job', creation: Date.now() } as any);
|
||||
|
||||
bitcoinRpcService.miningInfo.blocks = MockRecording1.BLOCK_TEMPLATE.height + 1;
|
||||
const changedTipTemplate = createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1);
|
||||
changedTipTemplate.previousblockhash = '11'.repeat(32);
|
||||
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
|
||||
blockTemplate$.next(createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1));
|
||||
blockTemplate$.next(changedTipTemplate);
|
||||
const jobTemplate = await nextTemplate;
|
||||
|
||||
expect(jobTemplate.blockData.clearJobs).toBe(true);
|
||||
expect(service.getJobById('old-job')).toBeUndefined();
|
||||
expect(service.getJobTemplateById(firstTemplate.blockData.id)).toBeUndefined();
|
||||
expect(jobTemplate.blockData.isNewBlock).toBe(true);
|
||||
expect(service.getJobById('old-job')).toBeDefined();
|
||||
expect(service.getJobTemplateById(firstTemplate.blockData.id)).toBe(firstTemplate);
|
||||
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
|
||||
});
|
||||
|
||||
it('should force a clean miner switch without treating the same tip as a new block', async () => {
|
||||
await firstValueFrom(service.newMiningJob$);
|
||||
service.addJob({ jobId: 'bridge-job', creation: Date.now() } as any);
|
||||
const fullTemplate = createTemplate();
|
||||
fullTemplate.forceCleanJobs = true;
|
||||
fullTemplate.transactions = fullTemplate.transactions.slice(0, -1);
|
||||
|
||||
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
|
||||
blockTemplate$.next(fullTemplate);
|
||||
const jobTemplate = await nextTemplate;
|
||||
|
||||
expect(jobTemplate.blockData.clearJobs).toBe(true);
|
||||
expect(jobTemplate.blockData.isNewBlock).toBe(false);
|
||||
expect(service.getJobById('bridge-job')).toBeDefined();
|
||||
});
|
||||
|
||||
it('emits the subsidy bridge only on the ordered SV1 stream, followed by canonical full work', async () => {
|
||||
await firstValueFrom(service.newMiningJob$);
|
||||
const bridgeSource = createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1);
|
||||
bridgeSource.previousblockhash = '12'.repeat(32);
|
||||
bridgeSource.transactions = [];
|
||||
bridgeSource.jobType = 'empty';
|
||||
bridgeSource.payoutMode = 'solo';
|
||||
bridgeSource.forceCleanJobs = true;
|
||||
|
||||
const bridgeResult = firstValueFrom(service.sv1MiningJob$.pipe(skip(1)));
|
||||
bridgeTemplate$.next(bridgeSource);
|
||||
const bridge = await bridgeResult;
|
||||
|
||||
expect(bridge.blockData.jobType).toBe('empty');
|
||||
expect(bridge.blockData.clearJobs).toBe(true);
|
||||
expect(bridge.merkle_branch).toEqual([]);
|
||||
|
||||
const fullSource = createTemplate(bridgeSource.height);
|
||||
fullSource.previousblockhash = bridgeSource.previousblockhash;
|
||||
fullSource.payoutMode = 'solo';
|
||||
fullSource.forceCleanJobs = true;
|
||||
const fullResult = firstValueFrom(service.sv1MiningJob$.pipe(skip(1)));
|
||||
blockTemplate$.next(fullSource);
|
||||
const full = await fullResult;
|
||||
|
||||
expect(full.blockData.jobType).toBe('full');
|
||||
expect(full.blockData.isNewBlock).toBe(false);
|
||||
expect(full.blockData.clearJobs).toBe(true);
|
||||
expect(full.blockData.tipKey).toBe(bridge.blockData.tipKey);
|
||||
expect(full.merkle_branch.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should keep same-tip bridge and full jobs current, then mark both stale on the next tip', async () => {
|
||||
const bridgeTemplate = await firstValueFrom(service.newMiningJob$);
|
||||
const payout = [{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }];
|
||||
const bridgeJob = service.getOrCreateJob(bitcoinjs.networks.testnet, payout, bridgeTemplate);
|
||||
|
||||
const fullSource = createTemplate();
|
||||
fullSource.forceCleanJobs = true;
|
||||
fullSource.transactions = fullSource.transactions.slice(0, -1);
|
||||
const fullResult = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
|
||||
blockTemplate$.next(fullSource);
|
||||
const fullTemplate = await fullResult;
|
||||
const fullJob = service.getOrCreateJob(bitcoinjs.networks.testnet, payout, fullTemplate);
|
||||
|
||||
expect(bridgeJob.tipKey).toBe(fullJob.tipKey);
|
||||
expect(service.getSubmissionContext(bridgeJob.jobId)?.status).toBe('current');
|
||||
expect(service.getSubmissionContext(fullJob.jobId)?.status).toBe('current');
|
||||
|
||||
const nextTipSource = createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1);
|
||||
nextTipSource.previousblockhash = '22'.repeat(32);
|
||||
const nextTipResult = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
|
||||
blockTemplate$.next(nextTipSource);
|
||||
const nextTipTemplate = await nextTipResult;
|
||||
const nextTipJob = service.getOrCreateJob(bitcoinjs.networks.testnet, payout, nextTipTemplate);
|
||||
|
||||
expect(service.getSubmissionContext(bridgeJob.jobId)?.status).toBe('stale');
|
||||
expect(service.getSubmissionContext(fullJob.jobId)?.status).toBe('stale');
|
||||
expect(service.getSubmissionContext(nextTipJob.jobId)?.status).toBe('current');
|
||||
});
|
||||
|
||||
it('keeps a new solo tip independent from delayed old-tip PPLNS work and pinning', async () => {
|
||||
const previousRetention = process.env.STRATUM_JOB_RETENTION_MS;
|
||||
process.env.STRATUM_JOB_RETENTION_MS = '1000';
|
||||
try {
|
||||
const sharedTemplate = await firstValueFrom(service.newMiningJob$);
|
||||
const payout = [{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }];
|
||||
const oldSoloJob = service.getOrCreateJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
payout,
|
||||
sharedTemplate,
|
||||
'solo-old-tip',
|
||||
'solo',
|
||||
);
|
||||
const oldPplnsJob = service.getOrCreateJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
payout,
|
||||
sharedTemplate,
|
||||
'pplns-old-tip',
|
||||
'pplns',
|
||||
);
|
||||
|
||||
const newSoloSource = createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1);
|
||||
newSoloSource.previousblockhash = '44'.repeat(32);
|
||||
newSoloSource.payoutMode = 'solo';
|
||||
const newSoloResult = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
|
||||
blockTemplate$.next(newSoloSource);
|
||||
const newSoloTemplate = await newSoloResult;
|
||||
const newSoloJob = service.getOrCreateJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
payout,
|
||||
newSoloTemplate,
|
||||
'solo-new-tip',
|
||||
'solo',
|
||||
);
|
||||
|
||||
expect(service.getSubmissionContext(oldSoloJob.jobId)?.status).toBe('stale');
|
||||
expect(service.getSubmissionContext(oldPplnsJob.jobId)?.status).toBe('current');
|
||||
expect(service.getSubmissionContext(newSoloJob.jobId)?.status).toBe('current');
|
||||
|
||||
sharedTemplate.blockData.creation = Date.now() - 1001;
|
||||
oldSoloJob.creation = Date.now() - 1001;
|
||||
oldPplnsJob.creation = Date.now() - 1001;
|
||||
const delayedPplnsSource = createTemplate();
|
||||
delayedPplnsSource.payoutMode = 'pplns';
|
||||
delayedPplnsSource.payoutSnapshotId = 'delayed-old-tip';
|
||||
delayedPplnsSource.payoutOutputs = [{
|
||||
address: payout[0].address,
|
||||
amountSats: delayedPplnsSource.coinbasevalue,
|
||||
}];
|
||||
const delayedPplnsResult = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
|
||||
blockTemplate$.next(delayedPplnsSource);
|
||||
const delayedPplnsTemplate = await delayedPplnsResult;
|
||||
const delayedPplnsJob = service.getOrCreateJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
delayedPplnsSource.payoutOutputs,
|
||||
delayedPplnsTemplate,
|
||||
'pplns-delayed-old-tip',
|
||||
'pplns',
|
||||
);
|
||||
|
||||
expect(delayedPplnsTemplate.blockData.isNewBlock).toBe(false);
|
||||
expect(delayedPplnsTemplate.blockData.clearJobs).toBe(false);
|
||||
expect(service.getJobById(oldSoloJob.jobId)).toBeUndefined();
|
||||
expect(service.getSubmissionContext(oldPplnsJob.jobId)?.status).toBe('current');
|
||||
expect(service.getSubmissionContext(delayedPplnsJob.jobId)?.status).toBe('current');
|
||||
expect(service.getSubmissionContext(newSoloJob.jobId)?.status).toBe('current');
|
||||
expect(service.getJobTemplateById(sharedTemplate.blockData.id)).toBe(sharedTemplate);
|
||||
|
||||
delayedPplnsTemplate.blockData.creation = Date.now() - 1001;
|
||||
delayedPplnsJob.creation = Date.now() - 1001;
|
||||
const alignedPplnsSource = createTemplate(newSoloSource.height);
|
||||
alignedPplnsSource.previousblockhash = newSoloSource.previousblockhash;
|
||||
alignedPplnsSource.payoutMode = 'pplns';
|
||||
alignedPplnsSource.payoutSnapshotId = 'aligned-new-tip';
|
||||
alignedPplnsSource.payoutOutputs = [{
|
||||
address: payout[0].address,
|
||||
amountSats: alignedPplnsSource.coinbasevalue,
|
||||
}];
|
||||
const alignedPplnsResult = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
|
||||
blockTemplate$.next(alignedPplnsSource);
|
||||
const alignedPplnsTemplate = await alignedPplnsResult;
|
||||
const alignedPplnsJob = service.getOrCreateJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
alignedPplnsSource.payoutOutputs,
|
||||
alignedPplnsTemplate,
|
||||
'pplns-aligned-new-tip',
|
||||
'pplns',
|
||||
);
|
||||
|
||||
expect(alignedPplnsTemplate.blockData.isNewBlock).toBe(true);
|
||||
expect(service.getJobById(oldPplnsJob.jobId)).toBeUndefined();
|
||||
expect(service.getJobById(delayedPplnsJob.jobId)).toBeUndefined();
|
||||
expect(service.getJobTemplateById(sharedTemplate.blockData.id)).toBeUndefined();
|
||||
expect(service.getJobTemplateById(delayedPplnsTemplate.blockData.id)).toBeUndefined();
|
||||
expect(service.getSubmissionContext(newSoloJob.jobId)?.status).toBe('current');
|
||||
expect(service.getSubmissionContext(alignedPplnsJob.jobId)?.status).toBe('current');
|
||||
} finally {
|
||||
if (previousRetention == null) {
|
||||
delete process.env.STRATUM_JOB_RETENTION_MS;
|
||||
} else {
|
||||
process.env.STRATUM_JOB_RETENTION_MS = previousRetention;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('classifies concurrent startup replays independently in either mode order', async () => {
|
||||
const payout = [{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }];
|
||||
const soloSource = createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1);
|
||||
soloSource.previousblockhash = '55'.repeat(32);
|
||||
soloSource.payoutMode = 'solo';
|
||||
soloSource.forceCleanJobs = true;
|
||||
const pplnsSource = createTemplate();
|
||||
pplnsSource.payoutMode = 'pplns';
|
||||
pplnsSource.forceCleanJobs = true;
|
||||
pplnsSource.payoutSnapshotId = 'startup-pplns';
|
||||
pplnsSource.payoutOutputs = [{
|
||||
address: payout[0].address,
|
||||
amountSats: pplnsSource.coinbasevalue,
|
||||
}];
|
||||
const sources = { solo: soloSource, pplns: pplnsSource };
|
||||
|
||||
for (const canonicalMode of ['solo', 'pplns'] as const) {
|
||||
const bridgeMode = canonicalMode === 'solo' ? 'pplns' : 'solo';
|
||||
const canonical$ = new BehaviorSubject(sources[canonicalMode]);
|
||||
const bridge$ = new BehaviorSubject(sources[bridgeMode]);
|
||||
const startupService = new StratumV1JobsService({
|
||||
newBlockTemplate$: canonical$.asObservable(),
|
||||
newSv1BridgeTemplate$: bridge$.asObservable(),
|
||||
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height },
|
||||
} as any);
|
||||
await firstValueFrom(startupService.sv1MiningJob$);
|
||||
|
||||
const soloTemplate = startupService.getLatestJobTemplate('solo');
|
||||
const pplnsTemplate = startupService.getLatestJobTemplate('pplns');
|
||||
const soloJob = startupService.getOrCreateJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
payout,
|
||||
soloTemplate,
|
||||
`startup-solo-${canonicalMode}`,
|
||||
'solo',
|
||||
);
|
||||
const pplnsJob = startupService.getOrCreateJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
pplnsSource.payoutOutputs,
|
||||
pplnsTemplate,
|
||||
`startup-pplns-${canonicalMode}`,
|
||||
'pplns',
|
||||
);
|
||||
|
||||
expect(startupService.getSubmissionContext(soloJob.jobId)?.status).toBe('current');
|
||||
expect(startupService.getSubmissionContext(pplnsJob.jobId)?.status).toBe('current');
|
||||
}
|
||||
});
|
||||
|
||||
it('should skip identical non-clean template refreshes', async () => {
|
||||
await firstValueFrom(service.newMiningJob$);
|
||||
|
||||
@@ -129,6 +390,69 @@ describe('StratumV1JobsService', () => {
|
||||
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
|
||||
});
|
||||
|
||||
it('should honor the configured job retention window', async () => {
|
||||
const previousRetention = process.env.STRATUM_JOB_RETENTION_MS;
|
||||
process.env.STRATUM_JOB_RETENTION_MS = '1000';
|
||||
try {
|
||||
const oldTemplate = await firstValueFrom(service.newMiningJob$);
|
||||
const oldJob = service.getOrCreateJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
|
||||
oldTemplate,
|
||||
);
|
||||
oldTemplate.blockData.creation = Date.now() - 1001;
|
||||
oldJob.creation = Date.now() - 1001;
|
||||
|
||||
const nextTip = createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1);
|
||||
nextTip.previousblockhash = '33'.repeat(32);
|
||||
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
|
||||
blockTemplate$.next(nextTip);
|
||||
await nextTemplate;
|
||||
|
||||
expect(service.getJobById(oldJob.jobId)).toBeUndefined();
|
||||
expect(service.getJobTemplateById(oldTemplate.blockData.id)).toBeUndefined();
|
||||
} finally {
|
||||
if (previousRetention == null) {
|
||||
delete process.env.STRATUM_JOB_RETENTION_MS;
|
||||
} else {
|
||||
process.env.STRATUM_JOB_RETENTION_MS = previousRetention;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps clean current-tip work reconstructable beyond the normal retention window', async () => {
|
||||
const previousRetention = process.env.STRATUM_JOB_RETENTION_MS;
|
||||
process.env.STRATUM_JOB_RETENTION_MS = '1000';
|
||||
try {
|
||||
const cleanTemplate = await firstValueFrom(service.newMiningJob$);
|
||||
const job = service.getOrCreateJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
|
||||
cleanTemplate,
|
||||
);
|
||||
cleanTemplate.blockData.creation = Date.now() - 10_000;
|
||||
job.creation = Date.now() - 10_000;
|
||||
|
||||
const refresh = createTemplate();
|
||||
refresh.transactions = refresh.transactions.slice(0, -1);
|
||||
const refreshResult = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
|
||||
blockTemplate$.next(refresh);
|
||||
await refreshResult;
|
||||
|
||||
expect(service.getSubmissionContext(job.jobId)).toEqual(expect.objectContaining({
|
||||
status: 'current',
|
||||
job,
|
||||
jobTemplate: cleanTemplate,
|
||||
}));
|
||||
} finally {
|
||||
if (previousRetention == null) {
|
||||
delete process.env.STRATUM_JOB_RETENTION_MS;
|
||||
} else {
|
||||
process.env.STRATUM_JOB_RETENTION_MS = previousRetention;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should increment job ids when jobs are added', () => {
|
||||
expect(service.getNextId()).toBe('1');
|
||||
|
||||
@@ -137,4 +461,40 @@ describe('StratumV1JobsService', () => {
|
||||
expect(service.getNextId()).toBe('2');
|
||||
expect(service.getJobById('1')).toEqual(expect.objectContaining({ jobId: '1' }));
|
||||
});
|
||||
|
||||
it('should cache one mining job per template and payout identity', async () => {
|
||||
const template = await firstValueFrom(service.newMiningJob$);
|
||||
const payout = [{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }];
|
||||
|
||||
const first = service.getOrCreateJob(bitcoinjs.networks.testnet, payout, template);
|
||||
const second = service.getOrCreateJob(bitcoinjs.networks.testnet, [{ ...payout[0] }], template);
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(first.ownership).toEqual(expect.objectContaining({ payoutMode: 'solo' }));
|
||||
expect(Object.keys(service.jobs)).toEqual([first.jobId]);
|
||||
});
|
||||
|
||||
it('should keep identical payout identities isolated by connection payout mode', async () => {
|
||||
const template = await firstValueFrom(service.newMiningJob$);
|
||||
const payout = [{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }];
|
||||
|
||||
const solo = service.getOrCreateJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
payout,
|
||||
template,
|
||||
'shared-identity',
|
||||
'solo',
|
||||
);
|
||||
const pplns = service.getOrCreateJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
payout,
|
||||
template,
|
||||
'shared-identity',
|
||||
'pplns',
|
||||
);
|
||||
|
||||
expect(pplns).not.toBe(solo);
|
||||
expect(solo.ownership).toEqual({ payoutMode: 'solo', payoutIdentity: 'shared-identity' });
|
||||
expect(pplns.ownership).toEqual({ payoutMode: 'pplns', payoutIdentity: 'shared-identity' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import * as merkle from 'merkle-lib';
|
||||
import * as merkleProof from 'merkle-lib/proof';
|
||||
import { combineLatest, delay, filter, from, interval, map, Observable, shareReplay, startWith, switchMap, tap } from 'rxjs';
|
||||
import * as crypto from 'crypto';
|
||||
import { EMPTY, filter, map, merge, Observable, shareReplay, tap } from 'rxjs';
|
||||
|
||||
import { IBlockTemplateTx } from '../models/bitcoin-rpc/IBlockTemplate';
|
||||
import { IBlockTemplate, IBlockTemplateTx } from '../models/bitcoin-rpc/IBlockTemplate';
|
||||
import { AddressObject, MiningJob } from '../models/MiningJob';
|
||||
import { hash256 } from '../utils/hash.utils';
|
||||
import { PayoutMode } from '../types/payout-mode';
|
||||
import { BitcoinRpcService } from './bitcoin-rpc.service';
|
||||
import {
|
||||
createPreparedMiningJob,
|
||||
PreparedMiningJobBodyReference,
|
||||
} from './prepared-mining-job.factory';
|
||||
|
||||
export interface IJobTemplate {
|
||||
|
||||
@@ -19,116 +22,164 @@ export interface IJobTemplate {
|
||||
coinbasevalue: number;
|
||||
networkDifficulty: number;
|
||||
height: number;
|
||||
tipKey: string;
|
||||
clearJobs: boolean;
|
||||
isNewBlock: boolean;
|
||||
jobType: 'full' | 'empty';
|
||||
payoutMode: PayoutMode | 'all';
|
||||
notificationEventId?: string;
|
||||
notificationPublishedAtMs?: number;
|
||||
payoutSnapshotId?: string;
|
||||
payoutOutputs?: AddressObject[];
|
||||
transactions?: IBlockTemplateTx[];
|
||||
bodyReference?: PreparedMiningJobBodyReference;
|
||||
sigoplimit?: number;
|
||||
sizelimit?: number;
|
||||
weightlimit?: number;
|
||||
requiredVersionBits?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IJobSubmissionContext {
|
||||
job: MiningJob;
|
||||
jobTemplate: IJobTemplate;
|
||||
status: 'current' | 'stale';
|
||||
}
|
||||
|
||||
const DEFAULT_JOB_RETENTION_MS = 5 * 60 * 1000;
|
||||
const PAYOUT_MODES: readonly PayoutMode[] = ['solo', 'pplns'];
|
||||
|
||||
export function createPayoutOutputIdentity(
|
||||
payoutMode: PayoutMode,
|
||||
payoutInformation: readonly AddressObject[],
|
||||
): string {
|
||||
const fingerprint = crypto.createHash('sha256')
|
||||
.update(JSON.stringify(payoutInformation.map(output => ({
|
||||
address: output.address,
|
||||
amountSats: output.amountSats ?? null,
|
||||
percent: output.percent ?? null,
|
||||
}))))
|
||||
.digest('hex');
|
||||
return `${payoutMode}\0outputs\0${fingerprint}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StratumV1JobsService {
|
||||
|
||||
public newMiningJob$: Observable<IJobTemplate>;
|
||||
/** Ordered SV1-only stream: subsidy bridge first, canonical full job second. */
|
||||
public sv1MiningJob$: Observable<IJobTemplate>;
|
||||
public latestJobId: number = 1;
|
||||
public latestJobTemplateId: number = 1;
|
||||
public jobs: { [jobId: string]: MiningJob } = {};
|
||||
public blocks: { [id: number]: IJobTemplate } = {};
|
||||
|
||||
private lastBlockHeight = 0;
|
||||
private lastWorkSignature: string;
|
||||
private readonly lastPreviousBlockHashes = new Map<PayoutMode, string>();
|
||||
private readonly lastWorkSignatures = new Map<PayoutMode, string>();
|
||||
private readonly currentTipKeys = new Map<PayoutMode, string>();
|
||||
private readonly cachedJobs = new Map<string, MiningJob>();
|
||||
private readonly latestJobTemplates = new Map<PayoutMode | 'all', IJobTemplate>();
|
||||
private readonly pinnedCurrentTipTemplateIds = new Map<PayoutMode, Set<string>>([
|
||||
['solo', new Set<string>()],
|
||||
['pplns', new Set<string>()],
|
||||
]);
|
||||
|
||||
constructor(
|
||||
private readonly bitcoinRpcService: BitcoinRpcService
|
||||
) {
|
||||
|
||||
this.newMiningJob$ = this.bitcoinRpcService.newBlockTemplate$.pipe(
|
||||
const createMiningJobStream = (blockTemplates: Observable<IBlockTemplate>) => blockTemplates.pipe(
|
||||
map((blockTemplate) => {
|
||||
|
||||
if (process.env.MASTER == 'true') {
|
||||
console.log('Updating block template');
|
||||
}
|
||||
|
||||
let clearJobs = false;
|
||||
const currentBlockHeight = this.bitcoinRpcService.miningInfo.blocks;
|
||||
const prepared = createPreparedMiningJob(blockTemplate);
|
||||
const payoutModes = this.getAffectedPayoutModes(prepared.payoutMode);
|
||||
const isNewBlock = payoutModes.some(payoutMode =>
|
||||
this.lastPreviousBlockHashes.get(payoutMode) == null
|
||||
|| this.lastPreviousBlockHashes.get(payoutMode) !== prepared.header.previousBlockHash,
|
||||
);
|
||||
const clearJobs = isNewBlock || prepared.forceCleanJobs;
|
||||
|
||||
if(this.lastBlockHeight == 0 || this.lastBlockHeight != currentBlockHeight){
|
||||
if(isNewBlock){
|
||||
console.log('New template is new block, clearing jobs');
|
||||
clearJobs = true;
|
||||
this.lastBlockHeight = currentBlockHeight;
|
||||
payoutModes.forEach(payoutMode => {
|
||||
this.lastPreviousBlockHashes.set(
|
||||
payoutMode,
|
||||
prepared.header.previousBlockHash,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const currentTime = Math.floor(new Date().getTime() / 1000);
|
||||
const timestamp = blockTemplate.mintime > currentTime ? blockTemplate.mintime : currentTime;
|
||||
const timestamp = Math.max(
|
||||
prepared.header.minTime,
|
||||
currentTime,
|
||||
);
|
||||
const workSignature = [
|
||||
blockTemplate.previousblockhash,
|
||||
blockTemplate.version,
|
||||
blockTemplate.bits,
|
||||
prepared.tipKey,
|
||||
prepared.header.version,
|
||||
prepared.header.bitsHex,
|
||||
timestamp,
|
||||
blockTemplate.height,
|
||||
blockTemplate.coinbasevalue,
|
||||
blockTemplate.payoutSnapshotId ?? '',
|
||||
...(blockTemplate.payoutOutputs ?? []).map(output => [
|
||||
prepared.coinbase.valueSats,
|
||||
prepared.coinbase.payoutSnapshotId ?? '',
|
||||
...(prepared.coinbase.payoutOutputs ?? []).map(output => [
|
||||
output.address,
|
||||
output.amountSats ?? '',
|
||||
output.percent ?? ''
|
||||
].join(':')),
|
||||
...blockTemplate.transactions.map(tx => tx.hash ?? tx.txid ?? tx.data)
|
||||
prepared.body.reference,
|
||||
...prepared.coinbase.merkleBranch,
|
||||
].join('|');
|
||||
|
||||
if (!clearJobs && workSignature === this.lastWorkSignature) {
|
||||
if (!clearJobs && payoutModes.every(payoutMode =>
|
||||
this.lastWorkSignatures.get(payoutMode) === workSignature,
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
this.lastWorkSignature = workSignature;
|
||||
payoutModes.forEach(payoutMode => {
|
||||
this.lastWorkSignatures.set(payoutMode, workSignature);
|
||||
});
|
||||
|
||||
return {
|
||||
version: blockTemplate.version,
|
||||
bits: parseInt(blockTemplate.bits, 16),
|
||||
prevHash: this.convertToLittleEndian(blockTemplate.previousblockhash),
|
||||
transactions: blockTemplate.transactions.map(t => bitcoinjs.Transaction.fromHex(t.data)),
|
||||
coinbasevalue: blockTemplate.coinbasevalue,
|
||||
prepared,
|
||||
timestamp,
|
||||
networkDifficulty: this.calculateNetworkDifficulty(parseInt(blockTemplate.bits, 16)),
|
||||
networkDifficulty: this.calculateNetworkDifficulty(prepared.header.bits),
|
||||
clearJobs,
|
||||
height: blockTemplate.height,
|
||||
payoutSnapshotId: blockTemplate.payoutSnapshotId,
|
||||
payoutOutputs: blockTemplate.payoutOutputs,
|
||||
isNewBlock,
|
||||
notificationEventId: blockTemplate.notificationEventId,
|
||||
notificationPublishedAtMs: blockTemplate.notificationPublishedAtMs,
|
||||
rawTransactions: blockTemplate.transactions,
|
||||
sigoplimit: blockTemplate.sigoplimit,
|
||||
sizelimit: blockTemplate.sizelimit,
|
||||
weightlimit: blockTemplate.weightlimit,
|
||||
requiredVersionBits: blockTemplate.vbrequired >>> 0,
|
||||
};
|
||||
}),
|
||||
filter(next => next != null),
|
||||
map(({ version, bits, prevHash, transactions, timestamp, coinbasevalue, networkDifficulty, clearJobs, height, payoutSnapshotId, payoutOutputs, rawTransactions, sigoplimit, sizelimit, weightlimit }) => {
|
||||
map(({ prepared, timestamp, networkDifficulty, clearJobs, isNewBlock, notificationEventId, notificationPublishedAtMs, rawTransactions, sigoplimit, sizelimit, weightlimit, requiredVersionBits }) => {
|
||||
const block = new bitcoinjs.Block();
|
||||
|
||||
//create an empty coinbase tx
|
||||
// Keep only a placeholder coinbase on the hot path. The full raw body
|
||||
// remains in blockData and is parsed only for a block candidate.
|
||||
const tempCoinbaseTx = new bitcoinjs.Transaction();
|
||||
tempCoinbaseTx.version = 2;
|
||||
tempCoinbaseTx.addInput(Buffer.alloc(32, 0), 0xffffffff, 0xffffffff);
|
||||
tempCoinbaseTx.ins[0].witness = [Buffer.alloc(32, 0)];
|
||||
transactions.unshift(tempCoinbaseTx);
|
||||
const merkle_branch = [...prepared.coinbase.merkleBranch];
|
||||
|
||||
const transactionBuffers = transactions.map(tx => tx.getHash(false));
|
||||
|
||||
const merkleTree = merkle(transactionBuffers, hash256);
|
||||
const merkleBranches: Buffer[] = merkleProof(merkleTree, transactionBuffers[0]).filter(h => h != null);
|
||||
block.merkleRoot = merkleBranches.pop();
|
||||
|
||||
// remove the first (coinbase) and last (root) element from the branch
|
||||
const merkle_branch = merkleBranches.slice(1, merkleBranches.length).map(b => b.toString('hex'))
|
||||
|
||||
block.prevHash = prevHash;
|
||||
block.version = version;
|
||||
block.bits = bits;
|
||||
block.prevHash = Buffer.from(prepared.header.previousBlockHashLE, 'hex');
|
||||
block.version = prepared.header.version;
|
||||
block.bits = prepared.header.bits;
|
||||
block.timestamp = timestamp;
|
||||
|
||||
block.transactions = transactions;
|
||||
block.witnessCommit = bitcoinjs.Block.calculateMerkleRoot(transactions, true);
|
||||
block.transactions = [tempCoinbaseTx];
|
||||
block.merkleRoot = MiningJob.calculateMerkleRootFromCoinbaseHash(
|
||||
tempCoinbaseTx.getHash(false),
|
||||
merkle_branch.map(branch => Buffer.from(branch, 'hex')),
|
||||
);
|
||||
block.witnessCommit = Buffer.from(prepared.coinbase.witnessCommitmentHash, 'hex');
|
||||
|
||||
const id = this.getNextTemplateId();
|
||||
this.latestJobTemplateId++;
|
||||
@@ -138,50 +189,61 @@ export class StratumV1JobsService {
|
||||
blockData: {
|
||||
id,
|
||||
creation: new Date().getTime(),
|
||||
coinbasevalue,
|
||||
coinbasevalue: prepared.coinbase.valueSats,
|
||||
networkDifficulty,
|
||||
height,
|
||||
height: prepared.height,
|
||||
tipKey: prepared.tipKey,
|
||||
clearJobs,
|
||||
payoutSnapshotId,
|
||||
payoutOutputs,
|
||||
isNewBlock,
|
||||
jobType: prepared.jobType,
|
||||
payoutMode: prepared.payoutMode,
|
||||
notificationEventId,
|
||||
notificationPublishedAtMs,
|
||||
payoutSnapshotId: prepared.coinbase.payoutSnapshotId,
|
||||
payoutOutputs: prepared.coinbase.payoutOutputs?.map(output => ({ ...output })),
|
||||
transactions: rawTransactions,
|
||||
bodyReference: prepared.body,
|
||||
sigoplimit,
|
||||
sizelimit,
|
||||
weightlimit,
|
||||
requiredVersionBits,
|
||||
}
|
||||
}
|
||||
}),
|
||||
tap((data) => {
|
||||
if (data.blockData.clearJobs) {
|
||||
this.blocks = {};
|
||||
this.jobs = {};
|
||||
}else{
|
||||
let templatesDeleted = 0;
|
||||
let jobsDeleted = 0;
|
||||
const now = new Date().getTime();
|
||||
// Delete old templates (5 minutes)
|
||||
for(const templateId in this.blocks){
|
||||
if(now - this.blocks[templateId].blockData.creation > (1000 * 60 * 5)){
|
||||
delete this.blocks[templateId];
|
||||
templatesDeleted++;
|
||||
}
|
||||
}
|
||||
// Delete old jobs (5 minutes)
|
||||
for (const jobId in this.jobs) {
|
||||
if(now - this.jobs[jobId].creation > (1000 * 60 * 5)){
|
||||
delete this.jobs[jobId];
|
||||
jobsDeleted++;
|
||||
}
|
||||
}
|
||||
//console.log(`Deleted ${templatesDeleted} templates and ${jobsDeleted} jobs.`)
|
||||
}
|
||||
this.blocks[data.blockData.id] = data;
|
||||
this.getAffectedPayoutModes(data.blockData.payoutMode).forEach(payoutMode => {
|
||||
const pinnedTemplateIds = this.pinnedCurrentTipTemplateIds.get(payoutMode);
|
||||
if (this.currentTipKeys.get(payoutMode) !== data.blockData.tipKey) {
|
||||
pinnedTemplateIds.clear();
|
||||
}
|
||||
this.currentTipKeys.set(payoutMode, data.blockData.tipKey);
|
||||
if (data.blockData.clearJobs) {
|
||||
// ESP-class miners only switch on clean work. Keep each mode's
|
||||
// bridge and clean full replacement reconstructable for its tip.
|
||||
pinnedTemplateIds.add(data.blockData.id);
|
||||
}
|
||||
});
|
||||
this.latestJobTemplates.set(data.blockData.payoutMode, data);
|
||||
if (data.blockData.payoutMode === 'all') {
|
||||
this.latestJobTemplates.set('solo', data);
|
||||
this.latestJobTemplates.set('pplns', data);
|
||||
}
|
||||
this.cleanupExpiredJobsAndTemplates();
|
||||
}),
|
||||
shareReplay({ refCount: true, bufferSize: 1 })
|
||||
)
|
||||
);
|
||||
|
||||
if (process.env.API_ONLY !== 'true') {
|
||||
this.newMiningJob$.subscribe();
|
||||
this.newMiningJob$ = createMiningJobStream(this.bitcoinRpcService.newBlockTemplate$);
|
||||
const bridgeMiningJob$ = createMiningJobStream(
|
||||
this.bitcoinRpcService.newSv1BridgeTemplate$ ?? EMPTY,
|
||||
);
|
||||
this.sv1MiningJob$ = merge(bridgeMiningJob$, this.newMiningJob$).pipe(
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
if (process.env.API_ONLY !== 'true' && process.env.MASTER !== 'true') {
|
||||
this.sv1MiningJob$.subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,25 +259,83 @@ export class StratumV1JobsService {
|
||||
return difficulty;
|
||||
}
|
||||
|
||||
private convertToLittleEndian(hash: string): Buffer {
|
||||
const bytes = Buffer.from(hash, 'hex');
|
||||
Array.prototype.reverse.call(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public getJobTemplateById(jobTemplateId: string): IJobTemplate | null {
|
||||
return this.blocks[jobTemplateId];
|
||||
}
|
||||
|
||||
public getLatestJobTemplate(payoutMode: PayoutMode): IJobTemplate | null {
|
||||
return this.latestJobTemplates.get(payoutMode)
|
||||
?? this.latestJobTemplates.get('all')
|
||||
?? null;
|
||||
}
|
||||
|
||||
public addJob(job: MiningJob) {
|
||||
this.jobs[job.jobId] = job;
|
||||
this.latestJobId++;
|
||||
}
|
||||
|
||||
public getOrCreateJob(
|
||||
network: bitcoinjs.networks.Network,
|
||||
payoutInformation: AddressObject[],
|
||||
jobTemplate: IJobTemplate,
|
||||
payoutIdentity?: string,
|
||||
payoutMode?: PayoutMode,
|
||||
): MiningJob {
|
||||
const effectivePayoutMode = payoutMode
|
||||
?? (jobTemplate.blockData.payoutMode === 'pplns' ? 'pplns' : 'solo');
|
||||
const effectivePayoutIdentity = payoutIdentity
|
||||
?? createPayoutOutputIdentity(effectivePayoutMode, payoutInformation);
|
||||
const cacheKey = [
|
||||
jobTemplate.blockData.id,
|
||||
jobTemplate.block.timestamp,
|
||||
jobTemplate.blockData.clearJobs,
|
||||
effectivePayoutMode,
|
||||
effectivePayoutIdentity,
|
||||
].join(':');
|
||||
const cached = this.cachedJobs.get(cacheKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const job = new MiningJob(
|
||||
network,
|
||||
this.getNextId(),
|
||||
payoutInformation,
|
||||
jobTemplate,
|
||||
{
|
||||
payoutMode: effectivePayoutMode,
|
||||
payoutIdentity: effectivePayoutIdentity,
|
||||
},
|
||||
);
|
||||
this.addJob(job);
|
||||
this.cachedJobs.set(cacheKey, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
public getJobById(jobId: string) {
|
||||
return this.jobs[jobId];
|
||||
}
|
||||
|
||||
public getSubmissionContext(jobId: string): IJobSubmissionContext | null {
|
||||
const job = this.jobs[jobId];
|
||||
if (job == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const jobTemplate = this.blocks[job.jobTemplateId];
|
||||
if (jobTemplate == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
job,
|
||||
jobTemplate,
|
||||
status: this.getJobPayoutModes(job, jobTemplate).some(payoutMode =>
|
||||
job.tipKey === this.currentTipKeys.get(payoutMode),
|
||||
) ? 'current' : 'stale',
|
||||
};
|
||||
}
|
||||
|
||||
public getNextTemplateId() {
|
||||
return this.latestJobTemplateId.toString(16);
|
||||
}
|
||||
@@ -223,4 +343,69 @@ export class StratumV1JobsService {
|
||||
return this.latestJobId.toString(16);
|
||||
}
|
||||
|
||||
private cleanupExpiredJobsAndTemplates() {
|
||||
const cutoff = Date.now() - this.getJobRetentionMs();
|
||||
const protectedTemplateIds = new Set<string>();
|
||||
for (const payoutMode of PAYOUT_MODES) {
|
||||
this.pinnedCurrentTipTemplateIds.get(payoutMode)
|
||||
.forEach(templateId => protectedTemplateIds.add(templateId));
|
||||
const latestTemplate = this.latestJobTemplates.get(payoutMode);
|
||||
if (latestTemplate != null) {
|
||||
protectedTemplateIds.add(latestTemplate.blockData.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const jobId in this.jobs) {
|
||||
if (this.jobs[jobId].creation < cutoff
|
||||
&& !this.isJobProtected(this.jobs[jobId], protectedTemplateIds)) {
|
||||
delete this.jobs[jobId];
|
||||
}
|
||||
}
|
||||
|
||||
const retainedTemplateIds = new Set(
|
||||
Object.values(this.jobs).map(job => job.jobTemplateId),
|
||||
);
|
||||
protectedTemplateIds.forEach(templateId => retainedTemplateIds.add(templateId));
|
||||
|
||||
for (const templateId in this.blocks) {
|
||||
if (this.blocks[templateId].blockData.creation < cutoff
|
||||
&& !retainedTemplateIds.has(templateId)) {
|
||||
delete this.blocks[templateId];
|
||||
}
|
||||
}
|
||||
|
||||
for (const [cacheKey, job] of this.cachedJobs.entries()) {
|
||||
if (this.jobs[job.jobId] !== job || this.blocks[job.jobTemplateId] == null) {
|
||||
this.cachedJobs.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getAffectedPayoutModes(payoutMode: PayoutMode | 'all'): readonly PayoutMode[] {
|
||||
return payoutMode === 'all' ? PAYOUT_MODES : [payoutMode];
|
||||
}
|
||||
|
||||
private getJobPayoutModes(job: MiningJob, jobTemplate: IJobTemplate): readonly PayoutMode[] {
|
||||
if (job.ownership?.payoutMode != null) {
|
||||
return [job.ownership.payoutMode];
|
||||
}
|
||||
return this.getAffectedPayoutModes(jobTemplate.blockData.payoutMode);
|
||||
}
|
||||
|
||||
private isJobProtected(job: MiningJob, legacyProtectedTemplateIds: ReadonlySet<string>): boolean {
|
||||
const payoutMode = job.ownership?.payoutMode;
|
||||
if (payoutMode == null) {
|
||||
return legacyProtectedTemplateIds.has(job.jobTemplateId);
|
||||
}
|
||||
return this.pinnedCurrentTipTemplateIds.get(payoutMode).has(job.jobTemplateId)
|
||||
|| this.latestJobTemplates.get(payoutMode)?.blockData.id === job.jobTemplateId;
|
||||
}
|
||||
|
||||
private getJobRetentionMs(): number {
|
||||
const configured = Number(process.env.STRATUM_JOB_RETENTION_MS);
|
||||
return Number.isInteger(configured) && configured > 0
|
||||
? configured
|
||||
: DEFAULT_JOB_RETENTION_MS;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import { StratumV1Service } from './stratum-v1.service';
|
||||
|
||||
describe('StratumV1Service', () => {
|
||||
@@ -16,6 +18,7 @@ describe('StratumV1Service', () => {
|
||||
let userAgentReportService;
|
||||
let stratumV2Service;
|
||||
let redisMessagingService;
|
||||
let miningJobs: Subject<any>;
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
|
||||
@@ -32,13 +35,14 @@ describe('StratumV1Service', () => {
|
||||
createClient: jest.fn()
|
||||
};
|
||||
redisMessagingService = {};
|
||||
miningJobs = new Subject();
|
||||
service = new StratumV1Service(
|
||||
{} as any,
|
||||
clientService,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ newMiningJob$: miningJobs.asObservable() } as any,
|
||||
{} as any,
|
||||
stratumV2Service as any,
|
||||
userAgentReportService as any,
|
||||
@@ -101,6 +105,108 @@ describe('StratumV1Service', () => {
|
||||
expect(startSecureSocketServerSpy).toHaveBeenCalledWith(14333, 'pplns');
|
||||
});
|
||||
|
||||
it('should fan out each job once through the worker broadcaster', async () => {
|
||||
process.env.MASTER = 'false';
|
||||
process.env.STRATUM_PORTS = '';
|
||||
process.env.STRATUM_SECURE = 'false';
|
||||
const client = {
|
||||
broadcastMiningJob: jest.fn().mockReturnValue({
|
||||
status: 'written',
|
||||
bytes: 500,
|
||||
bufferedBytes: 0,
|
||||
}),
|
||||
};
|
||||
(service as any).clients.add(client);
|
||||
await service.onModuleInit();
|
||||
|
||||
const job = {
|
||||
blockData: {
|
||||
id: 'a',
|
||||
height: 900001,
|
||||
jobType: 'full',
|
||||
isNewBlock: true,
|
||||
clearJobs: true,
|
||||
},
|
||||
};
|
||||
miningJobs.next(job);
|
||||
|
||||
expect(client.broadcastMiningJob).toHaveBeenCalledTimes(1);
|
||||
expect(client.broadcastMiningJob).toHaveBeenCalledWith(job);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('stratum_job_fanout'));
|
||||
service.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('enqueues a 100,000-client fanout without per-client async serialization', () => {
|
||||
const clientCount = 100_000;
|
||||
let writes = 0;
|
||||
const broadcastMiningJob = () => {
|
||||
writes++;
|
||||
return { status: 'written', bytes: 256, bufferedBytes: 0 };
|
||||
};
|
||||
for (let index = 0; index < clientCount; index++) {
|
||||
(service as any).clients.add({ broadcastMiningJob });
|
||||
}
|
||||
const job = {
|
||||
blockData: {
|
||||
id: 'load',
|
||||
height: 900001,
|
||||
jobType: 'empty',
|
||||
isNewBlock: true,
|
||||
clearJobs: true,
|
||||
notificationEventId: 'load-test',
|
||||
notificationPublishedAtMs: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
(service as any).broadcastMiningJob(job);
|
||||
|
||||
const trace = JSON.parse(consoleLogSpy.mock.calls.at(-1)?.[0]);
|
||||
expect(writes).toBe(clientCount);
|
||||
expect(trace).toEqual(expect.objectContaining({
|
||||
event: 'stratum_job_fanout',
|
||||
eventId: 'load-test',
|
||||
clients: clientCount,
|
||||
written: clientCount,
|
||||
}));
|
||||
expect(trace.milestoneMs).toEqual(expect.objectContaining({
|
||||
p50: expect.any(Number),
|
||||
p95: expect.any(Number),
|
||||
p99: expect.any(Number),
|
||||
}));
|
||||
expect(trace.totalMs).toBeLessThan(1_000);
|
||||
});
|
||||
|
||||
it('reports a buffer-limited client as closed without counting an unwritten job', () => {
|
||||
(service as any).clients.add({
|
||||
broadcastMiningJob: jest.fn().mockReturnValue({
|
||||
status: 'closed',
|
||||
bytes: 0,
|
||||
bufferedBytes: 262_144,
|
||||
}),
|
||||
});
|
||||
const job = {
|
||||
blockData: {
|
||||
id: 'buffer-limit',
|
||||
height: 900001,
|
||||
jobType: 'empty',
|
||||
isNewBlock: true,
|
||||
clearJobs: true,
|
||||
},
|
||||
};
|
||||
|
||||
(service as any).broadcastMiningJob(job);
|
||||
|
||||
const trace = JSON.parse(consoleLogSpy.mock.calls.at(-1)?.[0]);
|
||||
expect(trace).toEqual(expect.objectContaining({
|
||||
clients: 1,
|
||||
closed: 1,
|
||||
written: 0,
|
||||
backpressured: 0,
|
||||
bytesQueued: 0,
|
||||
maxBufferedBytes: 262_144,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should pause listeners when worker backpressure is high', () => {
|
||||
const close = jest.fn((callback?: (error?: Error) => void) => callback?.());
|
||||
(service as any).listeners.push({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Server, Socket } from 'net';
|
||||
import { monitorEventLoopDelay } from 'perf_hooks';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
import { StratumV1Client } from '../models/StratumV1Client';
|
||||
import { StratumV2Client } from '../models/StratumV2Client';
|
||||
@@ -44,7 +45,7 @@ const DEFAULT_TCP_KEEPALIVE_INITIAL_DELAY_MS = 1000 * 60;
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class StratumV1Service implements OnModuleInit {
|
||||
export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
private socketTimeout = 0;
|
||||
private emptySocket = 0;
|
||||
@@ -54,6 +55,8 @@ export class StratumV1Service implements OnModuleInit {
|
||||
private readonly eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
|
||||
private backpressureMonitor: NodeJS.Timeout | null = null;
|
||||
private healthyBackpressureChecks = 0;
|
||||
private readonly clients = new Set<StratumV1Client>();
|
||||
private jobBroadcastSubscription: Subscription | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly bitcoinRpcService: BitcoinRpcService,
|
||||
@@ -86,6 +89,14 @@ export class StratumV1Service implements OnModuleInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.jobBroadcastSubscription = (
|
||||
this.stratumV1JobsService.sv1MiningJob$
|
||||
?? this.stratumV1JobsService.newMiningJob$
|
||||
).subscribe({
|
||||
next: jobTemplate => this.broadcastMiningJob(jobTemplate),
|
||||
error: error => console.error(`SV1 job broadcast subscription failed: ${error.message}`),
|
||||
});
|
||||
|
||||
// wait for all the other processes to init for an even connection distribution
|
||||
setTimeout(() => {
|
||||
parsePayoutModePorts(
|
||||
@@ -116,6 +127,15 @@ export class StratumV1Service implements OnModuleInit {
|
||||
|
||||
}
|
||||
|
||||
public onModuleDestroy(): void {
|
||||
this.jobBroadcastSubscription?.unsubscribe();
|
||||
this.jobBroadcastSubscription = null;
|
||||
if (this.backpressureMonitor != null) {
|
||||
clearInterval(this.backpressureMonitor);
|
||||
this.backpressureMonitor = null;
|
||||
}
|
||||
}
|
||||
|
||||
private startSocketServer(port: number, payoutMode: PayoutMode) {
|
||||
const listener: StratumListenerState = {
|
||||
port,
|
||||
@@ -132,6 +152,7 @@ export class StratumV1Service implements OnModuleInit {
|
||||
const server = new Server(async (socket: Socket) => {
|
||||
socket.setTimeout(this.getSocketTimeoutMs());
|
||||
socket.setKeepAlive(true, this.getTcpKeepAliveInitialDelayMs());
|
||||
socket.setNoDelay(true);
|
||||
|
||||
let client: StratumV1Client | StratumV2Client = null;
|
||||
let protocol: 'v1' | 'v2' | null = null;
|
||||
@@ -152,6 +173,9 @@ export class StratumV1Service implements OnModuleInit {
|
||||
const initializedClient = protocol === 'v2'
|
||||
|| (currentClient as StratumV1Client).extraNonceAndSessionId != null;
|
||||
await currentClient.destroy();
|
||||
if (protocol === 'v1') {
|
||||
this.clients.delete(currentClient as StratumV1Client);
|
||||
}
|
||||
if (initializedClient) {
|
||||
if (reason == 'Error') {
|
||||
this.errorClosure++;
|
||||
@@ -229,7 +253,7 @@ export class StratumV1Service implements OnModuleInit {
|
||||
}
|
||||
|
||||
private createV1Client(socket: Socket, accountingProtocol: 'sv1' | 'sv1_tls', payoutMode: PayoutMode): StratumV1Client {
|
||||
return new StratumV1Client(
|
||||
const client = new StratumV1Client(
|
||||
socket,
|
||||
this.stratumV1JobsService,
|
||||
this.bitcoinRpcService,
|
||||
@@ -244,6 +268,8 @@ export class StratumV1Service implements OnModuleInit {
|
||||
accountingProtocol,
|
||||
payoutMode,
|
||||
);
|
||||
this.clients.add(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
private startSecureSocketServer(port: number, payoutMode: PayoutMode) {
|
||||
@@ -273,6 +299,7 @@ export class StratumV1Service implements OnModuleInit {
|
||||
const server = createServer(tlsOptions, async (socket: TLSSocket) => {
|
||||
socket.setTimeout(this.getSocketTimeoutMs());
|
||||
socket.setKeepAlive(true, this.getTcpKeepAliveInitialDelayMs());
|
||||
socket.setNoDelay(true);
|
||||
|
||||
const client = this.createV1Client(socket, 'sv1_tls', payoutMode);
|
||||
let cleanedUp = false;
|
||||
@@ -286,6 +313,7 @@ export class StratumV1Service implements OnModuleInit {
|
||||
try {
|
||||
const initializedClient = client.extraNonceAndSessionId != null;
|
||||
await client.destroy();
|
||||
this.clients.delete(client);
|
||||
if (initializedClient) {
|
||||
if (reason === 'Error') {
|
||||
this.errorClosure++;
|
||||
@@ -430,6 +458,78 @@ export class StratumV1Service implements OnModuleInit {
|
||||
return Math.round(this.eventLoopDelay.percentile(95) / 1e6);
|
||||
}
|
||||
|
||||
private broadcastMiningJob(jobTemplate: import('./stratum-v1-jobs.service').IJobTemplate): void {
|
||||
const startedAt = process.hrtime.bigint();
|
||||
const totalClients = this.clients.size;
|
||||
const milestoneIndexes = {
|
||||
p50: Math.max(1, Math.ceil(totalClients * 0.5)),
|
||||
p95: Math.max(1, Math.ceil(totalClients * 0.95)),
|
||||
p99: Math.max(1, Math.ceil(totalClients * 0.99)),
|
||||
};
|
||||
const milestoneMs: { p50?: number; p95?: number; p99?: number } = {};
|
||||
let visited = 0;
|
||||
let written = 0;
|
||||
let skipped = 0;
|
||||
let backpressured = 0;
|
||||
let closed = 0;
|
||||
let errors = 0;
|
||||
let bytesQueued = 0;
|
||||
let maxBufferedBytes = 0;
|
||||
|
||||
const elapsedMs = () => Number(process.hrtime.bigint() - startedAt) / 1e6;
|
||||
for (const client of this.clients) {
|
||||
visited++;
|
||||
try {
|
||||
const result = client.broadcastMiningJob(jobTemplate);
|
||||
bytesQueued += result.bytes;
|
||||
maxBufferedBytes = Math.max(maxBufferedBytes, result.bufferedBytes);
|
||||
switch (result.status) {
|
||||
case 'written': written++; break;
|
||||
case 'backpressured': backpressured++; break;
|
||||
case 'closed': closed++; break;
|
||||
case 'error': errors++; break;
|
||||
default: skipped++; break;
|
||||
}
|
||||
} catch (error) {
|
||||
errors++;
|
||||
void client.destroy();
|
||||
}
|
||||
|
||||
if (milestoneMs.p50 == null && visited >= milestoneIndexes.p50) {
|
||||
milestoneMs.p50 = elapsedMs();
|
||||
}
|
||||
if (milestoneMs.p95 == null && visited >= milestoneIndexes.p95) {
|
||||
milestoneMs.p95 = elapsedMs();
|
||||
}
|
||||
if (milestoneMs.p99 == null && visited >= milestoneIndexes.p99) {
|
||||
milestoneMs.p99 = elapsedMs();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
event: 'stratum_job_fanout',
|
||||
eventId: jobTemplate.blockData.notificationEventId,
|
||||
redisToFanoutStartMs: jobTemplate.blockData.notificationPublishedAtMs == null
|
||||
? undefined
|
||||
: Date.now() - jobTemplate.blockData.notificationPublishedAtMs,
|
||||
templateId: jobTemplate.blockData.id,
|
||||
height: jobTemplate.blockData.height,
|
||||
jobType: jobTemplate.blockData.jobType,
|
||||
isNewBlock: jobTemplate.blockData.isNewBlock,
|
||||
cleanJobs: jobTemplate.blockData.clearJobs,
|
||||
clients: totalClients,
|
||||
written,
|
||||
skipped,
|
||||
backpressured,
|
||||
closed,
|
||||
errors,
|
||||
bytesQueued,
|
||||
maxBufferedBytes,
|
||||
milestoneMs,
|
||||
totalMs: elapsedMs(),
|
||||
}));
|
||||
}
|
||||
|
||||
private isBackpressureDisabled() {
|
||||
return process.env.STRATUM_BACKPRESSURE_ENABLED?.toLowerCase() === 'false';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { Socket } from 'net';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
|
||||
import { StratumV2Client } from '../models/StratumV2Client';
|
||||
import { IJobTemplate } from './stratum-v1-jobs.service';
|
||||
import { StratumV2Service } from './stratum-v2.service';
|
||||
|
||||
describe('StratumV2Service canonical job broadcaster', () => {
|
||||
const originalApiOnly = process.env.API_ONLY;
|
||||
const originalMaster = process.env.MASTER;
|
||||
const originalNamespaceEnv = {
|
||||
NODE_APP_INSTANCE: process.env.NODE_APP_INSTANCE,
|
||||
pm_id: process.env.pm_id,
|
||||
restart_time: process.env.restart_time,
|
||||
PM2_ENABLED: process.env.PM2_ENABLED,
|
||||
SV2_EXTRANONCE_NAMESPACE_BASE: process.env.SV2_EXTRANONCE_NAMESPACE_BASE,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.API_ONLY;
|
||||
delete process.env.MASTER;
|
||||
for (const key of Object.keys(originalNamespaceEnv)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.API_ONLY = originalApiOnly;
|
||||
process.env.MASTER = originalMaster;
|
||||
for (const [key, value] of Object.entries(originalNamespaceEnv)) {
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('owns one canonical subscription and fans out without awaiting slow clients', async () => {
|
||||
const { service, templates, subscribeSpy } = createService();
|
||||
await service.onModuleInit();
|
||||
await service.onModuleInit();
|
||||
let releaseSlow: () => void;
|
||||
const slowGate = new Promise<void>((resolve) => {
|
||||
releaseSlow = resolve;
|
||||
});
|
||||
const slowClient = createClientMock(jest.fn(() => slowGate));
|
||||
const fastClient = createClientMock();
|
||||
service.registerClient(slowClient);
|
||||
service.registerClient(fastClient);
|
||||
|
||||
templates.next(createJobTemplate('1', 'all'));
|
||||
|
||||
expect(subscribeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(slowClient.enqueueCanonicalJob).toHaveBeenCalledTimes(1);
|
||||
expect(fastClient.enqueueCanonicalJob).toHaveBeenCalledTimes(1);
|
||||
releaseSlow();
|
||||
await slowGate;
|
||||
await service.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('isolates a failed client and continues the same fanout', async () => {
|
||||
const { service, templates } = createService();
|
||||
await service.onModuleInit();
|
||||
const failedClient = createClientMock(
|
||||
jest.fn(() => {
|
||||
throw new Error('socket failed');
|
||||
}),
|
||||
);
|
||||
const healthyClient = createClientMock();
|
||||
service.registerClient(failedClient);
|
||||
service.registerClient(healthyClient);
|
||||
|
||||
templates.next(createJobTemplate('2', 'all'));
|
||||
|
||||
expect(failedClient.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(healthyClient.enqueueCanonicalJob).toHaveBeenCalledTimes(1);
|
||||
expect((service as any).clients.has(failedClient)).toBe(false);
|
||||
await service.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('removes an asynchronously failed client without blocking later broadcasts', async () => {
|
||||
const { service, templates } = createService();
|
||||
await service.onModuleInit();
|
||||
const failedClient = createClientMock(
|
||||
jest.fn().mockRejectedValue(new Error('async socket failure')),
|
||||
);
|
||||
const healthyClient = createClientMock();
|
||||
service.registerClient(failedClient);
|
||||
service.registerClient(healthyClient);
|
||||
|
||||
templates.next(createJobTemplate('async-failure', 'all'));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(failedClient.destroy).toHaveBeenCalledTimes(1);
|
||||
expect((service as any).clients.has(failedClient)).toBe(false);
|
||||
expect(healthyClient.enqueueCanonicalJob).toHaveBeenCalledTimes(1);
|
||||
|
||||
templates.next(createJobTemplate('after-failure', 'all'));
|
||||
expect(healthyClient.enqueueCanonicalJob).toHaveBeenCalledTimes(2);
|
||||
await service.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('tracks the latest canonical template independently by payout mode', async () => {
|
||||
const { service, templates } = createService();
|
||||
await service.onModuleInit();
|
||||
const shared = createJobTemplate('shared', 'all');
|
||||
const solo = createJobTemplate('solo', 'solo');
|
||||
const pplns = createJobTemplate('pplns', 'pplns');
|
||||
|
||||
templates.next(shared);
|
||||
expect(service.getLatestCanonicalJob('solo')).toBe(shared);
|
||||
expect(service.getLatestCanonicalJob('pplns')).toBe(shared);
|
||||
|
||||
templates.next(solo);
|
||||
templates.next(pplns);
|
||||
expect(service.getLatestCanonicalJob('solo')).toBe(solo);
|
||||
expect(service.getLatestCanonicalJob('pplns')).toBe(pplns);
|
||||
await service.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('owns one activation subscription, deduplicates payout envelopes, and fans out without awaiting', async () => {
|
||||
const {
|
||||
service,
|
||||
activations,
|
||||
activationSubscribeSpy,
|
||||
} = createService();
|
||||
await service.onModuleInit();
|
||||
await service.onModuleInit();
|
||||
let releaseSlow: () => void;
|
||||
const slowGate = new Promise<void>((resolve) => {
|
||||
releaseSlow = resolve;
|
||||
});
|
||||
const slowClient = createClientMock(undefined, jest.fn(() => slowGate));
|
||||
const fastClient = createClientMock();
|
||||
service.registerClient(slowClient);
|
||||
service.registerClient(fastClient);
|
||||
|
||||
const solo = createActivationTemplate(900_001, '11'.repeat(32), 'solo');
|
||||
activations.next(solo);
|
||||
activations.next({ ...solo, payoutMode: 'pplns' });
|
||||
|
||||
expect(activationSubscribeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(slowClient.enqueueWorkActivation).toHaveBeenCalledTimes(1);
|
||||
expect(fastClient.enqueueWorkActivation).toHaveBeenCalledTimes(1);
|
||||
expect(service.getLatestWorkActivationTemplate()).toBe(solo);
|
||||
releaseSlow();
|
||||
await slowGate;
|
||||
await service.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('registers created clients and unregisters them during client destruction', async () => {
|
||||
const { service } = createService();
|
||||
(service as any).noiseConfig = createNoiseConfig();
|
||||
const socket = createSocket();
|
||||
|
||||
const client = service.createClient(socket, Buffer.alloc(0));
|
||||
expect((service as any).clients.has(client)).toBe(true);
|
||||
|
||||
await client.destroy();
|
||||
expect((service as any).clients.has(client)).toBe(false);
|
||||
});
|
||||
|
||||
it('allocates one collision-free prefix space across channel types, workers, and reloads', () => {
|
||||
const allocateBothChannelTypes = (service: StratumV2Service): Buffer[] => {
|
||||
const standardChannelId = service.getNextChannelId();
|
||||
const standard = service.generateExtranoncePrefix(standardChannelId);
|
||||
const extendedChannelId = service.getNextChannelId();
|
||||
const extended = service.allocateExtendedExtranoncePrefix(extendedChannelId);
|
||||
expect(service.getExtendedMinerExtranonceSize()).toBe(10);
|
||||
expect(service.getExtendedTotalExtranonceSize()).toBe(14);
|
||||
return [standard, extended];
|
||||
};
|
||||
|
||||
process.env.NODE_APP_INSTANCE = '0';
|
||||
process.env.restart_time = '0';
|
||||
const worker0 = allocateBothChannelTypes(createService().service);
|
||||
|
||||
process.env.NODE_APP_INSTANCE = '1';
|
||||
process.env.restart_time = '0';
|
||||
const worker1 = allocateBothChannelTypes(createService().service);
|
||||
|
||||
process.env.NODE_APP_INSTANCE = '0';
|
||||
process.env.restart_time = '1';
|
||||
const reloadedWorker0 = allocateBothChannelTypes(createService().service);
|
||||
|
||||
const prefixes = [...worker0, ...worker1, ...reloadedWorker0];
|
||||
expect(prefixes.every(prefix => prefix.length === 4)).toBe(true);
|
||||
expect(new Set(prefixes.map(prefix => prefix.toString('hex'))).size).toBe(prefixes.length);
|
||||
expect(prefixes.map(prefix => prefix[0])).toEqual([0, 0, 2, 2, 1, 1]);
|
||||
});
|
||||
|
||||
it('fails worker initialization when PM2 exposes no representable process identity', async () => {
|
||||
process.env.PM2_ENABLED = 'true';
|
||||
const { service } = createService();
|
||||
|
||||
await expect(service.onModuleInit())
|
||||
.rejects.toThrow('cannot allocate collision-free extranonces');
|
||||
});
|
||||
|
||||
it('unsubscribes and destroys all registered clients on module shutdown', async () => {
|
||||
const { service } = createService();
|
||||
await service.onModuleInit();
|
||||
const first = createClientMock();
|
||||
const second = createClientMock();
|
||||
service.registerClient(first);
|
||||
service.registerClient(second);
|
||||
const subscription = (service as any).canonicalJobSubscription;
|
||||
const activationSubscription = (service as any).workActivationSubscription;
|
||||
|
||||
await service.onModuleDestroy();
|
||||
|
||||
expect(subscription.closed).toBe(true);
|
||||
expect(activationSubscription.closed).toBe(true);
|
||||
expect(first.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(second.destroy).toHaveBeenCalledTimes(1);
|
||||
expect((service as any).clients.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function createService(): {
|
||||
service: StratumV2Service;
|
||||
templates: Subject<IJobTemplate>;
|
||||
subscribeSpy: jest.Mock;
|
||||
activations: Subject<any>;
|
||||
activationSubscribeSpy: jest.Mock;
|
||||
} {
|
||||
const templates = new Subject<IJobTemplate>();
|
||||
const subscribeSpy = jest.fn((observer) => templates.subscribe(observer));
|
||||
const activations = new Subject<any>();
|
||||
const activationSubscribeSpy = jest.fn((observer) => activations.subscribe(observer));
|
||||
const jobsService = {
|
||||
newMiningJob$: new Observable((subscriber) => subscribeSpy(subscriber)),
|
||||
getLatestJobTemplate: jest.fn().mockReturnValue(null),
|
||||
};
|
||||
const configService = {
|
||||
get: jest.fn((key: string) => (key === 'NETWORK' ? 'testnet' : null)),
|
||||
};
|
||||
const clientService = {
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const bitcoinRpcService = {
|
||||
workActivationTemplate$: new Observable((subscriber) => activationSubscribeSpy(subscriber)),
|
||||
};
|
||||
const service = new StratumV2Service(
|
||||
bitcoinRpcService as any,
|
||||
clientService as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
configService as unknown as ConfigService,
|
||||
jobsService as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
return { service, templates, subscribeSpy, activations, activationSubscribeSpy };
|
||||
}
|
||||
|
||||
function createClientMock(
|
||||
enqueue = jest.fn().mockResolvedValue(undefined),
|
||||
enqueueActivation = jest.fn().mockResolvedValue(undefined),
|
||||
): StratumV2Client & {
|
||||
enqueueCanonicalJob: jest.Mock;
|
||||
enqueueWorkActivation: jest.Mock;
|
||||
destroy: jest.Mock;
|
||||
} {
|
||||
return {
|
||||
enqueueCanonicalJob: enqueue,
|
||||
enqueueWorkActivation: enqueueActivation,
|
||||
destroy: jest.fn().mockResolvedValue(undefined),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createActivationTemplate(
|
||||
height: number,
|
||||
previousblockhash: string,
|
||||
payoutMode: 'solo' | 'pplns',
|
||||
) {
|
||||
return {
|
||||
height,
|
||||
previousblockhash,
|
||||
payoutMode,
|
||||
jobType: 'empty',
|
||||
version: 0x20000000,
|
||||
vbrequired: 0,
|
||||
bits: '1d00ffff',
|
||||
mintime: 1_700_000_001,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createJobTemplate(
|
||||
id: string,
|
||||
payoutMode: 'solo' | 'pplns' | 'all',
|
||||
): IJobTemplate {
|
||||
const block = new bitcoinjs.Block();
|
||||
block.timestamp = 1_700_000_000;
|
||||
return {
|
||||
block,
|
||||
merkle_branch: [],
|
||||
blockData: {
|
||||
id,
|
||||
creation: Date.now(),
|
||||
coinbasevalue: 312_500_000,
|
||||
networkDifficulty: 1,
|
||||
height: 900_000,
|
||||
tipKey: `900000:${'00'.repeat(32)}`,
|
||||
clearJobs: true,
|
||||
isNewBlock: true,
|
||||
jobType: 'full',
|
||||
payoutMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNoiseConfig() {
|
||||
return {
|
||||
staticKeypair: {
|
||||
privateKey: Buffer.alloc(32, 1),
|
||||
publicKey: Buffer.alloc(64, 2),
|
||||
},
|
||||
certificateMessage: {
|
||||
version: 0,
|
||||
validFrom: 0,
|
||||
notValidAfter: 1,
|
||||
signature: Buffer.alloc(64),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSocket(): Socket {
|
||||
const socket: any = {
|
||||
destroyed: false,
|
||||
writableEnded: false,
|
||||
on: jest.fn().mockReturnThis(),
|
||||
write: jest.fn((_data, callback) => {
|
||||
callback?.();
|
||||
return true;
|
||||
}),
|
||||
destroy: jest.fn(function destroy() {
|
||||
socket.destroyed = true;
|
||||
return socket;
|
||||
}),
|
||||
};
|
||||
return socket as Socket;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as crypto from 'crypto';
|
||||
import { Server, Socket } from 'net';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
|
||||
import { BlocksService } from '../ORM/blocks/blocks.service';
|
||||
@@ -9,8 +10,16 @@ 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 { StratumV2Client } from '../models/StratumV2Client';
|
||||
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
|
||||
import { encodeSv2AuthorityPublicKey } from '../models/sv2/sv2-authority-key';
|
||||
import { Sv2ExtranonceManager } from '../models/sv2/sv2-extranonce-manager';
|
||||
import {
|
||||
resolveSv2ProcessNamespace,
|
||||
Sv2ExtranonceManager,
|
||||
} from '../models/sv2/sv2-extranonce-manager';
|
||||
import {
|
||||
EXTRANONCE1_SIZE_BYTES,
|
||||
SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES,
|
||||
} from '../models/stratum.constants';
|
||||
import {
|
||||
createSignatureNoiseMessage,
|
||||
generateServerKeypair,
|
||||
@@ -22,7 +31,7 @@ import { BitcoinRpcService } from './bitcoin-rpc.service';
|
||||
import { CustomWorkService } from './custom-work.service';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { RedisMessagingService } from './redis-messaging.service';
|
||||
import { StratumV1JobsService } from './stratum-v1-jobs.service';
|
||||
import { IJobTemplate, StratumV1JobsService } from './stratum-v1-jobs.service';
|
||||
import { Sv2JobDeclarationRegistryService } from './sv2-job-declaration-registry.service';
|
||||
import { parsePayoutModePorts, PayoutMode } from '../types/payout-mode';
|
||||
|
||||
@@ -30,15 +39,21 @@ const DEFAULT_SOCKET_TIMEOUT_MS = 1000 * 60 * 60;
|
||||
const DEFAULT_TCP_KEEPALIVE_INITIAL_DELAY_MS = 1000 * 60;
|
||||
|
||||
@Injectable()
|
||||
export class StratumV2Service implements OnModuleInit {
|
||||
export class StratumV2Service implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly servers: Server[] = [];
|
||||
private readonly clients = new Set<StratumV2Client>();
|
||||
private readonly latestCanonicalJobs = new Map<PayoutMode | 'all', IJobTemplate>();
|
||||
private canonicalJobSubscription: Subscription = null;
|
||||
private workActivationSubscription: Subscription = null;
|
||||
private latestWorkActivationTemplate: IBlockTemplate = null;
|
||||
private latestWorkActivationKey: string = null;
|
||||
private authorityPrivKey: Buffer;
|
||||
private authorityPublicKeyXOnly: Buffer;
|
||||
private authorityKeyConfigured = false;
|
||||
private serverKeypair: Sv2ServerKeypair;
|
||||
private noiseConfig: Sv2NoiseConfig;
|
||||
private channelIdCounter = 1;
|
||||
private readonly extranonceManager = new Sv2ExtranonceManager();
|
||||
private extranonceManager: Sv2ExtranonceManager = null;
|
||||
|
||||
constructor(
|
||||
private readonly bitcoinRpcService: BitcoinRpcService,
|
||||
@@ -65,6 +80,10 @@ export class StratumV2Service implements OnModuleInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.getExtranonceManager();
|
||||
this.startCanonicalJobBroadcaster();
|
||||
this.startWorkActivationBroadcaster();
|
||||
|
||||
const ports = this.getPorts();
|
||||
if (ports.length === 0) {
|
||||
return;
|
||||
@@ -74,6 +93,23 @@ export class StratumV2Service implements OnModuleInit {
|
||||
ports.forEach(({ port, payoutMode }) => this.startSocketServer(port, payoutMode));
|
||||
}
|
||||
|
||||
public async onModuleDestroy(): Promise<void> {
|
||||
this.canonicalJobSubscription?.unsubscribe();
|
||||
this.canonicalJobSubscription = null;
|
||||
this.workActivationSubscription?.unsubscribe();
|
||||
this.workActivationSubscription = null;
|
||||
|
||||
const clients = Array.from(this.clients);
|
||||
this.clients.clear();
|
||||
await Promise.allSettled(clients.map(client => client.destroy()));
|
||||
|
||||
for (const server of this.servers) {
|
||||
if (server.listening) {
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ensureInitialized(): Promise<void> {
|
||||
if (this.noiseConfig != null) {
|
||||
return;
|
||||
@@ -87,7 +123,7 @@ export class StratumV2Service implements OnModuleInit {
|
||||
throw new Error('Stratum V2 service is not initialized');
|
||||
}
|
||||
|
||||
return new StratumV2Client(
|
||||
const client = new StratumV2Client(
|
||||
socket,
|
||||
firstChunk,
|
||||
this,
|
||||
@@ -105,6 +141,26 @@ export class StratumV2Service implements OnModuleInit {
|
||||
this.payoutSnapshotService,
|
||||
payoutMode,
|
||||
);
|
||||
this.registerClient(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
public registerClient(client: StratumV2Client): void {
|
||||
this.clients.add(client);
|
||||
}
|
||||
|
||||
public unregisterClient(client: StratumV2Client): void {
|
||||
this.clients.delete(client);
|
||||
}
|
||||
|
||||
public getLatestCanonicalJob(payoutMode: PayoutMode): IJobTemplate | null {
|
||||
return this.latestCanonicalJobs.get(payoutMode)
|
||||
?? this.latestCanonicalJobs.get('all')
|
||||
?? this.stratumV1JobsService.getLatestJobTemplate(payoutMode);
|
||||
}
|
||||
|
||||
public getLatestWorkActivationTemplate(): IBlockTemplate | null {
|
||||
return this.latestWorkActivationTemplate;
|
||||
}
|
||||
|
||||
public getNoiseConfig(): Sv2NoiseConfig {
|
||||
@@ -121,30 +177,120 @@ export class StratumV2Service implements OnModuleInit {
|
||||
}
|
||||
|
||||
public getNextChannelId(): number {
|
||||
if (this.channelIdCounter > 0xffffffff) {
|
||||
throw new Error('SV2 channel ID space exhausted');
|
||||
}
|
||||
return this.channelIdCounter++;
|
||||
}
|
||||
|
||||
public generateExtranoncePrefix(): Buffer {
|
||||
const prefix = Buffer.alloc(4);
|
||||
prefix.writeUInt16BE(this.channelIdCounter & 0xffff, 0);
|
||||
crypto.randomBytes(2).copy(prefix, 2);
|
||||
return prefix;
|
||||
public generateExtranoncePrefix(channelId: number): Buffer {
|
||||
return this.getExtranonceManager().allocate(channelId);
|
||||
}
|
||||
|
||||
public allocateExtendedExtranoncePrefix(channelId: number): Buffer {
|
||||
return this.extranonceManager.allocate(channelId);
|
||||
return this.getExtranonceManager().allocate(channelId);
|
||||
}
|
||||
|
||||
public releaseExtranoncePrefix(channelId: number): void {
|
||||
this.extranonceManager?.release(channelId);
|
||||
}
|
||||
|
||||
public releaseExtendedExtranoncePrefix(channelId: number): void {
|
||||
this.extranonceManager.release(channelId);
|
||||
this.releaseExtranoncePrefix(channelId);
|
||||
}
|
||||
|
||||
public getExtendedMinerExtranonceSize(): number {
|
||||
return this.extranonceManager.minerExtranonceSize;
|
||||
return this.getExtranonceManager().minerExtranonceSize;
|
||||
}
|
||||
|
||||
public getExtendedTotalExtranonceSize(): number {
|
||||
return this.extranonceManager.totalSize;
|
||||
return this.getExtranonceManager().totalSize;
|
||||
}
|
||||
|
||||
private getExtranonceManager(): Sv2ExtranonceManager {
|
||||
if (this.extranonceManager == null) {
|
||||
const clusterWorkerId = (require('cluster') as { worker?: { id?: number } }).worker?.id;
|
||||
const clusterWorkerIndex = clusterWorkerId == null
|
||||
? undefined
|
||||
: clusterWorkerId - 1;
|
||||
const processNamespace = resolveSv2ProcessNamespace(
|
||||
process.env,
|
||||
clusterWorkerIndex,
|
||||
);
|
||||
this.extranonceManager = new Sv2ExtranonceManager(
|
||||
EXTRANONCE1_SIZE_BYTES,
|
||||
SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES,
|
||||
processNamespace,
|
||||
);
|
||||
console.log(`SV2 extranonce namespace ${processNamespace} initialized`);
|
||||
}
|
||||
return this.extranonceManager;
|
||||
}
|
||||
|
||||
private startCanonicalJobBroadcaster(): void {
|
||||
if (this.canonicalJobSubscription != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.canonicalJobSubscription = this.stratumV1JobsService.newMiningJob$.subscribe({
|
||||
next: jobTemplate => this.broadcastCanonicalJob(jobTemplate),
|
||||
error: error => console.error(`SV2 canonical job subscription failed: ${error.message}`),
|
||||
});
|
||||
}
|
||||
|
||||
private broadcastCanonicalJob(jobTemplate: IJobTemplate): void {
|
||||
this.latestCanonicalJobs.set(jobTemplate.blockData.payoutMode, jobTemplate);
|
||||
if (jobTemplate.blockData.payoutMode === 'all') {
|
||||
this.latestCanonicalJobs.set('solo', jobTemplate);
|
||||
this.latestCanonicalJobs.set('pplns', jobTemplate);
|
||||
}
|
||||
|
||||
for (const client of this.clients) {
|
||||
try {
|
||||
void client.enqueueCanonicalJob(jobTemplate).catch(error => {
|
||||
console.error(`SV2 canonical job enqueue failed: ${error.message}`);
|
||||
this.unregisterClient(client);
|
||||
void client.destroy();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`SV2 canonical job enqueue failed: ${error.message}`);
|
||||
this.unregisterClient(client);
|
||||
void client.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startWorkActivationBroadcaster(): void {
|
||||
if (this.workActivationSubscription != null || this.bitcoinRpcService.workActivationTemplate$ == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.workActivationSubscription = this.bitcoinRpcService.workActivationTemplate$.subscribe({
|
||||
next: template => this.broadcastWorkActivation(template),
|
||||
error: error => console.error(`SV2 work activation subscription failed: ${error.message}`),
|
||||
});
|
||||
}
|
||||
|
||||
private broadcastWorkActivation(template: IBlockTemplate): void {
|
||||
const activationKey = `${template.height}:${template.previousblockhash}`;
|
||||
if (activationKey === this.latestWorkActivationKey) {
|
||||
return;
|
||||
}
|
||||
this.latestWorkActivationKey = activationKey;
|
||||
this.latestWorkActivationTemplate = template;
|
||||
for (const client of this.clients) {
|
||||
try {
|
||||
void client.enqueueWorkActivation(template).catch(error => {
|
||||
console.error(`SV2 work activation enqueue failed: ${error.message}`);
|
||||
this.unregisterClient(client);
|
||||
void client.destroy();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`SV2 work activation enqueue failed: ${error.message}`);
|
||||
this.unregisterClient(client);
|
||||
void client.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeNoiseConfig(): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
|
||||
import {
|
||||
calculateBlockSubsidySats,
|
||||
createSubsidyOnlyBlockTemplate,
|
||||
EMPTY_DEFAULT_WITNESS_COMMITMENT,
|
||||
EMPTY_WITNESS_COMMITMENT_HASH,
|
||||
} from './subsidy-only-template.factory';
|
||||
|
||||
describe('subsidy-only template factory', () => {
|
||||
it('calculates mainnet subsidy at halving boundaries', () => {
|
||||
expect(calculateBlockSubsidySats(0, 'mainnet')).toBe(5_000_000_000);
|
||||
expect(calculateBlockSubsidySats(209_999, 'mainnet')).toBe(5_000_000_000);
|
||||
expect(calculateBlockSubsidySats(210_000, 'mainnet')).toBe(2_500_000_000);
|
||||
expect(calculateBlockSubsidySats(840_000, 'mainnet')).toBe(312_500_000);
|
||||
expect(calculateBlockSubsidySats(210_000 * 64, 'mainnet')).toBe(0);
|
||||
});
|
||||
|
||||
it('uses the Bitcoin Core regtest halving interval', () => {
|
||||
expect(calculateBlockSubsidySats(149, 'regtest')).toBe(5_000_000_000);
|
||||
expect(calculateBlockSubsidySats(150, 'regtest')).toBe(2_500_000_000);
|
||||
expect(calculateBlockSubsidySats(300, 'regtest')).toBe(1_250_000_000);
|
||||
});
|
||||
|
||||
it('accepts an explicit halving interval for custom chains', () => {
|
||||
expect(calculateBlockSubsidySats(20, 'mainnet', 10)).toBe(1_250_000_000);
|
||||
});
|
||||
|
||||
it('rejects invalid candidate heights and halving intervals', () => {
|
||||
expect(() => calculateBlockSubsidySats(-1, 'mainnet')).toThrow(
|
||||
'Candidate height',
|
||||
);
|
||||
expect(() => calculateBlockSubsidySats(1.5, 'mainnet')).toThrow(
|
||||
'Candidate height',
|
||||
);
|
||||
expect(() => calculateBlockSubsidySats(1, 'mainnet', 0)).toThrow(
|
||||
'halving interval',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds a solo empty template while preserving authoritative header fields', () => {
|
||||
const authoritativeTemplate = createAuthoritativeTemplate();
|
||||
const originalTransaction = authoritativeTemplate.transactions[0];
|
||||
|
||||
const result = createSubsidyOnlyBlockTemplate({
|
||||
authoritativeTemplate,
|
||||
network: 'mainnet',
|
||||
payoutMode: 'solo',
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
version: authoritativeTemplate.version,
|
||||
bits: authoritativeTemplate.bits,
|
||||
previousblockhash: authoritativeTemplate.previousblockhash,
|
||||
mintime: authoritativeTemplate.mintime,
|
||||
height: authoritativeTemplate.height,
|
||||
coinbasevalue: 312_500_000,
|
||||
transactions: [],
|
||||
default_witness_commitment: EMPTY_DEFAULT_WITNESS_COMMITMENT,
|
||||
forceCleanJobs: true,
|
||||
jobType: 'empty',
|
||||
payoutMode: 'solo',
|
||||
payoutSnapshotId: undefined,
|
||||
payoutOutputs: undefined,
|
||||
}),
|
||||
);
|
||||
expect(authoritativeTemplate.transactions).toEqual([originalTransaction]);
|
||||
expect(authoritativeTemplate.coinbasevalue).toBe(312_512_345);
|
||||
});
|
||||
|
||||
it('computes the empty-set BIP141 witness commitment', () => {
|
||||
expect(EMPTY_WITNESS_COMMITMENT_HASH).toBe(
|
||||
'e2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf9',
|
||||
);
|
||||
expect(EMPTY_DEFAULT_WITNESS_COMMITMENT).toBe(
|
||||
'6a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf9',
|
||||
);
|
||||
});
|
||||
|
||||
it('requires explicit PPLNS subsidy payout data instead of synthesizing a fallback', () => {
|
||||
expect(() =>
|
||||
createSubsidyOnlyBlockTemplate({
|
||||
authoritativeTemplate: createAuthoritativeTemplate(),
|
||||
network: 'mainnet',
|
||||
payoutMode: 'pplns',
|
||||
}),
|
||||
).toThrow('explicit payout snapshot');
|
||||
});
|
||||
|
||||
it('uses only the explicitly supplied PPLNS subsidy snapshot', () => {
|
||||
const authoritativeTemplate = createAuthoritativeTemplate();
|
||||
const result = createSubsidyOnlyBlockTemplate({
|
||||
authoritativeTemplate,
|
||||
network: 'mainnet',
|
||||
payoutMode: 'pplns',
|
||||
payoutSnapshot: {
|
||||
id: 'subsidy-snapshot',
|
||||
payoutOutputs: [
|
||||
{ address: 'bc1qfirst', amountSats: 200_000_000 },
|
||||
{ address: 'bc1qsecond', amountSats: 112_500_000 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.payoutSnapshotId).toBe('subsidy-snapshot');
|
||||
expect(result.payoutOutputs).toEqual([
|
||||
{ address: 'bc1qfirst', amountSats: 200_000_000 },
|
||||
{ address: 'bc1qsecond', amountSats: 112_500_000 },
|
||||
]);
|
||||
expect(result.payoutOutputs).not.toBe(authoritativeTemplate.payoutOutputs);
|
||||
expect(result.payoutMode).toBe('pplns');
|
||||
});
|
||||
|
||||
it('rejects PPLNS outputs that are not subsidy-valued fixed amounts', () => {
|
||||
const authoritativeTemplate = createAuthoritativeTemplate();
|
||||
|
||||
expect(() =>
|
||||
createSubsidyOnlyBlockTemplate({
|
||||
authoritativeTemplate,
|
||||
network: 'mainnet',
|
||||
payoutMode: 'pplns',
|
||||
payoutSnapshot: {
|
||||
id: 'percentage-snapshot',
|
||||
payoutOutputs: [{ address: 'bc1qpercentage', percent: 100 }],
|
||||
},
|
||||
}),
|
||||
).toThrow('amountSats');
|
||||
|
||||
expect(() =>
|
||||
createSubsidyOnlyBlockTemplate({
|
||||
authoritativeTemplate,
|
||||
network: 'mainnet',
|
||||
payoutMode: 'pplns',
|
||||
payoutSnapshot: {
|
||||
id: 'overpaying-snapshot',
|
||||
payoutOutputs: [{ address: 'bc1qoverpaid', amountSats: 312_500_001 }],
|
||||
},
|
||||
}),
|
||||
).toThrow('entire block subsidy');
|
||||
|
||||
expect(() =>
|
||||
createSubsidyOnlyBlockTemplate({
|
||||
authoritativeTemplate,
|
||||
network: 'mainnet',
|
||||
payoutMode: 'pplns',
|
||||
payoutSnapshot: {
|
||||
id: 'underpaying-snapshot',
|
||||
payoutOutputs: [{ address: 'bc1qunderpaid', amountSats: 312_499_999 }],
|
||||
},
|
||||
}),
|
||||
).toThrow('entire block subsidy');
|
||||
});
|
||||
});
|
||||
|
||||
function createAuthoritativeTemplate(): IBlockTemplate {
|
||||
return {
|
||||
version: 0x20000000,
|
||||
rules: ['segwit'],
|
||||
vbavailable: {},
|
||||
vbrequired: 0,
|
||||
previousblockhash: '00'.repeat(32),
|
||||
transactions: [
|
||||
{
|
||||
data: '00',
|
||||
txid: '11'.repeat(32),
|
||||
hash: '22'.repeat(32),
|
||||
depends: [],
|
||||
fee: 12_345,
|
||||
sigops: 1,
|
||||
weight: 400,
|
||||
},
|
||||
],
|
||||
coinbaseaux: {},
|
||||
coinbasevalue: 312_512_345,
|
||||
longpollid: 'longpoll',
|
||||
target: '00'.repeat(32),
|
||||
mintime: 1_700_000_000,
|
||||
mutable: ['time', 'transactions', 'prevblock'],
|
||||
noncerange: '00000000ffffffff',
|
||||
sigoplimit: 80_000,
|
||||
sizelimit: 4_000_000,
|
||||
weightlimit: 4_000_000,
|
||||
curtime: 1_700_000_001,
|
||||
bits: '170fffff',
|
||||
height: 840_000,
|
||||
default_witness_commitment: `6a24aa21a9ed${'33'.repeat(32)}`,
|
||||
capabilities: ['proposal'],
|
||||
payoutSnapshotId: 'full-template-snapshot',
|
||||
payoutOutputs: [{ address: 'bc1qfulltemplate', amountSats: 312_512_345 }],
|
||||
forceCleanJobs: false,
|
||||
jobType: 'full',
|
||||
payoutMode: 'all',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type {
|
||||
IBlockTemplate,
|
||||
IBlockTemplatePayoutOutput,
|
||||
} from '../models/bitcoin-rpc/IBlockTemplate';
|
||||
import type { PayoutMode } from '../types/payout-mode';
|
||||
import { hash256 } from '../utils/hash.utils';
|
||||
|
||||
export type BitcoinNetworkName = 'mainnet' | 'testnet' | 'regtest';
|
||||
|
||||
export interface SubsidyOnlyPayoutSnapshot {
|
||||
id: string;
|
||||
payoutOutputs: IBlockTemplatePayoutOutput[];
|
||||
}
|
||||
|
||||
export interface CreateSubsidyOnlyTemplateInput {
|
||||
authoritativeTemplate: Readonly<IBlockTemplate>;
|
||||
network: BitcoinNetworkName;
|
||||
payoutMode: PayoutMode;
|
||||
/** Overrides the network default, for custom chains and explicit test fixtures. */
|
||||
halvingInterval?: number;
|
||||
/** Required for PPLNS so the factory can never invent a miner-address fallback. */
|
||||
payoutSnapshot?: SubsidyOnlyPayoutSnapshot;
|
||||
}
|
||||
|
||||
const SATOSHIS_PER_BITCOIN = 100_000_000n;
|
||||
const INITIAL_BLOCK_SUBSIDY_SATS = 50n * SATOSHIS_PER_BITCOIN;
|
||||
const MAX_SUBSIDY_HALVINGS = 64;
|
||||
|
||||
export const NETWORK_SUBSIDY_HALVING_INTERVALS: Readonly<
|
||||
Record<BitcoinNetworkName, number>
|
||||
> = {
|
||||
mainnet: 210_000,
|
||||
testnet: 210_000,
|
||||
regtest: 150,
|
||||
};
|
||||
|
||||
export const EMPTY_WITNESS_COMMITMENT_HASH = hash256(Buffer.alloc(64)).toString(
|
||||
'hex',
|
||||
);
|
||||
export const EMPTY_DEFAULT_WITNESS_COMMITMENT = `6a24aa21a9ed${EMPTY_WITNESS_COMMITMENT_HASH}`;
|
||||
|
||||
export function calculateBlockSubsidySats(
|
||||
candidateHeight: number,
|
||||
network: BitcoinNetworkName,
|
||||
halvingInterval = NETWORK_SUBSIDY_HALVING_INTERVALS[network],
|
||||
): number {
|
||||
if (!Number.isSafeInteger(candidateHeight) || candidateHeight < 0) {
|
||||
throw new Error('Candidate height must be a non-negative safe integer');
|
||||
}
|
||||
if (!Number.isSafeInteger(halvingInterval) || halvingInterval <= 0) {
|
||||
throw new Error('Subsidy halving interval must be a positive safe integer');
|
||||
}
|
||||
|
||||
const halvings = Math.floor(candidateHeight / halvingInterval);
|
||||
if (halvings >= MAX_SUBSIDY_HALVINGS) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Number(INITIAL_BLOCK_SUBSIDY_SATS >> BigInt(halvings));
|
||||
}
|
||||
|
||||
export function createSubsidyOnlyBlockTemplate(
|
||||
input: CreateSubsidyOnlyTemplateInput,
|
||||
): IBlockTemplate {
|
||||
const {
|
||||
authoritativeTemplate,
|
||||
network,
|
||||
payoutMode,
|
||||
halvingInterval,
|
||||
payoutSnapshot,
|
||||
} = input;
|
||||
const subsidySats = calculateBlockSubsidySats(
|
||||
authoritativeTemplate.height,
|
||||
network,
|
||||
halvingInterval,
|
||||
);
|
||||
|
||||
const payout = resolvePayout(payoutMode, payoutSnapshot, subsidySats);
|
||||
const authoritativeFields = { ...authoritativeTemplate };
|
||||
delete authoritativeFields.payoutSnapshotId;
|
||||
delete authoritativeFields.payoutOutputs;
|
||||
|
||||
return {
|
||||
...authoritativeFields,
|
||||
rules: [...authoritativeTemplate.rules],
|
||||
vbavailable: { ...authoritativeTemplate.vbavailable },
|
||||
coinbaseaux: { ...authoritativeTemplate.coinbaseaux },
|
||||
mutable: [...authoritativeTemplate.mutable],
|
||||
capabilities: [...authoritativeTemplate.capabilities],
|
||||
transactions: [],
|
||||
coinbasevalue: subsidySats,
|
||||
default_witness_commitment: EMPTY_DEFAULT_WITNESS_COMMITMENT,
|
||||
forceCleanJobs: true,
|
||||
jobType: 'empty',
|
||||
payoutMode,
|
||||
...payout,
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePayout(
|
||||
payoutMode: PayoutMode,
|
||||
payoutSnapshot: SubsidyOnlyPayoutSnapshot | undefined,
|
||||
subsidySats: number,
|
||||
): Pick<IBlockTemplate, 'payoutSnapshotId' | 'payoutOutputs'> {
|
||||
if (payoutMode === 'solo') {
|
||||
return {
|
||||
payoutSnapshotId: undefined,
|
||||
payoutOutputs: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (payoutSnapshot == null || payoutSnapshot.id.trim().length === 0) {
|
||||
throw new Error(
|
||||
'PPLNS subsidy-only templates require an explicit payout snapshot',
|
||||
);
|
||||
}
|
||||
if (payoutSnapshot.payoutOutputs.length === 0) {
|
||||
throw new Error('PPLNS subsidy-only templates require payout outputs');
|
||||
}
|
||||
|
||||
let allocatedSats = 0;
|
||||
const payoutOutputs = payoutSnapshot.payoutOutputs.map((output) => {
|
||||
if (output.address.trim().length === 0) {
|
||||
throw new Error('PPLNS payout output address must not be empty');
|
||||
}
|
||||
if (!Number.isSafeInteger(output.amountSats) || output.amountSats < 0) {
|
||||
throw new Error(
|
||||
'PPLNS subsidy-only payout outputs require non-negative integer amountSats',
|
||||
);
|
||||
}
|
||||
allocatedSats += output.amountSats;
|
||||
return {
|
||||
address: output.address,
|
||||
amountSats: output.amountSats,
|
||||
};
|
||||
});
|
||||
|
||||
if (allocatedSats !== subsidySats) {
|
||||
throw new Error('PPLNS payout outputs must allocate the entire block subsidy');
|
||||
}
|
||||
|
||||
return {
|
||||
payoutSnapshotId: payoutSnapshot.id,
|
||||
payoutOutputs,
|
||||
};
|
||||
}
|
||||
@@ -104,7 +104,7 @@ export class TemplateProviderService implements OnModuleInit {
|
||||
) {}
|
||||
|
||||
public onModuleInit(): void {
|
||||
if (this.subscription != null) {
|
||||
if (this.subscription != null || process.env.API_ONLY === 'true' || process.env.MASTER === 'true') {
|
||||
return;
|
||||
}
|
||||
this.subscription = this.jobsService.newMiningJob$.subscribe(template => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DifficultyUtils } from './difficulty.utils';
|
||||
import { hash256 } from './hash.utils';
|
||||
|
||||
describe('DifficultyUtils', () => {
|
||||
function incrementLe256(target: Buffer): Buffer {
|
||||
@@ -36,6 +37,146 @@ describe('DifficultyUtils', () => {
|
||||
expect(DifficultyUtils.meetsTarget(easier, target)).toBe(false);
|
||||
});
|
||||
|
||||
it('decodes compact targets from Bitcoin mainnet examples into little-endian bytes', () => {
|
||||
const examples = [
|
||||
{
|
||||
nBits: 0x1d00ffff,
|
||||
target: '00000000ffff0000000000000000000000000000000000000000000000000000',
|
||||
},
|
||||
{
|
||||
nBits: 0x181bc330,
|
||||
target: '00000000000000001bc330000000000000000000000000000000000000000000',
|
||||
},
|
||||
{
|
||||
nBits: 0x1a44b9f2,
|
||||
target: '00000000000044b9f20000000000000000000000000000000000000000000000',
|
||||
},
|
||||
];
|
||||
|
||||
for (const example of examples) {
|
||||
const target = DifficultyUtils.compactToTarget(example.nBits);
|
||||
|
||||
expect(target).not.toBeNull();
|
||||
expect(Buffer.from(target).reverse().toString('hex')).toBe(example.target);
|
||||
}
|
||||
});
|
||||
|
||||
it('matches Bitcoin Core compact decoding for exponents at or below three', () => {
|
||||
const examples = [
|
||||
{ nBits: 0x01123456, target: 0x12 },
|
||||
{ nBits: 0x02008000, target: 0x80 },
|
||||
{ nBits: 0x03009234, target: 0x9234 },
|
||||
{ nBits: 0x05009234, target: 0x92340000 },
|
||||
];
|
||||
|
||||
for (const example of examples) {
|
||||
const expected = Buffer.alloc(32);
|
||||
expected.writeUInt32LE(example.target, 0);
|
||||
expect(DifficultyUtils.compactToTarget(example.nBits)).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('matches known Bitcoin mainnet header hashes using internal hash byte order', () => {
|
||||
const examples = [
|
||||
{
|
||||
nBits: 0x1d00ffff,
|
||||
header: [
|
||||
'01000000',
|
||||
'0000000000000000000000000000000000000000000000000000000000000000',
|
||||
'3ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a',
|
||||
'29ab5f49',
|
||||
'ffff001d',
|
||||
'1dac2b7c',
|
||||
].join(''),
|
||||
internalHash: '6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000',
|
||||
},
|
||||
{
|
||||
nBits: 0x1a44b9f2,
|
||||
header: [
|
||||
'01000000',
|
||||
'81cd02ab7e569e8bcd9317e2fe99f2de44d49ab2b8851ba4a308000000000000',
|
||||
'e320b6c2fffc8d750423db8b1eb942ae710e951ed797f7affc8892b0f1fc122b',
|
||||
'c7f5d74d',
|
||||
'f2b9441a',
|
||||
'42a14695',
|
||||
].join(''),
|
||||
internalHash: '1dbd981fe6985776b644b173a4d0385ddc1aa2a829688d1e0000000000000000',
|
||||
},
|
||||
];
|
||||
|
||||
for (const example of examples) {
|
||||
const hash = hash256(Buffer.from(example.header, 'hex'));
|
||||
|
||||
expect(hash.toString('hex')).toBe(example.internalHash);
|
||||
expect(DifficultyUtils.meetsCompactTarget(hash, example.nBits)).toBe(true);
|
||||
expect(DifficultyUtils.meetsCompactTarget(Buffer.from(hash).reverse(), example.nBits)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('compares compact targets exactly at the little-endian boundary', () => {
|
||||
const nBits = 0x1d00ffff;
|
||||
const target = DifficultyUtils.compactToTarget(nBits);
|
||||
expect(target).not.toBeNull();
|
||||
|
||||
expect(DifficultyUtils.meetsCompactTarget(target, nBits)).toBe(true);
|
||||
expect(DifficultyUtils.meetsCompactTarget(incrementLe256(target), nBits)).toBe(false);
|
||||
expect(DifficultyUtils.meetsCompactTarget(Buffer.alloc(32), nBits)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects negative compact targets using Bitcoin Core sign semantics', () => {
|
||||
for (const nBits of [0x04923456, 0x1d80ffff]) {
|
||||
expect(DifficultyUtils.compactToTarget(nBits)).toBeNull();
|
||||
expect(DifficultyUtils.meetsCompactTarget(Buffer.alloc(32), nBits)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects all compact target overflow forms at the 256-bit boundary', () => {
|
||||
for (const nBits of [
|
||||
0x23000001,
|
||||
0x22000100,
|
||||
0x21010000,
|
||||
]) {
|
||||
expect(DifficultyUtils.compactToTarget(nBits)).toBeNull();
|
||||
expect(DifficultyUtils.meetsCompactTarget(Buffer.alloc(32), nBits)).toBe(false);
|
||||
}
|
||||
|
||||
const size34Boundary = DifficultyUtils.compactToTarget(0x220000ff);
|
||||
const size33Boundary = DifficultyUtils.compactToTarget(0x2100ffff);
|
||||
expect(size34Boundary).not.toBeNull();
|
||||
expect(size33Boundary).not.toBeNull();
|
||||
expect(Buffer.from(size34Boundary).reverse().toString('hex')).toBe(`ff${'00'.repeat(31)}`);
|
||||
expect(Buffer.from(size33Boundary).reverse().toString('hex')).toBe(`ffff${'00'.repeat(30)}`);
|
||||
});
|
||||
|
||||
it('rejects zero targets and values outside an unsigned 32-bit nBits field', () => {
|
||||
for (const nBits of [
|
||||
0,
|
||||
0x01003456,
|
||||
-1,
|
||||
0x1_0000_0000,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
]) {
|
||||
expect(DifficultyUtils.compactToTarget(nBits)).toBeNull();
|
||||
expect(DifficultyUtils.meetsCompactTarget(Buffer.alloc(32), nBits)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects ambiguous hash or target buffer lengths', () => {
|
||||
expect(() => DifficultyUtils.meetsTarget(
|
||||
Buffer.alloc(31),
|
||||
Buffer.alloc(32),
|
||||
)).toThrow('Hash must be 32 bytes');
|
||||
expect(() => DifficultyUtils.meetsTarget(
|
||||
Buffer.alloc(32),
|
||||
Buffer.alloc(31),
|
||||
)).toThrow('Target must be 32 bytes');
|
||||
expect(() => DifficultyUtils.meetsCompactTarget(
|
||||
Buffer.alloc(33),
|
||||
0x1d00ffff,
|
||||
)).toThrow('Hash must be 32 bytes');
|
||||
});
|
||||
|
||||
it('calculates difficulty consistently with the returned hash buffer', () => {
|
||||
const header = Buffer.alloc(80, 1);
|
||||
const result = DifficultyUtils.calculateDifficulty(header);
|
||||
|
||||
@@ -19,9 +19,62 @@ export class DifficultyUtils {
|
||||
}
|
||||
|
||||
public static meetsTarget(hashBuffer: Buffer, target: Buffer): boolean {
|
||||
if (hashBuffer.length !== 32) {
|
||||
throw new Error('Hash must be 32 bytes');
|
||||
}
|
||||
if (target.length !== 32) {
|
||||
throw new Error('Target must be 32 bytes');
|
||||
}
|
||||
return DifficultyUtils.compareLe256(hashBuffer, target) <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Bitcoin's compact nBits value to the 32-byte little-endian target
|
||||
* representation used by raw double-SHA256 digests in this service.
|
||||
* Returns null for zero, negative, overflowing, or non-uint32 encodings.
|
||||
*/
|
||||
public static compactToTarget(nBits: number): Buffer | null {
|
||||
if (!Number.isInteger(nBits) || nBits < 0 || nBits > 0xffffffff) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const size = nBits >>> 24;
|
||||
let word = nBits & 0x007fffff;
|
||||
if (size <= 3) {
|
||||
word >>>= 8 * (3 - size);
|
||||
}
|
||||
|
||||
const negative = word !== 0 && (nBits & 0x00800000) !== 0;
|
||||
const overflow = word !== 0 && (
|
||||
size > 34
|
||||
|| (word > 0xff && size > 33)
|
||||
|| (word > 0xffff && size > 32)
|
||||
);
|
||||
if (word === 0 || negative || overflow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = Buffer.alloc(32);
|
||||
const offset = size <= 3 ? 0 : size - 3;
|
||||
for (let byte = 0; byte < 3 && offset + byte < target.length; byte++) {
|
||||
target[offset + byte] = (word >>> (byte * 8)) & 0xff;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a raw, internal-byte-order block hash against a compact target.
|
||||
* Invalid nBits never match.
|
||||
*/
|
||||
public static meetsCompactTarget(hashBuffer: Buffer, nBits: number): boolean {
|
||||
if (hashBuffer.length !== 32) {
|
||||
throw new Error('Hash must be 32 bytes');
|
||||
}
|
||||
|
||||
const target = DifficultyUtils.compactToTarget(nBits);
|
||||
return target != null && DifficultyUtils.compareLe256(hashBuffer, target) <= 0;
|
||||
}
|
||||
|
||||
public static difficultyToTarget(difficulty: number): Buffer {
|
||||
if (!Number.isFinite(difficulty) || difficulty <= 0) {
|
||||
return Buffer.alloc(32, 0xff);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import axios from 'axios';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
||||
|
||||
import { MiningJob } from '../src/models/MiningJob';
|
||||
import { IBlockTemplate } from '../src/models/bitcoin-rpc/IBlockTemplate';
|
||||
import { createSubsidyOnlyBlockTemplate } from '../src/services/subsidy-only-template.factory';
|
||||
import { StratumV1JobsService } from '../src/services/stratum-v1-jobs.service';
|
||||
|
||||
const describeRegtest = process.env.RUN_BITCOIN_REGTEST_INTEGRATION === 'true'
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
describeRegtest('Bitcoin Core regtest block proposals', () => {
|
||||
const rpcUrl = process.env.BITCOIN_REGTEST_RPC_URL ?? 'http://127.0.0.1:28332';
|
||||
const rpcUser = process.env.BITCOIN_REGTEST_RPC_USER ?? 'bitcoin';
|
||||
const rpcPassword = process.env.BITCOIN_REGTEST_RPC_PASSWORD ?? 'bitcoin';
|
||||
let rpcId = 0;
|
||||
|
||||
it('accepts reconstructed subsidy-only and full jobs in proposal mode', async () => {
|
||||
const authoritative = await rpc<IBlockTemplate>('getblocktemplate', [{
|
||||
mode: 'template',
|
||||
rules: ['segwit'],
|
||||
capabilities: ['proposal'],
|
||||
}]);
|
||||
const empty = createSubsidyOnlyBlockTemplate({
|
||||
authoritativeTemplate: authoritative,
|
||||
network: 'regtest',
|
||||
payoutMode: 'solo',
|
||||
});
|
||||
|
||||
const emptyBlock = await reconstruct(empty);
|
||||
const fullBlock = await reconstruct({
|
||||
...authoritative,
|
||||
payoutMode: 'solo',
|
||||
jobType: 'full',
|
||||
forceCleanJobs: true,
|
||||
});
|
||||
|
||||
expect(emptyBlock.transactions).toHaveLength(1);
|
||||
expect(fullBlock.transactions).toHaveLength(authoritative.transactions.length + 1);
|
||||
await expect(propose(emptyBlock)).resolves.toBeNull();
|
||||
await expect(propose(fullBlock)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
async function reconstruct(template: IBlockTemplate): Promise<bitcoinjs.Block> {
|
||||
const source = new BehaviorSubject(template);
|
||||
const jobs = new StratumV1JobsService({
|
||||
newBlockTemplate$: source.asObservable(),
|
||||
miningInfo: { blocks: template.height - 1 },
|
||||
} as any);
|
||||
const jobTemplate = await firstValueFrom(jobs.newMiningJob$);
|
||||
const payoutAddress = bitcoinjs.payments.p2wpkh({
|
||||
hash: Buffer.alloc(20, 1),
|
||||
network: bitcoinjs.networks.regtest,
|
||||
}).address!;
|
||||
const job = new MiningJob(
|
||||
bitcoinjs.networks.regtest,
|
||||
'1',
|
||||
[{ address: payoutAddress, percent: 100 }],
|
||||
jobTemplate,
|
||||
);
|
||||
return job.copyAndUpdateBlock(
|
||||
jobTemplate,
|
||||
0,
|
||||
0,
|
||||
'01020304',
|
||||
'05060708090a0b0c',
|
||||
Math.max(template.mintime, template.curtime, Math.floor(Date.now() / 1000)),
|
||||
);
|
||||
}
|
||||
|
||||
async function propose(block: bitcoinjs.Block): Promise<string | null> {
|
||||
return rpc<string | null>('getblocktemplate', [{
|
||||
mode: 'proposal',
|
||||
data: block.toHex(false),
|
||||
rules: ['segwit'],
|
||||
}]);
|
||||
}
|
||||
|
||||
async function rpc<T>(method: string, params: unknown[]): Promise<T> {
|
||||
const response = await axios.post(rpcUrl, {
|
||||
jsonrpc: '1.0',
|
||||
id: ++rpcId,
|
||||
method,
|
||||
params,
|
||||
}, {
|
||||
auth: { username: rpcUser, password: rpcPassword },
|
||||
timeout: 30_000,
|
||||
});
|
||||
if (response.data.error != null) {
|
||||
throw new Error(response.data.error.message ?? JSON.stringify(response.data.error));
|
||||
}
|
||||
return response.data.result as T;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user