31 Commits
Author SHA1 Message Date
Ben 789b529bd5 Fix one-off hashrate calculation 2026-09-15 21:54:06 -04:00
Ben 5889e3af5b constrain suggest difficulty 2026-08-27 22:41:48 -04:00
Ben f10cef61ce Automate deployment of renewed TLS certificates 2026-08-18 08:52:25 -04:00
Ben 07a2b173d0 Harden Stratum V1 share validation 2026-08-05 16:34:43 -04:00
Ben 4c56839c02 Prevent accounting cache refresh stampedes 2026-08-04 23:47:41 -04:00
Ben 1cdf3fbf7e Cap concurrent API connections 2026-08-04 19:24:29 -04:00
Ben d1ebc55871 Bound public API connection lifetimes 2026-08-04 19:20:48 -04:00
Ben 7d96c06bbf Avoid retaining accounting summary promises 2026-08-04 12:30:29 -04:00
Ben dd39c6edcf Fix Timescale rollup index migration 2026-08-03 23:03:36 -04:00
Ben 86ad296189 Restore accepted share rollup lookup indexes 2026-08-03 22:44:49 -04:00
Ben f49e7b5b8f Accept BIP320 version rolling in SV2 shares 2026-08-03 10:58:23 -04:00
Ben 3cb4cbfbdf Isolate API worker module 2026-08-03 10:27:03 -04:00
Ben f31cdef7f0 Fix retained share high score ordering 2026-07-29 00:57:14 -04:00
Ben 943dd60eef Limit pool summary aggregate refresh window 2026-07-29 00:41:11 -04:00
Ben 4150d1e425 Add pool-level share summary aggregate 2026-07-29 00:24:48 -04:00
Ben fd226f206f Raise SV2 retained job limit 2026-07-14 22:55:36 -04:00
Ben 0b030fecca Split maintenance duties from notifier 2026-07-13 22:58:54 -04:00
Ben 448f7d4c52 Fix pool accounting summary fallback 2026-07-13 22:47:53 -04:00
Ben d830227178 Speed up prestaged block activation 2026-07-13 22:15:18 -04:00
Ben fe287c32c7 Switch block ZMQ notifications to hashblock 2026-07-13 13:00:26 -04:00
Ben 65101a98a2 Block found notifications 2026-07-13 12:20:37 -04:00
Ben c60264bbe5 Optimize SV1 block notify fanout 2026-07-13 11:55:26 -04:00
Ben 3d58802feb Add block notification timing logs 2026-07-12 00:31:12 -04:00
Ben bc6f2eebd8 Reduce production hot-path logging 2026-07-11 23:52:38 -04:00
Ben 4f29269fed Speed up template publication and share validation 2026-07-11 23:33:24 -04:00
Ben 126b05224f Read best difficulty in API workers 2026-06-21 17:45:00 -04:00
Ben 12f3983c8f Filter stale address workers 2026-06-21 17:30:06 -04:00
Ben 9998c93eae Reject truncated DATUM PPLNS coinbases 2026-06-21 11:39:01 -04:00
Ben bdd3c7c3ae Revert "Fix DATUM PPLNS coinbaser snapshots"
This reverts commit 1943ea7768.
2026-06-21 11:12:09 -04:00
Ben 1943ea7768 Fix DATUM PPLNS coinbaser snapshots 2026-06-21 11:07:41 -04:00
Ben 6dcbb8436d Reduce DATUM false rejects and filter miner report 2026-06-21 10:27:58 -04:00
84 changed files with 14995 additions and 966 deletions
+80 -4
View File
@@ -10,8 +10,20 @@ 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
# Optional comma-separated Bitcoin Core RPC endpoints. Each keeps an independent
# GBT longpoll open, but its template is published only when the primary Core's
# best block hash exactly authorizes the same tip. Endpoints currently share the
# primary RPC credentials/port.
#BITCOIN_RPC_AUX_URLS=http://bitcoin-core-2,http://bitcoin-core-3
# Enable in bitcoin.conf with:
# zmqpubrawblock=tcp://0.0.0.0:3000
# zmqpubhashblock=tcp://0.0.0.0:3000
BITCOIN_ZMQ_TOPIC=hashblock
BITCOIN_ZMQ_HOST=tcp://192.168.1.100:3000
API_PORT=3334
@@ -20,21 +32,74 @@ API_PORT=3334
API_BIND_HOST=127.0.0.1
API_PUBLIC_PORT=3334
API_WORKERS=4
# Bound abandoned or slow API sockets. These settings apply only to the
# HTTP(S) listener and do not affect Stratum connections.
API_CONNECTION_TIMEOUT_MS=15000
API_KEEP_ALIVE_TIMEOUT_MS=5000
API_REQUEST_TIMEOUT_MS=15000
API_HEADERS_TIMEOUT_MS=10000
API_MAX_REQUESTS_PER_SOCKET=100
API_MAX_CONNECTIONS_PER_WORKER=500
# Docker json-file log rotation. Applies when using the compose files.
DOCKER_LOG_MAX_SIZE=100m
DOCKER_LOG_MAX_FILES=5
# Keep high-volume production logging off by default. Enable only during a
# focused diagnostic window.
STRATUM_FANOUT_LOG_ENABLED=false
BLOCK_NOTIFICATION_TRACE_LOG_ENABLED=false
DATUM_VERBOSE_LOGGING=false
SV2_AUTH_FAILURE_LOG_ENABLED=false
# Plain TCP Stratum ports accept both SV1 JSON-RPC and SV2 Noise/binary traffic.
# Legacy/default ports mine in solo mode.
STRATUM_PORTS=3333,3332,3331,3330
# Optional PPLNS-mode SV1/SV2 autodetect ports.
#PPLNS_STRATUM_PORTS=
STRATUM_WORKERS=2
# `auto` assigns available CPUs after reserving API_WORKERS plus one master CPU.
# For fixed topology, set an explicit positive integer.
STRATUM_WORKERS=auto
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
STRATUM_MAX_INBOUND_LINE_BYTES=65536
# 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
# Publish only authoritative fixed-width GBT fields on the first Redis command.
# Workers promote their already-built next-height coinbase/notify buffers.
SV1_COMPACT_PRESTAGE_ACTIVATION_ENABLED=true
# Rolling-deploy aid for old workers. Leave false after workers understand the
# compact activation protocol; true publishes the larger empty bridge afterward.
SV1_COMPACT_ACTIVATION_COMPATIBILITY_BRIDGE=false
# 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
# Give the dedicated urgent Redis socket a short opportunity to acknowledge the
# empty bridge before full-template hashing/serialization begins. On timeout the
# master retries immediately through the independent normal command socket.
SV1_BRIDGE_PUBLISH_BUDGET_MS=10
# Build next-height per-miner coinbases in background batches and yield between
# batches so normal share processing remains responsive.
SV1_PRESTAGE_BATCH_SIZE=500
# Operational target only: fanout logs flag workers above this client count so
# STRATUM_WORKERS can be raised before one event loop becomes the bottleneck.
STRATUM_FANOUT_TARGET_CLIENTS_PER_WORKER=10000
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 +108,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=64
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=
@@ -99,6 +171,10 @@ SHARE_ACCOUNTING_FLUSH_INTERVAL_MS=25
SHARE_ACCOUNTING_MAX_QUEUE_SIZE=50000
SHARE_ACCOUNTING_SUMMARY_CACHE_MS=2500
SHARE_ACCOUNTING_SUMMARY_CACHE_MAX=10000
SHARE_ACCOUNTING_REDIS_SUMMARY_CACHE_MS=300000
SHARE_ACCOUNTING_REDIS_SUMMARY_STALE_CACHE_MS=3600000
SHARE_ACCOUNTING_REDIS_SUMMARY_LOCK_MS=30000
SHARE_ACCOUNTING_REDIS_SUMMARY_LOCK_WAIT_MS=5000
CLIENT_HASHRATE_PERSIST_INTERVAL_MS=60000
SHARE_ROLLUP_ENABLED=true
SHARE_ROLLUP_INTERVAL_MS=60000
@@ -113,11 +189,11 @@ PAYOUT_BOOTSTRAP_WINDOW=true
# solo preserves current one-address coinbase payouts. Set to snapshot to pay
# the latest payout snapshot directly in coinbase outputs.
PAYOUT_COINBASE_MODE=solo
PAYOUT_MAX_COINBASE_OUTPUTS=10
PAYOUT_MAX_COINBASE_OUTPUTS=200
PAYOUT_MIN_OUTPUT_SATS=546
PAYOUT_FEE_ADDRESS=
PAYOUT_FEE_PERCENT=0
PAYOUT_COINBASE_WEIGHT_BUDGET=50000
PAYOUT_COINBASE_WEIGHT_BUDGET=26000
#redis
REDIS_URL=redis://redis:6379
+2
View File
@@ -1,6 +1,8 @@
# compiled output
/dist
/node_modules
__pycache__/
*.pyc
# Logs
logs
+117 -1
View File
@@ -59,10 +59,118 @@ $ NODE_CLUSTER_SCHED_POLICY=none pm2 start ecosystem.config.js
Cluster-mode connection dropping requires Node.js `22.12.0` or newer.
`STRATUM_WORKERS=auto` is the default. It uses the container's available CPU
count after reserving `API_WORKERS` CPUs plus one for the master. A numeric value
still pins an exact topology. Size fixed deployments from the busiest worker,
not just total connection capacity: keep roughly 10,000 or fewer miners per
worker when low new-tip fanout latency matters. The `stratum_job_fanout`
`overTargetClients` field uses `STRATUM_FANOUT_TARGET_CLIENTS_PER_WORKER` to
make an undersized worker tier visible. Pools beyond one host's practical CPU or
socket capacity should shard listeners across multiple worker hosts connected to
the same urgent Redis channel.
`STRATUM_MAX_CONNECTIONS_PER_LISTENER` is enforced per worker and Stratum port.
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;
hashblock ZMQ remains a watchdog and duplicate results are discarded. Optional
endpoints in `BITCOIN_RPC_AUX_URLS` keep independent longpolls open and race the
primary source, reducing dependence on one node's block-relay peers. An auxiliary
template is only eligible after the primary Core's `getbestblockhash` exactly
matches its previous block hash; a mismatch or unavailable primary fails closed
before any urgent or canonical miner notification. PM2 runs the master through a
minimal, non-HTTP notifier entrypoint so API, Stratum, reporting, and integration
timers cannot delay its Core longpoll callback.
On a new tip, the notifier's first Redis command is a compact SV1 prestage
activation containing only Core-authoritative fixed-width header fields, height,
subsidy, and payout identity. It does not serialize the transaction template or
a second block-template object. Workers consume this on dedicated urgent Redis
publisher/subscriber connections and promote their retained next-height jobs
directly, bypassing the canonical template/RxJS preparation pipeline. It waits
only `SV1_BRIDGE_PUBLISH_BUDGET_MS` for acknowledgement before proceeding. A
timeout immediately retries the activation on the independent normal Redis
command socket. If compact delivery fails or the feature is disabled, the
self-contained subsidy-only bridge remains the fallback.
After canonical publication, the master publishes a durable placeholder-prevhash
empty template for the following height. Stratum workers replay it after restart
and prebuild every connected miner's height- and payout-bound coinbase in batches
of `SV1_PRESTAGE_BATCH_SIZE`, yielding between batches. A newly authorized miner
also stages against the latest template. The next authoritative bridge promotes
the same cached job, patches the authoritative header fields into its already
serialized notify buffer, and writes it without rebuilding the coinbase or JSON.
Canonical full work then follows as a second clean switch. Payout
snapshot creation and Postgres persistence remain outside the urgent path.
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 64 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. Before any bridge is
published, the master verifies that Core's `coinbasevalue` equals the locally
calculated consensus subsidy plus every GBT transaction fee. Master startup also
fails if `NETWORK` does not match Core's reported chain.
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 notifier is required for compact activation. During a mixed
deployment, `SV1_COMPACT_ACTIVATION_COMPATIBILITY_BRIDGE=true` publishes the
larger empty bridge immediately after compact activation; disable it after every
worker supports the compact protocol. 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 true source-to-fanout-start time, master publish,
worker receipt/handling, prestage hits, client count, bytes, backpressure, and
p50/p95/p99/last enqueue time, correlated by `eventId`.
- `sv1_job_prestage` reports the number of miners prepared for the next height and
the background preparation duration.
- `sv1_prestage_activation_miss` identifies a worker that could not match an
authoritative activation to retained prestage state and therefore waits for
the fallback bridge or canonical full job.
For upstream latency, place the Core nodes in different well-connected networks,
enable normal compact-block relay, and keep Redis and Stratum workers close
together. Auxiliary nodes remain untrusted candidates for tip detection: the
primary Core authorizes their exact tip before publication. Do not point
`BITCOIN_RPC_AUX_URLS` at third-party RPC services.
## Docker
The default compose stack includes Public Pool, TimescaleDB, and Redis. TimescaleDB
@@ -139,7 +247,7 @@ RPC block template table.
```
rpcallowip=172.16.0.0/12
zmqpubrawblock=tcp://0.0.0.0:3000
zmqpubhashblock=tcp://0.0.0.0:3000
```
to your bitcoin.conf.
@@ -163,3 +271,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
```
+13 -3
View File
@@ -74,8 +74,14 @@ services:
API_PORT: ${API_PORT:-3334}
API_SECURE: ${API_SECURE:-false}
API_WORKERS: ${API_WORKERS:-4}
API_CONNECTION_TIMEOUT_MS: ${API_CONNECTION_TIMEOUT_MS:-15000}
API_KEEP_ALIVE_TIMEOUT_MS: ${API_KEEP_ALIVE_TIMEOUT_MS:-5000}
API_REQUEST_TIMEOUT_MS: ${API_REQUEST_TIMEOUT_MS:-15000}
API_HEADERS_TIMEOUT_MS: ${API_HEADERS_TIMEOUT_MS:-10000}
API_MAX_REQUESTS_PER_SOCKET: ${API_MAX_REQUESTS_PER_SOCKET:-100}
API_MAX_CONNECTIONS_PER_WORKER: ${API_MAX_CONNECTIONS_PER_WORKER:-500}
PM2_ENABLED: ${PM2_ENABLED:-true}
STRATUM_WORKERS: ${STRATUM_WORKERS:-2}
STRATUM_WORKERS: ${STRATUM_WORKERS:-auto}
STRATUM_PORTS: ${STRATUM_PORTS:-3333,3332,3331,3330}
PPLNS_STRATUM_PORTS: ${PPLNS_STRATUM_PORTS:-}
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
@@ -100,6 +106,10 @@ services:
SHARE_ACCOUNTING_MAX_QUEUE_SIZE: ${SHARE_ACCOUNTING_MAX_QUEUE_SIZE:-50000}
SHARE_ACCOUNTING_SUMMARY_CACHE_MS: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MS:-2500}
SHARE_ACCOUNTING_SUMMARY_CACHE_MAX: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MAX:-10000}
SHARE_ACCOUNTING_REDIS_SUMMARY_CACHE_MS: ${SHARE_ACCOUNTING_REDIS_SUMMARY_CACHE_MS:-300000}
SHARE_ACCOUNTING_REDIS_SUMMARY_STALE_CACHE_MS: ${SHARE_ACCOUNTING_REDIS_SUMMARY_STALE_CACHE_MS:-3600000}
SHARE_ACCOUNTING_REDIS_SUMMARY_LOCK_MS: ${SHARE_ACCOUNTING_REDIS_SUMMARY_LOCK_MS:-30000}
SHARE_ACCOUNTING_REDIS_SUMMARY_LOCK_WAIT_MS: ${SHARE_ACCOUNTING_REDIS_SUMMARY_LOCK_WAIT_MS:-5000}
SHARE_ROLLUP_ENABLED: ${SHARE_ROLLUP_ENABLED:-true}
SHARE_ROLLUP_INTERVAL_MS: ${SHARE_ROLLUP_INTERVAL_MS:-60000}
SHARE_ROLLUP_SAFETY_LAG_SECONDS: ${SHARE_ROLLUP_SAFETY_LAG_SECONDS:-30}
@@ -108,11 +118,11 @@ services:
PAYOUT_METHOD: ${PAYOUT_METHOD:-pplns}
PAYOUT_WINDOW_FACTOR: ${PAYOUT_WINDOW_FACTOR:-4}
PAYOUT_BOOTSTRAP_WINDOW: ${PAYOUT_BOOTSTRAP_WINDOW:-true}
PAYOUT_MAX_COINBASE_OUTPUTS: ${PAYOUT_MAX_COINBASE_OUTPUTS:-10}
PAYOUT_MAX_COINBASE_OUTPUTS: ${PAYOUT_MAX_COINBASE_OUTPUTS:-200}
PAYOUT_MIN_OUTPUT_SATS: ${PAYOUT_MIN_OUTPUT_SATS:-546}
PAYOUT_FEE_ADDRESS: ${PAYOUT_FEE_ADDRESS:-}
PAYOUT_FEE_PERCENT: ${PAYOUT_FEE_PERCENT:-0}
PAYOUT_COINBASE_WEIGHT_BUDGET: ${PAYOUT_COINBASE_WEIGHT_BUDGET:-50000}
PAYOUT_COINBASE_WEIGHT_BUDGET: ${PAYOUT_COINBASE_WEIGHT_BUDGET:-26000}
healthcheck:
test: ["CMD-SHELL", "node -e \"const http=require('http'); const https=require('https'); const secure=process.env.API_SECURE==='true'; const client=secure?https:http; const req=client.get({hostname:'127.0.0.1',port:process.env.API_PORT||3334,path:'/api/network',rejectUnauthorized:false},res=>process.exit(res.statusCode<500?0:1)); req.on('error',()=>process.exit(1)); req.setTimeout(5000,()=>{req.destroy(); process.exit(1);});\""]
interval: 30s
+11 -1
View File
@@ -104,8 +104,18 @@ services:
API_PORT: ${API_PORT:-3334}
API_SECURE: ${API_SECURE:-false}
API_WORKERS: ${API_WORKERS:-4}
API_CONNECTION_TIMEOUT_MS: ${API_CONNECTION_TIMEOUT_MS:-15000}
API_KEEP_ALIVE_TIMEOUT_MS: ${API_KEEP_ALIVE_TIMEOUT_MS:-5000}
API_REQUEST_TIMEOUT_MS: ${API_REQUEST_TIMEOUT_MS:-15000}
API_HEADERS_TIMEOUT_MS: ${API_HEADERS_TIMEOUT_MS:-10000}
API_MAX_REQUESTS_PER_SOCKET: ${API_MAX_REQUESTS_PER_SOCKET:-100}
API_MAX_CONNECTIONS_PER_WORKER: ${API_MAX_CONNECTIONS_PER_WORKER:-500}
SHARE_ACCOUNTING_REDIS_SUMMARY_CACHE_MS: ${SHARE_ACCOUNTING_REDIS_SUMMARY_CACHE_MS:-300000}
SHARE_ACCOUNTING_REDIS_SUMMARY_STALE_CACHE_MS: ${SHARE_ACCOUNTING_REDIS_SUMMARY_STALE_CACHE_MS:-3600000}
SHARE_ACCOUNTING_REDIS_SUMMARY_LOCK_MS: ${SHARE_ACCOUNTING_REDIS_SUMMARY_LOCK_MS:-30000}
SHARE_ACCOUNTING_REDIS_SUMMARY_LOCK_WAIT_MS: ${SHARE_ACCOUNTING_REDIS_SUMMARY_LOCK_WAIT_MS:-5000}
PM2_ENABLED: ${PM2_ENABLED:-true}
STRATUM_WORKERS: ${STRATUM_WORKERS:-2}
STRATUM_WORKERS: ${STRATUM_WORKERS:-auto}
STRATUM_PORTS: ${STRATUM_PORTS:-3333,3332,3331,3330}
PPLNS_STRATUM_PORTS: ${PPLNS_STRATUM_PORTS:-}
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
+84 -44
View File
@@ -1,54 +1,94 @@
const { availableParallelism, cpus } = require('os');
const dockerLogConfig = {
out_file: '/dev/stdout',
error_file: '/dev/stderr',
merge_logs: true,
};
const cpuCount =
typeof availableParallelism === 'function'
? availableParallelism()
: cpus().length;
const positiveInteger = (name, value, fallback) => {
const candidate = value == null || value === '' ? fallback : Number(value);
if (!Number.isInteger(candidate) || candidate <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return candidate;
};
const apiWorkers = positiveInteger('API_WORKERS', process.env.API_WORKERS, 4);
const automaticStratumWorkers = Math.max(1, cpuCount - apiWorkers - 1);
const stratumWorkers =
process.env.STRATUM_WORKERS == null ||
process.env.STRATUM_WORKERS === '' ||
process.env.STRATUM_WORKERS.toLowerCase() === 'auto'
? automaticStratumWorkers
: positiveInteger('STRATUM_WORKERS', process.env.STRATUM_WORKERS);
module.exports = {
apps: [
// API instance
{
...dockerLogConfig,
name: 'api',
script: './dist/main.js',
instances: parseInt(process.env.API_WORKERS || '4', 10),
exec_mode: 'cluster',
env: {
MASTER: 'false',
API_ONLY: 'true',
API_ENABLED: 'true',
NODE_CLUSTER_SCHED_POLICY: 'none',
},
time: true
apps: [
// API instance
{
...dockerLogConfig,
name: 'api',
script: './dist/main.js',
instances: apiWorkers,
exec_mode: 'cluster',
env: {
MASTER: 'false',
API_ONLY: 'true',
API_ENABLED: 'true',
NODE_CLUSTER_SCHED_POLICY: 'none',
},
// Master instance
{
...dockerLogConfig,
name: 'master',
script: './dist/main.js',
instances: 1,
exec_mode: 'fork',
env: {
MASTER: 'true',
API_ENABLED: 'false',
NODE_CLUSTER_SCHED_POLICY: 'none',
},
time: true
time: true,
},
// Minimal authoritative template/notifier instance. This entrypoint avoids
// initializing API, Stratum, reporting, and notification integrations.
{
...dockerLogConfig,
name: 'master',
script: './dist/notifier-main.js',
instances: 1,
exec_mode: 'fork',
env: {
MASTER: 'true',
API_ENABLED: 'false',
NODE_CLUSTER_SCHED_POLICY: 'none',
},
// Worker instances
{
...dockerLogConfig,
name: 'workers',
script: './dist/main.js',
instances: parseInt(process.env.STRATUM_WORKERS || '2', 10),
exec_mode: "cluster",
max_memory_restart: process.env.STRATUM_WORKER_MAX_MEMORY_RESTART || '4096M',
env: {
MASTER: 'false',
API_ENABLED: 'false',
NODE_CLUSTER_SCHED_POLICY: 'none',
},
time: true
time: true,
},
// Non-hot-path master duties: notifications, reporting, and cleanup.
{
...dockerLogConfig,
name: 'maintenance',
script: './dist/maintenance-main.js',
instances: 1,
exec_mode: 'fork',
env: {
MASTER: 'true',
API_ENABLED: 'false',
NODE_CLUSTER_SCHED_POLICY: 'none',
},
],
};
time: true,
},
// Worker instances
{
...dockerLogConfig,
name: 'workers',
script: './dist/main.js',
instances: stratumWorkers,
exec_mode: 'cluster',
max_memory_restart:
process.env.STRATUM_WORKER_MAX_MEMORY_RESTART || '4096M',
env: {
MASTER: 'false',
API_ENABLED: 'false',
NODE_CLUSTER_SCHED_POLICY: 'none',
},
time: true,
},
],
};
+1 -1
View File
@@ -19,7 +19,7 @@ rpcuser=bitcoin
server=1
# notify pool master about new blocks
zmqpubrawblock=tcp://0.0.0.0:3000
zmqpubhashblock=tcp://0.0.0.0:3000
[main]
[test]
+1 -1
View File
@@ -22,4 +22,4 @@ rpcuser=bitcoin
prune=550
# notify pool master about new blocks
zmqpubrawblock=tcp://0.0.0.0:3000
zmqpubhashblock=tcp://0.0.0.0:3000
+1 -1
View File
@@ -19,7 +19,7 @@ rpcuser=bitcoin
server=1
# notify pool master about new blocks
zmqpubrawblock=tcp://0.0.0.0:3000
zmqpubhashblock=tcp://0.0.0.0:3000
[main]
[test]
+12 -2
View File
@@ -108,6 +108,16 @@ services:
DB_DATABASE: public_pool_mainnet
REDIS_URL: redis://redis:6379
API_WORKERS: ${API_WORKERS:-4}
API_CONNECTION_TIMEOUT_MS: ${API_CONNECTION_TIMEOUT_MS:-15000}
API_KEEP_ALIVE_TIMEOUT_MS: ${API_KEEP_ALIVE_TIMEOUT_MS:-5000}
API_REQUEST_TIMEOUT_MS: ${API_REQUEST_TIMEOUT_MS:-15000}
API_HEADERS_TIMEOUT_MS: ${API_HEADERS_TIMEOUT_MS:-10000}
API_MAX_REQUESTS_PER_SOCKET: ${API_MAX_REQUESTS_PER_SOCKET:-100}
API_MAX_CONNECTIONS_PER_WORKER: ${API_MAX_CONNECTIONS_PER_WORKER:-500}
SHARE_ACCOUNTING_REDIS_SUMMARY_CACHE_MS: ${SHARE_ACCOUNTING_REDIS_SUMMARY_CACHE_MS:-300000}
SHARE_ACCOUNTING_REDIS_SUMMARY_STALE_CACHE_MS: ${SHARE_ACCOUNTING_REDIS_SUMMARY_STALE_CACHE_MS:-3600000}
SHARE_ACCOUNTING_REDIS_SUMMARY_LOCK_MS: ${SHARE_ACCOUNTING_REDIS_SUMMARY_LOCK_MS:-30000}
SHARE_ACCOUNTING_REDIS_SUMMARY_LOCK_WAIT_MS: ${SHARE_ACCOUNTING_REDIS_SUMMARY_LOCK_WAIT_MS:-5000}
PM2_ENABLED: "true"
STRATUM_WORKERS: ${STRATUM_WORKERS:-2}
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
@@ -122,11 +132,11 @@ services:
PAYOUT_WINDOW_FACTOR: ${PAYOUT_WINDOW_FACTOR:-4}
PAYOUT_BOOTSTRAP_WINDOW: ${PAYOUT_BOOTSTRAP_WINDOW:-true}
PAYOUT_COINBASE_MODE: ${PAYOUT_COINBASE_MODE:-solo}
PAYOUT_MAX_COINBASE_OUTPUTS: ${PAYOUT_MAX_COINBASE_OUTPUTS:-10}
PAYOUT_MAX_COINBASE_OUTPUTS: ${PAYOUT_MAX_COINBASE_OUTPUTS:-200}
PAYOUT_MIN_OUTPUT_SATS: ${PAYOUT_MIN_OUTPUT_SATS:-546}
PAYOUT_FEE_ADDRESS: ${PAYOUT_FEE_ADDRESS:-}
PAYOUT_FEE_PERCENT: ${PAYOUT_FEE_PERCENT:-0}
PAYOUT_COINBASE_WEIGHT_BUDGET: ${PAYOUT_COINBASE_WEIGHT_BUDGET:-50000}
PAYOUT_COINBASE_WEIGHT_BUDGET: ${PAYOUT_COINBASE_WEIGHT_BUDGET:-26000}
networks:
bitcoin:
+1
View File
@@ -4,6 +4,7 @@ BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=8332
BITCOIN_RPC_TIMEOUT=10000
BITCOIN_ZMQ_TOPIC=hashblock
BITCOIN_ZMQ_HOST=tcp://bitcoin:3000
API_PORT=3334
+1
View File
@@ -4,6 +4,7 @@ BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=28332
BITCOIN_RPC_TIMEOUT=10000
BITCOIN_ZMQ_TOPIC=hashblock
BITCOIN_ZMQ_HOST=tcp://bitcoin-regtest:3000
API_PORT=23334
+1
View File
@@ -4,6 +4,7 @@ BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=18332
BITCOIN_RPC_TIMEOUT=10000
BITCOIN_ZMQ_TOPIC=hashblock
BITCOIN_ZMQ_HOST=tcp://bitcoin-testnet:3000
API_PORT=13334
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
set -eu
lineage=${RENEWED_LINEAGE:-/etc/letsencrypt/live/public-pool.io}
secrets_dir=${PUBLIC_POOL_SECRETS_DIR:-/home/ben/public-pool-timescaledb-test/secrets}
container=${PUBLIC_POOL_CONTAINER:-public-pool}
owner=${PUBLIC_POOL_CERT_OWNER:-ben}
group=${PUBLIC_POOL_CERT_GROUP:-ben}
cert_source="$lineage/fullchain.pem"
key_source="$lineage/privkey.pem"
test -r "$cert_source"
test -r "$key_source"
openssl x509 -in "$cert_source" -noout >/dev/null
openssl pkey -in "$key_source" -noout >/dev/null
cert_temp=$(mktemp "$secrets_dir/.cert.pem.XXXXXX")
key_temp=$(mktemp "$secrets_dir/.key.pem.XXXXXX")
trap 'rm -f "$cert_temp" "$key_temp"' EXIT HUP INT TERM
cat "$cert_source" > "$cert_temp"
cat "$key_source" > "$key_temp"
chown "$owner:$group" "$cert_temp" "$key_temp"
chmod 0644 "$cert_temp"
chmod 0600 "$key_temp"
mv -f "$cert_temp" "$secrets_dir/cert.pem"
mv -f "$key_temp" "$secrets_dir/key.pem"
# HTTPS can reload its secure context, but the Stratum TLS listeners currently
# read their certificate only when they start.
docker restart --time 30 "$container" >/dev/null
@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AcceptedShare10mLookupIndexes1781403000000 implements MigrationInterface {
public name = 'AcceptedShare10mLookupIndexes1781403000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_10m_bucket"
ON "accepted_share_10m" ("bucket" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_10m_address_bucket"
ON "accepted_share_10m" ("address", "bucket" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_10m_address_mode_bucket"
ON "accepted_share_10m" ("address", "payoutMode", "bucket" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_10m_group_bucket"
ON "accepted_share_10m" ("address", "clientName", "bucket" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_10m_group_mode_bucket"
ON "accepted_share_10m" ("address", "clientName", "payoutMode", "bucket" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_10m_client_bucket"
ON "accepted_share_10m" ("clientId", "bucket" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_10m_client_mode_bucket"
ON "accepted_share_10m" ("clientId", "payoutMode", "bucket" DESC)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_10m_client_mode_bucket"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_10m_group_mode_bucket"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_10m_address_mode_bucket"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_10m_client_bucket"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_10m_group_bucket"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_10m_address_bucket"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_10m_bucket"`);
}
}
@@ -0,0 +1,250 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AcceptedShareHighScoreNumericRetention1781402000000 implements MigrationInterface {
public name = 'AcceptedShareHighScoreNumericRetention1781402000000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_block_10m_best"
ON "accepted_share_block_10m" ("bestSubmissionDifficulty" DESC, "bucket" DESC)
WHERE "bestSubmissionDifficulty" > 0
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_block_10m_mode_best"
ON "accepted_share_block_10m" ("payoutMode", "bestSubmissionDifficulty" DESC, "bucket" DESC)
WHERE "bestSubmissionDifficulty" > 0
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_client_best_difficulty_mode"
ON "client_entity" ("payoutMode", "bestDifficulty" DESC, "updatedAt" DESC)
WHERE "bestDifficulty" > 0
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_address_settings_best_difficulty"
ON "address_settings_entity" ("bestDifficulty" DESC, "updatedAt" DESC)
WHERE "bestDifficulty" > 0
`);
await this.backfillRollupAllTime(queryRunner);
await this.backfillClientAllTime(queryRunner);
await this.backfillAddressSettingsAllTime(queryRunner);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_address_settings_best_difficulty"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_client_best_difficulty_mode"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_block_10m_mode_best"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_block_10m_best"`);
}
private async backfillRollupAllTime(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
WITH candidates AS (
SELECT
"payoutMode",
"bucket",
"blockHeight",
"bestSubmissionDifficulty"
FROM "accepted_share_block_10m"
WHERE "bestSubmissionDifficulty" IS NOT NULL
AND "bestSubmissionDifficulty" > 0
UNION ALL
SELECT
'all' AS "payoutMode",
"bucket",
"blockHeight",
"bestSubmissionDifficulty"
FROM "accepted_share_block_10m"
WHERE "bestSubmissionDifficulty" IS NOT NULL
AND "bestSubmissionDifficulty" > 0
),
best_rows AS (
SELECT DISTINCT ON ("payoutMode")
"payoutMode",
"bucket",
"blockHeight",
"bestSubmissionDifficulty"
FROM candidates
ORDER BY "payoutMode", "bestSubmissionDifficulty" DESC, "bucket" DESC
)
INSERT INTO "accepted_share_high_score" (
"scope",
"payoutMode",
"bucketDate",
"submissionDifficulty",
"acceptedAt",
"bucket",
"blockHeight",
"address",
"clientName",
"protocol"
)
SELECT
'all_time',
"payoutMode",
DATE '1970-01-01',
"bestSubmissionDifficulty",
"bucket",
"bucket",
"blockHeight",
NULL,
NULL,
NULL
FROM best_rows
ON CONFLICT ("scope", "payoutMode", "bucketDate")
DO UPDATE SET
"submissionDifficulty" = EXCLUDED."submissionDifficulty",
"acceptedAt" = EXCLUDED."acceptedAt",
"bucket" = EXCLUDED."bucket",
"blockHeight" = EXCLUDED."blockHeight",
"address" = EXCLUDED."address",
"clientName" = EXCLUDED."clientName",
"protocol" = EXCLUDED."protocol",
"updatedAt" = NOW()
WHERE EXCLUDED."submissionDifficulty" > "accepted_share_high_score"."submissionDifficulty"
OR (
EXCLUDED."submissionDifficulty" = "accepted_share_high_score"."submissionDifficulty"
AND EXCLUDED."acceptedAt" > COALESCE("accepted_share_high_score"."acceptedAt", '-infinity'::timestamptz)
)
`);
}
private async backfillClientAllTime(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
WITH candidates AS (
SELECT
"payoutMode",
"bestDifficulty",
"updatedAt",
"address",
"clientName",
"userAgent"
FROM "client_entity"
WHERE "bestDifficulty" IS NOT NULL
AND "bestDifficulty" > 0
UNION ALL
SELECT
'all' AS "payoutMode",
"bestDifficulty",
"updatedAt",
"address",
"clientName",
"userAgent"
FROM "client_entity"
WHERE "bestDifficulty" IS NOT NULL
AND "bestDifficulty" > 0
),
best_rows AS (
SELECT DISTINCT ON ("payoutMode")
"payoutMode",
"bestDifficulty",
"updatedAt",
"address",
"clientName",
"userAgent"
FROM candidates
ORDER BY "payoutMode", "bestDifficulty" DESC, "updatedAt" DESC
)
INSERT INTO "accepted_share_high_score" (
"scope",
"payoutMode",
"bucketDate",
"submissionDifficulty",
"acceptedAt",
"bucket",
"blockHeight",
"address",
"clientName",
"protocol"
)
SELECT
'all_time',
"payoutMode",
DATE '1970-01-01',
"bestDifficulty",
"updatedAt",
NULL,
NULL,
"address",
"clientName",
"userAgent"
FROM best_rows
ON CONFLICT ("scope", "payoutMode", "bucketDate")
DO UPDATE SET
"submissionDifficulty" = EXCLUDED."submissionDifficulty",
"acceptedAt" = EXCLUDED."acceptedAt",
"bucket" = EXCLUDED."bucket",
"blockHeight" = EXCLUDED."blockHeight",
"address" = EXCLUDED."address",
"clientName" = EXCLUDED."clientName",
"protocol" = EXCLUDED."protocol",
"updatedAt" = NOW()
WHERE EXCLUDED."submissionDifficulty" > "accepted_share_high_score"."submissionDifficulty"
OR (
EXCLUDED."submissionDifficulty" = "accepted_share_high_score"."submissionDifficulty"
AND EXCLUDED."acceptedAt" > COALESCE("accepted_share_high_score"."acceptedAt", '-infinity'::timestamptz)
)
`);
}
private async backfillAddressSettingsAllTime(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
WITH best_row AS (
SELECT
"bestDifficulty",
"updatedAt",
"address",
"bestDifficultyUserAgent"
FROM "address_settings_entity"
WHERE "bestDifficulty" IS NOT NULL
AND "bestDifficulty" > 0
ORDER BY "bestDifficulty" DESC, "updatedAt" DESC
LIMIT 1
)
INSERT INTO "accepted_share_high_score" (
"scope",
"payoutMode",
"bucketDate",
"submissionDifficulty",
"acceptedAt",
"bucket",
"blockHeight",
"address",
"clientName",
"protocol"
)
SELECT
'all_time',
'all',
DATE '1970-01-01',
"bestDifficulty",
"updatedAt",
NULL,
NULL,
"address",
NULL,
"bestDifficultyUserAgent"
FROM best_row
ON CONFLICT ("scope", "payoutMode", "bucketDate")
DO UPDATE SET
"submissionDifficulty" = EXCLUDED."submissionDifficulty",
"acceptedAt" = EXCLUDED."acceptedAt",
"bucket" = EXCLUDED."bucket",
"blockHeight" = EXCLUDED."blockHeight",
"address" = EXCLUDED."address",
"clientName" = EXCLUDED."clientName",
"protocol" = EXCLUDED."protocol",
"updatedAt" = NOW()
WHERE EXCLUDED."submissionDifficulty" > "accepted_share_high_score"."submissionDifficulty"
OR (
EXCLUDED."submissionDifficulty" = "accepted_share_high_score"."submissionDifficulty"
AND EXCLUDED."acceptedAt" > COALESCE("accepted_share_high_score"."acceptedAt", '-infinity'::timestamptz)
)
`);
}
}
@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PoolSummaryContinuousAggregate1781400000000 implements MigrationInterface {
public name = 'PoolSummaryContinuousAggregate1781400000000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_pool_10m"
WITH (timescaledb.continuous) AS
SELECT
time_bucket(INTERVAL '10 minutes', "acceptedAt") AS "bucket",
"payoutMode",
SUM("creditedDifficulty") AS "shares",
COUNT(*) AS "acceptedCount"
FROM "accepted_share_entity"
GROUP BY "bucket", "payoutMode"
WITH NO DATA
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_pool_10m',
start_offset => INTERVAL '25 hours',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => TRUE
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_pool_10m_bucket"
ON "accepted_share_pool_10m" ("bucket" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_pool_10m_mode_bucket"
ON "accepted_share_pool_10m" ("payoutMode", "bucket" DESC)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_pool_10m_mode_bucket"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_pool_10m_bucket"`);
await queryRunner.query(`
SELECT remove_continuous_aggregate_policy(
'accepted_share_pool_10m',
if_exists => TRUE
)
`);
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "accepted_share_pool_10m"`);
}
}
@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PoolSummaryRefreshWindow1781401000000 implements MigrationInterface {
public name = 'PoolSummaryRefreshWindow1781401000000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
SELECT remove_continuous_aggregate_policy(
'accepted_share_pool_10m',
if_exists => TRUE
)
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_pool_10m',
start_offset => INTERVAL '25 hours',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => TRUE
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
SELECT remove_continuous_aggregate_policy(
'accepted_share_pool_10m',
if_exists => TRUE
)
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_pool_10m',
start_offset => INTERVAL '2 days',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => TRUE
)
`);
}
}
@@ -0,0 +1,54 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UserAgentReportNonzeroHashrate1781313000000 implements MigrationInterface {
public name = 'UserAgentReportNonzeroHashrate1781313000000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "user_agent_report_view"`);
await queryRunner.query(`
CREATE MATERIALIZED VIEW "user_agent_report_view" AS
SELECT
"userAgent",
COUNT("userAgent") AS "count",
MAX("bestDifficulty") AS "bestDifficulty",
SUM("hashRate") AS "totalHashRate"
FROM "client_entity"
WHERE "deletedAt" IS NULL
AND "hashRate" > 0
GROUP BY "userAgent"
ORDER BY "totalHashRate" DESC
`);
await queryRunner.query(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_client_working_report_updated_user_agent"
ON "client_entity" ("updatedAt" DESC, "userAgent")
INCLUDE ("hashRate", "bestDifficulty")
WHERE "deletedAt" IS NULL
AND "hashRate" > 0
`);
await queryRunner.query(`DROP INDEX CONCURRENTLY IF EXISTS "IDX_client_active_report_updated_user_agent"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX CONCURRENTLY IF EXISTS "IDX_client_working_report_updated_user_agent"`);
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "user_agent_report_view"`);
await queryRunner.query(`
CREATE MATERIALIZED VIEW "user_agent_report_view" AS
SELECT
"userAgent",
COUNT("userAgent") AS "count",
MAX("bestDifficulty") AS "bestDifficulty",
SUM("hashRate") AS "totalHashRate"
FROM "client_entity"
WHERE "deletedAt" IS NULL
GROUP BY "userAgent"
ORDER BY "totalHashRate" DESC
`);
await queryRunner.query(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_client_active_report_updated_user_agent"
ON "client_entity" ("updatedAt" DESC, "userAgent")
INCLUDE ("hashRate", "bestDifficulty")
WHERE "deletedAt" IS NULL
`);
}
}
@@ -55,6 +55,7 @@ describe('UserAgentReportService', () => {
'client.updatedAt > :activeSince',
expect.objectContaining({ activeSince: expect.any(Date) }),
);
expect(clientRepository.queryBuilder.andWhere).toHaveBeenCalledWith('client.hashRate > 0');
});
it('falls back to the materialized view when no active database clients exist', async () => {
@@ -72,6 +72,7 @@ export class UserAgentReportService {
.addSelect('COALESCE(SUM(client.hashRate), 0)', 'totalHashRate')
.where('client.deletedAt IS NULL')
.andWhere('client.updatedAt > :activeSince', { activeSince })
.andWhere('client.hashRate > 0')
.groupBy('COALESCE(NULLIF(client.userAgent, \'\'), \'Other\')')
.orderBy('"totalHashRate"', 'DESC')
.getRawMany<UserAgentReportView>();
@@ -13,6 +13,7 @@ import { ClientEntity } from '../../client/client.entity';
.addSelect('SUM(client.hashRate)', 'totalHashRate')
.from(ClientEntity, 'client')
.where('client.deletedAt IS NULL')
.andWhere('client.hashRate > 0')
.groupBy('client.userAgent')
.orderBy('"totalHashRate"', 'DESC')
})
+19 -9
View File
@@ -4,10 +4,11 @@ import { In, Repository } from 'typeorm';
import { ClientEntity } from './client.entity';
const DEFAULT_CLIENT_ACTIVE_WINDOW_MS = 30 * 60 * 1000;
@Injectable()
export class ClientService {
private readonly activeWindowMs = this.readPositiveInt('CLIENT_REPORT_ACTIVE_WINDOW_MS', DEFAULT_CLIENT_ACTIVE_WINDOW_MS);
constructor(
@InjectRepository(ClientEntity)
@@ -84,14 +85,15 @@ export class ClientService {
}
public async getByAddress(address: string): Promise<ClientEntity[]> {
return await this.clientRepository.find({
where: {
address
},
order: {
updatedAt: 'DESC',
},
})
const activeSince = new Date(Date.now() - this.activeWindowMs);
return await this.clientRepository
.createQueryBuilder('client')
.where('client.address = :address', { address })
.andWhere('client.deletedAt IS NULL')
.andWhere('client.updatedAt > :activeSince', { activeSince })
.andWhere('client.hashRate > 0')
.orderBy('client.updatedAt', 'DESC')
.getMany();
}
@@ -135,4 +137,12 @@ export class ClientService {
// return result;
// }
private readPositiveInt(name: string, defaultValue: number): number {
const value = Number(process.env[name]);
if (Number.isInteger(value) && value > 0) {
return value;
}
return defaultValue;
}
}
@@ -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' }])
@@ -51,13 +51,16 @@ export interface ExpectedPayout {
percent: number;
}
const DEFAULT_MAX_COINBASE_OUTPUTS = 10;
const DEFAULT_MAX_COINBASE_OUTPUTS = 200;
const DEFAULT_MIN_OUTPUT_SATS = 546;
const DEFAULT_PAYOUT_METHOD = 'pplns';
const DEFAULT_PAYOUT_WINDOW_FACTOR = 4;
const DEFAULT_COINBASE_WEIGHT_BUDGET = 50_000;
const DEFAULT_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[] {
@@ -272,6 +272,62 @@ describe('ShareAccountingService', () => {
);
});
it('should serve API-only pool accounting from rollups when the precomputed pool cache is missing', async () => {
process.env.API_ONLY = 'true';
const redis = {
getJsonCache: jest.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null),
setJsonCache: jest.fn().mockResolvedValue(undefined),
};
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '12',
totalCreditedDifficulty: '384',
acceptedSharesLast10Minutes: '4',
creditedDifficultyLast10Minutes: '128',
acceptedSharesLastHour: '10',
creditedDifficultyLastHour: '320',
acceptedSharesLastDay: '12',
creditedDifficultyLastDay: '384',
hashRateLast10Minutes: '916259689.8',
hashRateLastHour: '381774870.2',
latestShareAt: new Date('2026-06-07T12:30:00Z'),
}])
.mockResolvedValueOnce([{
bestSubmissionDifficulty: '4096',
bestSubmissionDifficultyAt: new Date('2026-06-07T12:20:00Z'),
currentRoundAcceptedShares: '11',
workSinceLastBlock: '352',
currentRoundNetworkDifficulty: '1000',
}])
.mockResolvedValueOnce([]),
};
const service = new ShareAccountingService(repository as any, redis as any);
await expect(service.getPoolSummary('solo')).resolves.toEqual(expect.objectContaining({
totalAcceptedShares: 12,
totalCreditedDifficulty: 384,
acceptedSharesLast10Minutes: 4,
creditedDifficultyLast10Minutes: 128,
bestSubmissionDifficulty: 4096,
workSinceLastBlock: 352,
networkDifficultyPercent: 35.2,
}));
expect(repository.query).toHaveBeenNthCalledWith(
1,
expect.stringContaining('FROM "accepted_share_pool_10m"'),
['solo'],
);
expect(repository.query).toHaveBeenNthCalledWith(
2,
expect.stringContaining('FROM "accepted_share_block_10m"'),
['solo'],
);
});
it('should use Redis cache for share accounting summaries across API workers', async () => {
process.env.SHARE_ACCOUNTING_SUMMARY_CACHE_MS = '0';
const repository = {
@@ -309,11 +365,65 @@ describe('ShareAccountingService', () => {
expect(repository.query).toHaveBeenCalledTimes(1);
expect(redis.setJsonCache).toHaveBeenCalledWith(
expect.stringContaining('accounting:summary:'),
expect.objectContaining({ totalAcceptedShares: 1 }),
30000,
expect.objectContaining({
schemaVersion: 1,
refreshedAtMs: expect.any(Number),
value: expect.objectContaining({ totalAcceptedShares: 1 }),
}),
3600000,
);
});
it('serves a stale shared summary while one API worker refreshes it', async () => {
process.env.SHARE_ACCOUNTING_SUMMARY_CACHE_MS = '0';
process.env.SHARE_ACCOUNTING_REDIS_SUMMARY_CACHE_MS = '100';
const staleSummary = {
...new ShareAccountingService({} as any).emptySummary(),
totalAcceptedShares: 7,
};
const repository = {
query: jest.fn().mockResolvedValueOnce([{
totalAcceptedShares: '8',
totalCreditedDifficulty: '256',
acceptedSharesLast10Minutes: '1',
creditedDifficultyLast10Minutes: '32',
acceptedSharesLastHour: '8',
creditedDifficultyLastHour: '256',
acceptedSharesLastDay: '8',
creditedDifficultyLastDay: '256',
hashRateLast10Minutes: '1',
hashRateLastHour: '1',
latestShareAt: null,
}]),
};
const redis = {
getJsonCache: jest.fn().mockResolvedValue({
schemaVersion: 1,
refreshedAtMs: Date.now() - 1000,
value: staleSummary,
}),
setJsonCache: jest.fn().mockResolvedValue(undefined),
tryAcquireJsonCacheLock: jest.fn().mockResolvedValue(true),
releaseJsonCacheLock: jest.fn().mockResolvedValue(undefined),
};
const service = new ShareAccountingService(repository as any, redis as any);
await expect(service.getAddressSummary('bc1qstale')).resolves.toEqual(staleSummary);
await new Promise(resolve => setImmediate(resolve));
expect(repository.query).toHaveBeenCalledTimes(1);
expect(redis.tryAcquireJsonCacheLock).toHaveBeenCalledTimes(1);
expect(redis.setJsonCache).toHaveBeenCalledWith(
expect.stringContaining('accounting:summary:'),
expect.objectContaining({
schemaVersion: 1,
value: expect.objectContaining({ totalAcceptedShares: 8 }),
}),
3600000,
);
expect(redis.releaseJsonCacheLock).toHaveBeenCalledTimes(1);
});
it('should refresh pool summaries from completed rollup buckets and current round rollups', async () => {
const redis = {
getJsonCache: jest.fn().mockResolvedValue(null),
@@ -357,6 +467,11 @@ describe('ShareAccountingService', () => {
networkDifficultyPercent: 35.2,
latestShareAt: '2026-06-07T12:10:00.000Z',
}));
expect(repository.query).toHaveBeenNthCalledWith(
1,
expect.stringContaining('FROM "accepted_share_pool_10m"'),
[],
);
expect(repository.query).toHaveBeenNthCalledWith(
2,
expect.stringContaining('FROM "accepted_share_block_10m"'),
@@ -429,8 +544,8 @@ describe('ShareAccountingService', () => {
};
const service = new ShareAccountingService(repository as any);
await service.getPoolSummary();
await service.getPoolSummary();
await service.getAddressSummary('bc1qcached');
await service.getAddressSummary('bc1qcached');
expect(repository.query).toHaveBeenCalledTimes(1);
});
@@ -1,5 +1,6 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { Repository } from 'typeorm';
import { AcceptedShareEntity } from '../accepted-share/accepted-share.entity';
@@ -88,7 +89,10 @@ const DEFAULT_FLUSH_INTERVAL_MS = 25;
const DEFAULT_MAX_QUEUE_SIZE = 50000;
const DEFAULT_SUMMARY_CACHE_MS = 2500;
const DEFAULT_SUMMARY_CACHE_MAX = 10000;
const DEFAULT_REDIS_SUMMARY_CACHE_MS = 30000;
const DEFAULT_REDIS_SUMMARY_CACHE_MS = 5 * 60 * 1000;
const DEFAULT_REDIS_SUMMARY_STALE_CACHE_MS = 60 * 60 * 1000;
const DEFAULT_REDIS_SUMMARY_LOCK_MS = 30 * 1000;
const DEFAULT_REDIS_SUMMARY_LOCK_WAIT_MS = 5 * 1000;
const DEFAULT_ROLLUP_INTERVAL_MS = 60000;
const DEFAULT_ROLLUP_SAFETY_LAG_SECONDS = 10;
const DEFAULT_ROLLUP_MAX_SHARES_PER_BATCH = 5000000;
@@ -102,6 +106,8 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
private rollupTimer: NodeJS.Timeout | null = null;
private activeRollup: Promise<void> | null = null;
private summaryCache = new Map<string, SummaryCacheEntry>();
private summaryInFlight = new Map<string, Promise<ShareAccountingSummary>>();
private redisSummaryRefreshInFlight = new Map<string, Promise<void>>();
private readonly poolSummaryCacheKey = 'accounting:pool-summary';
private readonly batchSize = this.readPositiveInt('SHARE_ACCOUNTING_BATCH_SIZE', DEFAULT_BATCH_SIZE);
private readonly flushIntervalMs = this.readPositiveInt('SHARE_ACCOUNTING_FLUSH_INTERVAL_MS', DEFAULT_FLUSH_INTERVAL_MS);
@@ -109,6 +115,12 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
private readonly summaryCacheMs = this.readNonNegativeInt('SHARE_ACCOUNTING_SUMMARY_CACHE_MS', DEFAULT_SUMMARY_CACHE_MS);
private readonly summaryCacheMax = this.readPositiveInt('SHARE_ACCOUNTING_SUMMARY_CACHE_MAX', DEFAULT_SUMMARY_CACHE_MAX);
private readonly redisSummaryCacheMs = this.readNonNegativeInt('SHARE_ACCOUNTING_REDIS_SUMMARY_CACHE_MS', DEFAULT_REDIS_SUMMARY_CACHE_MS);
private readonly redisSummaryStaleCacheMs = Math.max(
this.redisSummaryCacheMs,
this.readPositiveInt('SHARE_ACCOUNTING_REDIS_SUMMARY_STALE_CACHE_MS', DEFAULT_REDIS_SUMMARY_STALE_CACHE_MS),
);
private readonly redisSummaryLockMs = this.readPositiveInt('SHARE_ACCOUNTING_REDIS_SUMMARY_LOCK_MS', DEFAULT_REDIS_SUMMARY_LOCK_MS);
private readonly redisSummaryLockWaitMs = this.readPositiveInt('SHARE_ACCOUNTING_REDIS_SUMMARY_LOCK_WAIT_MS', DEFAULT_REDIS_SUMMARY_LOCK_WAIT_MS);
private readonly shareRollupEnabled = this.readBoolean('SHARE_ROLLUP_ENABLED', true);
private readonly shareRollupIntervalMs = this.readPositiveInt('SHARE_ROLLUP_INTERVAL_MS', DEFAULT_ROLLUP_INTERVAL_MS);
private readonly shareRollupSafetyLagSeconds = this.readNonNegativeInt('SHARE_ROLLUP_SAFETY_LAG_SECONDS', DEFAULT_ROLLUP_SAFETY_LAG_SECONDS);
@@ -360,11 +372,7 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
return cached;
}
if (process.env.API_ONLY === 'true') {
return this.emptySummary();
}
return this.getSummary({ payoutMode: mode });
return this.withPoolRollupOverlay(await this.getSummary({ payoutMode: mode }), mode);
}
public async refreshPoolSummary(payoutMode?: PayoutMode): Promise<ShareAccountingSummary> {
@@ -380,10 +388,6 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
}
private async withPoolRollupOverlay(summary: ShareAccountingSummary, payoutMode?: PayoutMode): Promise<ShareAccountingSummary> {
if (process.env.API_ONLY === 'true') {
return summary;
}
const [currentRoundRow] = await this.acceptedShareRepository.query(`
WITH latest_found_block AS (
SELECT COALESCE(MAX("height"), 0) AS "height"
@@ -573,46 +577,219 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
return cached.value;
}
const value = this.loadCachedSummary(filter, cacheKey).catch(error => {
if (cached != null) {
this.summaryCache.delete(cacheKey);
throw error;
});
if (this.summaryCacheMs > 0) {
this.summaryCache.set(cacheKey, {
expiresAt: now + this.summaryCacheMs,
value,
});
this.trimSummaryCache();
}
const inFlight = this.summaryInFlight.get(cacheKey);
if (inFlight != null) {
return inFlight;
}
const value = this.loadCachedSummary(filter, cacheKey)
.then(summary => {
if (this.summaryCacheMs > 0) {
this.summaryCache.set(cacheKey, {
expiresAt: Date.now() + this.summaryCacheMs,
value: summary,
});
this.trimSummaryCache();
}
return summary;
})
.finally(() => {
this.summaryInFlight.delete(cacheKey);
});
this.summaryInFlight.set(cacheKey, value);
return value;
}
private async loadCachedSummary(filter: AccountingFilter, cacheKey: string): Promise<ShareAccountingSummary> {
const redisCacheKey = `accounting:summary:${cacheKey}`;
if (this.redisMessagingService == null || this.redisSummaryCacheMs <= 0) {
return this.loadSummary(filter);
}
const cached = await this.readRedisSummaryCache(redisCacheKey);
if (cached != null) {
if (Date.now() - cached.refreshedAtMs >= this.redisSummaryCacheMs) {
this.refreshRedisSummaryCacheInBackground(filter, redisCacheKey);
}
return cached.value;
}
return this.loadColdRedisSummaryCache(filter, redisCacheKey);
}
private async readRedisSummaryCache(redisCacheKey: string): Promise<RedisSummaryCacheEntry | null> {
const cached = await this.redisMessagingService
?.getJsonCache<ShareAccountingSummary>(redisCacheKey)
?.getJsonCache<RedisSummaryCacheEntry | ShareAccountingSummary>(redisCacheKey)
.catch(error => {
console.error(`Share accounting summary cache read failed: ${error.message}`);
return null;
});
if (cached != null) {
if (cached == null) {
return null;
}
if (this.isRedisSummaryCacheEntry(cached)) {
return cached;
}
// Cache entries written by older workers have no timestamp. Their old
// short Redis TTL still bounds how long they can be treated as fresh.
return {
schemaVersion: 1,
refreshedAtMs: Date.now(),
value: cached,
};
}
private async loadColdRedisSummaryCache(
filter: AccountingFilter,
redisCacheKey: string,
): Promise<ShareAccountingSummary> {
if (!this.supportsRedisSummaryLock()) {
const summary = await this.loadSummary(filter);
await this.storeRedisSummaryCache(redisCacheKey, summary);
return summary;
}
const owner = randomUUID();
const acquired = await this.tryAcquireRedisSummaryLock(redisCacheKey, owner);
if (acquired === true) {
return this.loadAndStoreRedisSummary(filter, redisCacheKey, owner);
}
if (acquired == null) {
const summary = await this.loadSummary(filter);
await this.storeRedisSummaryCache(redisCacheKey, summary);
return summary;
}
const deadline = Date.now() + this.redisSummaryLockWaitMs;
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 50));
const cached = await this.readRedisSummaryCache(redisCacheKey);
if (cached != null) {
return cached.value;
}
}
// Redis locking is an optimization, not an availability dependency.
// If a lock holder died or a refresh exceeded its budget, fail open.
const summary = await this.loadSummary(filter);
await this.redisMessagingService
?.setJsonCache(redisCacheKey, summary, this.redisSummaryCacheMs)
.catch(error => {
console.error(`Share accounting summary cache write failed: ${error.message}`);
});
await this.storeRedisSummaryCache(redisCacheKey, summary);
return summary;
}
private refreshRedisSummaryCacheInBackground(filter: AccountingFilter, redisCacheKey: string): void {
if (this.redisSummaryRefreshInFlight.has(redisCacheKey)) {
return;
}
const refresh = this.refreshRedisSummaryCache(filter, redisCacheKey)
.catch(error => {
console.error(`Share accounting summary background refresh failed: ${error.message}`);
})
.finally(() => {
this.redisSummaryRefreshInFlight.delete(redisCacheKey);
});
this.redisSummaryRefreshInFlight.set(redisCacheKey, refresh);
}
private async refreshRedisSummaryCache(filter: AccountingFilter, redisCacheKey: string): Promise<void> {
if (!this.supportsRedisSummaryLock()) {
const summary = await this.loadSummary(filter);
await this.storeRedisSummaryCache(redisCacheKey, summary);
return;
}
const owner = randomUUID();
if (await this.tryAcquireRedisSummaryLock(redisCacheKey, owner) !== true) {
return;
}
await this.loadAndStoreRedisSummary(filter, redisCacheKey, owner);
}
private async loadAndStoreRedisSummary(
filter: AccountingFilter,
redisCacheKey: string,
owner: string,
): Promise<ShareAccountingSummary> {
try {
const summary = await this.loadSummary(filter);
await this.storeRedisSummaryCache(redisCacheKey, summary);
return summary;
} finally {
if (this.supportsRedisSummaryLock()) {
await this.redisMessagingService
.releaseJsonCacheLock(redisCacheKey, owner)
.catch(error => {
console.error(`Share accounting summary cache lock release failed: ${error.message}`);
});
}
}
}
private async storeRedisSummaryCache(
redisCacheKey: string,
summary: ShareAccountingSummary,
): Promise<void> {
const entry: RedisSummaryCacheEntry = {
schemaVersion: 1,
refreshedAtMs: Date.now(),
value: summary,
};
await this.redisMessagingService
?.setJsonCache(redisCacheKey, entry, this.redisSummaryStaleCacheMs)
.catch(error => {
console.error(`Share accounting summary cache write failed: ${error.message}`);
});
}
private async tryAcquireRedisSummaryLock(redisCacheKey: string, owner: string): Promise<boolean | null> {
if (!this.supportsRedisSummaryLock()) {
return null;
}
try {
return await this.redisMessagingService
.tryAcquireJsonCacheLock(redisCacheKey, owner, this.redisSummaryLockMs);
} catch (error) {
console.error(`Share accounting summary cache lock failed: ${error.message}`);
return null;
}
}
private supportsRedisSummaryLock(): boolean {
return typeof this.redisMessagingService?.tryAcquireJsonCacheLock === 'function'
&& typeof this.redisMessagingService?.releaseJsonCacheLock === 'function';
}
private isRedisSummaryCacheEntry(
cached: RedisSummaryCacheEntry | ShareAccountingSummary,
): cached is RedisSummaryCacheEntry {
const candidate = cached as Partial<RedisSummaryCacheEntry>;
return candidate.schemaVersion === 1
&& Number.isFinite(candidate.refreshedAtMs)
&& candidate.value != null;
}
private async loadSummary(filter: AccountingFilter): Promise<ShareAccountingSummary> {
if (this.isPoolSummaryFilter(filter)) {
try {
return await this.loadPoolSummary(filter.payoutMode);
} catch (error) {
if (error?.code !== '42P01') {
throw error;
}
}
}
const { whereSql, params } = this.buildWhereClause(filter);
const [summary] = await this.acceptedShareRepository.query(`
WITH clock AS MATERIALIZED (
@@ -657,6 +834,59 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
FROM filtered_rows
`, params);
return this.mapSummaryRow(summary);
}
private async loadPoolSummary(payoutMode?: PayoutMode): Promise<ShareAccountingSummary> {
const params = payoutMode == null ? [] : [payoutMode];
const whereSql = payoutMode == null
? 'WHERE "accepted_share_pool_10m"."bucket" <= bounds."latestCompletedBucket"'
: 'WHERE "accepted_share_pool_10m"."payoutMode" = $1 AND "accepted_share_pool_10m"."bucket" <= bounds."latestCompletedBucket"';
const [summary] = await this.acceptedShareRepository.query(`
WITH clock AS MATERIALIZED (
SELECT
time_bucket(INTERVAL '10 minutes', NOW()) AS "currentBucket"
),
latest_bucket AS MATERIALIZED (
SELECT "bucket"
FROM "accepted_share_pool_10m", clock
WHERE "bucket" < clock."currentBucket"
ORDER BY "bucket" DESC
LIMIT 1
),
bounds AS MATERIALIZED (
SELECT
"currentBucket",
COALESCE(
(SELECT "bucket" FROM latest_bucket),
"currentBucket" - INTERVAL '10 minutes'
) AS "latestCompletedBucket"
FROM clock
),
filtered_rows AS (
SELECT "accepted_share_pool_10m".*, bounds."currentBucket", bounds."latestCompletedBucket"
FROM "accepted_share_pool_10m", bounds
${whereSql}
)
SELECT
COALESCE(SUM("acceptedCount"), 0)::int AS "totalAcceptedShares",
COALESCE(SUM("shares"), 0)::float AS "totalCreditedDifficulty",
COALESCE(SUM("acceptedCount") FILTER (WHERE "bucket" = "latestCompletedBucket"), 0)::int AS "acceptedSharesLast10Minutes",
COALESCE(SUM("shares") FILTER (WHERE "bucket" = "latestCompletedBucket"), 0)::float AS "creditedDifficultyLast10Minutes",
COALESCE(SUM("acceptedCount") FILTER (WHERE "bucket" > "latestCompletedBucket" - INTERVAL '1 hour' AND "bucket" <= "latestCompletedBucket"), 0)::int AS "acceptedSharesLastHour",
COALESCE(SUM("shares") FILTER (WHERE "bucket" > "latestCompletedBucket" - INTERVAL '1 hour' AND "bucket" <= "latestCompletedBucket"), 0)::float AS "creditedDifficultyLastHour",
COALESCE(SUM("acceptedCount") FILTER (WHERE "bucket" > "latestCompletedBucket" - INTERVAL '1 day' AND "bucket" <= "latestCompletedBucket"), 0)::int AS "acceptedSharesLastDay",
COALESCE(SUM("shares") FILTER (WHERE "bucket" > "latestCompletedBucket" - INTERVAL '1 day' AND "bucket" <= "latestCompletedBucket"), 0)::float AS "creditedDifficultyLastDay",
COALESCE((SUM("shares") FILTER (WHERE "bucket" = "latestCompletedBucket") * ${HASHES_PER_DIFFICULTY}) / ${ROLLUP_BUCKET_SECONDS}, 0)::float AS "hashRateLast10Minutes",
COALESCE((SUM("shares") FILTER (WHERE "bucket" > "latestCompletedBucket" - INTERVAL '1 hour' AND "bucket" <= "latestCompletedBucket") * ${HASHES_PER_DIFFICULTY}) / 3600, 0)::float AS "hashRateLastHour",
MAX("bucket") AS "latestShareAt"
FROM filtered_rows
`, params);
return this.mapSummaryRow(summary);
}
private mapSummaryRow(summary: Record<string, unknown> | undefined): ShareAccountingSummary {
return {
totalAcceptedShares: this.toNumber(summary?.totalAcceptedShares),
totalCreditedDifficulty: this.toNumber(summary?.totalCreditedDifficulty),
@@ -677,11 +907,17 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
blockCandidateCount: 0,
latestShareAt: summary?.latestShareAt == null
? null
: new Date(summary.latestShareAt).toISOString(),
: new Date(summary.latestShareAt as string | Date).toISOString(),
protocolBreakdown: [],
};
}
private isPoolSummaryFilter(filter: AccountingFilter): boolean {
return filter.address == null
&& filter.clientName == null
&& filter.clientId == null;
}
private scheduleFlush(): void {
if (this.flushTimer != null || this.activeFlush != null) {
return;
@@ -804,6 +1040,13 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
}
private trimSummaryCache(): void {
const now = Date.now();
for (const [key, entry] of this.summaryCache) {
if (entry.expiresAt <= now) {
this.summaryCache.delete(key);
}
}
while (this.summaryCache.size > this.summaryCacheMax) {
const firstKey = this.summaryCache.keys().next().value;
if (firstKey == null) {
@@ -840,5 +1083,11 @@ interface PendingShare {
interface SummaryCacheEntry {
expiresAt: number;
value: Promise<ShareAccountingSummary>;
value: ShareAccountingSummary;
}
interface RedisSummaryCacheEntry {
schemaVersion: 1;
refreshedAtMs: number;
value: ShareAccountingSummary;
}
@@ -0,0 +1,67 @@
import { DataSource, EntityManager } from 'typeorm';
import { ShareHighScoreService } from './share-high-score.service';
describe('ShareHighScoreService', () => {
it('orders aggregate candidates numerically and retains all-time client/address highs', async () => {
const upserts: unknown[][] = [];
const manager = {
query: jest.fn(async (sql: string, params: unknown[] = []) => {
if (sql.includes('pg_try_advisory_xact_lock')) {
return [{ locked: true }];
}
if (sql.includes('WITH candidate_rows')) {
expect(sql).toContain('"bestSubmissionDifficulty"::numeric DESC');
return [];
}
if (sql.includes('FROM "accepted_share_block_10m"')) {
return [];
}
if (sql.includes('FROM "client_entity"')) {
const payoutMode = params[0] as string | undefined;
const difficultyByMode = {
solo: '10828324014691.838',
pplns: '2688488439606.743',
all: '10828324014691.838',
};
const mode = payoutMode ?? 'all';
return [{
submissionDifficulty: difficultyByMode[mode],
acceptedAt: new Date('2026-07-16T06:33:41.270Z'),
address: 'bc1qe4zjanpz5tg96a278ew3l2g0h9h0qddgcdvp43',
clientName: 'bitaxe',
protocol: 'bitaxe',
}];
}
if (sql.includes('FROM "address_settings_entity"')) {
return [{
submissionDifficulty: '294141974944674.8',
acceptedAt: new Date('2026-07-10T03:31:15.987Z'),
address: 'bc1q0pp74ghs25vpn2ah6auz4vkehvzy8z8ddyzts7',
protocol: 'bitaxe',
}];
}
if (sql.includes('INSERT INTO "accepted_share_high_score"')) {
upserts.push(params);
return [{ inserted: 1 }];
}
throw new Error(`Unexpected query: ${sql}`);
}),
} as unknown as EntityManager;
const dataSource = {
transaction: jest.fn((callback: (manager: EntityManager) => Promise<unknown>) => callback(manager)),
} as unknown as DataSource;
const service = new ShareHighScoreService(dataSource);
await expect(service.refreshHighScores()).resolves.toEqual({
processed: true,
updatedRows: 4,
});
expect(upserts).toEqual(expect.arrayContaining([
expect.arrayContaining(['all_time', 'all', '1970-01-01', '294141974944674.8']),
expect.arrayContaining(['all_time', 'solo', '1970-01-01', '10828324014691.838']),
expect.arrayContaining(['all_time', 'pplns', '1970-01-01', '2688488439606.743']),
]));
});
});
@@ -139,6 +139,11 @@ export class ShareHighScoreService implements OnModuleInit, OnModuleDestroy {
});
}
const allTimeRecords = await this.loadAllTimeRecords(manager, payoutMode);
for (const record of allTimeRecords) {
updatedRows += await this.upsertHighScore(manager, record);
}
return updatedRows;
}
@@ -167,10 +172,130 @@ export class ShareHighScoreService implements OnModuleInit, OnModuleDestroy {
"blockHeight"::text AS "blockHeight",
"bestSubmissionDifficulty"::text AS "bestSubmissionDifficulty"
FROM candidate_rows
ORDER BY "bucketDate", "bestSubmissionDifficulty" DESC, "bucket" DESC
ORDER BY "bucketDate", "bestSubmissionDifficulty"::numeric DESC, "bucket" DESC
`, params);
}
private async loadAllTimeRecords(manager: EntityManager, payoutMode: HighScorePayoutMode): Promise<HighScoreRecord[]> {
const records: HighScoreRecord[] = [];
const rollupCandidate = await this.loadRollupAllTimeCandidate(manager, payoutMode);
if (rollupCandidate != null) {
records.push(this.toRecord('all_time', payoutMode, ALL_TIME_BUCKET_DATE, rollupCandidate, null));
}
const clientRecord = await this.loadClientAllTimeRecord(manager, payoutMode);
if (clientRecord != null) {
records.push(clientRecord);
}
if (payoutMode === 'all') {
const addressRecord = await this.loadAddressSettingsAllTimeRecord(manager);
if (addressRecord != null) {
records.push(addressRecord);
}
}
return records;
}
private async loadRollupAllTimeCandidate(
manager: EntityManager,
payoutMode: HighScorePayoutMode,
): Promise<HighScoreCandidate | null> {
const params: unknown[] = [];
const modeFilter = payoutMode === 'all'
? ''
: `AND "payoutMode" = $${params.push(payoutMode)}`;
const rows = await manager.query(`
SELECT
date_trunc('day', "bucket")::date::text AS "bucketDate",
"bucket",
"blockHeight"::text AS "blockHeight",
"bestSubmissionDifficulty"::text AS "bestSubmissionDifficulty"
FROM "accepted_share_block_10m"
WHERE "bestSubmissionDifficulty" IS NOT NULL
AND "bestSubmissionDifficulty" > 0
${modeFilter}
ORDER BY "bestSubmissionDifficulty" DESC, "bucket" DESC
LIMIT 1
`, params);
return rows[0] ?? null;
}
private async loadClientAllTimeRecord(
manager: EntityManager,
payoutMode: HighScorePayoutMode,
): Promise<HighScoreRecord | null> {
const params: unknown[] = [];
const modeFilter = payoutMode === 'all'
? ''
: `AND "payoutMode" = $${params.push(payoutMode)}`;
const rows = await manager.query(`
SELECT
"bestDifficulty"::text AS "submissionDifficulty",
"updatedAt" AS "acceptedAt",
"address",
"clientName",
"userAgent" AS "protocol"
FROM "client_entity"
WHERE "bestDifficulty" IS NOT NULL
AND "bestDifficulty" > 0
${modeFilter}
ORDER BY "bestDifficulty" DESC, "updatedAt" DESC
LIMIT 1
`, params);
const row = rows[0];
if (row == null) {
return null;
}
return {
scope: 'all_time',
payoutMode,
bucketDate: ALL_TIME_BUCKET_DATE,
submissionDifficulty: row.submissionDifficulty,
acceptedAt: row.acceptedAt ?? null,
bucket: null,
blockHeight: null,
address: row.address ?? null,
clientName: row.clientName ?? null,
protocol: row.protocol ?? null,
};
}
private async loadAddressSettingsAllTimeRecord(manager: EntityManager): Promise<HighScoreRecord | null> {
const rows = await manager.query(`
SELECT
"bestDifficulty"::text AS "submissionDifficulty",
"updatedAt" AS "acceptedAt",
"address",
"bestDifficultyUserAgent" AS "protocol"
FROM "address_settings_entity"
WHERE "bestDifficulty" IS NOT NULL
AND "bestDifficulty" > 0
ORDER BY "bestDifficulty" DESC, "updatedAt" DESC
LIMIT 1
`);
const row = rows[0];
if (row == null) {
return null;
}
return {
scope: 'all_time',
payoutMode: 'all',
bucketDate: ALL_TIME_BUCKET_DATE,
submissionDifficulty: row.submissionDifficulty,
acceptedAt: row.acceptedAt ?? null,
bucket: null,
blockHeight: null,
address: row.address ?? null,
clientName: null,
protocol: row.protocol ?? null,
};
}
private async loadExactHighScore(
manager: EntityManager,
candidate: HighScoreCandidate,
+62
View File
@@ -0,0 +1,62 @@
import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AddressController } from './controllers/address/address.controller';
import { ClientController } from './controllers/client/client.controller';
import { UserAgentReportModule } from './ORM/_views/user-agent-report/user-agent-report.module';
import { AddressSettingsModule } from './ORM/address-settings/address-settings.module';
import { BlocksModule } from './ORM/blocks/blocks.module';
import { ClientStatisticsModule } from './ORM/client-statistics/client-statistics.module';
import { ClientModule } from './ORM/client/client.module';
import { PayoutSnapshotModule } from './ORM/payout-snapshot/payout-snapshot.module';
import { RpcBlocksModule } from './ORM/rpc-block/rpc-block.module';
import { ShareAccountingModule } from './ORM/share-accounting/share-accounting.module';
import { createDatabaseOptions } from './database.config';
import { BitcoinAddressValidator } from './models/validators/bitcoin-address.validator';
import { BitcoinRpcService } from './services/bitcoin-rpc.service';
import { RedisMessagingModule } from './services/redis-messaging.module';
import { Sv2AuthorityService } from './services/sv2-authority.service';
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => createDatabaseOptions({
...process.env,
DB_HOST: configService.get('DB_HOST'),
DB_PORT: configService.get('DB_PORT'),
DB_USERNAME: configService.get('DB_USERNAME'),
DB_PASSWORD: configService.get('DB_PASSWORD'),
DB_DATABASE: configService.get('DB_DATABASE'),
DB_LOGGING: configService.get('DB_LOGGING'),
DB_POOL_SIZE: configService.get('DB_POOL_SIZE'),
}),
imports: [ConfigModule],
inject: [ConfigService],
}),
CacheModule.register(),
RedisMessagingModule,
ClientStatisticsModule,
ClientModule,
AddressSettingsModule,
BlocksModule,
RpcBlocksModule,
UserAgentReportModule,
ShareAccountingModule,
PayoutSnapshotModule,
],
controllers: [
AppController,
ClientController,
AddressController,
],
providers: [
BitcoinRpcService,
BitcoinAddressValidator,
Sv2AuthorityService,
],
})
export class ApiModule {}
+2 -2
View File
@@ -6,7 +6,7 @@ describe('AppController', () => {
blocksService?: any;
addressSettingsService?: any;
userAgentReportService?: any;
stratumV2Service?: any;
sv2AuthorityService?: any;
redisMessagingService?: any;
} = {}) => {
return new AppController(
@@ -26,7 +26,7 @@ describe('AppController', () => {
overrides.userAgentReportService ?? {
getReport: jest.fn().mockResolvedValue([]),
},
overrides.stratumV2Service ?? {
overrides.sv2AuthorityService ?? {
getPoolAuthorityPublicKey: jest.fn().mockResolvedValue({
publicKey: '',
configured: false,
+3 -3
View File
@@ -10,9 +10,9 @@ import { ClientStatisticsService } from './ORM/client-statistics/client-statisti
import { ClientService } from './ORM/client/client.service';
import { BitcoinRpcService } from './services/bitcoin-rpc.service';
import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
import { StratumV2Service } from './services/stratum-v2.service';
import { ShareAccountingService } from './ORM/share-accounting/share-accounting.service';
import { RedisMessagingService } from './services/redis-messaging.service';
import { Sv2AuthorityService } from './services/sv2-authority.service';
import { normalizePayoutMode } from './types/payout-mode';
@Controller()
@@ -30,7 +30,7 @@ export class AppController {
private readonly bitcoinRpcService: BitcoinRpcService,
private readonly addressSettingsService: AddressSettingsService,
private readonly userAgentReportService: UserAgentReportService,
private readonly stratumV2Service: StratumV2Service,
private readonly sv2AuthorityService: Sv2AuthorityService,
private readonly shareAccountingService: ShareAccountingService,
private readonly redisMessagingService: RedisMessagingService
) { }
@@ -78,7 +78,7 @@ export class AppController {
const [blockData, highScores, poolAuthority, userAgentReport] = await Promise.all([
withInfoTimeout('found blocks', this.blocksService.getFoundBlocks(), staleInfo?.blockData ?? []),
withInfoTimeout('high scores', this.addressSettingsService.getHighScores(), staleInfo?.highScores ?? []),
withInfoTimeout('SV2 authority', this.stratumV2Service.getPoolAuthorityPublicKey(), {
withInfoTimeout('SV2 authority', this.sv2AuthorityService.getPoolAuthorityPublicKey(), {
publicKey: staleInfo?.sv2?.poolAuthorityPublicKey ?? '',
configured: staleInfo?.sv2?.authorityKeyConfigured ?? false
}),
+2
View File
@@ -33,6 +33,7 @@ import { StratumV1Service } from './services/stratum-v1.service';
import { Sv2JobDeclarationRegistryService } from './services/sv2-job-declaration-registry.service';
import { Sv2JobDeclarationService } from './services/sv2-job-declaration.service';
import { Sv2TemplateDistributionService } from './services/sv2-template-distribution.service';
import { Sv2AuthorityService } from './services/sv2-authority.service';
import { StratumV2Service } from './services/stratum-v2.service';
import { TelegramService } from './services/telegram.service';
import { TemplateProviderService } from './services/template-provider.service';
@@ -94,6 +95,7 @@ const ORMModules = [
Sv2JobDeclarationRegistryService,
Sv2JobDeclarationService,
Sv2TemplateDistributionService,
Sv2AuthorityService,
CustomWorkService,
DatumService,
BTCPayService,
@@ -105,6 +105,34 @@ describe('ClientController', () => {
}));
});
it('should read address best difficulty in API-only mode', async () => {
const originalApiOnly = process.env.API_ONLY;
process.env.API_ONLY = 'true';
try {
addressSettingsService.getSettings.mockResolvedValue({ bestDifficulty: 8192 });
shareAccountingService.getSessionSummaries.mockResolvedValue(new Map());
shareAccountingService.getAddressSummary.mockResolvedValue({
totalAcceptedShares: 10,
totalCreditedDifficulty: 100,
bestSubmissionDifficulty: 0,
});
await expect(controller.getClientInfo('bc1qtest')).resolves.toEqual(expect.objectContaining({
bestDifficulty: 8192,
accounting: expect.objectContaining({
bestSubmissionDifficulty: 8192,
}),
}));
expect(addressSettingsService.getSettings).toHaveBeenCalledWith('bc1qtest', false);
} finally {
if (originalApiOnly == null) {
delete process.env.API_ONLY;
} else {
process.env.API_ONLY = originalApiOnly;
}
}
});
it('should expose the latest current PPLNS expected payout for an address', async () => {
addressSettingsService.getSettings.mockResolvedValue(null);
shareAccountingService.getAddressSummary.mockResolvedValue({
@@ -145,6 +173,8 @@ describe('ClientController', () => {
});
it('should expose active database workers for an address', async () => {
const recent = new Date(Date.now() - 60 * 1000).toISOString();
const stale = new Date(Date.now() - 60 * 60 * 1000).toISOString();
clientService.getByAddress.mockResolvedValue([
{
id: 'active-client',
@@ -155,7 +185,7 @@ describe('ClientController', () => {
bestDifficulty: 64,
hashRate: 1024,
startTime: '2026-06-08T12:00:00.000Z',
updatedAt: '2026-06-08T12:10:00.000Z',
updatedAt: recent,
},
{
id: 'solo-client',
@@ -166,7 +196,41 @@ describe('ClientController', () => {
bestDifficulty: 128,
hashRate: 2048,
startTime: '2026-06-08T12:00:00.000Z',
updatedAt: '2026-06-08T12:10:00.000Z',
updatedAt: recent,
},
{
id: 'stale-client',
address: 'bc1qtest',
sessionId: 'stale1',
clientName: 'stale-worker',
payoutMode: 'pplns',
bestDifficulty: 256,
hashRate: 4096,
startTime: '2026-06-08T12:00:00.000Z',
updatedAt: stale,
},
{
id: 'idle-client',
address: 'bc1qtest',
sessionId: 'idle1',
clientName: 'idle-worker',
payoutMode: 'pplns',
bestDifficulty: 512,
hashRate: 0,
startTime: '2026-06-08T12:00:00.000Z',
updatedAt: recent,
},
{
id: 'deleted-client',
address: 'bc1qtest',
sessionId: 'deleted1',
clientName: 'deleted-worker',
payoutMode: 'pplns',
bestDifficulty: 1024,
hashRate: 8192,
startTime: '2026-06-08T12:00:00.000Z',
updatedAt: recent,
deletedAt: recent,
},
]);
addressSettingsService.getSettings.mockResolvedValue(null);
+18 -3
View File
@@ -7,9 +7,11 @@ import { PayoutSnapshotService } from '../../ORM/payout-snapshot/payout-snapshot
import { ShareAccountingService } from '../../ORM/share-accounting/share-accounting.service';
import { normalizePayoutMode, PayoutMode } from '../../types/payout-mode';
const DEFAULT_CLIENT_ACTIVE_WINDOW_MS = 30 * 60 * 1000;
@Controller('client')
export class ClientController {
private readonly activeWindowMs = this.readPositiveInt('CLIENT_REPORT_ACTIVE_WINDOW_MS', DEFAULT_CLIENT_ACTIVE_WINDOW_MS);
constructor(
private readonly clientService: ClientService,
@@ -26,9 +28,7 @@ export class ClientController {
const workers = await this.getActiveAddressWorkers(address, mode);
const sessionSummaries = await this.shareAccountingService.getSessionSummaries(workers.map(worker => worker.clientId));
const addressSettings = process.env.API_ONLY === 'true'
? null
: await this.addressSettingsService.getSettings(address, false);
const addressSettings = await this.addressSettingsService.getSettings(address, false);
const bestDifficulty = addressSettings?.bestDifficulty ?? workers.reduce((best, worker) => {
return Math.max(best, Number(worker.bestDifficulty ?? 0));
}, 0);
@@ -178,8 +178,15 @@ export class ClientController {
private async getActiveAddressWorkers(address: string, payoutMode?: PayoutMode) {
const workers = await this.clientService.getByAddress(address);
const activeSince = Date.now() - this.activeWindowMs;
return workers
.filter(worker => payoutMode == null || worker.payoutMode === payoutMode)
.filter(worker => worker.deletedAt == null)
.filter(worker => Number(worker.hashRate ?? 0) > 0)
.filter(worker => {
const updatedAt = worker.updatedAt == null ? 0 : new Date(worker.updatedAt).getTime();
return Number.isFinite(updatedAt) && updatedAt > activeSince;
})
.map(worker => ({
clientId: worker.id,
address: worker.address,
@@ -193,4 +200,12 @@ export class ClientController {
bestDifficulty: Number(worker.bestDifficulty ?? 0),
}));
}
private readPositiveInt(name: string, defaultValue: number): number {
const value = Number(process.env[name]);
if (Number.isInteger(value) && value > 0) {
return value;
}
return defaultValue;
}
}
+10
View File
@@ -17,6 +17,11 @@ import { BlocksSubmissionMetadata1781220000000 } from './ORM/_migrations/BlocksS
import { PayoutModes1781300000000 } from './ORM/_migrations/PayoutModes1781300000000';
import { ShareRollupStoragePolicy1781305000000 } from './ORM/_migrations/ShareRollupStoragePolicy1781305000000';
import { AcceptedShareHighScores1781309000000 } from './ORM/_migrations/AcceptedShareHighScores1781309000000';
import { UserAgentReportNonzeroHashrate1781313000000 } from './ORM/_migrations/UserAgentReportNonzeroHashrate1781313000000';
import { PoolSummaryContinuousAggregate1781400000000 } from './ORM/_migrations/PoolSummaryContinuousAggregate1781400000000';
import { PoolSummaryRefreshWindow1781401000000 } from './ORM/_migrations/PoolSummaryRefreshWindow1781401000000';
import { AcceptedShareHighScoreNumericRetention1781402000000 } from './ORM/_migrations/AcceptedShareHighScoreNumericRetention1781402000000';
import { AcceptedShare10mLookupIndexes1781403000000 } from './ORM/_migrations/AcceptedShare10mLookupIndexes1781403000000';
import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
import { AcceptedShareEntity } from './ORM/accepted-share/accepted-share.entity';
import { AddressSettingsEntity } from './ORM/address-settings/address-settings.entity';
@@ -60,6 +65,11 @@ export const databaseMigrations = [
PayoutModes1781300000000,
ShareRollupStoragePolicy1781305000000,
AcceptedShareHighScores1781309000000,
UserAgentReportNonzeroHashrate1781313000000,
PoolSummaryContinuousAggregate1781400000000,
PoolSummaryRefreshWindow1781401000000,
AcceptedShareHighScoreNumericRetention1781402000000,
AcceptedShare10mLookupIndexes1781403000000,
];
export function createDatabaseOptions(env: NodeJS.ProcessEnv): TypeOrmModuleOptions & DataSourceOptions {
+77
View File
@@ -0,0 +1,77 @@
/* eslint-disable @typescript-eslint/no-var-requires */
describe('PM2 worker sizing', () => {
const originalApiWorkers = process.env.API_WORKERS;
const originalStratumWorkers = process.env.STRATUM_WORKERS;
afterEach(() => {
restoreEnv('API_WORKERS', originalApiWorkers);
restoreEnv('STRATUM_WORKERS', originalStratumWorkers);
jest.resetModules();
jest.unmock('os');
});
it('assigns remaining CPUs to Stratum workers in auto mode', () => {
process.env.API_WORKERS = '4';
process.env.STRATUM_WORKERS = 'auto';
jest.doMock('os', () => ({
availableParallelism: () => 14,
cpus: () => Array.from({ length: 14 }),
}));
const config = require('../ecosystem.config.js');
expect(config.apps.find((app) => app.name === 'api').instances).toBe(4);
expect(config.apps.find((app) => app.name === 'workers').instances).toBe(9);
});
it('preserves an explicit Stratum worker count', () => {
process.env.API_WORKERS = '2';
process.env.STRATUM_WORKERS = '7';
const config = require('../ecosystem.config.js');
expect(config.apps.find((app) => app.name === 'workers').instances).toBe(7);
});
it('runs the master through the isolated notifier entrypoint', () => {
const config = require('../ecosystem.config.js');
expect(config.apps.find((app) => app.name === 'master')).toEqual(
expect.objectContaining({
script: './dist/notifier-main.js',
instances: 1,
exec_mode: 'fork',
env: expect.objectContaining({ MASTER: 'true', API_ENABLED: 'false' }),
}),
);
});
it('runs maintenance duties outside the isolated notifier process', () => {
const config = require('../ecosystem.config.js');
expect(config.apps.find((app) => app.name === 'maintenance')).toEqual(
expect.objectContaining({
script: './dist/maintenance-main.js',
instances: 1,
exec_mode: 'fork',
env: expect.objectContaining({ MASTER: 'true', API_ENABLED: 'false' }),
}),
);
});
it('rejects invalid fixed worker counts instead of silently starting no workers', () => {
process.env.STRATUM_WORKERS = 'many';
expect(() => require('../ecosystem.config.js')).toThrow(
'STRATUM_WORKERS must be a positive integer',
);
});
});
function restoreEnv(key: string, value: string | undefined) {
if (value == null) {
delete process.env[key];
return;
}
process.env[key] = value;
}
+35 -10
View File
@@ -7,8 +7,21 @@ import { readFileSync, watch } from 'fs';
import * as path from 'path';
import * as ecc from 'tiny-secp256k1';
import { ApiModule } from './api.module';
import { AppModule } from './app.module';
const DEFAULT_API_CONNECTION_TIMEOUT_MS = 15_000;
const DEFAULT_API_KEEP_ALIVE_TIMEOUT_MS = 5_000;
const DEFAULT_API_REQUEST_TIMEOUT_MS = 15_000;
const DEFAULT_API_HEADERS_TIMEOUT_MS = 10_000;
const DEFAULT_API_MAX_REQUESTS_PER_SOCKET = 100;
const DEFAULT_API_MAX_CONNECTIONS_PER_WORKER = 500;
function readPositiveInt(name: string, fallback: number): number {
const value = Number(process.env[name]);
return Number.isInteger(value) && value > 0 ? value : fallback;
}
async function bootstrap() {
if (process.env.API_PORT == null) {
console.error('It appears your environment is not configured, create and populate an .env file.');
@@ -22,17 +35,23 @@ async function bootstrap() {
const keyPath = path.join(currentDirectory, 'secrets', 'key.pem');
const certPath = path.join(currentDirectory, 'secrets', 'cert.pem');
let options: any = {};
let options: any = serveApi
? {
connectionTimeout: readPositiveInt('API_CONNECTION_TIMEOUT_MS', DEFAULT_API_CONNECTION_TIMEOUT_MS),
keepAliveTimeout: readPositiveInt('API_KEEP_ALIVE_TIMEOUT_MS', DEFAULT_API_KEEP_ALIVE_TIMEOUT_MS),
requestTimeout: readPositiveInt('API_REQUEST_TIMEOUT_MS', DEFAULT_API_REQUEST_TIMEOUT_MS),
maxRequestsPerSocket: readPositiveInt('API_MAX_REQUESTS_PER_SOCKET', DEFAULT_API_MAX_REQUESTS_PER_SOCKET),
}
: {};
if (secure) {
options = {
https: {
key: readFileSync(keyPath),
cert: readFileSync(certPath),
}
options.https = {
key: readFileSync(keyPath),
cert: readFileSync(certPath),
};
}
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(options));
const appModule = process.env.API_ONLY === 'true' ? ApiModule : AppModule;
const app = await NestFactory.create<NestFastifyApplication>(appModule, new FastifyAdapter(options));
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
@@ -54,7 +73,7 @@ async function bootstrap() {
});
app.enableCors();
useContainer(app.select(AppModule), { fallbackOnErrors: true });
useContainer(app.select(appModule), { fallbackOnErrors: true });
// Taproot
bitcoinjs.initEccLib(ecc);
@@ -73,11 +92,17 @@ async function bootstrap() {
console.log(`API listening on ${address}`);
});
const server: any = app.getHttpServer();
server.headersTimeout = readPositiveInt('API_HEADERS_TIMEOUT_MS', DEFAULT_API_HEADERS_TIMEOUT_MS);
server.maxConnections = readPositiveInt(
'API_MAX_CONNECTIONS_PER_WORKER',
DEFAULT_API_MAX_CONNECTIONS_PER_WORKER,
);
server.dropMaxConnection = true;
// --- Live-reload TLS certs/keys when they change on disk ---
if (secure) {
// Fastify's underlying Node https server
const server: any = app.getHttpServer();
// Guard: only HTTPS servers expose setSecureContext
if (typeof server?.setSecureContext === 'function') {
let reloadTimer: NodeJS.Timeout | null = null;
+16
View File
@@ -0,0 +1,16 @@
import { NestFactory } from '@nestjs/core';
import { MaintenanceModule } from './maintenance.module';
async function bootstrap(): Promise<void> {
process.env.MASTER = 'true';
process.env.API_ENABLED = 'false';
const application = await NestFactory.createApplicationContext(
MaintenanceModule,
);
application.enableShutdownHooks();
console.log('Maintenance services started');
}
void bootstrap();
+53
View File
@@ -0,0 +1,53 @@
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserAgentReportModule } from './ORM/_views/user-agent-report/user-agent-report.module';
import { ClientModule } from './ORM/client/client.module';
import { RpcBlocksModule } from './ORM/rpc-block/rpc-block.module';
import { ShareAccountingModule } from './ORM/share-accounting/share-accounting.module';
import { TelegramSubscriptionsModule } from './ORM/telegram-subscriptions/telegram-subscriptions.module';
import { createDatabaseOptions } from './database.config';
import { AppService } from './services/app.service';
import { DiscordService } from './services/discord.service';
import { NotificationService } from './services/notification.service';
import { RedisMessagingModule } from './services/redis-messaging.module';
import { TelegramService } from './services/telegram.service';
/**
* Background process for non-hot-path master duties. Keep this separate from
* NotifierModule so chat integrations, reporting, and cleanup timers cannot
* delay block-template notification.
*/
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => createDatabaseOptions({
...process.env,
DB_HOST: configService.get('DB_HOST'),
DB_PORT: configService.get('DB_PORT'),
DB_USERNAME: configService.get('DB_USERNAME'),
DB_PASSWORD: configService.get('DB_PASSWORD'),
DB_DATABASE: configService.get('DB_DATABASE'),
DB_LOGGING: configService.get('DB_LOGGING'),
DB_POOL_SIZE: configService.get('DB_POOL_SIZE'),
}),
}),
RedisMessagingModule,
ClientModule,
RpcBlocksModule,
UserAgentReportModule,
ShareAccountingModule,
TelegramSubscriptionsModule,
],
providers: [
AppService,
DiscordService,
NotificationService,
TelegramService,
],
})
export class MaintenanceModule { }
+181
View File
@@ -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);
});
});
+304 -75
View File
@@ -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,48 @@ export interface AddressObject {
percent?: number;
amountSats?: number;
}
export interface MiningJobOwnership {
payoutMode: PayoutMode;
payoutIdentity: string;
}
export interface MiningNotifyHeaderFields {
previousBlockHash: string;
version: string;
bits: string;
timestamp: string;
cleanJobs: boolean;
}
interface PreStagedNotifyLayout {
fields: MiningNotifyHeaderFields;
offsets: {
previousBlockHash: number;
version: number;
bits: number;
timestamp: number;
};
}
export class MiningJob {
private static readonly paymentScriptCache = new Map<string, Buffer>();
private static readonly paymentScriptCacheMaxEntries = 250_000;
private static readonly placeholderPrevHash = Buffer.alloc(32, 0);
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;
private preStagedNotifyLayout: PreStagedNotifyLayout;
public jobTemplateId: string;
public tipKey: string;
public networkDifficulty: number;
public creation: number;
@@ -30,11 +63,14 @@ 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.networkDifficulty = jobTemplate.blockData.networkDifficulty;
this.merkleBranchBuffers = jobTemplate.merkle_branch.map(branch => Buffer.from(branch, 'hex'));
this.coinbaseTransaction = this.createCoinbaseTransaction(payoutInformation, jobTemplate.blockData.coinbasevalue);
@@ -49,18 +85,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 +125,115 @@ export class MiningJob {
return Buffer.from(this.coinbasePart2Buffer);
}
public buildHeaderBuffer(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): Buffer {
/**
* Promote a coinbase that was built during the prior height to the
* authoritative empty template. The coinbase bytes are height/payout bound;
* header fields and submission ownership are replaced only at activation.
*/
public activatePreStagedTemplate(
jobTemplate: IJobTemplate,
headerFields = MiningJob.createNotifyHeaderFields(jobTemplate),
): void {
this.jobTemplateId = jobTemplate.blockData.id;
this.tipKey = jobTemplate.blockData.tipKey;
this.networkDifficulty = jobTemplate.blockData.networkDifficulty;
this.creation = Date.now();
if (!this.patchPreStagedNotify(headerFields)) {
this.miningNotifyResponseBuffer = null;
}
this.miningNotifyResponse = null;
this.preStagedNotifyLayout = null;
}
/**
* Serialize the miner-specific notify while the prior height is active.
* Activation can then overwrite the fixed-width header fields in place,
* avoiding JSON serialization and Buffer allocation in the new-tip loop.
*/
public preparePreStagedNotify(
jobTemplate: IJobTemplate,
headerFields = MiningJob.createNotifyHeaderFields(jobTemplate),
): void {
if (!jobTemplate.block.prevHash.equals(MiningJob.placeholderPrevHash)) {
throw new Error('Pre-staged notify requires a placeholder prevhash');
}
this.responseBuffer(jobTemplate, headerFields);
const response = this.miningNotifyResponse;
let cursor = response.indexOf(`"${this.jobId}"`);
const locate = (value: string): number => {
const offset = response.indexOf(`"${value}"`, cursor);
if (offset < 0) {
throw new Error('Unable to locate pre-staged notify field');
}
cursor = offset + value.length + 2;
return offset + 1;
};
this.preStagedNotifyLayout = {
fields: { ...headerFields },
offsets: {
previousBlockHash: locate(headerFields.previousBlockHash),
version: locate(headerFields.version),
bits: locate(headerFields.bits),
timestamp: locate(headerFields.timestamp),
},
};
}
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 +243,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 +284,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 +296,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,53 +328,150 @@ export class MiningJob {
return coinbaseTransaction;
}
private getPaymentScript(address: string): Buffer {
const addressInfo = getAddressInfo(address);
switch (addressInfo.type) {
case AddressType.p2wpkh: {
return bitcoinjs.payments.p2wpkh({ address, network: this.network }).output;
}
case AddressType.p2pkh: {
return bitcoinjs.payments.p2pkh({ address, network: this.network }).output;
}
case AddressType.p2sh: {
return bitcoinjs.payments.p2sh({ address, network: this.network }).output;
}
case AddressType.p2tr: {
return bitcoinjs.payments.p2tr({ address, network: this.network }).output;
}
case AddressType.p2wsh: {
return bitcoinjs.payments.p2wsh({ address, network: this.network }).output;
}
default: {
return Buffer.alloc(0);
}
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]);
}
public response(jobTemplate: IJobTemplate): string {
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: {
paymentScript = bitcoinjs.payments.p2wpkh({ address, network: this.network }).output;
break;
}
case AddressType.p2pkh: {
paymentScript = bitcoinjs.payments.p2pkh({ address, network: this.network }).output;
break;
}
case AddressType.p2sh: {
paymentScript = bitcoinjs.payments.p2sh({ address, network: this.network }).output;
break;
}
case AddressType.p2tr: {
paymentScript = bitcoinjs.payments.p2tr({ address, network: this.network }).output;
break;
}
case AddressType.p2wsh: {
paymentScript = bitcoinjs.payments.p2wsh({ address, network: this.network }).output;
break;
}
default: {
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,
headerFields = MiningJob.createNotifyHeaderFields(jobTemplate),
): string {
if (this.miningNotifyResponse != null) {
return this.miningNotifyResponse;
}
if (this.miningNotifyResponseBuffer != null) {
this.miningNotifyResponse = this.miningNotifyResponseBuffer.toString();
return this.miningNotifyResponse;
}
const job: IMiningNotify = {
id: null,
method: eResponseMethod.MINING_NOTIFY,
params: [
this.jobId,
this.swapEndianWords(jobTemplate.block.prevHash).toString('hex'),
headerFields.previousBlockHash,
this.coinbasePart1,
this.coinbasePart2,
jobTemplate.merkle_branch,
jobTemplate.block.version.toString(16),
jobTemplate.block.bits.toString(16),
jobTemplate.block.timestamp.toString(16),
jobTemplate.blockData.clearJobs
headerFields.version,
headerFields.bits,
headerFields.timestamp,
headerFields.cleanJobs
]
};
return JSON.stringify(job) + '\n';
this.miningNotifyResponse = JSON.stringify(job) + '\n';
this.miningNotifyResponseBuffer = Buffer.from(this.miningNotifyResponse);
return this.miningNotifyResponse;
}
public responseBuffer(
jobTemplate: IJobTemplate,
headerFields = MiningJob.createNotifyHeaderFields(jobTemplate),
): Buffer {
if (this.miningNotifyResponseBuffer == null) {
this.response(jobTemplate, headerFields);
}
return this.miningNotifyResponseBuffer;
}
public static createNotifyHeaderFields(jobTemplate: IJobTemplate): MiningNotifyHeaderFields {
return {
previousBlockHash: MiningJob.swapEndianWords(jobTemplate.block.prevHash).toString('hex'),
version: jobTemplate.block.version.toString(16),
bits: jobTemplate.block.bits.toString(16),
timestamp: jobTemplate.block.timestamp.toString(16),
cleanJobs: jobTemplate.blockData.clearJobs,
};
}
private patchPreStagedNotify(headerFields: MiningNotifyHeaderFields): boolean {
const layout = this.preStagedNotifyLayout;
const response = this.miningNotifyResponseBuffer;
if (layout == null
|| response == null
|| layout.fields.cleanJobs !== headerFields.cleanJobs) {
return false;
}
const fields = [
['previousBlockHash', headerFields.previousBlockHash],
['version', headerFields.version],
['bits', headerFields.bits],
['timestamp', headerFields.timestamp],
] as const;
if (fields.some(([name, value]) =>
value.length !== layout.fields[name].length)) {
return false;
}
for (const [name, value] of fields) {
response.write(value, layout.offsets[name], value.length, 'ascii');
}
return true;
}
private swapEndianWords(buffer: Buffer): Buffer {
private static swapEndianWords(buffer: Buffer): Buffer {
const swappedBuffer = Buffer.alloc(buffer.length);
for (let i = 0; i < buffer.length; i += 4) {
+611 -31
View File
@@ -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;
@@ -167,8 +169,60 @@ describe('StratumV1Client', () => {
expect(socket.on).toHaveBeenCalled();
});
it('disconnects when an unterminated inbound message exceeds the configured limit', async () => {
(configService.get as jest.Mock).mockImplementation((key: string) => {
if (key === 'STRATUM_MAX_INBOUND_LINE_BYTES') return '16';
if (key === 'NETWORK') return 'testnet';
return null;
});
client = new StratumV1Client(
socket,
stratumV1JobsService,
bitcoinRpcService,
clientService,
notificationService,
blocksService,
configService,
addressSettings,
shareAccountingService as any,
redisMessagingService as any,
);
socketEmitter(Buffer.from('x'.repeat(17)));
await Promise.resolve();
expect(socket.destroy).toHaveBeenCalled();
expect((client as any).buffer).toBe('');
});
it('accepts multiple complete messages when each line is within the inbound limit', async () => {
(configService.get as jest.Mock).mockImplementation((key: string) => {
if (key === 'STRATUM_MAX_INBOUND_LINE_BYTES') return '64';
if (key === 'NETWORK') return 'testnet';
return null;
});
client = new StratumV1Client(
socket,
stratumV1JobsService,
bitcoinRpcService,
clientService,
notificationService,
blocksService,
configService,
addressSettings,
shareAccountingService as any,
redisMessagingService as any,
);
jest.spyOn(client as any, 'handleMessage').mockResolvedValue(undefined);
socketEmitter(Buffer.from('{"id":1}\n{"id":2}\n'));
await Promise.resolve();
expect((client as any).handleMessage).toHaveBeenCalledTimes(2);
expect(socket.destroy).not.toHaveBeenCalled();
});
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 +230,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 +273,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) {
@@ -317,24 +462,47 @@ describe('StratumV1Client', () => {
expect(socket.write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[512]}\n`, expect.any(Function));
});
it('should clamp suggested difficulty to the configured minimum', async () => {
(configService.get as jest.Mock).mockImplementation((key: string) => {
switch (key) {
case 'STRATUM_MIN_DIFFICULTY':
return '1';
case 'DEV_FEE_ADDRESS':
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
case 'NETWORK':
return 'testnet';
}
return null;
});
it('should reject suggested difficulty below the protocol minimum', async () => {
jest.spyOn(socket, 'write').mockImplementation((data) => true);
emitMessage(`{"id":4,"method":"mining.suggest_difficulty","params":[0]}`);
await new Promise((r) => setTimeout(r, 1));
expect(socket.write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[1]}\n`, expect.any(Function));
expect(socket.write).toHaveBeenCalledWith(
expect.stringContaining('Suggest difficulty validation error'),
expect.any(Function),
);
expect((client as any).sessionDifficulty).toBe(100000);
});
it.each([12884901888, 1e303])(
'should reject excessive suggested difficulty %s',
async (suggestedDifficulty) => {
jest.spyOn(socket, 'write').mockImplementation((data) => true);
emitMessage(`{"id":4,"method":"mining.suggest_difficulty","params":[${suggestedDifficulty}]}`);
await new Promise((r) => setTimeout(r, 1));
expect(socket.write).toHaveBeenCalledWith(
expect.stringContaining('Suggest difficulty validation error'),
expect.any(Function),
);
expect((client as any).sessionDifficulty).toBe(100000);
},
);
it('should reject excessive password-provided starting difficulty', async () => {
jest.spyOn(socket, 'write').mockImplementation((data) => true);
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker","d=12884901888"]}`);
await new Promise((r) => setTimeout(r, 1));
expect(socket.write).toHaveBeenCalledWith(
expect.stringContaining('Authorization validation error'),
expect.any(Function),
);
expect((client as any).sessionDifficulty).toBe(100000);
});
it('should set difficulty', async () => {
@@ -348,11 +516,49 @@ 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));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
@@ -376,10 +582,11 @@ 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);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
@@ -389,7 +596,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);
@@ -405,7 +612,7 @@ describe('StratumV1Client', () => {
sessionId: MockRecording1.EXTRA_NONCE,
jobId: '1',
jobTemplateId: '1',
creditedDifficulty: 0,
creditedDifficulty: 1e-9,
isBlockCandidate: false,
}));
});
@@ -416,7 +623,7 @@ describe('StratumV1Client', () => {
const fullBlockSpy = jest.spyOn(MiningJob.prototype, 'copyAndUpdateBlock');
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
@@ -432,7 +639,7 @@ describe('StratumV1Client', () => {
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(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
@@ -466,7 +673,7 @@ describe('StratumV1Client', () => {
const clientUpdateIfHigherSpy = jest.spyOn(clientService as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 });
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
@@ -504,11 +711,43 @@ 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));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
@@ -520,12 +759,271 @@ 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": [1e-9]}`);
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": [1e-9]}`);
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": [1e-9]}`);
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": [1e-9]}`);
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": [1e-9]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).isDuplicateSubmission('old-tip-share')).toBe(false);
expect((client as any).rememberSubmission('old-tip-share', '1')).toBe(true);
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 preserve live accepted-share dedup entries when capacity is reached', () => {
const getSubmissionContext = jest.spyOn(stratumV1JobsService, 'getSubmissionContext')
.mockReturnValue({} as any);
(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).rememberSubmission('one', '1')).toBe(true);
expect((client as any).isDuplicateSubmission('two')).toBe(false);
expect((client as any).rememberSubmission('two', '1')).toBe(true);
expect((client as any).rememberSubmission('three', '1')).toBe(false);
expect([...(client as any).miningSubmissionHashes.keys()]).toEqual(['one', 'two']);
jest.advanceTimersByTime(1001);
expect((client as any).isDuplicateSubmission('two')).toBe(true);
getSubmissionContext.mockReturnValue(null);
expect((client as any).isDuplicateSubmission('two')).toBe(false);
expect((client as any).rememberSubmission('three', '1')).toBe(true);
expect([...(client as any).miningSubmissionHashes.keys()]).toEqual(['three']);
});
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": [1e-9]}`);
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": [1e-9]}`);
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": [1e-9]}`);
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');
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
@@ -542,7 +1040,7 @@ describe('StratumV1Client', () => {
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(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
stratumV1JobsService.blocks = {};
@@ -550,7 +1048,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 () => {
@@ -567,6 +1065,88 @@ describe('StratumV1Client', () => {
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[23,"Difficulty too low",""]}\n`);
expect(await clientService.connectedClientCount()).toBe(1);
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
expect((client as any).miningSubmissionHashes.size).toBe(0);
});
it.each([
{ label: 'before the advertised job time', offsetSeconds: -1 },
{ label: 'more than two hours in the future', offsetSeconds: (2 * 60 * 60) + 1 },
])('rejects ntime $label before hashing or accounting', async ({ offsetSeconds }) => {
jest.spyOn(client as any, 'write').mockResolvedValue(true);
const calculateDifficultySpy = jest.spyOn(client as any, 'calculateDifficulty');
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((resolve) => setTimeout(resolve, 100));
const submission = JSON.parse(MockRecording1.MINING_SUBMIT);
const baseTime = parseInt(MockRecording1.TIME, 16);
submission.params[3] = (baseTime + offsetSeconds).toString(16).padStart(8, '0');
emitMessage(JSON.stringify(submission));
await new Promise((resolve) => setTimeout(resolve, 100));
expect((client as any).write).lastCalledWith(
`{"id":5,"result":null,"error":[20,"Invalid ntime",""]}\n`,
);
expect(calculateDifficultySpy).not.toHaveBeenCalled();
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": [1e-9]}`);
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 () => {
@@ -644,7 +1224,7 @@ describe('StratumV1Client', () => {
jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined);
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
+502 -152
View File
@@ -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,24 @@ 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 DEFAULT_MAX_INBOUND_LINE_BYTES = 64 * 1024;
const MAX_NTIME_FUTURE_SECONDS = 2 * 60 * 60;
const VERSION_ROLLING_MASK = 0x1fffe000;
export interface MiningJobBroadcastResult {
status: 'written' | 'backpressured' | 'skipped' | 'closed' | 'error';
bytes: number;
bufferedBytes: number;
preStaged?: boolean;
}
interface SubmissionDedupEntry {
expiresAt: number;
jobId: string;
}
export class StratumV1Client {
private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>();
@@ -47,7 +70,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 +91,15 @@ export class StratumV1Client {
private buffer: string = '';
private connectionClosed = false;
private lastSentMiningJobTimestamp: number = null;
private lastSentMiningJobSignature: string = null;
private lastSentMiningTipKey: string = null;
private lastSentMiningJobType: 'full' | 'empty' | null = null;
private lastHashRatePersistedAt = 0;
private readonly network: bitcoinjs.Network;
private readonly maxSocketBufferBytes: number;
private readonly maxInboundLineBytes: number;
private miningSubmissionHashes = new Set<string>()
private miningSubmissionHashes = new Map<string, SubmissionDedupEntry>();
constructor(
public readonly socket: Socket,
@@ -93,6 +121,9 @@ export class StratumV1Client {
void this.handleSocketData(data);
};
this.socket.on('data', this.socketDataHandler);
this.network = this.getNetwork();
this.maxSocketBufferBytes = this.readMaxSocketBufferBytes();
this.maxInboundLineBytes = this.readMaxInboundLineBytes();
}
@@ -111,11 +142,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);
}
@@ -133,9 +159,15 @@ export class StratumV1Client {
return;
}
this.buffer += data.toString();
const lines = this.buffer.split('\n');
this.buffer = lines.pop() || ''; // Save the last part of the data (incomplete line) to the buffer
const lines = `${this.buffer}${data.toString()}`.split('\n');
const incompleteLine = lines.pop() || '';
if (Buffer.byteLength(incompleteLine) > this.maxInboundLineBytes
|| lines.some(line => Buffer.byteLength(line) > this.maxInboundLineBytes)) {
this.buffer = '';
this.closeSocket();
return;
}
this.buffer = incompleteLine;
for (const m of lines.filter(l => l.length > 0)) {
if (this.connectionClosed || this.socket.destroyed || this.socket.writableEnded) {
@@ -405,7 +437,6 @@ export class StratumV1Client {
}
private async initStratum() {
this.stratumInitialized = true;
this.socket.setTimeout(0);
if (this.isBlockedUserAgent(this.clientSubscription.userAgent)) {
@@ -423,21 +454,21 @@ 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);
const latestPrestage = this.stratumV1JobsService.getLatestPrestageJobTemplate(this.payoutMode);
if (latestPrestage != null) {
this.preStageMiningJob(latestPrestage);
}
this.backgroundWork.push(
setInterval(async () => {
await this.checkDifficulty();
setInterval(() => {
void this.checkDifficulty().catch((error) => {
console.error('Stratum difficulty check failed; closing client connection', error);
this.closeSocket();
});
}, 60 * 1000)
);
@@ -448,10 +479,71 @@ 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 preStageMiningJob(jobTemplate: IJobTemplate): boolean {
if (!this.isReadyForMiningJobs()
|| jobTemplate.blockData.jobType !== 'empty'
|| jobTemplate.blockData.payoutMode !== this.payoutMode) {
return false;
}
const payoutInformation = this.getPayoutInformation(
jobTemplate,
this.clientAuthorization.address,
);
if (payoutInformation == null) {
return false;
}
const payoutIdentity = this.getPayoutIdentity(jobTemplate);
return this.stratumV1JobsService.preStageJob(
this.network,
payoutInformation,
jobTemplate,
payoutIdentity,
this.payoutMode,
) != null;
}
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 };
}
if (!force
&& jobTemplate.blockData.jobType === 'empty'
&& this.lastSentMiningJobType === 'empty'
&& this.lastSentMiningTipKey === jobTemplate.blockData.tipKey) {
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 +566,69 @@ 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(),
payoutInformation,
jobTemplate
const payoutIdentity = this.getPayoutIdentity(jobTemplate);
const preStagedJob = (
jobTemplate.blockData.jobType === 'empty'
? this.stratumV1JobsService.activatePreStagedJob(
jobTemplate,
payoutIdentity,
this.payoutMode,
)
: null
);
this.stratumV1JobsService.addJob(job);
const success = await this.write(job.response(jobTemplate));
if (!success) {
return;
const job = preStagedJob ?? this.stratumV1JobsService.getOrCreateJob(
this.network,
payoutInformation,
jobTemplate,
payoutIdentity,
this.payoutMode,
);
const payload = job.responseBuffer(
jobTemplate,
this.stratumV1JobsService.getNotifyHeaderFields?.(jobTemplate),
);
const bufferedBeforeWrite = this.socket.writableLength ?? 0;
if (bufferedBeforeWrite >= maximumBufferedBytes
|| payload.length >= maximumBufferedBytes - bufferedBeforeWrite) {
this.closeSocket();
return {
status: 'closed',
bytes: 0,
bufferedBytes: bufferedBeforeWrite,
...(preStagedJob == null ? {} : { preStaged: true }),
};
}
try {
const accepted = this.socket.write(payload);
this.lastSentMiningJobTimestamp = jobTemplate.block.timestamp;
this.lastSentMiningJobSignature = signature;
this.lastSentMiningTipKey = jobTemplate.blockData.tipKey;
this.lastSentMiningJobType = jobTemplate.blockData.jobType;
const bufferedAfterWrite = this.socket.writableLength ?? 0;
if (bufferedAfterWrite >= maximumBufferedBytes) {
this.closeSocket();
return {
status: 'closed',
bytes: payload.length,
bufferedBytes: bufferedAfterWrite,
...(preStagedJob == null ? {} : { preStaged: true }),
};
}
return {
status: accepted ? 'written' : 'backpressured',
bytes: payload.length,
bufferedBytes: bufferedAfterWrite,
...(preStagedJob == null ? {} : { preStaged: true }),
};
} catch (error) {
this.closeSocket();
return {
status: 'error',
bytes: 0,
bufferedBytes: this.socket.writableLength ?? 0,
...(preStagedJob == null ? {} : { preStaged: true }),
};
}
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 +656,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 +672,69 @@ 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;
}
const maximumNtime = Math.floor(Date.now() / 1000) + MAX_NTIME_FUTURE_SECONDS;
if (timestamp < jobTemplate.block.timestamp || timestamp > maximumNtime) {
await this.writeSubmissionError(
submission,
eStratumErrorCode.OtherUnknown,
'Invalid ntime',
);
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 +744,91 @@ 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;
if (!this.rememberSubmission(submissionHash, job.jobId)) {
await this.writeSubmissionError(
submission,
eStratumErrorCode.OtherUnknown,
'Submission dedup capacity exceeded',
);
this.closeSocket();
return false;
}
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 +842,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 +867,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 +874,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 +959,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 +974,7 @@ export class StratumV1Client {
}),
blockData: { ...jobTemplate.blockData, clearJobs: true }
};
await this.sendNewMiningJob(refreshedJobTemplate);
this.broadcastMiningJob(refreshedJobTemplate, true);
}
}
@@ -860,6 +1086,75 @@ export class StratumV1Client {
return DEFAULT_CLIENT_HASHRATE_PERSIST_INTERVAL_MS;
}
private isDuplicateSubmission(submissionHash: string): boolean {
const now = Date.now();
this.pruneExpiredSubmissions(now);
return this.miningSubmissionHashes.has(submissionHash);
}
private rememberSubmission(submissionHash: string, jobId: string): boolean {
const now = Date.now();
this.pruneExpiredSubmissions(now);
if (this.miningSubmissionHashes.has(submissionHash)
|| this.miningSubmissionHashes.size >= this.getSubmissionDedupMaxEntries()) {
return false;
}
this.miningSubmissionHashes.set(submissionHash, {
expiresAt: now + this.getSubmissionDedupTtlMs(),
jobId,
});
return true;
}
private pruneExpiredSubmissions(now: number): void {
for (const [hash, entry] of this.miningSubmissionHashes) {
if (entry.expiresAt <= now
&& this.stratumV1JobsService.getSubmissionContext(entry.jobId) == null) {
this.miningSubmissionHashes.delete(hash);
}
}
}
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 readMaxInboundLineBytes(): number {
const configured = Number(
this.configService.get<string>('STRATUM_MAX_INBOUND_LINE_BYTES')
?? process.env.STRATUM_MAX_INBOUND_LINE_BYTES,
);
return Number.isSafeInteger(configured) && configured > 0
? configured
: DEFAULT_MAX_INBOUND_LINE_BYTES;
}
private getValidationErrorSignature(errors: ValidationError[]): string {
if (errors.length === 0) {
return 'unknown';
@@ -905,14 +1200,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!';
}
+42 -3
View File
@@ -32,7 +32,30 @@ describe('StratumV1ClientStatistics', () => {
await statistics.addShares(client, 64);
}
expect(statistics.hashRate).toBeGreaterThan(0);
expect(statistics.hashRate).toBeCloseTo((64 * 4294967296) / 62);
});
it('keeps exactly the configured number of share samples', async () => {
for (let i = 0; i < 31; i++) {
jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 31000)));
await statistics.addShares(client, 64);
}
expect((statistics as any).submissionCache).toHaveLength(30);
expect((statistics as any).submissionCacheDifficultySum).toBe(30 * 64);
});
it('excludes pre-window work and corrects share-terminated sampling bias', async () => {
for (let i = 0; i < 30; i++) {
jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 31000)));
await statistics.addShares(client, 64);
}
const elapsedSeconds = 29 * 31;
const expectedUnbiasedDifficulty = 28 * 64;
expect(statistics.hashRate).toBeCloseTo(
(expectedUnbiasedDifficulty * 4294967296) / elapsedSeconds,
);
});
it('should not suggest a difficulty change before enough time or shares have passed', () => {
@@ -51,7 +74,23 @@ describe('StratumV1ClientStatistics', () => {
await statistics.addShares(client, 64);
}
expect(statistics.getSuggestedDifficulty(64)).toBe(2048);
expect(statistics.getSuggestedDifficulty(64)).toBe(1024);
});
it('does not retarget when accepted shares have a zero-duration sample window', async () => {
for (let i = 0; i < 5; i++) {
await statistics.addShares(client, 64);
}
expect(() => statistics.getSuggestedDifficulty(64)).not.toThrow();
expect(statistics.getSuggestedDifficulty(64)).toBeNull();
});
it('returns a finite power-of-two difficulty for values above 32-bit range', () => {
const result = (statistics as any).nearestPowerOfTwo(2 ** 40 + 1);
expect(result).toBe(2 ** 40);
expect(Number.isFinite(result)).toBe(true);
});
it('should decrease difficulty for slow submissions', async () => {
@@ -60,7 +99,7 @@ describe('StratumV1ClientStatistics', () => {
await statistics.addShares(client, 64);
}
expect(statistics.getSuggestedDifficulty(128)).toBe(16);
expect(statistics.getSuggestedDifficulty(128)).toBe(8);
});
it('should not suggest a difficulty below the configured minimum', () => {
+46 -28
View File
@@ -16,9 +16,9 @@ export class StratumV1ClientStatistics {
}
public async addShares(_client: ClientEntity, targetDifficulty: number) {
var date = new Date();
const date = new Date();
if (this.submissionCache.length > CACHE_SIZE) {
if (this.submissionCache.length >= CACHE_SIZE) {
this.submissionCacheDifficultySum -= this.submissionCache[0].difficulty;
this.submissionCache.shift();
}
@@ -28,9 +28,12 @@ export class StratumV1ClientStatistics {
});
this.submissionCacheDifficultySum += targetDifficulty;
const time = new Date().getTime() - this.submissionCache[0].time.getTime();
if(time > 60000 && this.submissionCache.length > 2) {
this.hashRate = (this.submissionCacheDifficultySum * 4294967296) / (time / 1000);
const elapsedSeconds = (date.getTime() - this.submissionCache[0].time.getTime()) / 1000;
if (elapsedSeconds > 60) {
const difficultyPerSecond = this.getDifficultyPerSecond(elapsedSeconds);
if (difficultyPerSecond != null) {
this.hashRate = difficultyPerSecond * 4294967296;
}
}
}
@@ -46,15 +49,16 @@ export class StratumV1ClientStatistics {
}
}
const sum = this.submissionCache.reduce((pre, cur) => {
pre += cur.difficulty;
return pre;
}, 0);
const diffSeconds = (this.submissionCache[this.submissionCache.length - 1].time.getTime() - this.submissionCache[0].time.getTime()) / 1000;
const difficultyPerSecond = sum / diffSeconds;
const difficultyPerSecond = this.getDifficultyPerSecond(diffSeconds);
if (difficultyPerSecond == null) {
return null;
}
const targetDifficulty = difficultyPerSecond * this.targetSubmitShareEveryNSeconds;
if (!Number.isFinite(targetDifficulty) || targetDifficulty <= 0) {
return null;
}
if ((clientDifficulty * 2) < targetDifficulty || (clientDifficulty / 2) > targetDifficulty) {
return this.nearestPowerOfTwo(targetDifficulty)
@@ -63,27 +67,41 @@ export class StratumV1ClientStatistics {
return null;
}
private nearestPowerOfTwo(val): number {
if (val === 0) {
/**
* Estimate work rate from a share-terminated sample window.
*
* A cache of N shares contains N - 1 observed inter-share intervals. The
* first share's work predates the window and must not be counted. Because
* the window closes on a share arrival, the reciprocal elapsed time also
* has the usual finite-sample Poisson bias; multiplying by
* (intervalCount - 1) / intervalCount removes it.
*/
private getDifficultyPerSecond(elapsedSeconds: number): number | null {
const sampleCount = this.submissionCache.length;
if (sampleCount <= 2 || !Number.isFinite(elapsedSeconds) || elapsedSeconds <= 0) {
return null;
}
if (val < this.minDifficulty) {
const intervalCount = sampleCount - 1;
const observedDifficulty = this.submissionCacheDifficultySum
- this.submissionCache[0].difficulty;
const unbiasedDifficulty = observedDifficulty * (intervalCount - 1) / intervalCount;
const difficultyPerSecond = unbiasedDifficulty / elapsedSeconds;
return Number.isFinite(difficultyPerSecond) && difficultyPerSecond > 0
? difficultyPerSecond
: null;
}
private nearestPowerOfTwo(val: number): number {
if (!Number.isFinite(val) || val <= 0) {
return null;
}
if (val <= this.minDifficulty) {
return this.minDifficulty;
}
let x = val | (val >> 1);
x = x | (x >> 2);
x = x | (x >> 4);
x = x | (x >> 8);
x = x | (x >> 16);
x = x | (x >> 32);
const res = x - (x >> 1);
if (res == 0 && val * 100 < this.minDifficulty) {
return this.minDifficulty;
}
if (res == 0) {
return this.nearestPowerOfTwo(val * 100) / 100;
}
return res;
const result = 2 ** Math.floor(Math.log2(val));
return Number.isFinite(result) ? Math.max(this.minDifficulty, result) : null;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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,25 @@ 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;
/** Wall-clock time when the master first observed the source block notification. */
sourceNotificationReceivedAtMs?: number;
/** Wall-clock time when the compact job finished construction on the master. */
notificationPreparedAtMs?: number;
/** Wall-clock time immediately before the Redis publish command was issued. */
notificationPublishedAtMs?: number;
/** Worker-local wall-clock time when the urgent Redis subscriber received the bridge. */
notificationWorkerReceivedAtMs?: number;
/** Worker-local wall-clock time immediately before the bridge entered job preparation. */
notificationWorkerHandledAtMs?: number;
/** Timestamp of the rolling PPLNS snapshot seed used by an empty bridge. */
payoutBridgeSeedCreatedAtMs?: number;
}
@@ -1,9 +1,10 @@
import { Expose, Transform } from 'class-transformer';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsNumber, IsOptional, IsString, MaxLength } from 'class-validator';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsNumber, IsOptional, IsPositive, IsString, Max, MaxLength } from 'class-validator';
import { eRequestMethod } from '../enums/eRequestMethod';
import { IsBitcoinAddress } from '../validators/bitcoin-address.validator';
import { StratumBaseMessage } from './StratumBaseMessage';
import { MAX_STRATUM_DIFFICULTY } from './SuggestDifficultyMessage';
export class AuthorizationMessage extends StratumBaseMessage {
@@ -35,6 +36,8 @@ export class AuthorizationMessage extends StratumBaseMessage {
@Expose()
@IsNumber()
@IsPositive()
@Max(MAX_STRATUM_DIFFICULTY)
@Transform(({ value, key, obj, type }) => {
const password: string | null = obj.params[1];
if (password?.includes('d=')) {
@@ -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,10 +1,12 @@
import { Expose, Transform } from 'class-transformer';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsNumber } from 'class-validator';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsNumber, IsPositive, Max } from 'class-validator';
import { eRequestMethod } from '../enums/eRequestMethod';
import { eResponseMethod } from '../enums/eResponseMethod';
import { StratumBaseMessage } from './StratumBaseMessage';
export const MAX_STRATUM_DIFFICULTY = 2 ** 32;
export class SuggestDifficulty extends StratumBaseMessage {
@IsArray()
@ArrayMinSize(1)
@@ -16,6 +18,8 @@ export class SuggestDifficulty extends StratumBaseMessage {
@Expose()
@IsNumber()
@IsPositive()
@Max(MAX_STRATUM_DIFFICULTY)
@Transform(({ value, key, obj, type }) => {
return Number(obj.params[0]);
})
@@ -37,4 +41,3 @@ export class SuggestDifficulty extends StratumBaseMessage {
}
}
}
+68 -1
View File
@@ -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');
});
});
+105 -21
View File
@@ -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`,
);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NestFactory } from '@nestjs/core';
import { NotifierModule } from './notifier.module';
async function bootstrap(): Promise<void> {
process.env.MASTER = 'true';
process.env.API_ENABLED = 'false';
const application = await NestFactory.createApplicationContext(
NotifierModule,
);
application.enableShutdownHooks();
console.log('Authoritative block notifier started');
}
void bootstrap();
+41
View File
@@ -0,0 +1,41 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { createDatabaseOptions } from './database.config';
import { PayoutSnapshotModule } from './ORM/payout-snapshot/payout-snapshot.module';
import { RpcBlocksModule } from './ORM/rpc-block/rpc-block.module';
import { BitcoinRpcService } from './services/bitcoin-rpc.service';
import { RedisMessagingModule } from './services/redis-messaging.module';
/**
* Minimal process graph for new-tip notification. Keeping API, Stratum,
* reporting, chat integrations, and accounting providers out of this Nest
* context prevents their timers and callbacks from delaying the Core longpoll.
*/
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => createDatabaseOptions({
...process.env,
DB_HOST: configService.get('DB_HOST'),
DB_PORT: configService.get('DB_PORT'),
DB_USERNAME: configService.get('DB_USERNAME'),
DB_PASSWORD: configService.get('DB_PASSWORD'),
DB_DATABASE: configService.get('DB_DATABASE'),
DB_LOGGING: configService.get('DB_LOGGING'),
DB_POOL_SIZE: configService.get('DB_POOL_SIZE'),
}),
}),
RedisMessagingModule,
RpcBlocksModule,
PayoutSnapshotModule,
],
providers: [
BitcoinRpcService,
],
})
export class NotifierModule { }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+146
View File
@@ -329,6 +329,49 @@ describe('DatumService job validation', () => {
expect(validation.error).toBe('output-count-mismatch');
});
it('rejects DATUM PPLNS coinbases that only fit a smaller profile and add a pool remainder', () => {
const configService = {
get: jest.fn((key: string) => {
if (key === 'NETWORK') {
return 'testnet';
}
if (key === 'DATUM_POOL_PAYOUT_ADDRESS') {
return 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2';
}
return undefined;
}),
};
const service = createService({ configService }) as any;
const extranonce = Buffer.alloc(12, 1);
const expectedOutputs = [
{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 421 },
{ address: 'tb1q9r8gvnx3j4d6jvl0fqjrmy3dar4k4l3052af7q', amountSats: 133 },
{ address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2', amountSats: 42 },
];
const coinbase = createDatumCoinbaseSplit([
{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 421 },
{ address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2', amountSats: 175 },
], extranonce);
const latestTemplate = {
blockData: {
coinbasevalue: 596,
payoutOutputs: expectedOutputs,
},
};
const validation = service.validateDatumCoinbasePayouts(
coinbase,
{ extranonce, targetByte: 0 },
latestTemplate,
596n,
undefined,
'pplns',
);
expect(validation.valid).toBe(false);
expect(validation.error).toBe('output-count-mismatch');
});
it('rejects DATUM coinbases that do not pay the pool snapshot outputs', () => {
const service = createService() as any;
const extranonce = Buffer.alloc(12, 1);
@@ -389,6 +432,109 @@ describe('DatumService job validation', () => {
).valid).toBe(true);
});
it('accepts DATUM coinbases matching another recent pool-issued coinbaser context for the same height', () => {
const service = createService() as any;
const extranonce = Buffer.alloc(12, 1);
const staleOutputs = (service as any).getDatumPayoutOutputs({
blockData: {
payoutOutputs: [{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 596 }],
},
}, 596, 'pplns');
const submittedOutputs = (service as any).getDatumPayoutOutputs({
blockData: {
payoutOutputs: [{ address: 'tb1q9r8gvnx3j4d6jvl0fqjrmy3dar4k4l3052af7q', amountSats: 596 }],
},
}, 596, 'pplns');
const coinbase = createDatumCoinbaseSplit([
{ address: 'tb1q9r8gvnx3j4d6jvl0fqjrmy3dar4k4l3052af7q', amountSats: 596 },
], extranonce);
const latestTemplate = {
blockData: {
coinbasevalue: 596,
payoutOutputs: [{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 596 }],
},
};
const datumJob = {
height: 4991366,
coinbaseValue: 596n,
expectedPayoutOutputs: staleOutputs,
coinbasePairs: new Map(),
};
const state = {
payoutMode: 'pplns',
coinbaserPayoutContexts: new Map([
[1, {
payoutOutputs: staleOutputs,
payoutSnapshotId: 'stale',
blockHeight: 4991366,
payoutMode: 'pplns',
}],
[2, {
payoutOutputs: submittedOutputs,
payoutSnapshotId: 'submitted',
blockHeight: 4991366,
payoutMode: 'pplns',
}],
]),
};
const result = service.validateDatumCoinbasePayoutContext(
coinbase,
{ extranonce, targetByte: 0 },
latestTemplate,
datumJob,
state,
);
expect(result.validation.valid).toBe(true);
expect(result.context?.payoutSnapshotId).toBe('submitted');
});
it('still rejects DATUM coinbases that do not match any pool-issued coinbaser context', () => {
const service = createService() as any;
const extranonce = Buffer.alloc(12, 1);
const poolOutputs = (service as any).getDatumPayoutOutputs({
blockData: {
payoutOutputs: [{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 596 }],
},
}, 596, 'pplns');
const soloCoinbase = createDatumCoinbaseSplit([
{ address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2', amountSats: 596 },
], extranonce);
const latestTemplate = {
blockData: {
coinbasevalue: 596,
payoutOutputs: [{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 596 }],
},
};
const datumJob = {
height: 4991366,
coinbaseValue: 596n,
expectedPayoutOutputs: poolOutputs,
coinbasePairs: new Map(),
};
const state = {
payoutMode: 'pplns',
coinbaserPayoutContexts: new Map([[1, {
payoutOutputs: poolOutputs,
payoutSnapshotId: 'pool',
blockHeight: 4991366,
payoutMode: 'pplns',
}]]),
};
const result = service.validateDatumCoinbasePayoutContext(
soloCoinbase,
{ extranonce, targetByte: 0 },
latestTemplate,
datumJob,
state,
);
expect(result.validation.valid).toBe(false);
expect(result.validation.error).toBe('output-mismatch');
});
it('carries the coinbaser payout snapshot id into the DATUM job cache', () => {
const service = createService() as any;
const payoutOutputs = [{
+63 -7
View File
@@ -116,7 +116,7 @@ export class DatumService implements OnModuleInit {
statistics: new StratumV1ClientStatistics(this.getConfiguredDatumShareDifficulty()),
lastHashRatePersistedAt: 0,
};
console.log(`[DATUM ${state.sessionId}] connection accepted from ${socket.remoteAddress}:${socket.remotePort}`);
this.logVerbose(`[DATUM ${state.sessionId}] connection accepted from ${socket.remoteAddress}:${socket.remotePort}`);
const close = () => {
void this.destroyClient(state);
@@ -152,11 +152,11 @@ export class DatumService implements OnModuleInit {
if (protoCmd === DatumProtocolCommand.HANDSHAKE_INIT) {
const hello = state.session.openHandshake(payload);
state.userAgent = hello.userAgent || 'datum/unknown';
console.log(`[DATUM ${state.sessionId}] handshake init from ${state.userAgent}`);
this.logVerbose(`[DATUM ${state.sessionId}] handshake init from ${state.userAgent}`);
await this.writeRaw(socket, state.session.buildHandshakeResponse(hello, 'public-pool DATUM'));
console.log(`[DATUM ${state.sessionId}] handshake response sent`);
this.logVerbose(`[DATUM ${state.sessionId}] handshake response sent`);
await this.sendDatumClientConfigure(socket, state);
console.log(`[DATUM ${state.sessionId}] client configure sent`);
this.logVerbose(`[DATUM ${state.sessionId}] client configure sent`);
this.startDatumPing(socket, state);
return;
}
@@ -296,12 +296,16 @@ export class DatumService implements OnModuleInit {
await this.sendShareResponse(socket, state, DatumShareResponseStatus.REJECTED, DatumRejectReason.OTHER, pow.nonce, pow.targetByte, pow.jobId);
return;
}
const payoutValidation = this.validateDatumCoinbasePayouts(coinbase, pow, latestTemplate, datumJob.coinbaseValue, datumJob.expectedPayoutOutputs, state.payoutMode);
if (!payoutValidation.valid) {
this.logDatumCoinbaseMismatchOnce(state, payoutValidation);
const payoutValidation = this.validateDatumCoinbasePayoutContext(coinbase, pow, latestTemplate, datumJob, state);
if (!payoutValidation.validation.valid) {
this.logDatumCoinbaseMismatchOnce(state, payoutValidation.validation);
await this.sendShareResponse(socket, state, DatumShareResponseStatus.REJECTED, DatumRejectReason.BAD_COINBASER_ID, pow.nonce, pow.targetByte, pow.jobId);
return;
}
if (payoutValidation.context != null) {
datumJob.expectedPayoutOutputs = payoutValidation.context.payoutOutputs;
datumJob.payoutSnapshotId = payoutValidation.context.payoutSnapshotId;
}
const shareDifficulty = this.getDatumSubmittedShareDifficulty(pow);
const nBits = datumJob.nBits.readUInt32LE(0);
@@ -623,6 +627,52 @@ export class DatumService implements OnModuleInit {
};
}
private validateDatumCoinbasePayoutContext(
coinbase: { coinb1: Buffer; coinb2: Buffer },
pow: DatumPowSubmit,
latestTemplate: IJobTemplate,
datumJob: DatumJobCache,
state: DatumClientState,
): { validation: DatumCoinbasePayoutValidation; context?: DatumCoinbaserPayoutContext } {
const primaryValidation = this.validateDatumCoinbasePayouts(
coinbase,
pow,
latestTemplate,
datumJob.coinbaseValue,
datumJob.expectedPayoutOutputs,
state.payoutMode,
);
if (primaryValidation.valid) {
return { validation: primaryValidation };
}
for (const context of state.coinbaserPayoutContexts.values()) {
if (context.payoutMode != null && context.payoutMode !== state.payoutMode) {
continue;
}
if (context.blockHeight != null && datumJob.height != null && context.blockHeight !== datumJob.height) {
continue;
}
if (context.payoutOutputs === datumJob.expectedPayoutOutputs) {
continue;
}
const validation = this.validateDatumCoinbasePayouts(
coinbase,
pow,
latestTemplate,
datumJob.coinbaseValue,
context.payoutOutputs,
state.payoutMode,
);
if (validation.valid) {
return { validation, context };
}
}
return { validation: primaryValidation };
}
private getDatumPayoutOutputs(latestTemplate: { blockData?: { payoutOutputs?: AddressObject[] } }, rewardValue: number, payoutMode: PayoutMode = 'solo'): DatumPayoutOutput[] {
const configuredOutputs = latestTemplate?.blockData?.payoutOutputs;
const payoutAddresses = payoutMode === 'pplns' && configuredOutputs?.length > 0
@@ -852,6 +902,12 @@ export class DatumService implements OnModuleInit {
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_DATUM_SHARE_DIFFICULTY;
}
private logVerbose(message: string): void {
if (process.env.DATUM_VERBOSE_LOGGING?.toLowerCase() === 'true') {
console.log(message);
}
}
private getDatumSubmittedShareDifficulty(pow: Pick<DatumPowSubmit, 'targetByte'>): number {
if (!Number.isInteger(pow.targetByte) || pow.targetByte < 0 || pow.targetByte > 63) {
return this.getConfiguredDatumShareDifficulty();
+1 -1
View File
@@ -124,7 +124,7 @@ export class DiscordService implements OnModuleInit {
}
}
public async notifySubscribersBlockFound(height: number, block: Block, message: string) {
public async notifySubscribersBlockFound(height: number, block: Block | undefined, message: string) {
if (process.env.MASTER == 'true') {
if (this.bot == null) {
return;
+147
View File
@@ -0,0 +1,147 @@
import { Block } from 'bitcoinjs-lib';
import { NotificationService } from './notification.service';
describe('NotificationService', () => {
const originalMaster = process.env.MASTER;
let telegramService: { notifySubscribersBlockFound: jest.Mock };
let discordService: {
notifyRestarted: jest.Mock;
notifySubscribersBlockFound: jest.Mock;
};
let redisMessagingService: {
publishBlockFoundNotification: jest.Mock;
subscribeBlockFoundNotifications: jest.Mock;
};
beforeEach(() => {
telegramService = {
notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined),
};
discordService = {
notifyRestarted: jest.fn().mockResolvedValue(undefined),
notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined),
};
redisMessagingService = {
publishBlockFoundNotification: jest.fn().mockResolvedValue(true),
subscribeBlockFoundNotifications: jest.fn().mockResolvedValue(undefined),
};
});
afterEach(() => {
process.env.MASTER = originalMaster;
jest.clearAllMocks();
});
it('publishes block found notifications from worker processes', async () => {
process.env.MASTER = 'false';
const service = createService();
await service.notifySubscribersBlockFound(
'bc1qminer',
900001,
createBlock('11'.repeat(32)),
'accepted',
);
expect(redisMessagingService.publishBlockFoundNotification).toHaveBeenCalledWith({
schemaVersion: 1,
eventId: 'block-found:900001:1111111111111111111111111111111111111111111111111111111111111111:bc1qminer',
address: 'bc1qminer',
height: 900001,
blockHash: '11'.repeat(32),
message: 'accepted',
publishedAtMs: expect.any(Number),
});
expect(discordService.notifySubscribersBlockFound).not.toHaveBeenCalled();
expect(telegramService.notifySubscribersBlockFound).not.toHaveBeenCalled();
});
it('subscribes on the master process and dispatches received notifications', async () => {
process.env.MASTER = 'true';
const service = createService();
await service.onModuleInit();
const handler = redisMessagingService.subscribeBlockFoundNotifications.mock.calls[0][0];
await handler({
schemaVersion: 1,
eventId: 'block-found:900001:blockhash:bc1qminer',
address: 'bc1qminer',
height: 900001,
blockHash: 'blockhash',
message: 'accepted',
publishedAtMs: 123,
});
expect(redisMessagingService.subscribeBlockFoundNotifications).toHaveBeenCalledTimes(1);
expect(discordService.notifyRestarted).toHaveBeenCalledTimes(1);
expect(discordService.notifySubscribersBlockFound).toHaveBeenCalledWith(
900001,
undefined,
'accepted',
);
expect(telegramService.notifySubscribersBlockFound).toHaveBeenCalledWith(
'bc1qminer',
900001,
undefined,
'accepted',
);
});
it('deduplicates repeated master notifications for the same block event', async () => {
process.env.MASTER = 'true';
const service = createService();
const notification = {
schemaVersion: 1 as const,
eventId: 'block-found:900001:blockhash:bc1qminer',
address: 'bc1qminer',
height: 900001,
blockHash: 'blockhash',
message: 'accepted',
publishedAtMs: 123,
};
await service.onModuleInit();
const handler = redisMessagingService.subscribeBlockFoundNotifications.mock.calls[0][0];
await handler(notification);
await handler(notification);
expect(discordService.notifySubscribersBlockFound).toHaveBeenCalledTimes(1);
expect(telegramService.notifySubscribersBlockFound).toHaveBeenCalledTimes(1);
});
it('dispatches directly when called in the master process', async () => {
process.env.MASTER = 'true';
const service = createService();
const block = createBlock('22'.repeat(32));
await service.notifySubscribersBlockFound('bc1qminer', 900002, block, 'accepted');
expect(redisMessagingService.publishBlockFoundNotification).not.toHaveBeenCalled();
expect(discordService.notifySubscribersBlockFound).toHaveBeenCalledWith(
900002,
block,
'accepted',
);
expect(telegramService.notifySubscribersBlockFound).toHaveBeenCalledWith(
'bc1qminer',
900002,
block,
'accepted',
);
});
function createService(): NotificationService {
return new NotificationService(
telegramService as any,
discordService as any,
redisMessagingService as any,
);
}
});
function createBlock(blockHash: string): Block {
return {
getId: () => blockHash,
} as unknown as Block;
}
+91 -3
View File
@@ -2,15 +2,20 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
import { Block } from 'bitcoinjs-lib';
import { DiscordService } from './discord.service';
import { BlockFoundNotification, RedisMessagingService } from './redis-messaging.service';
import { TelegramService } from './telegram.service';
const BLOCK_NOTIFICATION_DEDUPE_TTL_MS = 10 * 60 * 1000;
const BLOCK_NOTIFICATION_DEDUPE_MAX_ENTRIES = 1000;
@Injectable()
export class NotificationService implements OnModuleInit {
private readonly processedBlockNotifications = new Map<string, number>();
constructor(
private readonly telegramService: TelegramService,
private readonly discordService: DiscordService
private readonly discordService: DiscordService,
private readonly redisMessagingService: RedisMessagingService
) { }
async onModuleInit(): Promise<void> {
@@ -18,11 +23,94 @@ export class NotificationService implements OnModuleInit {
return;
}
await this.redisMessagingService.subscribeBlockFoundNotifications(async notification => {
await this.dispatchBlockFoundNotification(notification);
});
await this.discordService.notifyRestarted();
}
public async notifySubscribersBlockFound(address: string, height: number, block: Block, message: string) {
await this.discordService.notifySubscribersBlockFound(height, block, message);
await this.telegramService.notifySubscribersBlockFound(address, height, block, message);
const blockHash = this.getBlockHash(block);
const notification: BlockFoundNotification = {
schemaVersion: 1,
eventId: this.createBlockFoundEventId(address, height, blockHash, message),
address,
height,
blockHash,
message,
publishedAtMs: Date.now(),
};
if (process.env.MASTER === 'true') {
await this.dispatchBlockFoundNotification(notification, block);
return;
}
const published = await this.redisMessagingService.publishBlockFoundNotification(notification);
if (!published) {
console.error(`Unable to publish block found notification ${notification.eventId}`);
}
}
private async dispatchBlockFoundNotification(
notification: BlockFoundNotification,
block?: Block,
): Promise<void> {
if (this.hasProcessedBlockNotification(notification.eventId)) {
return;
}
await this.discordService.notifySubscribersBlockFound(notification.height, block, notification.message);
await this.telegramService.notifySubscribersBlockFound(
notification.address,
notification.height,
block,
notification.message,
);
}
private createBlockFoundEventId(
address: string,
height: number,
blockHash: string,
message: string,
): string {
if (blockHash !== 'unknown') {
return `block-found:${height}:${blockHash}:${address}`;
}
return `block-found:${height}:${address}:${message}`;
}
private getBlockHash(block: Block): string {
try {
const maybeBlock = block as Block & {
getId?: () => string;
getHash?: () => Buffer;
};
if (typeof maybeBlock.getId === 'function') {
const id = maybeBlock.getId();
return typeof id === 'string' && id.length > 0 ? id : 'unknown';
}
if (typeof maybeBlock.getHash === 'function') {
return Buffer.from(maybeBlock.getHash()).reverse().toString('hex');
}
} catch {
return 'unknown';
}
return 'unknown';
}
private hasProcessedBlockNotification(eventId: string): boolean {
const now = Date.now();
for (const [key, processedAtMs] of this.processedBlockNotifications) {
if (now - processedAtMs > BLOCK_NOTIFICATION_DEDUPE_TTL_MS
|| this.processedBlockNotifications.size > BLOCK_NOTIFICATION_DEDUPE_MAX_ENTRIES) {
this.processedBlockNotifications.delete(key);
}
}
if (this.processedBlockNotifications.has(eventId)) {
return true;
}
this.processedBlockNotifications.set(eventId, now);
return false;
}
}
@@ -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 };
}
+238
View File
@@ -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}`,
);
}
}
+460 -13
View File
@@ -14,9 +14,17 @@ describe('RedisMessagingService', () => {
beforeEach(() => {
store.clear();
sets.clear();
subscriptions.clear();
clientsByRole.publisher = null;
clientsByRole.subscriber = null;
clients = [createRedisClient(), createRedisClient()];
clientsByRole.urgentPublisher = null;
clientsByRole.urgentSubscriber = null;
clients = [
createRedisClient(),
createRedisClient(),
createRedisClient(),
createRedisClient(),
];
(createClient as jest.Mock).mockImplementation(() => clients.shift());
service = new RedisMessagingService({
get: jest.fn((key: string) => key === 'REDIS_URL' ? 'redis://test-redis:6379' : null),
@@ -31,11 +39,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,25 +113,403 @@ 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('uses an owner token when locking JSON cache refreshes', async () => {
await service.connect();
await expect(service.tryAcquireJsonCacheLock('summary', 'owner-a', 1000)).resolves.toBe(true);
await expect(service.tryAcquireJsonCacheLock('summary', 'owner-b', 1000)).resolves.toBe(false);
await service.releaseJsonCacheLock('summary', 'owner-b');
await expect(service.tryAcquireJsonCacheLock('summary', 'owner-b', 1000)).resolves.toBe(false);
await service.releaseJsonCacheLock('summary', 'owner-a');
await expect(service.tryAcquireJsonCacheLock('summary', 'owner-b', 1000)).resolves.toBe(true);
});
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('publishes validated block found notifications', async () => {
await service.connect();
const handler = jest.fn().mockResolvedValue(undefined);
const notification = {
schemaVersion: 1 as const,
eventId: 'block-found:900001:blockhash:bc1qminer',
address: 'bc1qminer',
height: 900001,
blockHash: 'aa'.repeat(32),
message: 'accepted',
publishedAtMs: 123,
};
await service.subscribeBlockFoundNotifications(handler);
await expect(service.publishBlockFoundNotification(notification)).resolves.toBe(true);
expect(handler).toHaveBeenCalledWith(notification);
});
it('ignores malformed block found notifications', async () => {
await service.connect();
const handler = jest.fn();
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
await service.subscribeBlockFoundNotifications(handler);
await subscriptions.get('block-found.notification')!(JSON.stringify({
schemaVersion: 1,
eventId: '',
height: 900001,
}));
expect(handler).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid Redis block found notification'));
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 expect(service.publishSv1BridgeUpdate(update)).resolves.toBe(true);
expect(handler).toHaveBeenCalledWith(expect.objectContaining({
...update,
workerReceivedAtMs: expect.any(Number),
}));
expect(await service.getLatestSv1BridgeUpdate()).toEqual(update);
});
it('uses the normal command socket as an explicit bridge fallback lane', async () => {
await service.connect();
const update = createBridgeUpdate('solo', 'fallback-bridge');
await expect(service.publishSv1BridgeUpdate(update, 'fallback')).resolves.toBe(true);
expect(clientsByRole.publisher.publish).toHaveBeenCalledWith(
'sv1-bridge.updated',
JSON.stringify(update),
);
expect(clientsByRole.urgentPublisher.publish).not.toHaveBeenCalled();
});
it('publishes compact prestage activation on the urgent socket', async () => {
await service.connect();
const handler = jest.fn().mockResolvedValue(undefined);
const activation = createPrestageActivation();
await service.subscribeSv1PrestageActivations(handler);
await expect(service.publishSv1PrestageActivation(activation)).resolves.toBe(true);
expect(clientsByRole.urgentPublisher.publish).toHaveBeenCalledWith(
'sv1-prestage.activate',
JSON.stringify(activation),
);
expect(handler).toHaveBeenCalledWith(expect.objectContaining({
...activation,
workerReceivedAtMs: expect.any(Number),
}));
});
it('uses the normal Redis socket for compact activation fallback', async () => {
await service.connect();
const activation = createPrestageActivation();
await expect(service.publishSv1PrestageActivation(
activation,
'fallback',
)).resolves.toBe(true);
expect(clientsByRole.publisher.publish).toHaveBeenCalledWith(
'sv1-prestage.activate',
JSON.stringify(activation),
);
});
it('rejects malformed compact prestage activation fields', async () => {
await service.connect();
const activation = createPrestageActivation();
await expect(service.publishSv1PrestageActivation({
...activation,
previousBlockHash: 'not-a-hash',
})).rejects.toThrow('unsupported SV1 prestage activation');
await expect(service.publishSv1PrestageActivation({
...activation,
payoutMode: 'pplns',
})).rejects.toThrow('unsupported SV1 prestage activation');
});
it('reports bridge delivery failure when Redis cannot connect', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
clients[0].connect.mockRejectedValueOnce(new Error('redis unavailable'));
await expect(service.publishSv1BridgeUpdate(
createBridgeUpdate('solo', 'unavailable-bridge'),
)).resolves.toBe(false);
errorSpy.mockRestore();
});
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('publishes and durably replays next-height SV1 prestage templates', async () => {
await service.connect();
const handler = jest.fn().mockResolvedValue(undefined);
const update = {
schemaVersion: 1 as const,
type: 'subsidy-prestage' as const,
eventId: 'prestage:solo:900002',
preparedAtMs: 456,
template: {
height: 900002,
previousblockhash: '0'.repeat(64),
payoutMode: 'solo' as const,
jobType: 'empty' as const,
transactions: [],
} as any,
};
await service.subscribeSv1PrestageUpdates(handler);
await service.publishSv1PrestageUpdate(update);
expect(handler).toHaveBeenCalledWith(update);
expect(await service.getLatestSv1PrestageUpdates()).toEqual([update]);
});
it('rejects a prestage template that claims an authoritative prevhash', async () => {
await service.connect();
const update = {
schemaVersion: 1 as const,
type: 'subsidy-prestage' as const,
eventId: 'unsafe-prestage',
preparedAtMs: 456,
template: {
height: 900002,
previousblockhash: '11'.repeat(32),
payoutMode: 'solo' as const,
jobType: 'empty' as const,
transactions: [],
} as any,
};
await expect(service.publishSv1PrestageUpdate(update)).rejects.toThrow(
'unsupported SV1 prestage update',
);
});
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,
};
}
function createPrestageActivation() {
return {
schemaVersion: 1 as const,
type: 'prestage-activation' as const,
eventId: 'activate:solo:900001',
height: 900001,
previousBlockHash: '55'.repeat(32),
version: 0x20000000,
bits: '17034219',
minTime: 1_700_000_000,
currentTime: 1_700_000_001,
subsidySats: 312_500_000,
payoutMode: 'solo' as const,
requiredVersionBits: 0,
sourceNotificationReceivedAtMs: 123,
publishedAtMs: 124,
};
}
const store = new Map<string, string>();
const sets = new Map<string, Set<string>>();
const clientsByRole: { publisher?: any; subscriber?: any } = {};
const clientsByRole: {
publisher?: any;
subscriber?: any;
urgentPublisher?: any;
urgentSubscriber?: any;
} = {};
const subscriptions = new Map<string, (message: string) => Promise<void>>();
function createRedisClient() {
const client = {
connect: jest.fn().mockResolvedValue(undefined),
quit: jest.fn().mockResolvedValue(undefined),
on: jest.fn(),
set: jest.fn((key: string, value: string) => {
set: jest.fn((key: string, value: string, options?: { NX?: boolean }) => {
if (options?.NX && store.has(key)) {
return Promise.resolve(null);
}
store.set(key, value);
return Promise.resolve('OK');
}),
@@ -91,6 +528,15 @@ function createRedisClient() {
});
return Promise.resolve(deleted);
}),
eval: jest.fn((_script: string, options: { keys: string[], arguments: string[] }) => {
const [key] = options.keys;
const [owner] = options.arguments;
if (store.get(key) !== owner) {
return Promise.resolve(0);
}
store.delete(key);
return Promise.resolve(1);
}),
sAdd: jest.fn((key: string, value: string) => {
const set = sets.get(key) ?? new Set<string>();
set.add(value);
@@ -116,20 +562,21 @@ 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();
}),
};
if (clientsByRole.publisher == null) {
clientsByRole.publisher = client;
} else {
clientsByRole.subscriber = client;
const role = (['publisher', 'subscriber', 'urgentPublisher', 'urgentSubscriber'] as const)
.find(candidate => clientsByRole[candidate] == null);
if (role == null) {
throw new Error('Unexpected extra Redis test client');
}
clientsByRole[role] = client;
return client;
}
+578 -10
View File
@@ -4,17 +4,106 @@ 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 SV1_PRESTAGE_ACTIVATION_CHANNEL = 'sv1-prestage.activate';
const SV1_PRESTAGE_CHANNEL = 'sv1-prestage.updated';
const BLOCK_FOUND_NOTIFICATION_CHANNEL = 'block-found.notification';
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 SV1_PRESTAGE_LATEST_KEY = 'sv1-prestage:latest';
const BLOCK_TEMPLATE_CACHE_TTL_SECONDS = 60 * 60;
const sv1BridgeLatestKey = (payoutMode: PayoutMode) =>
`${SV1_BRIDGE_LATEST_KEY}:${payoutMode}`;
const sv1PrestageLatestKey = (payoutMode: PayoutMode) =>
`${SV1_PRESTAGE_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}`;
const jsonCacheLockKey = (key: string) => `json-cache-lock:${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;
/** Local worker timestamp; populated after Redis delivery, never serialized by the master. */
workerReceivedAtMs?: number;
}
export interface Sv1PrestageUpdate {
schemaVersion: 1;
type: 'subsidy-prestage';
eventId: string;
template: IBlockTemplate;
preparedAtMs: number;
}
/**
* Header-only activation for work whose coinbase and notify buffers were
* prepared during the prior height. Every field comes from an authoritative
* GBT; no next-block consensus field is inferred from ZMQ.
*/
export interface Sv1PrestageActivation {
schemaVersion: 1;
type: 'prestage-activation';
eventId: string;
height: number;
previousBlockHash: string;
version: number;
bits: string;
minTime: number;
currentTime: number;
subsidySats: number;
payoutMode: PayoutMode;
payoutSnapshotId?: string;
requiredVersionBits: number;
sourceNotificationReceivedAtMs?: number;
publishedAtMs: number;
/** Local worker timestamp; populated after Redis delivery. */
workerReceivedAtMs?: number;
}
export interface BlockFoundNotification {
schemaVersion: 1;
eventId: string;
address: string;
height: number;
blockHash: string;
message: string;
publishedAtMs: number;
}
@Injectable()
export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
private publisher: RedisClientType;
private subscriber: RedisClientType;
/** Dedicated command socket so full-template writes cannot queue ahead of a new-tip bridge. */
private urgentPublisher: RedisClientType;
/** Dedicated Pub/Sub socket so canonical/replay callbacks cannot delay bridge receipt. */
private urgentSubscriber: RedisClientType;
private connected = false;
constructor(
@@ -31,6 +120,8 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
await Promise.all([
this.publisher?.quit().catch(() => undefined),
this.subscriber?.quit().catch(() => undefined),
this.urgentPublisher?.quit().catch(() => undefined),
this.urgentSubscriber?.quit().catch(() => undefined),
]);
}
@@ -45,13 +136,24 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
};
this.publisher = createClient({ url, socket });
this.subscriber = createClient({ url, socket });
this.urgentPublisher = createClient({ url, socket });
this.urgentSubscriber = createClient({ url, socket });
this.publisher.on('error', error => console.error(`Redis publisher error: ${error.message}`));
this.subscriber.on('error', error => console.error(`Redis subscriber error: ${error.message}`));
this.urgentPublisher.on('error', error => console.error(`Redis urgent publisher error: ${error.message}`));
this.urgentSubscriber.on('error', error => console.error(`Redis urgent subscriber error: ${error.message}`));
this.publisher.on('end', () => { this.connected = false; });
this.subscriber.on('end', () => { this.connected = false; });
this.urgentPublisher.on('end', () => { this.connected = false; });
this.urgentSubscriber.on('end', () => { this.connected = false; });
await Promise.all([this.publisher.connect(), this.subscriber.connect()]);
await Promise.all([
this.publisher.connect(),
this.subscriber.connect(),
this.urgentPublisher.connect(),
this.urgentSubscriber.connect(),
]);
this.connected = true;
}
@@ -75,6 +177,212 @@ 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 publishBlockFoundNotification(notification: BlockFoundNotification): Promise<boolean> {
if (!await this.ensureConnected()) {
return false;
}
const serialized = JSON.stringify(notification);
const validated = this.parseBlockFoundNotification(serialized);
await this.publisher.publish(BLOCK_FOUND_NOTIFICATION_CHANNEL, JSON.stringify(validated));
return true;
}
public async subscribeBlockFoundNotifications(
handler: (notification: BlockFoundNotification) => Promise<void>,
): Promise<void> {
if (!await this.ensureConnected()) {
return;
}
await this.subscriber.subscribe(BLOCK_FOUND_NOTIFICATION_CHANNEL, async message => {
try {
await handler(this.parseBlockFoundNotification(message));
} catch (error) {
console.error(`Invalid Redis block found notification: ${error.message}`);
}
});
}
public async publishSv1BridgeUpdate(
update: Sv1BridgeUpdate,
lane: 'urgent' | 'fallback' = 'urgent',
): Promise<boolean> {
if (!await this.ensureConnected()) {
return false;
}
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.
const publisher = lane === 'urgent' ? this.urgentPublisher : this.publisher;
await 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}`);
});
return true;
}
public async publishSv1PrestageActivation(
activation: Sv1PrestageActivation,
lane: 'urgent' | 'fallback' = 'urgent',
): Promise<boolean> {
if (!await this.ensureConnected()) {
return false;
}
const serialized = JSON.stringify(activation);
this.parseSv1PrestageActivation(serialized);
const publisher = lane === 'urgent' ? this.urgentPublisher : this.publisher;
await publisher.publish(SV1_PRESTAGE_ACTIVATION_CHANNEL, serialized);
return true;
}
public async subscribeSv1PrestageActivations(
handler: (activation: Sv1PrestageActivation) => Promise<void>,
): Promise<void> {
if (!await this.ensureConnected()) {
return;
}
await this.urgentSubscriber.subscribe(
SV1_PRESTAGE_ACTIVATION_CHANNEL,
async message => {
try {
const activation = this.parseSv1PrestageActivation(message);
activation.workerReceivedAtMs = Date.now();
await handler(activation);
} catch (error) {
console.error(`Invalid Redis SV1 prestage activation: ${error.message}`);
}
},
);
}
public async subscribeSv1BridgeUpdates(handler: (update: Sv1BridgeUpdate) => Promise<void>): Promise<void> {
if (!await this.ensureConnected()) {
return;
}
await this.urgentSubscriber.subscribe(SV1_BRIDGE_CHANNEL, async message => {
try {
const update = this.parseSv1BridgeUpdate(message);
update.workerReceivedAtMs = Date.now();
await handler(update);
} 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 publishSv1PrestageUpdate(update: Sv1PrestageUpdate): Promise<void> {
if (!await this.ensureConnected()) {
return;
}
const serialized = JSON.stringify(update);
const validated = this.parseSv1PrestageUpdate(serialized);
const payoutMode = validated.template.payoutMode === 'pplns' ? 'pplns' : 'solo';
await Promise.all([
// Keep the current next-height seed until it is replaced. A Bitcoin
// block interval can exceed the canonical-template cache TTL, and a
// worker restart late in that interval must still be able to stage.
this.publisher.set(
sv1PrestageLatestKey(payoutMode),
serialized,
),
this.publisher.publish(SV1_PRESTAGE_CHANNEL, serialized),
]);
}
public async subscribeSv1PrestageUpdates(
handler: (update: Sv1PrestageUpdate) => Promise<void>,
): Promise<void> {
if (!await this.ensureConnected()) {
return;
}
await this.subscriber.subscribe(SV1_PRESTAGE_CHANNEL, async message => {
try {
await handler(this.parseSv1PrestageUpdate(message));
} catch (error) {
console.error(`Invalid Redis SV1 prestage update: ${error.message}`);
}
});
}
public async getLatestSv1PrestageUpdates(): Promise<Sv1PrestageUpdate[]> {
if (!await this.ensureConnected()) {
return [];
}
const values = await this.publisher.mGet([
sv1PrestageLatestKey('solo'),
sv1PrestageLatestKey('pplns'),
]) as Array<string | null>;
return values
.filter((value): value is string => value != null)
.map(value => this.parseSv1PrestageUpdate(value));
}
public async setLatestMiningInfo(miningInfo: IMiningInfo) {
if (!await this.ensureConnected()) {
return;
@@ -90,31 +398,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 +525,197 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
);
}
public async tryAcquireJsonCacheLock(key: string, owner: string, ttlMs: number): Promise<boolean | null> {
if (!await this.ensureConnected()) {
return null;
}
if (owner.length === 0 || ttlMs <= 0) {
return false;
}
const result = await this.publisher.set(
jsonCacheLockKey(key),
owner,
{ NX: true, PX: Math.max(1, Math.ceil(ttlMs)) },
);
return result === 'OK';
}
public async releaseJsonCacheLock(key: string, owner: string): Promise<void> {
if (!await this.ensureConnected() || owner.length === 0) {
return;
}
await this.publisher.eval(
`
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
`,
{
keys: [jsonCacheLockKey(key)],
arguments: [owner],
},
);
}
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 parseSv1PrestageUpdate(message: string): Sv1PrestageUpdate {
const update = JSON.parse(message) as Partial<Sv1PrestageUpdate>;
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-prestage'
|| typeof update.eventId !== 'string'
|| !Number.isFinite(update.preparedAtMs)
|| template?.jobType !== 'empty'
|| (payoutMode !== 'solo' && payoutMode !== 'pplns')
|| !Array.isArray(template?.transactions)
|| template.transactions.length !== 0
|| template.previousblockhash !== '0'.repeat(64)
|| !Number.isInteger(template.height)
|| !hasExplicitPplnsPayout) {
throw new Error('unsupported SV1 prestage update');
}
return update as Sv1PrestageUpdate;
}
private parseSv1PrestageActivation(message: string): Sv1PrestageActivation {
const activation = JSON.parse(message) as Partial<Sv1PrestageActivation>;
const payoutMode = activation.payoutMode;
const hasValidPayoutIdentity = payoutMode === 'solo'
? activation.payoutSnapshotId == null
: typeof activation.payoutSnapshotId === 'string'
&& activation.payoutSnapshotId.trim().length > 0;
if (activation.schemaVersion !== 1
|| activation.type !== 'prestage-activation'
|| typeof activation.eventId !== 'string'
|| activation.eventId.trim().length === 0
|| !Number.isSafeInteger(activation.height)
|| activation.height < 0
|| typeof activation.previousBlockHash !== 'string'
|| !/^[0-9a-f]{64}$/.test(activation.previousBlockHash)
|| !Number.isInteger(activation.version)
|| activation.version < -0x80000000
|| activation.version > 0x7fffffff
|| typeof activation.bits !== 'string'
|| !/^[0-9a-f]{8}$/.test(activation.bits)
|| !Number.isSafeInteger(activation.minTime)
|| !Number.isSafeInteger(activation.currentTime)
|| !Number.isSafeInteger(activation.subsidySats)
|| activation.subsidySats < 0
|| (payoutMode !== 'solo' && payoutMode !== 'pplns')
|| !hasValidPayoutIdentity
|| !Number.isInteger(activation.requiredVersionBits)
|| activation.requiredVersionBits < 0
|| activation.requiredVersionBits > 0xffffffff
|| !Number.isFinite(activation.publishedAtMs)) {
throw new Error('unsupported SV1 prestage activation');
}
return activation as Sv1PrestageActivation;
}
private parseBlockFoundNotification(message: string): BlockFoundNotification {
const notification = JSON.parse(message) as Partial<BlockFoundNotification>;
if (notification.schemaVersion !== 1
|| typeof notification.eventId !== 'string'
|| notification.eventId.trim().length < 1
|| typeof notification.address !== 'string'
|| notification.address.trim().length < 1
|| !Number.isInteger(notification.height)
|| typeof notification.blockHash !== 'string'
|| notification.blockHash.trim().length < 1
|| typeof notification.message !== 'string'
|| !Number.isFinite(notification.publishedAtMs)) {
throw new Error('unsupported block found notification');
}
return notification as BlockFoundNotification;
}
private async ensureConnected(): Promise<boolean> {
if (!this.connected) {
try {
+529 -9
View File
@@ -1,12 +1,17 @@
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';
import { EMPTY_DEFAULT_WITNESS_COMMITMENT } from './subsidy-only-template.factory';
describe('StratumV1JobsService', () => {
let blockTemplate$: BehaviorSubject<IBlockTemplate>;
let bitcoinRpcService: { newBlockTemplate$: any, miningInfo: { blocks: number } };
let bridgeTemplate$: Subject<IBlockTemplate>;
let prestageTemplate$: Subject<IBlockTemplate>;
let bitcoinRpcService: { newBlockTemplate$: any, newSv1BridgeTemplate$: any, newSv1PrestageTemplate$: any, miningInfo: { blocks: number } };
let service: StratumV1JobsService;
let consoleLogSpy: jest.SpyInstance;
@@ -22,8 +27,12 @@ describe('StratumV1JobsService', () => {
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
blockTemplate$ = new BehaviorSubject(createTemplate());
bridgeTemplate$ = new Subject<IBlockTemplate>();
prestageTemplate$ = new Subject<IBlockTemplate>();
bitcoinRpcService = {
newBlockTemplate$: blockTemplate$.asObservable(),
newSv1BridgeTemplate$: bridgeTemplate$.asObservable(),
newSv1PrestageTemplate$: prestageTemplate$.asObservable(),
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height }
};
service = new StratumV1JobsService(bitcoinRpcService as any);
@@ -36,33 +45,445 @@ 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('prebuilds a next-height coinbase and promotes the same job on authoritative activation', async () => {
await firstValueFrom(service.newMiningJob$);
const future = createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1);
future.previousblockhash = '0'.repeat(64);
future.transactions = [];
future.coinbasevalue = 312_500_000;
future.default_witness_commitment = EMPTY_DEFAULT_WITNESS_COMMITMENT;
future.jobType = 'empty';
future.payoutMode = 'solo';
future.forceCleanJobs = true;
const detachedResult = firstValueFrom(service.sv1PrestageJob$);
prestageTemplate$.next(future);
const detached = await detachedResult;
const payout = [{
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
percent: 100,
}];
const payoutIdentity = 'solo-prestaged-miner';
const staged = service.preStageJob(
bitcoinjs.networks.testnet,
payout,
detached,
payoutIdentity,
'solo',
);
expect(staged).not.toBeNull();
expect(service.getJobById(staged.jobId)).toBeUndefined();
const prebuiltNotifyBuffer = staged.responseBuffer(detached);
expect(JSON.parse(prebuiltNotifyBuffer.toString()).params[1]).toBe('0'.repeat(64));
const activatedSource = {
...future,
previousblockhash: 'ab'.repeat(32),
notificationEventId: 'authoritative-activation',
};
const activatedResult = firstValueFrom(service.sv1MiningJob$.pipe(skip(1)));
bridgeTemplate$.next(activatedSource);
const activatedTemplate = await activatedResult;
const activated = service.activatePreStagedJob(
activatedTemplate,
payoutIdentity,
'solo',
);
expect(activated).toBe(staged);
expect(staged.responseBuffer(activatedTemplate)).toBe(prebuiltNotifyBuffer);
expect(service.getJobById(staged.jobId)).toBe(staged);
expect(service.getSubmissionContext(staged.jobId)?.status).toBe('current');
const notify = JSON.parse(staged.response(activatedTemplate));
expect(notify.params[1]).toBe(
Buffer.from(activatedTemplate.block.prevHash).swap32().toString('hex'),
);
expect(notify.params.slice(5, 9)).toEqual([
activatedTemplate.block.version.toString(16),
activatedTemplate.block.bits.toString(16),
activatedTemplate.block.timestamp.toString(16),
true,
]);
expect((service as any).stagedJobs.size).toBe(1);
(service as any).cleanupPrestageJobs(activatedTemplate.blockData.height + 1);
expect((service as any).stagedJobs.size).toBe(0);
expect(service.getJobById(staged.jobId)).toBe(staged);
});
it('promotes the latest prestage directly from a compact authoritative activation', async () => {
await firstValueFrom(service.newMiningJob$);
const future = createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1);
future.previousblockhash = '0'.repeat(64);
future.transactions = [];
future.coinbasevalue = 312_500_000;
future.default_witness_commitment = EMPTY_DEFAULT_WITNESS_COMMITMENT;
future.jobType = 'empty';
future.payoutMode = 'solo';
future.forceCleanJobs = true;
const detachedResult = firstValueFrom(service.sv1PrestageJob$);
prestageTemplate$.next(future);
const detached = await detachedResult;
const payout = [{
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
percent: 100,
}];
const payoutIdentity = 'compact-activation-miner';
const staged = service.preStageJob(
bitcoinjs.networks.testnet,
payout,
detached,
payoutIdentity,
'solo',
);
const prebuiltNotify = staged.responseBuffer(detached);
const activation = {
schemaVersion: 1 as const,
type: 'prestage-activation' as const,
eventId: 'compact-authoritative-activation',
height: future.height,
previousBlockHash: 'ab'.repeat(32),
version: future.version,
bits: future.bits,
minTime: future.mintime,
currentTime: future.curtime,
subsidySats: future.coinbasevalue,
payoutMode: 'solo' as const,
requiredVersionBits: future.vbrequired >>> 0,
sourceNotificationReceivedAtMs: Date.now() - 2,
publishedAtMs: Date.now() - 1,
workerReceivedAtMs: Date.now(),
};
const activatedTemplate = service.activateLatestPrestage(activation);
const activatedJob = service.activatePreStagedJob(
activatedTemplate,
payoutIdentity,
'solo',
);
expect(activatedTemplate).toEqual(expect.objectContaining({
blockData: expect.objectContaining({
height: future.height,
tipKey: `${future.height}:${activation.previousBlockHash}`,
jobType: 'empty',
clearJobs: true,
notificationEventId: activation.eventId,
}),
}));
expect(activatedTemplate.block.prevHash.toString('hex')).toBe(
Buffer.from(activation.previousBlockHash, 'hex').reverse().toString('hex'),
);
expect(activatedTemplate.block.bits).toBe(parseInt(activation.bits, 16));
expect(activatedJob).toBe(staged);
expect(activatedJob.responseBuffer(activatedTemplate)).toBe(prebuiltNotify);
expect(service.getSubmissionContext(staged.jobId)?.status).toBe('current');
});
it('refuses compact activation when the worker has no matching prestage', async () => {
await firstValueFrom(service.newMiningJob$);
expect(service.activateLatestPrestage({
schemaVersion: 1,
type: 'prestage-activation',
eventId: 'missing-prestage',
height: MockRecording1.BLOCK_TEMPLATE.height + 1,
previousBlockHash: 'ab'.repeat(32),
version: MockRecording1.BLOCK_TEMPLATE.version,
bits: MockRecording1.BLOCK_TEMPLATE.bits,
minTime: MockRecording1.BLOCK_TEMPLATE.mintime,
currentTime: MockRecording1.BLOCK_TEMPLATE.curtime,
subsidySats: 312_500_000,
payoutMode: 'solo',
requiredVersionBits: 0,
publishedAtMs: Date.now(),
})).toBeNull();
});
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 +550,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 +621,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' });
});
});
+564 -88
View File
@@ -1,13 +1,17 @@
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 { AddressObject, MiningJob } from '../models/MiningJob';
import { hash256 } from '../utils/hash.utils';
import { IBlockTemplate, IBlockTemplateTx } from '../models/bitcoin-rpc/IBlockTemplate';
import { AddressObject, MiningJob, MiningNotifyHeaderFields } from '../models/MiningJob';
import { PayoutMode } from '../types/payout-mode';
import { BitcoinRpcService } from './bitcoin-rpc.service';
import { Sv1PrestageActivation } from './redis-messaging.service';
import {
createPreparedMiningJob,
PreparedMiningJobBodyReference,
} from './prepared-mining-job.factory';
export interface IJobTemplate {
@@ -19,116 +23,181 @@ export interface IJobTemplate {
coinbasevalue: number;
networkDifficulty: number;
height: number;
tipKey: string;
clearJobs: boolean;
isNewBlock: boolean;
jobType: 'full' | 'empty';
payoutMode: PayoutMode | 'all';
notificationEventId?: string;
sourceNotificationReceivedAtMs?: number;
notificationPreparedAtMs?: number;
notificationPublishedAtMs?: number;
notificationWorkerReceivedAtMs?: number;
notificationWorkerHandledAtMs?: 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'];
const PLACEHOLDER_PREV_HASH = Buffer.alloc(32, 0);
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>;
/** Detached next-height empty jobs prepared without changing active work. */
public sv1PrestageJob$: 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 notifyHeaderFields = new WeakMap<IJobTemplate, MiningNotifyHeaderFields>();
private readonly latestJobTemplates = new Map<PayoutMode | 'all', IJobTemplate>();
private readonly latestPrestageJobTemplates = new Map<PayoutMode, IJobTemplate>();
private readonly stagedJobs = new Map<string, {
job: MiningJob;
activatedTemplateId?: string;
}>();
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,
sourceNotificationReceivedAtMs: blockTemplate.sourceNotificationReceivedAtMs,
notificationPreparedAtMs: blockTemplate.notificationPreparedAtMs,
notificationPublishedAtMs: blockTemplate.notificationPublishedAtMs,
notificationWorkerReceivedAtMs: blockTemplate.notificationWorkerReceivedAtMs,
notificationWorkerHandledAtMs: blockTemplate.notificationWorkerHandledAtMs,
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, sourceNotificationReceivedAtMs, notificationPreparedAtMs, notificationPublishedAtMs, notificationWorkerReceivedAtMs, notificationWorkerHandledAtMs, 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 +207,76 @@ 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,
sourceNotificationReceivedAtMs,
notificationPreparedAtMs,
notificationPublishedAtMs,
notificationWorkerReceivedAtMs,
notificationWorkerHandledAtMs,
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 }),
);
this.sv1PrestageJob$ = (
this.bitcoinRpcService.newSv1PrestageTemplate$ ?? EMPTY
).pipe(
map(blockTemplate => this.createDetachedPrestageJobTemplate(blockTemplate)),
tap(jobTemplate => {
const payoutMode = jobTemplate.blockData.payoutMode === 'pplns' ? 'pplns' : 'solo';
this.latestPrestageJobTemplates.set(payoutMode, jobTemplate);
}),
shareReplay({ refCount: true, bufferSize: 2 }),
);
if (process.env.API_ONLY !== 'true' && process.env.MASTER !== 'true') {
this.sv1MiningJob$.subscribe();
this.sv1PrestageJob$.subscribe();
}
}
@@ -197,25 +292,237 @@ 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 getLatestPrestageJobTemplate(payoutMode: PayoutMode): IJobTemplate | null {
return this.latestPrestageJobTemplates.get(payoutMode) ?? null;
}
/**
* Promote a detached next-height job using only authoritative fixed-width
* header fields. This intentionally bypasses the full template/RxJS
* preparation pipeline on the new-tip event-loop turn.
*/
public activateLatestPrestage(
activation: Sv1PrestageActivation,
): IJobTemplate | null {
const payoutMode = activation.payoutMode;
const prestage = this.latestPrestageJobTemplates.get(payoutMode);
if (prestage == null
|| prestage.blockData.height !== activation.height
|| prestage.blockData.jobType !== 'empty'
|| prestage.blockData.payoutMode !== payoutMode
|| prestage.blockData.coinbasevalue !== activation.subsidySats
|| (prestage.blockData.payoutSnapshotId ?? undefined)
!== (activation.payoutSnapshotId ?? undefined)
|| !prestage.block.prevHash.equals(PLACEHOLDER_PREV_HASH)) {
return null;
}
const previousBlockHash = Buffer.from(activation.previousBlockHash, 'hex');
const bits = Buffer.from(activation.bits, 'hex');
if (previousBlockHash.length !== 32 || bits.length !== 4) {
return null;
}
const timestamp = Math.max(
activation.minTime,
activation.currentTime,
Math.floor(Date.now() / 1000),
);
const tipKey = `${activation.height}:${activation.previousBlockHash}`;
const latest = this.latestJobTemplates.get(payoutMode);
if (latest?.blockData.tipKey === tipKey) {
return latest.blockData.jobType === 'empty' ? latest : null;
}
const block = Object.assign(new bitcoinjs.Block(), prestage.block, {
prevHash: Buffer.from(previousBlockHash).reverse(),
version: activation.version,
bits: bits.readUInt32BE(0),
timestamp,
});
const id = this.getNextTemplateId();
this.latestJobTemplateId++;
const isNewBlock = this.lastPreviousBlockHashes.get(payoutMode)
!== activation.previousBlockHash;
this.lastPreviousBlockHashes.set(payoutMode, activation.previousBlockHash);
const activated: IJobTemplate = {
block,
merkle_branch: prestage.merkle_branch,
blockData: {
...prestage.blockData,
id,
creation: Date.now(),
networkDifficulty: this.calculateNetworkDifficulty(block.bits),
tipKey,
clearJobs: true,
isNewBlock,
requiredVersionBits: activation.requiredVersionBits >>> 0,
notificationEventId: activation.eventId,
sourceNotificationReceivedAtMs: activation.sourceNotificationReceivedAtMs,
notificationPreparedAtMs: activation.publishedAtMs,
notificationPublishedAtMs: activation.publishedAtMs,
notificationWorkerReceivedAtMs: activation.workerReceivedAtMs,
notificationWorkerHandledAtMs: Date.now(),
},
};
this.blocks[id] = activated;
const pinnedTemplateIds = this.pinnedCurrentTipTemplateIds.get(payoutMode);
if (this.currentTipKeys.get(payoutMode) !== tipKey) {
pinnedTemplateIds.clear();
}
this.currentTipKeys.set(payoutMode, tipKey);
pinnedTemplateIds.add(id);
this.latestJobTemplates.set(payoutMode, activated);
this.cleanupExpiredJobsAndTemplates();
return activated;
}
public preStageJob(
network: bitcoinjs.networks.Network,
payoutInformation: AddressObject[],
jobTemplate: IJobTemplate,
payoutIdentity: string,
payoutMode: PayoutMode,
): MiningJob | null {
if (jobTemplate.blockData.jobType !== 'empty'
|| jobTemplate.blockData.payoutMode !== payoutMode
|| !jobTemplate.block.prevHash.equals(PLACEHOLDER_PREV_HASH)) {
return null;
}
const key = this.getPrestageJobKey(jobTemplate, payoutIdentity, payoutMode);
const existing = this.stagedJobs.get(key);
if (existing != null) {
return existing.job;
}
const job = new MiningJob(
network,
this.getNextId(),
payoutInformation,
jobTemplate,
{ payoutMode, payoutIdentity },
);
job.preparePreStagedNotify(
jobTemplate,
this.getNotifyHeaderFields(jobTemplate),
);
// Reserve the id now; staged jobs are intentionally absent from `jobs`
// until their authoritative prevhash arrives and miners can submit them.
this.latestJobId++;
this.stagedJobs.set(key, { job });
this.cleanupPrestageJobs(jobTemplate.blockData.height);
return job;
}
public activatePreStagedJob(
jobTemplate: IJobTemplate,
payoutIdentity: string,
payoutMode: PayoutMode,
): MiningJob | null {
if (jobTemplate.blockData.jobType !== 'empty') {
return null;
}
const key = this.getPrestageJobKey(jobTemplate, payoutIdentity, payoutMode);
const staged = this.stagedJobs.get(key);
if (staged == null) {
return null;
}
if (staged.activatedTemplateId != null
&& staged.activatedTemplateId !== jobTemplate.blockData.id) {
// A same-height reorg needs a new job id/payload so a cached response
// can never retain the orphaned prevhash.
return null;
}
if (staged.activatedTemplateId == null) {
staged.job.activatePreStagedTemplate(
jobTemplate,
this.getNotifyHeaderFields(jobTemplate),
);
staged.activatedTemplateId = jobTemplate.blockData.id;
this.jobs[staged.job.jobId] = staged.job;
this.cachedJobs.set(
this.getJobCacheKey(jobTemplate, payoutMode, payoutIdentity),
staged.job,
);
}
return staged.job;
}
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 = this.getJobCacheKey(
jobTemplate,
effectivePayoutMode,
effectivePayoutIdentity,
);
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 +530,173 @@ 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 createDetachedPrestageJobTemplate(blockTemplate: IBlockTemplate): IJobTemplate {
const prepared = createPreparedMiningJob(blockTemplate);
if (prepared.jobType !== 'empty'
|| prepared.header.previousBlockHash !== '0'.repeat(64)
|| (prepared.payoutMode !== 'solo' && prepared.payoutMode !== 'pplns')) {
throw new Error('SV1 prestage must be a payout-specific empty template with a placeholder prevhash');
}
const block = new bitcoinjs.Block();
const tempCoinbaseTx = new bitcoinjs.Transaction();
tempCoinbaseTx.version = 2;
tempCoinbaseTx.addInput(Buffer.alloc(32, 0), 0xffffffff, 0xffffffff);
tempCoinbaseTx.ins[0].witness = [Buffer.alloc(32, 0)];
block.prevHash = Buffer.alloc(32, 0);
block.version = prepared.header.version;
block.bits = prepared.header.bits;
block.timestamp = Math.max(
prepared.header.minTime,
Math.floor(Date.now() / 1000),
);
block.transactions = [tempCoinbaseTx];
block.merkleRoot = tempCoinbaseTx.getHash(false);
block.witnessCommit = Buffer.from(prepared.coinbase.witnessCommitmentHash, 'hex');
const id = `prestage-${this.getNextTemplateId()}`;
this.latestJobTemplateId++;
return {
block,
merkle_branch: [],
blockData: {
id,
creation: Date.now(),
coinbasevalue: prepared.coinbase.valueSats,
networkDifficulty: this.calculateNetworkDifficulty(prepared.header.bits),
height: prepared.height,
tipKey: `prestage:${prepared.height}`,
clearJobs: true,
isNewBlock: false,
jobType: 'empty',
payoutMode: prepared.payoutMode,
notificationEventId: blockTemplate.notificationEventId,
notificationPreparedAtMs: blockTemplate.notificationPreparedAtMs,
payoutSnapshotId: prepared.coinbase.payoutSnapshotId,
payoutOutputs: prepared.coinbase.payoutOutputs?.map(output => ({ ...output })),
transactions: [],
bodyReference: prepared.body,
sigoplimit: blockTemplate.sigoplimit,
sizelimit: blockTemplate.sizelimit,
weightlimit: blockTemplate.weightlimit,
requiredVersionBits: blockTemplate.vbrequired >>> 0,
},
};
}
private getJobCacheKey(
jobTemplate: IJobTemplate,
payoutMode: PayoutMode,
payoutIdentity: string,
): string {
return [
jobTemplate.blockData.id,
jobTemplate.block.timestamp,
jobTemplate.blockData.clearJobs,
payoutMode,
payoutIdentity,
].join(':');
}
public getNotifyHeaderFields(jobTemplate: IJobTemplate): MiningNotifyHeaderFields {
const cached = this.notifyHeaderFields.get(jobTemplate);
if (cached != null) {
return cached;
}
const fields = MiningJob.createNotifyHeaderFields(jobTemplate);
this.notifyHeaderFields.set(jobTemplate, fields);
return fields;
}
private getPrestageJobKey(
jobTemplate: IJobTemplate,
payoutIdentity: string,
payoutMode: PayoutMode,
): string {
return [
jobTemplate.blockData.height,
jobTemplate.blockData.coinbasevalue,
jobTemplate.block.witnessCommit.toString('hex'),
payoutMode,
payoutIdentity,
jobTemplate.blockData.payoutSnapshotId ?? '',
].join(':');
}
private cleanupPrestageJobs(latestCandidateHeight: number): void {
for (const key of this.stagedJobs.keys()) {
const height = Number(key.slice(0, key.indexOf(':')));
if (height < latestCandidateHeight) {
// Activated jobs remain available through `jobs`/`cachedJobs` for
// candidate reconstruction and normal retention. The staging
// index is only needed through that height's fanout and would
// otherwise retain every miner payout identity forever.
this.stagedJobs.delete(key);
}
}
}
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;
}
}
+215 -2
View File
@@ -1,3 +1,5 @@
import { Subject } from 'rxjs';
import { StratumV1Service } from './stratum-v1.service';
describe('StratumV1Service', () => {
@@ -10,12 +12,16 @@ describe('StratumV1Service', () => {
const originalTlsHandshakeTimeoutMs = process.env.STRATUM_TLS_HANDSHAKE_TIMEOUT_MS;
const originalSocketTimeoutMs = process.env.STRATUM_SOCKET_TIMEOUT_MS;
const originalTcpKeepAliveInitialDelayMs = process.env.STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS;
const originalFanoutTargetClientsPerWorker = process.env.STRATUM_FANOUT_TARGET_CLIENTS_PER_WORKER;
let service: StratumV1Service;
let clientService;
let userAgentReportService;
let stratumV2Service;
let redisMessagingService;
let miningJobs: Subject<any>;
let prestageJobs: Subject<any>;
let prestageActivations: Subject<any>;
let consoleLogSpy: jest.SpyInstance;
let consoleWarnSpy: jest.SpyInstance;
@@ -32,13 +38,19 @@ describe('StratumV1Service', () => {
createClient: jest.fn()
};
redisMessagingService = {};
miningJobs = new Subject();
prestageJobs = new Subject();
prestageActivations = new Subject();
service = new StratumV1Service(
{} as any,
{ newSv1PrestageActivation$: prestageActivations.asObservable() } as any,
clientService,
{} as any,
{} as any,
{} as any,
{} as any,
{
newMiningJob$: miningJobs.asObservable(),
sv1PrestageJob$: prestageJobs.asObservable(),
} as any,
{} as any,
stratumV2Service as any,
userAgentReportService as any,
@@ -59,6 +71,7 @@ describe('StratumV1Service', () => {
restoreEnv('STRATUM_TLS_HANDSHAKE_TIMEOUT_MS', originalTlsHandshakeTimeoutMs);
restoreEnv('STRATUM_SOCKET_TIMEOUT_MS', originalSocketTimeoutMs);
restoreEnv('STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS', originalTcpKeepAliveInitialDelayMs);
restoreEnv('STRATUM_FANOUT_TARGET_CLIENTS_PER_WORKER', originalFanoutTargetClientsPerWorker);
consoleLogSpy.mockRestore();
consoleWarnSpy.mockRestore();
jest.useRealTimers();
@@ -101,6 +114,206 @@ 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', () => {
process.env.STRATUM_FANOUT_TARGET_CLIENTS_PER_WORKER = '10000';
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,
targetClientsPerWorker: 10_000,
overTargetClients: 90_000,
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('pre-stages next-height jobs for connected miners without broadcasting them', async () => {
process.env.MASTER = 'false';
process.env.STRATUM_PORTS = '';
process.env.STRATUM_SECURE = 'false';
const clients = Array.from({ length: 3 }, () => ({
preStageMiningJob: jest.fn().mockReturnValue(true),
broadcastMiningJob: jest.fn(),
}));
clients.forEach(client => (service as any).clients.add(client));
await service.onModuleInit();
const prestage = {
blockData: {
id: 'prestage-2',
height: 900002,
payoutMode: 'solo',
notificationEventId: 'prestage-event',
},
};
prestageJobs.next(prestage);
await Promise.resolve();
clients.forEach(client => {
expect(client.preStageMiningJob).toHaveBeenCalledWith(prestage);
expect(client.broadcastMiningJob).not.toHaveBeenCalled();
});
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('sv1_job_prestage'));
service.onModuleDestroy();
});
it('promotes and broadcasts compact prestage activations immediately', async () => {
process.env.MASTER = 'false';
process.env.STRATUM_PORTS = '';
process.env.STRATUM_SECURE = 'false';
const activatedJob = {
blockData: {
id: 'activated-2',
height: 900002,
tipKey: `900002:${'55'.repeat(32)}`,
payoutMode: 'solo',
jobType: 'empty',
isNewBlock: true,
clearJobs: true,
notificationEventId: 'activate-2',
},
};
const activateLatestPrestage = jest.fn().mockReturnValue(activatedJob);
(service as any).stratumV1JobsService.activateLatestPrestage = activateLatestPrestage;
const client = {
broadcastMiningJob: jest.fn().mockReturnValue({
status: 'written',
bytes: 256,
bufferedBytes: 0,
preStaged: true,
}),
};
(service as any).clients.add(client);
await service.onModuleInit();
const activation = {
eventId: 'activate-2',
height: 900002,
payoutMode: 'solo',
previousBlockHash: '55'.repeat(32),
};
prestageActivations.next(activation);
expect(activateLatestPrestage).toHaveBeenCalledWith(activation);
expect(client.broadcastMiningJob).toHaveBeenCalledWith(activatedJob);
service.onModuleDestroy();
});
it('does not log routine non-new-block fanout unless explicitly enabled', () => {
(service as any).clients.add({
broadcastMiningJob: jest.fn().mockReturnValue({
status: 'written',
bytes: 256,
bufferedBytes: 0,
}),
});
const job = {
blockData: {
id: 'routine',
height: 900001,
jobType: 'full',
isNewBlock: false,
clearJobs: false,
},
};
(service as any).broadcastMiningJob(job);
expect(consoleLogSpy.mock.calls.some(call => call[0]?.includes('stratum_job_fanout'))).toBe(false);
});
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({
+265 -3
View File
@@ -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';
@@ -14,6 +15,7 @@ import { ShareAccountingService } from '../ORM/share-accounting/share-accounting
import { BitcoinRpcService } from './bitcoin-rpc.service';
import { NotificationService } from './notification.service';
import { RedisMessagingService } from './redis-messaging.service';
import { Sv1PrestageActivation } from './redis-messaging.service';
import { StratumV1JobsService } from './stratum-v1-jobs.service';
import { StratumV2Service } from './stratum-v2.service';
import { parsePayoutModePorts, PayoutMode } from '../types/payout-mode';
@@ -40,11 +42,13 @@ const DEFAULT_MAX_CONNECTIONS_PER_LISTENER = 10000;
const DEFAULT_TLS_HANDSHAKE_TIMEOUT_MS = 10000;
const DEFAULT_SOCKET_TIMEOUT_MS = 1000 * 60 * 60;
const DEFAULT_TCP_KEEPALIVE_INITIAL_DELAY_MS = 1000 * 60;
const DEFAULT_PRESTAGE_BATCH_SIZE = 500;
const DEFAULT_FANOUT_TARGET_CLIENTS_PER_WORKER = 10000;
@Injectable()
export class StratumV1Service implements OnModuleInit {
export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
private socketTimeout = 0;
private emptySocket = 0;
@@ -54,6 +58,13 @@ 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;
private jobActivationSubscription: Subscription | null = null;
private jobPrestageSubscription: Subscription | null = null;
private readonly pendingPrestageJobs = new Map<PayoutMode, import('./stratum-v1-jobs.service').IJobTemplate>();
private prestageDrainRunning = false;
private prestageGeneration = 0;
constructor(
private readonly bitcoinRpcService: BitcoinRpcService,
@@ -86,6 +97,23 @@ export class StratumV1Service implements OnModuleInit {
return;
}
this.jobActivationSubscription = this.bitcoinRpcService
.newSv1PrestageActivation$?.subscribe({
next: activation => this.activateAndBroadcastPrestage(activation),
error: error => console.error(`SV1 prestage activation subscription failed: ${error.message}`),
}) ?? null;
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}`),
});
this.jobPrestageSubscription = this.stratumV1JobsService.sv1PrestageJob$?.subscribe({
next: jobTemplate => this.queuePrestageMiningJob(jobTemplate),
error: error => console.error(`SV1 job prestage subscription failed: ${error.message}`),
}) ?? null;
// wait for all the other processes to init for an even connection distribution
setTimeout(() => {
parsePayoutModePorts(
@@ -116,6 +144,21 @@ export class StratumV1Service implements OnModuleInit {
}
public onModuleDestroy(): void {
this.jobBroadcastSubscription?.unsubscribe();
this.jobBroadcastSubscription = null;
this.jobActivationSubscription?.unsubscribe();
this.jobActivationSubscription = null;
this.jobPrestageSubscription?.unsubscribe();
this.jobPrestageSubscription = null;
this.prestageGeneration++;
this.pendingPrestageJobs.clear();
if (this.backpressureMonitor != null) {
clearInterval(this.backpressureMonitor);
this.backpressureMonitor = null;
}
}
private startSocketServer(port: number, payoutMode: PayoutMode) {
const listener: StratumListenerState = {
port,
@@ -132,6 +175,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 +196,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 +276,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 +291,8 @@ export class StratumV1Service implements OnModuleInit {
accountingProtocol,
payoutMode,
);
this.clients.add(client);
return client;
}
private startSecureSocketServer(port: number, payoutMode: PayoutMode) {
@@ -273,6 +322,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 +336,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 +481,217 @@ export class StratumV1Service implements OnModuleInit {
return Math.round(this.eventLoopDelay.percentile(95) / 1e6);
}
private broadcastMiningJob(jobTemplate: import('./stratum-v1-jobs.service').IJobTemplate): void {
const fanoutStartedAtMs = Date.now();
const startedAt = process.hrtime.bigint();
const totalClients = this.clients.size;
const targetClientsPerWorker = this.getPositiveIntegerEnv(
'STRATUM_FANOUT_TARGET_CLIENTS_PER_WORKER',
DEFAULT_FANOUT_TARGET_CLIENTS_PER_WORKER,
);
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 preStaged = 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);
if (result.preStaged) {
preStaged++;
}
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();
}
}
if (!this.shouldLogJobFanout(jobTemplate.blockData.isNewBlock, errors)) {
return;
}
console.log(JSON.stringify({
event: 'stratum_job_fanout',
eventId: jobTemplate.blockData.notificationEventId,
sourceToFanoutStartMs: jobTemplate.blockData.sourceNotificationReceivedAtMs == null
? undefined
: fanoutStartedAtMs - jobTemplate.blockData.sourceNotificationReceivedAtMs,
masterPrepareMs: jobTemplate.blockData.notificationPreparedAtMs == null
|| jobTemplate.blockData.sourceNotificationReceivedAtMs == null
? undefined
: jobTemplate.blockData.notificationPreparedAtMs
- jobTemplate.blockData.sourceNotificationReceivedAtMs,
masterPublishRequestMs: jobTemplate.blockData.notificationPublishedAtMs == null
|| jobTemplate.blockData.sourceNotificationReceivedAtMs == null
? undefined
: jobTemplate.blockData.notificationPublishedAtMs
- jobTemplate.blockData.sourceNotificationReceivedAtMs,
masterToWorkerReceiveMs: jobTemplate.blockData.notificationPublishedAtMs == null
|| jobTemplate.blockData.notificationWorkerReceivedAtMs == null
? undefined
: jobTemplate.blockData.notificationWorkerReceivedAtMs
- jobTemplate.blockData.notificationPublishedAtMs,
workerReceiveToHandleMs: jobTemplate.blockData.notificationWorkerReceivedAtMs == null
|| jobTemplate.blockData.notificationWorkerHandledAtMs == null
? undefined
: jobTemplate.blockData.notificationWorkerHandledAtMs
- jobTemplate.blockData.notificationWorkerReceivedAtMs,
workerHandleToFanoutStartMs: jobTemplate.blockData.notificationWorkerHandledAtMs == null
? undefined
: fanoutStartedAtMs - jobTemplate.blockData.notificationWorkerHandledAtMs,
redisToFanoutStartMs: jobTemplate.blockData.notificationPublishedAtMs == null
? undefined
: fanoutStartedAtMs - 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,
targetClientsPerWorker,
overTargetClients: Math.max(0, totalClients - targetClientsPerWorker),
written,
skipped,
backpressured,
closed,
errors,
preStaged,
bytesQueued,
maxBufferedBytes,
milestoneMs,
totalMs: elapsedMs(),
}));
}
private activateAndBroadcastPrestage(activation: Sv1PrestageActivation): void {
const jobTemplate = this.stratumV1JobsService.activateLatestPrestage(activation);
if (jobTemplate == null) {
console.warn(JSON.stringify({
event: 'sv1_prestage_activation_miss',
eventId: activation.eventId,
height: activation.height,
payoutMode: activation.payoutMode,
previousBlockHash: activation.previousBlockHash,
workerReceivedAtMs: activation.workerReceivedAtMs,
}));
return;
}
this.broadcastMiningJob(jobTemplate);
}
private queuePrestageMiningJob(
jobTemplate: import('./stratum-v1-jobs.service').IJobTemplate,
): void {
const payoutMode = jobTemplate.blockData.payoutMode === 'pplns' ? 'pplns' : 'solo';
this.pendingPrestageJobs.set(payoutMode, jobTemplate);
if (this.prestageDrainRunning) {
return;
}
this.prestageDrainRunning = true;
const generation = this.prestageGeneration;
void this.drainPrestageMiningJobs(generation).finally(() => {
this.prestageDrainRunning = false;
if (generation === this.prestageGeneration
&& this.pendingPrestageJobs.size > 0) {
const latest = this.pendingPrestageJobs.values().next().value;
if (latest != null) {
this.queuePrestageMiningJob(latest);
}
}
});
}
private async drainPrestageMiningJobs(generation: number): Promise<void> {
while (generation === this.prestageGeneration
&& this.pendingPrestageJobs.size > 0) {
const [payoutMode, jobTemplate] = this.pendingPrestageJobs.entries().next().value as [
PayoutMode,
import('./stratum-v1-jobs.service').IJobTemplate,
];
this.pendingPrestageJobs.delete(payoutMode);
const clients = [...this.clients];
const batchSize = this.getPositiveIntegerEnv(
'SV1_PRESTAGE_BATCH_SIZE',
DEFAULT_PRESTAGE_BATCH_SIZE,
);
const startedAt = process.hrtime.bigint();
let staged = 0;
let skipped = 0;
let errors = 0;
for (let offset = 0; offset < clients.length; offset += batchSize) {
if (generation !== this.prestageGeneration) {
return;
}
for (const client of clients.slice(offset, offset + batchSize)) {
try {
if (client.preStageMiningJob(jobTemplate)) {
staged++;
} else {
skipped++;
}
} catch {
errors++;
}
}
// Staging is deliberately background work. Yield between bounded
// batches so share parsing and urgent bridge callbacks stay live.
if (offset + batchSize < clients.length) {
await new Promise<void>(resolve => setImmediate(resolve));
}
}
console.log(JSON.stringify({
event: 'sv1_job_prestage',
eventId: jobTemplate.blockData.notificationEventId,
height: jobTemplate.blockData.height,
payoutMode,
clients: clients.length,
staged,
skipped,
errors,
totalMs: Number(process.hrtime.bigint() - startedAt) / 1e6,
}));
}
}
private shouldLogJobFanout(isNewBlock: boolean, errors: number): boolean {
if (errors > 0 || isNewBlock) {
return true;
}
return process.env.STRATUM_FANOUT_LOG_ENABLED?.toLowerCase() === 'true';
}
private isBackpressureDisabled() {
return process.env.STRATUM_BACKPRESSURE_ENABLED?.toLowerCase() === 'false';
}
+419
View File
@@ -0,0 +1,419 @@
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 { Sv1PrestageActivation } from './redis-messaging.service';
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('converts compact activations into header-only SV2 work activation fanout', async () => {
const { service, compactActivations } = createService();
await service.onModuleInit();
const client = createClientMock();
service.registerClient(client);
compactActivations.next(createCompactActivation(900_002, '22'.repeat(32)));
expect(client.enqueueWorkActivation).toHaveBeenCalledTimes(1);
expect(client.enqueueWorkActivation).toHaveBeenCalledWith(expect.objectContaining({
height: 900_002,
previousblockhash: '22'.repeat(32),
transactions: [],
jobType: 'empty',
version: 0x20000000,
bits: '1d00ffff',
mintime: 1_700_000_002,
}));
expect(service.getLatestWorkActivationTemplate()).toEqual(expect.objectContaining({
height: 900_002,
notificationEventId: 'compact-900002',
}));
await service.onModuleDestroy();
});
it('deduplicates a legacy bridge that follows the compact activation for the same tip', async () => {
const { service, activations, compactActivations } = createService();
await service.onModuleInit();
const client = createClientMock();
service.registerClient(client);
const previousBlockHash = '33'.repeat(32);
compactActivations.next(createCompactActivation(900_003, previousBlockHash));
activations.next(createActivationTemplate(900_003, previousBlockHash, 'solo'));
expect(client.enqueueWorkActivation).toHaveBeenCalledTimes(1);
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>;
compactActivations: Subject<Sv1PrestageActivation>;
activationSubscribeSpy: jest.Mock;
} {
const templates = new Subject<IJobTemplate>();
const subscribeSpy = jest.fn((observer) => templates.subscribe(observer));
const activations = new Subject<any>();
const compactActivations = new Subject<Sv1PrestageActivation>();
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)),
newSv1PrestageActivation$: compactActivations.asObservable(),
};
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, compactActivations, 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 createCompactActivation(
height: number,
previousBlockHash: string,
): Sv1PrestageActivation {
return {
schemaVersion: 1,
type: 'prestage-activation',
eventId: `compact-${height}`,
height,
previousBlockHash,
version: 0x20000000,
bits: '1d00ffff',
minTime: 1_700_000_001,
currentTime: 1_700_000_002,
subsidySats: 312_500_000,
payoutMode: 'solo',
requiredVersionBits: 0,
publishedAtMs: 1_700_000_000_000,
};
}
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;
}
+208 -16
View File
@@ -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,
@@ -21,8 +30,8 @@ import {
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 { RedisMessagingService, Sv1PrestageActivation } from './redis-messaging.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,22 @@ 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 compactActivationSubscription: 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 +81,10 @@ export class StratumV2Service implements OnModuleInit {
return;
}
this.getExtranonceManager();
this.startCanonicalJobBroadcaster();
this.startWorkActivationBroadcaster();
const ports = this.getPorts();
if (ports.length === 0) {
return;
@@ -74,6 +94,25 @@ 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;
this.compactActivationSubscription?.unsubscribe();
this.compactActivationSubscription = 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 +126,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 +144,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 +180,163 @@ 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.compactActivationSubscription == null
&& this.bitcoinRpcService.newSv1PrestageActivation$ != null
) {
this.compactActivationSubscription = this.bitcoinRpcService.newSv1PrestageActivation$.subscribe({
next: activation => this.broadcastWorkActivation(
this.createCompactWorkActivationTemplate(activation),
),
error: error => console.error(`SV2 compact work activation subscription failed: ${error.message}`),
});
}
if (this.workActivationSubscription == null && this.bitcoinRpcService.workActivationTemplate$ != null) {
this.workActivationSubscription = this.bitcoinRpcService.workActivationTemplate$.subscribe({
next: template => this.broadcastWorkActivation(template),
error: error => console.error(`SV2 work activation subscription failed: ${error.message}`),
});
}
}
private createCompactWorkActivationTemplate(activation: Sv1PrestageActivation): IBlockTemplate {
return {
version: activation.version,
rules: [],
vbavailable: {},
vbrequired: activation.requiredVersionBits,
previousblockhash: activation.previousBlockHash,
transactions: [],
coinbaseaux: {},
coinbasevalue: activation.subsidySats,
longpollid: '',
target: '',
mintime: Math.max(activation.minTime, activation.currentTime),
mutable: [],
noncerange: '',
sigoplimit: 0,
sizelimit: 0,
weightlimit: 0,
curtime: activation.currentTime,
bits: activation.bits,
height: activation.height,
default_witness_commitment: '',
capabilities: [],
payoutSnapshotId: activation.payoutSnapshotId,
jobType: 'empty',
payoutMode: activation.payoutMode,
notificationEventId: activation.eventId,
sourceNotificationReceivedAtMs: activation.sourceNotificationReceivedAtMs,
notificationPublishedAtMs: activation.publishedAtMs,
notificationWorkerReceivedAtMs: activation.workerReceivedAtMs,
};
}
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,
};
}
+37
View File
@@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
import { encodeSv2AuthorityPublicKey } from '../models/sv2/sv2-authority-key';
import { xOnlyPubKeyFromPriv } from '../models/sv2/sv2-noise';
@Injectable()
export class Sv2AuthorityService {
private authorityPublicKey: string | null = null;
private authorityKeyConfigured = false;
constructor(
private readonly configService: ConfigService,
) {}
public async getPoolAuthorityPublicKey(): Promise<{ publicKey: string; configured: boolean }> {
if (this.authorityPublicKey != null) {
return {
publicKey: this.authorityPublicKey,
configured: this.authorityKeyConfigured,
};
}
const configuredAuthorityKey = this.configService.get<string>('SV2_AUTHORITY_PRIVKEY');
this.authorityKeyConfigured = configuredAuthorityKey?.length === 64;
const authorityPrivKey = configuredAuthorityKey?.length === 64
? Buffer.from(configuredAuthorityKey, 'hex')
: crypto.randomBytes(32);
this.authorityPublicKey = encodeSv2AuthorityPublicKey(xOnlyPubKeyFromPriv(authorityPrivKey));
return {
publicKey: this.authorityPublicKey,
configured: this.authorityKeyConfigured,
};
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ export class TelegramService implements OnModuleInit {
}, 2000);
}
public async notifySubscribersBlockFound(address: string, height: number, block: Block, message: string) {
public async notifySubscribersBlockFound(address: string, height: number, block: Block | undefined, message: string) {
if (this.bot == null) {
return;
}
+1 -1
View File
@@ -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 => {
+141
View File
@@ -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);
+53
View File
@@ -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;
}
});
+25 -4
View File
@@ -76,7 +76,7 @@ describe('TimescaleDB and Redis integration', () => {
const aggregates = await dataSource.query(`
SELECT view_name
FROM timescaledb_information.continuous_aggregates
WHERE view_name IN ('accepted_share_10m', 'accepted_share_1h', 'accepted_share_1d', 'accepted_share_block_10m')
WHERE view_name IN ('accepted_share_10m', 'accepted_share_1h', 'accepted_share_1d', 'accepted_share_block_10m', 'accepted_share_pool_10m')
ORDER BY view_name
`);
expect(aggregates.map(row => row.view_name)).toEqual([
@@ -84,6 +84,7 @@ describe('TimescaleDB and Redis integration', () => {
'accepted_share_1d',
'accepted_share_1h',
'accepted_share_block_10m',
'accepted_share_pool_10m',
]);
const legacyTables = await dataSource.query(`
@@ -228,6 +229,7 @@ describe('TimescaleDB and Redis integration', () => {
await dataSource.query(`CALL refresh_continuous_aggregate('accepted_share_10m', NULL, NULL)`);
await dataSource.query(`CALL refresh_continuous_aggregate('accepted_share_block_10m', NULL, NULL)`);
await dataSource.query(`CALL refresh_continuous_aggregate('accepted_share_pool_10m', NULL, NULL)`);
const aggregateRows = await dataSource.query(`
SELECT "shares"::float AS shares, "acceptedCount"::int AS "acceptedCount"
FROM accepted_share_10m
@@ -250,6 +252,16 @@ describe('TimescaleDB and Redis integration', () => {
expect(blockAggregateRows).toEqual(expect.arrayContaining([
expect.objectContaining({ shares: 96, acceptedCount: 2, networkDifficulty: 100000 }),
]));
const poolAggregateRows = await dataSource.query(`
SELECT "shares"::float AS shares, "acceptedCount"::int AS "acceptedCount"
FROM accepted_share_pool_10m
WHERE "payoutMode" = $1
`, ['solo']);
expect(poolAggregateRows).toEqual(expect.arrayContaining([
expect.objectContaining({ shares: 96, acceptedCount: 2 }),
]));
});
it('should retain daily and all-time best share from completed rollup buckets', async () => {
@@ -603,15 +615,24 @@ describe('TimescaleDB and Redis integration', () => {
redisMessagingService,
);
const activeClient = await repository.save({
await repository.save({
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'active-live-worker',
clientName: 'idle-live-worker',
sessionId: '91b2c3d4',
userAgent,
startTime: new Date(),
bestDifficulty: 11,
hashRate: 0,
});
const activeClient = await repository.save({
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'active-live-worker',
sessionId: '93b2c3d4',
userAgent,
startTime: new Date(),
bestDifficulty: 33,
hashRate: 300,
});
const staleClient = await repository.save({
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'stale-live-worker',
@@ -628,7 +649,7 @@ describe('TimescaleDB and Redis integration', () => {
userAgent,
count: '1',
bestDifficulty: Number(activeClient.bestDifficulty),
totalHashRate: '0',
totalHashRate: String(activeClient.hashRate),
}]));
});