mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 09:05:06 -07:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
789b529bd5 | ||
|
|
5889e3af5b | ||
|
|
f10cef61ce | ||
|
|
07a2b173d0 | ||
|
|
4c56839c02 | ||
|
|
1cdf3fbf7e | ||
|
|
d1ebc55871 | ||
|
|
7d96c06bbf | ||
|
|
dd39c6edcf | ||
|
|
86ad296189 | ||
|
|
f49e7b5b8f | ||
|
|
3cb4cbfbdf | ||
|
|
f31cdef7f0 | ||
|
|
943dd60eef | ||
|
|
4150d1e425 | ||
|
|
fd226f206f | ||
|
|
0b030fecca | ||
|
|
448f7d4c52 | ||
|
|
d830227178 | ||
|
|
fe287c32c7 | ||
|
|
65101a98a2 | ||
|
|
c60264bbe5 | ||
|
|
3d58802feb | ||
|
|
bc6f2eebd8 | ||
|
|
4f29269fed | ||
|
|
126b05224f | ||
|
|
12f3983c8f | ||
|
|
9998c93eae | ||
|
|
bdd3c7c3ae | ||
|
|
1943ea7768 | ||
|
|
6dcbb8436d |
+80
-4
@@ -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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Logs
|
||||
logs
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
+50
-10
@@ -1,9 +1,33 @@
|
||||
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
|
||||
@@ -11,7 +35,7 @@ module.exports = {
|
||||
...dockerLogConfig,
|
||||
name: 'api',
|
||||
script: './dist/main.js',
|
||||
instances: parseInt(process.env.API_WORKERS || '4', 10),
|
||||
instances: apiWorkers,
|
||||
exec_mode: 'cluster',
|
||||
env: {
|
||||
MASTER: 'false',
|
||||
@@ -19,13 +43,14 @@ module.exports = {
|
||||
API_ENABLED: 'true',
|
||||
NODE_CLUSTER_SCHED_POLICY: 'none',
|
||||
},
|
||||
time: true
|
||||
time: true,
|
||||
},
|
||||
// Master instance
|
||||
// Minimal authoritative template/notifier instance. This entrypoint avoids
|
||||
// initializing API, Stratum, reporting, and notification integrations.
|
||||
{
|
||||
...dockerLogConfig,
|
||||
name: 'master',
|
||||
script: './dist/main.js',
|
||||
script: './dist/notifier-main.js',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
env: {
|
||||
@@ -33,22 +58,37 @@ module.exports = {
|
||||
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: parseInt(process.env.STRATUM_WORKERS || '2', 10),
|
||||
exec_mode: "cluster",
|
||||
max_memory_restart: process.env.STRATUM_WORKER_MAX_MEMORY_RESTART || '4096M',
|
||||
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
|
||||
time: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+33
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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,7 +653,13 @@ export class PayoutSnapshotService {
|
||||
};
|
||||
}
|
||||
|
||||
private async insertSnapshotEntry(manager: EntityManager, snapshotId: string, entry: PayoutDistributionEntry): Promise<void> {
|
||||
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",
|
||||
@@ -657,37 +675,65 @@ export class PayoutSnapshotService {
|
||||
"includedInCoinbase",
|
||||
"rowType",
|
||||
"rank"
|
||||
) VALUES (
|
||||
)
|
||||
SELECT
|
||||
$1::bigint,
|
||||
$2,
|
||||
$3,
|
||||
$4::numeric,
|
||||
$5::bigint,
|
||||
$6::numeric,
|
||||
$7::bigint,
|
||||
$8::bigint,
|
||||
$9::bigint,
|
||||
$10::bigint,
|
||||
$11::boolean,
|
||||
$12,
|
||||
$13::int
|
||||
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,
|
||||
entry.address,
|
||||
entry.creditedDifficulty,
|
||||
entry.acceptedShareCount,
|
||||
entry.payoutWeight,
|
||||
entry.grossPayoutSats,
|
||||
entry.payoutSats,
|
||||
entry.balanceBeforeSats,
|
||||
entry.balanceAfterSats,
|
||||
entry.includedInCoinbase,
|
||||
entry.includedInCoinbase ? 'coinbase' : 'pending',
|
||||
entry.rank,
|
||||
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[] {
|
||||
let included = 0;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
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: now + this.summaryCacheMs,
|
||||
value,
|
||||
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.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;
|
||||
}
|
||||
|
||||
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.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,
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+33
-8
@@ -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: {
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
@@ -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 { }
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+291
-62
@@ -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);
|
||||
|
||||
let version = jobTemplate.block.version;
|
||||
if (versionMask !== undefined && versionMask != 0) {
|
||||
version = version ^ versionMask;
|
||||
return MiningJob.calculateMerkleRootFromCoinbaseHash(
|
||||
hash256(coinbaseBuffer),
|
||||
this.merkleBranchBuffers,
|
||||
);
|
||||
}
|
||||
|
||||
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 encodeCoinbaseHeight(height: number): Buffer {
|
||||
if (!Number.isSafeInteger(height) || height < 0) {
|
||||
throw new Error('Coinbase height must be a non-negative safe integer');
|
||||
}
|
||||
if (height === 0) {
|
||||
return Buffer.from([bitcoinjs.opcodes.OP_0]);
|
||||
}
|
||||
if (height <= 16) {
|
||||
return Buffer.from([bitcoinjs.opcodes.OP_1 + height - 1]);
|
||||
}
|
||||
|
||||
const encoded = bitcoinjs.script.number.encode(height);
|
||||
if (encoded.length >= bitcoinjs.opcodes.OP_PUSHDATA1) {
|
||||
throw new Error('Coinbase height encoding is unexpectedly large');
|
||||
}
|
||||
return Buffer.concat([Buffer.from([encoded.length]), encoded]);
|
||||
}
|
||||
|
||||
private getPaymentScript(address: string): Buffer {
|
||||
const cacheKey = `${this.network.bech32}:${this.network.pubKeyHash}:${this.network.scriptHash}:${address}`;
|
||||
const cached = MiningJob.paymentScriptCache.get(cacheKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const addressInfo = getAddressInfo(address);
|
||||
let paymentScript: Buffer;
|
||||
switch (addressInfo.type) {
|
||||
case AddressType.p2wpkh: {
|
||||
return bitcoinjs.payments.p2wpkh({ address, network: this.network }).output;
|
||||
paymentScript = bitcoinjs.payments.p2wpkh({ address, network: this.network }).output;
|
||||
break;
|
||||
}
|
||||
case AddressType.p2pkh: {
|
||||
return bitcoinjs.payments.p2pkh({ address, network: this.network }).output;
|
||||
paymentScript = bitcoinjs.payments.p2pkh({ address, network: this.network }).output;
|
||||
break;
|
||||
}
|
||||
case AddressType.p2sh: {
|
||||
return bitcoinjs.payments.p2sh({ address, network: this.network }).output;
|
||||
paymentScript = bitcoinjs.payments.p2sh({ address, network: this.network }).output;
|
||||
break;
|
||||
}
|
||||
case AddressType.p2tr: {
|
||||
return bitcoinjs.payments.p2tr({ address, network: this.network }).output;
|
||||
paymentScript = bitcoinjs.payments.p2tr({ address, network: this.network }).output;
|
||||
break;
|
||||
}
|
||||
case AddressType.p2wsh: {
|
||||
return bitcoinjs.payments.p2wsh({ address, network: this.network }).output;
|
||||
paymentScript = bitcoinjs.payments.p2wsh({ address, network: this.network }).output;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
paymentScript = Buffer.alloc(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public response(jobTemplate: IJobTemplate): string {
|
||||
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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
+505
-155
@@ -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.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);
|
||||
}
|
||||
await this.sendNewMiningJob(jobTemplate);
|
||||
} catch (e) {
|
||||
await this.socket.end();
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
|
||||
//console.log(`Sent new job to ${this.clientAuthorization.worker}.${this.extraNonceAndSessionId}. (clearJobs: ${jobTemplate.blockData.clearJobs}, fee?: ${!this.noFee})`)
|
||||
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
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,50 +744,156 @@ 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 (status === 'current' && isBlockCandidate) {
|
||||
blockSubmissionResult = await this.submitBlockCandidate(
|
||||
job,
|
||||
jobTemplate,
|
||||
submission,
|
||||
effectiveVersionBits,
|
||||
nonce,
|
||||
timestamp,
|
||||
);
|
||||
if (isBlockCandidate) {
|
||||
}
|
||||
await this.ensureClientEntity();
|
||||
try {
|
||||
await this.shareAccountingService?.recordAcceptedShare({
|
||||
protocol: this.accountingProtocol,
|
||||
payoutMode: this.payoutMode,
|
||||
address: this.clientAuthorization.address,
|
||||
clientName: this.clientAuthorization.worker,
|
||||
sessionId: this.extraNonceAndSessionId,
|
||||
clientId: this.clientEntity.id,
|
||||
jobId: job.jobId,
|
||||
jobTemplateId: job.jobTemplateId,
|
||||
blockHeight: jobTemplate.blockData.height,
|
||||
creditedDifficulty,
|
||||
submissionDifficulty,
|
||||
networkDifficulty: jobTemplate.blockData.networkDifficulty,
|
||||
nonce: submission.nonce,
|
||||
ntime: submission.ntime,
|
||||
version: submittedVersion.toString(16),
|
||||
extraNonce2: submission.extraNonce2,
|
||||
isBlockCandidate,
|
||||
blockSubmissionResult,
|
||||
});
|
||||
await this.statistics.addShares(this.clientEntity, creditedDifficulty);
|
||||
const now = new Date();
|
||||
this.clientEntity.updatedAt = now;
|
||||
this.clientEntity.hashRate = this.statistics.hashRate;
|
||||
await this.persistClientHashRate(now);
|
||||
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
if (submissionDifficulty > this.clientEntity.bestDifficulty) {
|
||||
await this.clientService.updateBestDifficultyIfHigher(this.clientEntity.id, submissionDifficulty);
|
||||
this.clientEntity.bestDifficulty = submissionDifficulty;
|
||||
await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.clientEntity.userAgent);
|
||||
}
|
||||
}
|
||||
|
||||
//await this.checkDifficulty();
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
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,
|
||||
versionMask,
|
||||
versionBits,
|
||||
nonce,
|
||||
this.extraNonceAndSessionId,
|
||||
submission.extraNonce2,
|
||||
timestamp
|
||||
timestamp,
|
||||
VERSION_ROLLING_MASK,
|
||||
);
|
||||
const blockHex = updatedJobBlock.toHex(false);
|
||||
blockSubmissionResult = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
|
||||
const blockSubmissionResult = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
|
||||
await this.blocksService.save({
|
||||
height: jobTemplate.blockData.height,
|
||||
minerAddress: this.clientAuthorization.address,
|
||||
@@ -646,71 +915,28 @@ export class StratumV1Client {
|
||||
});
|
||||
}
|
||||
|
||||
await this.notificationService.notifySubscribersBlockFound(this.clientAuthorization.address, jobTemplate.blockData.height, updatedJobBlock, blockSubmissionResult);
|
||||
//success
|
||||
await this.notificationService.notifySubscribersBlockFound(
|
||||
this.clientAuthorization.address,
|
||||
jobTemplate.blockData.height,
|
||||
updatedJobBlock,
|
||||
blockSubmissionResult,
|
||||
);
|
||||
if (this.isSuccessfulBlockSubmission(blockSubmissionResult)) {
|
||||
await this.addressSettingsService.resetBestDifficultyAndShares();
|
||||
}
|
||||
}
|
||||
await this.ensureClientEntity();
|
||||
try {
|
||||
await this.shareAccountingService?.recordAcceptedShare({
|
||||
protocol: this.accountingProtocol,
|
||||
payoutMode: this.payoutMode,
|
||||
address: this.clientAuthorization.address,
|
||||
clientName: this.clientAuthorization.worker,
|
||||
sessionId: this.extraNonceAndSessionId,
|
||||
clientId: this.clientEntity.id,
|
||||
jobId: job.jobId,
|
||||
jobTemplateId: job.jobTemplateId,
|
||||
blockHeight: jobTemplate.blockData.height,
|
||||
creditedDifficulty: this.sessionDifficulty,
|
||||
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),
|
||||
extraNonce2: submission.extraNonce2,
|
||||
isBlockCandidate,
|
||||
blockSubmissionResult,
|
||||
});
|
||||
await this.statistics.addShares(this.clientEntity, this.sessionDifficulty);
|
||||
const now = new Date();
|
||||
this.clientEntity.updatedAt = now;
|
||||
this.clientEntity.hashRate = this.statistics.hashRate;
|
||||
await this.persistClientHashRate(now);
|
||||
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return blockSubmissionResult;
|
||||
}
|
||||
|
||||
if (submissionDifficulty > this.clientEntity.bestDifficulty) {
|
||||
await this.clientService.updateBestDifficultyIfHigher(this.clientEntity.id, submissionDifficulty);
|
||||
this.clientEntity.bestDifficulty = submissionDifficulty;
|
||||
await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.clientEntity.userAgent);
|
||||
}
|
||||
|
||||
|
||||
|
||||
} else {
|
||||
const err = new StratumErrorMessage(
|
||||
private async writeSubmissionError(
|
||||
submission: MiningSubmitMessage,
|
||||
code: eStratumErrorCode,
|
||||
message: string,
|
||||
): Promise<boolean> {
|
||||
return this.write(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();
|
||||
return false;
|
||||
|
||||
code,
|
||||
message,
|
||||
).response());
|
||||
}
|
||||
|
||||
private async checkDifficulty() {
|
||||
@@ -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!';
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
+1243
-192
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
import type { PayoutMode } from '../../types/payout-mode';
|
||||
|
||||
export interface IBlockTemplateTx {
|
||||
data: string; //'hex', // (string) transaction data encoded in hexadecimal (byte-for-byte)
|
||||
txid: string; //'hex', // (string) transaction id encoded in little-endian hexadecimal
|
||||
@@ -44,5 +46,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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Sv2ExtranonceManager } from './sv2-extranonce-manager';
|
||||
import {
|
||||
resolveSv2ProcessNamespace,
|
||||
Sv2ExtranonceManager,
|
||||
} from './sv2-extranonce-manager';
|
||||
|
||||
|
||||
describe('Sv2ExtranonceManager', () => {
|
||||
@@ -114,4 +117,68 @@ describe('Sv2ExtranonceManager', () => {
|
||||
expect(mgr.allocatedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('partitions prefixes deterministically across PM2 workers and reload generations', () => {
|
||||
const worker0 = resolveSv2ProcessNamespace({
|
||||
NODE_APP_INSTANCE: '0',
|
||||
restart_time: '0',
|
||||
});
|
||||
const worker1 = resolveSv2ProcessNamespace({
|
||||
NODE_APP_INSTANCE: '1',
|
||||
restart_time: '0',
|
||||
});
|
||||
const worker0Reload = resolveSv2ProcessNamespace({
|
||||
NODE_APP_INSTANCE: '0',
|
||||
restart_time: '1',
|
||||
});
|
||||
const prefixes = [worker0, worker1, worker0Reload].map(namespace => (
|
||||
new Sv2ExtranonceManager(4, 14, namespace).allocate(1).toString('hex')
|
||||
));
|
||||
|
||||
expect([worker0, worker1, worker0Reload]).toEqual([0, 2, 1]);
|
||||
expect(new Set(prefixes).size).toBe(3);
|
||||
expect(prefixes).toEqual(['00000001', '02000001', '01000001']);
|
||||
});
|
||||
|
||||
it('offsets PM2 worker lanes with an explicit deployment namespace base', () => {
|
||||
expect(resolveSv2ProcessNamespace({
|
||||
SV2_EXTRANONCE_NAMESPACE_BASE: '32',
|
||||
NODE_APP_INSTANCE: '3',
|
||||
restart_time: '5',
|
||||
})).toBe(39);
|
||||
});
|
||||
|
||||
it('falls back to pm_id when NODE_APP_INSTANCE is unavailable', () => {
|
||||
expect(resolveSv2ProcessNamespace({
|
||||
pm_id: '7',
|
||||
restart_time: '2',
|
||||
})).toBe(14);
|
||||
});
|
||||
|
||||
it('fails closed for managed workers without a unique process identifier', () => {
|
||||
expect(() => resolveSv2ProcessNamespace({ PM2_ENABLED: 'true' }))
|
||||
.toThrow('cannot allocate collision-free extranonces');
|
||||
});
|
||||
|
||||
it('fails when the worker namespace cannot fit in the four-byte prefix', () => {
|
||||
expect(() => resolveSv2ProcessNamespace({
|
||||
NODE_APP_INSTANCE: '128',
|
||||
restart_time: '0',
|
||||
})).toThrow('does not fit in one byte');
|
||||
expect(() => new Sv2ExtranonceManager(4, 14, 256))
|
||||
.toThrow('must fit in one byte');
|
||||
});
|
||||
|
||||
it('rejects prefix and total sizes that cannot preserve the namespace', () => {
|
||||
expect(() => new Sv2ExtranonceManager(1, 14, 0))
|
||||
.toThrow('between 2 and 4 bytes');
|
||||
expect(() => new Sv2ExtranonceManager(4, 3, 0))
|
||||
.toThrow('must include the pool prefix');
|
||||
});
|
||||
|
||||
it('rejects channel identifiers that cannot be represented by SV2', () => {
|
||||
const mgr = new Sv2ExtranonceManager();
|
||||
expect(() => mgr.allocate(0)).toThrow('unsigned non-zero 32-bit integer');
|
||||
expect(() => mgr.allocate(0x1_0000_0000)).toThrow('unsigned non-zero 32-bit integer');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -6,12 +6,12 @@ import { SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES } from '../stratum.constants';
|
||||
|
||||
export class Sv2ExtranonceManager {
|
||||
private nextPrefix: number;
|
||||
private readonly workerOffset: number;
|
||||
private readonly maxPrefix: number;
|
||||
private allocatedPrefixes = new Map<number, number>(); // channelId → prefix
|
||||
private usedPrefixes = new Set<number>();
|
||||
private readonly prefixSize: number;
|
||||
private readonly totalExtranonceSize: number;
|
||||
private readonly processNamespace: number;
|
||||
|
||||
/**
|
||||
* @param prefixSize Bytes used for pool-assigned prefix (default 4)
|
||||
@@ -19,13 +19,25 @@ export class Sv2ExtranonceManager {
|
||||
* SV2 extended jobs patch the coinbase script length when the
|
||||
* negotiated total differs from the SV1-compatible template slot.
|
||||
*/
|
||||
constructor(prefixSize = 4, totalExtranonceSize = SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES) {
|
||||
constructor(
|
||||
prefixSize = 4,
|
||||
totalExtranonceSize = SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES,
|
||||
processNamespace = 0,
|
||||
) {
|
||||
if (!Number.isInteger(prefixSize) || prefixSize < 2 || prefixSize > 4) {
|
||||
throw new Error('SV2 extranonce prefix size must be between 2 and 4 bytes');
|
||||
}
|
||||
if (!Number.isInteger(totalExtranonceSize) || totalExtranonceSize < prefixSize) {
|
||||
throw new Error('SV2 total extranonce size must include the pool prefix');
|
||||
}
|
||||
if (!Number.isInteger(processNamespace) || processNamespace < 0 || processNamespace > 0xff) {
|
||||
throw new Error('SV2 process namespace must fit in one byte');
|
||||
}
|
||||
this.prefixSize = prefixSize;
|
||||
this.totalExtranonceSize = totalExtranonceSize;
|
||||
this.processNamespace = processNamespace;
|
||||
|
||||
const workerId = 0;
|
||||
const bitsPerWorker = (prefixSize - 1) * 8;
|
||||
this.workerOffset = workerId * Math.pow(2, bitsPerWorker);
|
||||
this.maxPrefix = Math.pow(2, bitsPerWorker) - 1; // max within this worker's slice
|
||||
this.nextPrefix = 1;
|
||||
}
|
||||
@@ -43,6 +55,9 @@ export class Sv2ExtranonceManager {
|
||||
* Returns a Buffer of `prefixSize` bytes.
|
||||
*/
|
||||
allocate(channelId: number): Buffer {
|
||||
if (!Number.isInteger(channelId) || channelId < 1 || channelId > 0xffffffff) {
|
||||
throw new Error('SV2 channel ID must be an unsigned non-zero 32-bit integer');
|
||||
}
|
||||
// Check if already allocated
|
||||
if (this.allocatedPrefixes.has(channelId)) {
|
||||
const existing = this.allocatedPrefixes.get(channelId)!;
|
||||
@@ -52,19 +67,17 @@ export class Sv2ExtranonceManager {
|
||||
// Find next available prefix within this worker's partition
|
||||
let localPrefix = this.nextPrefix;
|
||||
let attempts = 0;
|
||||
const globalPrefix = () => this.workerOffset + localPrefix;
|
||||
|
||||
while (this.usedPrefixes.has(globalPrefix()) && attempts <= this.maxPrefix) {
|
||||
while (this.usedPrefixes.has(localPrefix) && attempts <= this.maxPrefix) {
|
||||
localPrefix = localPrefix + 1;
|
||||
if (localPrefix > this.maxPrefix) localPrefix = 1; // Wrap within partition, skip 0
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (this.usedPrefixes.has(globalPrefix())) {
|
||||
if (this.usedPrefixes.has(localPrefix)) {
|
||||
throw new Error('Extranonce prefix space exhausted');
|
||||
}
|
||||
|
||||
const prefix = globalPrefix();
|
||||
const prefix = localPrefix;
|
||||
this.allocatedPrefixes.set(channelId, prefix);
|
||||
this.usedPrefixes.add(prefix);
|
||||
this.nextPrefix = localPrefix + 1;
|
||||
@@ -99,19 +112,90 @@ export class Sv2ExtranonceManager {
|
||||
|
||||
private prefixToBuffer(prefix: number): Buffer {
|
||||
const buf = Buffer.alloc(this.prefixSize);
|
||||
// Write as big-endian so prefix 1 = 0x00000001
|
||||
if (this.prefixSize === 4) {
|
||||
buf.writeUInt32BE(prefix, 0);
|
||||
} else if (this.prefixSize === 2) {
|
||||
buf.writeUInt16BE(prefix, 0);
|
||||
} else {
|
||||
// Generic: write big-endian
|
||||
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 >= 0; i--) {
|
||||
buf[i] = val & 0xff;
|
||||
val = val >>> 8;
|
||||
}
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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
+1974
-47
File diff suppressed because it is too large
Load Diff
@@ -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 = [{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
import type {
|
||||
IBlockTemplate,
|
||||
IBlockTemplatePayoutOutput,
|
||||
IBlockTemplateTx,
|
||||
} from '../models/bitcoin-rpc/IBlockTemplate';
|
||||
import type { PayoutMode } from '../types/payout-mode';
|
||||
import { hash256 } from '../utils/hash.utils';
|
||||
|
||||
const WITNESS_COMMITMENT_PREFIX = '6a24aa21a9ed';
|
||||
const HASH_BYTES = 32;
|
||||
const UINT32_MAX = 0xffffffff;
|
||||
const INT32_MIN = -0x80000000;
|
||||
const INT32_MAX = 0x7fffffff;
|
||||
|
||||
export const PREPARED_JOB_BODY_CHECKSUM_ALGORITHM =
|
||||
'sha256-txids-v1' as const;
|
||||
|
||||
export interface PreparedMiningJobHeader {
|
||||
/** Display/RPC byte order, normalized to lowercase hex. */
|
||||
previousBlockHash: string;
|
||||
/** Internal header byte order, normalized to lowercase hex. */
|
||||
previousBlockHashLE: string;
|
||||
version: number;
|
||||
bits: number;
|
||||
bitsHex: string;
|
||||
minTime: number;
|
||||
currentTime: number;
|
||||
}
|
||||
|
||||
export interface PreparedMiningJobCoinbase {
|
||||
valueSats: number;
|
||||
/** Internal-byte-order hashes, ready for the Stratum coinbase Merkle path. */
|
||||
merkleBranch: readonly string[];
|
||||
/** The 32-byte hash following the BIP141 commitment header. */
|
||||
witnessCommitmentHash: string;
|
||||
payoutSnapshotId?: string;
|
||||
payoutOutputs?: ReadonlyArray<Readonly<IBlockTemplatePayoutOutput>>;
|
||||
}
|
||||
|
||||
export interface PreparedMiningJobBodyReference {
|
||||
reference: string;
|
||||
checksum: string;
|
||||
checksumAlgorithm: typeof PREPARED_JOB_BODY_CHECKSUM_ALGORITHM;
|
||||
transactionCount: number;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
export interface PreparedMiningJob {
|
||||
tipKey: string;
|
||||
height: number;
|
||||
header: Readonly<PreparedMiningJobHeader>;
|
||||
coinbase: Readonly<PreparedMiningJobCoinbase>;
|
||||
body: Readonly<PreparedMiningJobBodyReference>;
|
||||
jobType: 'full' | 'empty';
|
||||
payoutMode: PayoutMode | 'all';
|
||||
forceCleanJobs: boolean;
|
||||
}
|
||||
|
||||
export function createPreparedMiningJob(
|
||||
template: Readonly<IBlockTemplate>,
|
||||
): PreparedMiningJob {
|
||||
validateTemplateNumbers(template);
|
||||
|
||||
const previousBlockHash = parseHex(
|
||||
template.previousblockhash,
|
||||
'previousblockhash',
|
||||
HASH_BYTES,
|
||||
);
|
||||
const bits = parseHex(template.bits, 'bits', 4);
|
||||
const witnessCommitmentHash = parseWitnessCommitment(
|
||||
template.default_witness_commitment,
|
||||
);
|
||||
const body = prepareBodyReference(template.transactions);
|
||||
const merkleBranch = buildCoinbaseMerkleBranch(template.transactions);
|
||||
const normalizedPreviousBlockHash = previousBlockHash.toString('hex');
|
||||
|
||||
return {
|
||||
tipKey: `${template.height}:${normalizedPreviousBlockHash}`,
|
||||
height: template.height,
|
||||
header: {
|
||||
previousBlockHash: normalizedPreviousBlockHash,
|
||||
previousBlockHashLE: Buffer.from(previousBlockHash)
|
||||
.reverse()
|
||||
.toString('hex'),
|
||||
version: template.version,
|
||||
bits: bits.readUInt32BE(0),
|
||||
bitsHex: bits.toString('hex'),
|
||||
minTime: template.mintime,
|
||||
currentTime: template.curtime,
|
||||
},
|
||||
coinbase: {
|
||||
valueSats: template.coinbasevalue,
|
||||
merkleBranch,
|
||||
witnessCommitmentHash,
|
||||
payoutSnapshotId: template.payoutSnapshotId,
|
||||
payoutOutputs: template.payoutOutputs?.map((output) => ({ ...output })),
|
||||
},
|
||||
body,
|
||||
jobType: template.jobType ?? 'full',
|
||||
payoutMode: template.payoutMode ?? 'all',
|
||||
forceCleanJobs: template.forceCleanJobs === true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCoinbaseMerkleBranch(
|
||||
transactions: readonly IBlockTemplateTx[],
|
||||
): string[] {
|
||||
let level: Buffer[] = [
|
||||
Buffer.alloc(HASH_BYTES),
|
||||
...transactions.map((transaction, index) =>
|
||||
Buffer.from(
|
||||
parseHex(transaction.txid, `transactions[${index}].txid`, HASH_BYTES),
|
||||
).reverse(),
|
||||
),
|
||||
];
|
||||
const branch: string[] = [];
|
||||
|
||||
while (level.length > 1) {
|
||||
if (level.length % 2 !== 0) {
|
||||
level.push(Buffer.from(level[level.length - 1]));
|
||||
}
|
||||
|
||||
branch.push(level[1].toString('hex'));
|
||||
const nextLevel: Buffer[] = [];
|
||||
for (let index = 0; index < level.length; index += 2) {
|
||||
nextLevel.push(hash256(Buffer.concat([level[index], level[index + 1]])));
|
||||
}
|
||||
level = nextLevel;
|
||||
}
|
||||
|
||||
return branch;
|
||||
}
|
||||
|
||||
function parseWitnessCommitment(commitment: string): string {
|
||||
const script = parseHex(commitment, 'default_witness_commitment');
|
||||
const minimumBytes = WITNESS_COMMITMENT_PREFIX.length / 2 + HASH_BYTES;
|
||||
if (script.length < minimumBytes) {
|
||||
throw new Error(
|
||||
`default_witness_commitment must be at least ${minimumBytes} bytes`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!script
|
||||
.subarray(0, WITNESS_COMMITMENT_PREFIX.length / 2)
|
||||
.equals(Buffer.from(WITNESS_COMMITMENT_PREFIX, 'hex'))
|
||||
) {
|
||||
throw new Error('default_witness_commitment has an invalid BIP141 prefix');
|
||||
}
|
||||
|
||||
return script
|
||||
.subarray(WITNESS_COMMITMENT_PREFIX.length / 2, minimumBytes)
|
||||
.toString('hex');
|
||||
}
|
||||
|
||||
function prepareBodyReference(
|
||||
transactions: readonly IBlockTemplateTx[],
|
||||
): PreparedMiningJobBodyReference {
|
||||
const checksum = crypto.createHash('sha256');
|
||||
let byteLength = 0;
|
||||
|
||||
transactions.forEach((transaction, index) => {
|
||||
const txid = parseHex(
|
||||
transaction.txid,
|
||||
`transactions[${index}].txid`,
|
||||
HASH_BYTES,
|
||||
);
|
||||
if (
|
||||
typeof transaction.data !== 'string' ||
|
||||
transaction.data.length === 0 ||
|
||||
transaction.data.length % 2 !== 0
|
||||
) {
|
||||
throw new Error(`transactions[${index}].data must not be empty`);
|
||||
}
|
||||
const transactionBytes = transaction.data.length / 2;
|
||||
if (transactionBytes > UINT32_MAX) {
|
||||
throw new Error(`transactions[${index}].data exceeds the checksum frame`);
|
||||
}
|
||||
checksum.update(txid);
|
||||
byteLength += transactionBytes;
|
||||
});
|
||||
|
||||
const checksumHex = checksum.digest('hex');
|
||||
return {
|
||||
reference: `sha256:${checksumHex}`,
|
||||
checksum: checksumHex,
|
||||
checksumAlgorithm: PREPARED_JOB_BODY_CHECKSUM_ALGORITHM,
|
||||
transactionCount: transactions.length,
|
||||
byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
function parseHex(value: string, field: string, exactBytes?: number): Buffer {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length % 2 !== 0 ||
|
||||
!/^[0-9a-f]*$/i.test(value)
|
||||
) {
|
||||
throw new Error(`${field} must be valid hexadecimal`);
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(value, 'hex');
|
||||
if (exactBytes != null && bytes.length !== exactBytes) {
|
||||
throw new Error(`${field} must be exactly ${exactBytes} bytes`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function validateTemplateNumbers(template: Readonly<IBlockTemplate>): void {
|
||||
if (
|
||||
!Number.isInteger(template.version) ||
|
||||
template.version < INT32_MIN ||
|
||||
template.version > INT32_MAX
|
||||
) {
|
||||
throw new Error('version must be a signed 32-bit integer');
|
||||
}
|
||||
validateUnsignedInteger(template.height, 'height', Number.MAX_SAFE_INTEGER);
|
||||
validateUnsignedInteger(
|
||||
template.coinbasevalue,
|
||||
'coinbasevalue',
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
validateUnsignedInteger(template.mintime, 'mintime', UINT32_MAX);
|
||||
validateUnsignedInteger(template.curtime, 'curtime', UINT32_MAX);
|
||||
}
|
||||
|
||||
function validateUnsignedInteger(
|
||||
value: number,
|
||||
field: string,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
|
||||
throw new Error(
|
||||
`${field} must be an unsigned integer no greater than ${maximum}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
public async getLatestBlockTemplate(): Promise<IBlockTemplate | 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(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 {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export class TemplateProviderService implements OnModuleInit {
|
||||
) {}
|
||||
|
||||
public onModuleInit(): void {
|
||||
if (this.subscription != null) {
|
||||
if (this.subscription != null || process.env.API_ONLY === 'true' || process.env.MASTER === 'true') {
|
||||
return;
|
||||
}
|
||||
this.subscription = this.jobsService.newMiningJob$.subscribe(template => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DifficultyUtils } from './difficulty.utils';
|
||||
import { hash256 } from './hash.utils';
|
||||
|
||||
describe('DifficultyUtils', () => {
|
||||
function incrementLe256(target: Buffer): Buffer {
|
||||
@@ -36,6 +37,146 @@ describe('DifficultyUtils', () => {
|
||||
expect(DifficultyUtils.meetsTarget(easier, target)).toBe(false);
|
||||
});
|
||||
|
||||
it('decodes compact targets from Bitcoin mainnet examples into little-endian bytes', () => {
|
||||
const examples = [
|
||||
{
|
||||
nBits: 0x1d00ffff,
|
||||
target: '00000000ffff0000000000000000000000000000000000000000000000000000',
|
||||
},
|
||||
{
|
||||
nBits: 0x181bc330,
|
||||
target: '00000000000000001bc330000000000000000000000000000000000000000000',
|
||||
},
|
||||
{
|
||||
nBits: 0x1a44b9f2,
|
||||
target: '00000000000044b9f20000000000000000000000000000000000000000000000',
|
||||
},
|
||||
];
|
||||
|
||||
for (const example of examples) {
|
||||
const target = DifficultyUtils.compactToTarget(example.nBits);
|
||||
|
||||
expect(target).not.toBeNull();
|
||||
expect(Buffer.from(target).reverse().toString('hex')).toBe(example.target);
|
||||
}
|
||||
});
|
||||
|
||||
it('matches Bitcoin Core compact decoding for exponents at or below three', () => {
|
||||
const examples = [
|
||||
{ nBits: 0x01123456, target: 0x12 },
|
||||
{ nBits: 0x02008000, target: 0x80 },
|
||||
{ nBits: 0x03009234, target: 0x9234 },
|
||||
{ nBits: 0x05009234, target: 0x92340000 },
|
||||
];
|
||||
|
||||
for (const example of examples) {
|
||||
const expected = Buffer.alloc(32);
|
||||
expected.writeUInt32LE(example.target, 0);
|
||||
expect(DifficultyUtils.compactToTarget(example.nBits)).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('matches known Bitcoin mainnet header hashes using internal hash byte order', () => {
|
||||
const examples = [
|
||||
{
|
||||
nBits: 0x1d00ffff,
|
||||
header: [
|
||||
'01000000',
|
||||
'0000000000000000000000000000000000000000000000000000000000000000',
|
||||
'3ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a',
|
||||
'29ab5f49',
|
||||
'ffff001d',
|
||||
'1dac2b7c',
|
||||
].join(''),
|
||||
internalHash: '6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000',
|
||||
},
|
||||
{
|
||||
nBits: 0x1a44b9f2,
|
||||
header: [
|
||||
'01000000',
|
||||
'81cd02ab7e569e8bcd9317e2fe99f2de44d49ab2b8851ba4a308000000000000',
|
||||
'e320b6c2fffc8d750423db8b1eb942ae710e951ed797f7affc8892b0f1fc122b',
|
||||
'c7f5d74d',
|
||||
'f2b9441a',
|
||||
'42a14695',
|
||||
].join(''),
|
||||
internalHash: '1dbd981fe6985776b644b173a4d0385ddc1aa2a829688d1e0000000000000000',
|
||||
},
|
||||
];
|
||||
|
||||
for (const example of examples) {
|
||||
const hash = hash256(Buffer.from(example.header, 'hex'));
|
||||
|
||||
expect(hash.toString('hex')).toBe(example.internalHash);
|
||||
expect(DifficultyUtils.meetsCompactTarget(hash, example.nBits)).toBe(true);
|
||||
expect(DifficultyUtils.meetsCompactTarget(Buffer.from(hash).reverse(), example.nBits)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('compares compact targets exactly at the little-endian boundary', () => {
|
||||
const nBits = 0x1d00ffff;
|
||||
const target = DifficultyUtils.compactToTarget(nBits);
|
||||
expect(target).not.toBeNull();
|
||||
|
||||
expect(DifficultyUtils.meetsCompactTarget(target, nBits)).toBe(true);
|
||||
expect(DifficultyUtils.meetsCompactTarget(incrementLe256(target), nBits)).toBe(false);
|
||||
expect(DifficultyUtils.meetsCompactTarget(Buffer.alloc(32), nBits)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects negative compact targets using Bitcoin Core sign semantics', () => {
|
||||
for (const nBits of [0x04923456, 0x1d80ffff]) {
|
||||
expect(DifficultyUtils.compactToTarget(nBits)).toBeNull();
|
||||
expect(DifficultyUtils.meetsCompactTarget(Buffer.alloc(32), nBits)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects all compact target overflow forms at the 256-bit boundary', () => {
|
||||
for (const nBits of [
|
||||
0x23000001,
|
||||
0x22000100,
|
||||
0x21010000,
|
||||
]) {
|
||||
expect(DifficultyUtils.compactToTarget(nBits)).toBeNull();
|
||||
expect(DifficultyUtils.meetsCompactTarget(Buffer.alloc(32), nBits)).toBe(false);
|
||||
}
|
||||
|
||||
const size34Boundary = DifficultyUtils.compactToTarget(0x220000ff);
|
||||
const size33Boundary = DifficultyUtils.compactToTarget(0x2100ffff);
|
||||
expect(size34Boundary).not.toBeNull();
|
||||
expect(size33Boundary).not.toBeNull();
|
||||
expect(Buffer.from(size34Boundary).reverse().toString('hex')).toBe(`ff${'00'.repeat(31)}`);
|
||||
expect(Buffer.from(size33Boundary).reverse().toString('hex')).toBe(`ffff${'00'.repeat(30)}`);
|
||||
});
|
||||
|
||||
it('rejects zero targets and values outside an unsigned 32-bit nBits field', () => {
|
||||
for (const nBits of [
|
||||
0,
|
||||
0x01003456,
|
||||
-1,
|
||||
0x1_0000_0000,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
]) {
|
||||
expect(DifficultyUtils.compactToTarget(nBits)).toBeNull();
|
||||
expect(DifficultyUtils.meetsCompactTarget(Buffer.alloc(32), nBits)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects ambiguous hash or target buffer lengths', () => {
|
||||
expect(() => DifficultyUtils.meetsTarget(
|
||||
Buffer.alloc(31),
|
||||
Buffer.alloc(32),
|
||||
)).toThrow('Hash must be 32 bytes');
|
||||
expect(() => DifficultyUtils.meetsTarget(
|
||||
Buffer.alloc(32),
|
||||
Buffer.alloc(31),
|
||||
)).toThrow('Target must be 32 bytes');
|
||||
expect(() => DifficultyUtils.meetsCompactTarget(
|
||||
Buffer.alloc(33),
|
||||
0x1d00ffff,
|
||||
)).toThrow('Hash must be 32 bytes');
|
||||
});
|
||||
|
||||
it('calculates difficulty consistently with the returned hash buffer', () => {
|
||||
const header = Buffer.alloc(80, 1);
|
||||
const result = DifficultyUtils.calculateDifficulty(header);
|
||||
|
||||
@@ -19,9 +19,62 @@ export class DifficultyUtils {
|
||||
}
|
||||
|
||||
public static meetsTarget(hashBuffer: Buffer, target: Buffer): boolean {
|
||||
if (hashBuffer.length !== 32) {
|
||||
throw new Error('Hash must be 32 bytes');
|
||||
}
|
||||
if (target.length !== 32) {
|
||||
throw new Error('Target must be 32 bytes');
|
||||
}
|
||||
return DifficultyUtils.compareLe256(hashBuffer, target) <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Bitcoin's compact nBits value to the 32-byte little-endian target
|
||||
* representation used by raw double-SHA256 digests in this service.
|
||||
* Returns null for zero, negative, overflowing, or non-uint32 encodings.
|
||||
*/
|
||||
public static compactToTarget(nBits: number): Buffer | null {
|
||||
if (!Number.isInteger(nBits) || nBits < 0 || nBits > 0xffffffff) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const size = nBits >>> 24;
|
||||
let word = nBits & 0x007fffff;
|
||||
if (size <= 3) {
|
||||
word >>>= 8 * (3 - size);
|
||||
}
|
||||
|
||||
const negative = word !== 0 && (nBits & 0x00800000) !== 0;
|
||||
const overflow = word !== 0 && (
|
||||
size > 34
|
||||
|| (word > 0xff && size > 33)
|
||||
|| (word > 0xffff && size > 32)
|
||||
);
|
||||
if (word === 0 || negative || overflow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = Buffer.alloc(32);
|
||||
const offset = size <= 3 ? 0 : size - 3;
|
||||
for (let byte = 0; byte < 3 && offset + byte < target.length; byte++) {
|
||||
target[offset + byte] = (word >>> (byte * 8)) & 0xff;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a raw, internal-byte-order block hash against a compact target.
|
||||
* Invalid nBits never match.
|
||||
*/
|
||||
public static meetsCompactTarget(hashBuffer: Buffer, nBits: number): boolean {
|
||||
if (hashBuffer.length !== 32) {
|
||||
throw new Error('Hash must be 32 bytes');
|
||||
}
|
||||
|
||||
const target = DifficultyUtils.compactToTarget(nBits);
|
||||
return target != null && DifficultyUtils.compareLe256(hashBuffer, target) <= 0;
|
||||
}
|
||||
|
||||
public static difficultyToTarget(difficulty: number): Buffer {
|
||||
if (!Number.isFinite(difficulty) || difficulty <= 0) {
|
||||
return Buffer.alloc(32, 0xff);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import axios from 'axios';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
||||
|
||||
import { MiningJob } from '../src/models/MiningJob';
|
||||
import { IBlockTemplate } from '../src/models/bitcoin-rpc/IBlockTemplate';
|
||||
import { createSubsidyOnlyBlockTemplate } from '../src/services/subsidy-only-template.factory';
|
||||
import { StratumV1JobsService } from '../src/services/stratum-v1-jobs.service';
|
||||
|
||||
const describeRegtest = process.env.RUN_BITCOIN_REGTEST_INTEGRATION === 'true'
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
describeRegtest('Bitcoin Core regtest block proposals', () => {
|
||||
const rpcUrl = process.env.BITCOIN_REGTEST_RPC_URL ?? 'http://127.0.0.1:28332';
|
||||
const rpcUser = process.env.BITCOIN_REGTEST_RPC_USER ?? 'bitcoin';
|
||||
const rpcPassword = process.env.BITCOIN_REGTEST_RPC_PASSWORD ?? 'bitcoin';
|
||||
let rpcId = 0;
|
||||
|
||||
it('accepts reconstructed subsidy-only and full jobs in proposal mode', async () => {
|
||||
const authoritative = await rpc<IBlockTemplate>('getblocktemplate', [{
|
||||
mode: 'template',
|
||||
rules: ['segwit'],
|
||||
capabilities: ['proposal'],
|
||||
}]);
|
||||
const empty = createSubsidyOnlyBlockTemplate({
|
||||
authoritativeTemplate: authoritative,
|
||||
network: 'regtest',
|
||||
payoutMode: 'solo',
|
||||
});
|
||||
|
||||
const emptyBlock = await reconstruct(empty);
|
||||
const fullBlock = await reconstruct({
|
||||
...authoritative,
|
||||
payoutMode: 'solo',
|
||||
jobType: 'full',
|
||||
forceCleanJobs: true,
|
||||
});
|
||||
|
||||
expect(emptyBlock.transactions).toHaveLength(1);
|
||||
expect(fullBlock.transactions).toHaveLength(authoritative.transactions.length + 1);
|
||||
await expect(propose(emptyBlock)).resolves.toBeNull();
|
||||
await expect(propose(fullBlock)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
async function reconstruct(template: IBlockTemplate): Promise<bitcoinjs.Block> {
|
||||
const source = new BehaviorSubject(template);
|
||||
const jobs = new StratumV1JobsService({
|
||||
newBlockTemplate$: source.asObservable(),
|
||||
miningInfo: { blocks: template.height - 1 },
|
||||
} as any);
|
||||
const jobTemplate = await firstValueFrom(jobs.newMiningJob$);
|
||||
const payoutAddress = bitcoinjs.payments.p2wpkh({
|
||||
hash: Buffer.alloc(20, 1),
|
||||
network: bitcoinjs.networks.regtest,
|
||||
}).address!;
|
||||
const job = new MiningJob(
|
||||
bitcoinjs.networks.regtest,
|
||||
'1',
|
||||
[{ address: payoutAddress, percent: 100 }],
|
||||
jobTemplate,
|
||||
);
|
||||
return job.copyAndUpdateBlock(
|
||||
jobTemplate,
|
||||
0,
|
||||
0,
|
||||
'01020304',
|
||||
'05060708090a0b0c',
|
||||
Math.max(template.mintime, template.curtime, Math.floor(Date.now() / 1000)),
|
||||
);
|
||||
}
|
||||
|
||||
async function propose(block: bitcoinjs.Block): Promise<string | null> {
|
||||
return rpc<string | null>('getblocktemplate', [{
|
||||
mode: 'proposal',
|
||||
data: block.toHex(false),
|
||||
rules: ['segwit'],
|
||||
}]);
|
||||
}
|
||||
|
||||
async function rpc<T>(method: string, params: unknown[]): Promise<T> {
|
||||
const response = await axios.post(rpcUrl, {
|
||||
jsonrpc: '1.0',
|
||||
id: ++rpcId,
|
||||
method,
|
||||
params,
|
||||
}, {
|
||||
auth: { username: rpcUser, password: rpcPassword },
|
||||
timeout: 30_000,
|
||||
});
|
||||
if (response.data.error != null) {
|
||||
throw new Error(response.data.error.message ?? JSON.stringify(response.data.error));
|
||||
}
|
||||
return response.data.result as T;
|
||||
}
|
||||
});
|
||||
@@ -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),
|
||||
}]));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user