mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 09:05:06 -07:00
Compare commits
22
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 |
+40
-3
@@ -15,9 +15,15 @@ BITCOIN_RPC_TIMEOUT=10000
|
||||
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
|
||||
@@ -26,6 +32,14 @@ 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
|
||||
@@ -43,7 +57,9 @@ SV2_AUTH_FAILURE_LOG_ENABLED=false
|
||||
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
|
||||
@@ -56,13 +72,30 @@ 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
|
||||
@@ -78,7 +111,7 @@ STRATUM_SUBMISSION_DEDUP_MAX_ENTRIES=10000
|
||||
# Per-client lifecycle bounds. Obsolete queued jobs are coalesced; clients are
|
||||
# disconnected instead of dropping still-recoverable candidates or buffering forever.
|
||||
SV2_JOB_RETENTION_MS=300000
|
||||
SV2_MAX_RETAINED_JOBS_PER_CHANNEL=16
|
||||
SV2_MAX_RETAINED_JOBS_PER_CHANNEL=64
|
||||
SV2_MAX_QUEUED_JOB_OPERATIONS=4
|
||||
SV2_MAX_SOCKET_BUFFER_BYTES=262144
|
||||
SV2_SOCKET_WRITE_TIMEOUT_MS=2000
|
||||
@@ -138,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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Logs
|
||||
logs
|
||||
|
||||
@@ -59,6 +59,16 @@ $ 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.
|
||||
@@ -78,11 +88,35 @@ reserve two namespace values per configured Stratum worker.
|
||||
### New-block notification path
|
||||
|
||||
The master keeps an authoritative Bitcoin Core `getblocktemplate` longpoll open;
|
||||
rawblock ZMQ remains a watchdog and duplicate results are discarded. On a new tip,
|
||||
the master publishes a compact subsidy-only SV1 job before serializing the full
|
||||
transaction template. Workers fan that job out through one process-level socket
|
||||
broadcaster, then issue the full fee-paying job as a second clean switch. Payout
|
||||
snapshot creation and Postgres persistence run after the immediate solo publish.
|
||||
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
|
||||
@@ -91,7 +125,7 @@ Standard and extended candidates retain exact header/body reconstruction, and
|
||||
late network-target candidates remain recoverable without crediting stale shares.
|
||||
Pending SV2 canonical jobs are coalesced per client, while a new-tip activation
|
||||
is moved ahead of any not-yet-started canonical work for that tip. The finite
|
||||
defaults are 16 retained jobs per channel, four queued operations, 256 KiB of
|
||||
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
|
||||
@@ -104,19 +138,38 @@ 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.
|
||||
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 master is still the preferred rollout order. When any PPLNS
|
||||
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 client count, bytes, backpressure, and p50/p95/p99/last enqueue time, correlated by `eventId`.
|
||||
- `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
|
||||
|
||||
@@ -194,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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
+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}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -18,6 +18,10 @@ import { PayoutModes1781300000000 } from './ORM/_migrations/PayoutModes178130000
|
||||
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';
|
||||
@@ -62,6 +66,10 @@ export const databaseMigrations = [
|
||||
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 { }
|
||||
+128
-9
@@ -20,10 +20,29 @@ export interface MiningJobOwnership {
|
||||
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;
|
||||
@@ -33,6 +52,7 @@ export class MiningJob {
|
||||
private merkleBranchBuffers: Buffer[];
|
||||
private miningNotifyResponse: string;
|
||||
private miningNotifyResponseBuffer: Buffer;
|
||||
private preStagedNotifyLayout: PreStagedNotifyLayout;
|
||||
|
||||
public jobTemplateId: string;
|
||||
public tipKey: string;
|
||||
@@ -50,6 +70,7 @@ export class MiningJob {
|
||||
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);
|
||||
@@ -104,6 +125,60 @@ export class MiningJob {
|
||||
return Buffer.from(this.coinbasePart2Buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -317,25 +392,32 @@ export class MiningJob {
|
||||
return paymentScript;
|
||||
}
|
||||
|
||||
public response(jobTemplate: IJobTemplate): string {
|
||||
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
|
||||
]
|
||||
};
|
||||
|
||||
@@ -344,15 +426,52 @@ export class MiningJob {
|
||||
return this.miningNotifyResponse;
|
||||
}
|
||||
|
||||
public responseBuffer(jobTemplate: IJobTemplate): Buffer {
|
||||
public responseBuffer(
|
||||
jobTemplate: IJobTemplate,
|
||||
headerFields = MiningJob.createNotifyHeaderFields(jobTemplate),
|
||||
): Buffer {
|
||||
if (this.miningNotifyResponseBuffer == null) {
|
||||
this.response(jobTemplate);
|
||||
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 swapEndianWords(buffer: Buffer): Buffer {
|
||||
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 static swapEndianWords(buffer: Buffer): Buffer {
|
||||
const swappedBuffer = Buffer.alloc(buffer.length);
|
||||
|
||||
for (let i = 0; i < buffer.length; i += 4) {
|
||||
|
||||
@@ -169,6 +169,59 @@ 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 timer = setInterval(() => undefined, 1000);
|
||||
const removeListenerSpy = jest.spyOn(socket, 'removeListener');
|
||||
@@ -409,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 () => {
|
||||
@@ -482,7 +558,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));
|
||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||
@@ -510,7 +586,7 @@ describe('StratumV1Client', () => {
|
||||
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -536,7 +612,7 @@ describe('StratumV1Client', () => {
|
||||
sessionId: MockRecording1.EXTRA_NONCE,
|
||||
jobId: '1',
|
||||
jobTemplateId: '1',
|
||||
creditedDifficulty: 0,
|
||||
creditedDifficulty: 1e-9,
|
||||
isBlockCandidate: false,
|
||||
}));
|
||||
});
|
||||
@@ -547,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));
|
||||
|
||||
@@ -563,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));
|
||||
|
||||
@@ -597,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));
|
||||
|
||||
@@ -671,7 +747,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));
|
||||
|
||||
@@ -693,7 +769,7 @@ describe('StratumV1Client', () => {
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
@@ -712,7 +788,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));
|
||||
|
||||
@@ -737,7 +813,7 @@ describe('StratumV1Client', () => {
|
||||
const calculateDifficultySpy = jest.spyOn(client as any, 'calculateDifficulty');
|
||||
|
||||
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));
|
||||
|
||||
@@ -775,7 +851,7 @@ describe('StratumV1Client', () => {
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
@@ -800,11 +876,12 @@ describe('StratumV1Client', () => {
|
||||
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);
|
||||
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),
|
||||
@@ -817,7 +894,9 @@ describe('StratumV1Client', () => {
|
||||
expect((client as any).isDuplicateSubmission('old-tip-share')).toBe(true);
|
||||
});
|
||||
|
||||
it('should bound duplicate tracking and expire entries by TTL', () => {
|
||||
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';
|
||||
@@ -826,13 +905,18 @@ describe('StratumV1Client', () => {
|
||||
});
|
||||
|
||||
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).isDuplicateSubmission('three')).toBe(false);
|
||||
expect([...(client as any).miningSubmissionHashes.keys()]).toEqual(['two', 'three']);
|
||||
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).miningSubmissionHashes.keys()]).toEqual(['two']);
|
||||
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 () => {
|
||||
@@ -846,7 +930,7 @@ describe('StratumV1Client', () => {
|
||||
const buildHeaderSpy = jest.spyOn(MiningJob.prototype, 'buildHeaderBuffer');
|
||||
|
||||
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));
|
||||
|
||||
@@ -876,7 +960,7 @@ describe('StratumV1Client', () => {
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
@@ -913,7 +997,7 @@ describe('StratumV1Client', () => {
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
@@ -939,7 +1023,7 @@ describe('StratumV1Client', () => {
|
||||
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));
|
||||
|
||||
@@ -956,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 = {};
|
||||
@@ -981,13 +1065,39 @@ 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": [0]}`);
|
||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1e-9]}`);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
@@ -1114,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));
|
||||
|
||||
|
||||
+135
-32
@@ -46,12 +46,20 @@ 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 {
|
||||
@@ -84,11 +92,14 @@ export class StratumV1Client {
|
||||
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 Map<string, number>();
|
||||
private miningSubmissionHashes = new Map<string, SubmissionDedupEntry>();
|
||||
|
||||
constructor(
|
||||
public readonly socket: Socket,
|
||||
@@ -112,6 +123,7 @@ export class StratumV1Client {
|
||||
this.socket.on('data', this.socketDataHandler);
|
||||
this.network = this.getNetwork();
|
||||
this.maxSocketBufferBytes = this.readMaxSocketBufferBytes();
|
||||
this.maxInboundLineBytes = this.readMaxInboundLineBytes();
|
||||
|
||||
|
||||
}
|
||||
@@ -147,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) {
|
||||
@@ -440,10 +458,17 @@ export class StratumV1Client {
|
||||
this.stratumInitialized = true;
|
||||
const latestJobTemplate = await this.getLatestPayoutJobTemplate();
|
||||
this.broadcastMiningJob(latestJobTemplate);
|
||||
const latestPrestage = this.stratumV1JobsService.getLatestPrestageJobTemplate(this.payoutMode);
|
||||
if (latestPrestage != null) {
|
||||
this.preStageMiningJob(latestPrestage);
|
||||
}
|
||||
|
||||
this.backgroundWork.push(
|
||||
setInterval(async () => {
|
||||
await this.checkDifficulty();
|
||||
setInterval(() => {
|
||||
void this.checkDifficulty().catch((error) => {
|
||||
console.error('Stratum difficulty check failed; closing client connection', error);
|
||||
this.closeSocket();
|
||||
});
|
||||
}, 60 * 1000)
|
||||
);
|
||||
|
||||
@@ -461,6 +486,29 @@ export class StratumV1Client {
|
||||
&& !this.socket.writableEnded;
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -478,6 +526,12 @@ export class StratumV1Client {
|
||||
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;
|
||||
@@ -512,24 +566,44 @@ export class StratumV1Client {
|
||||
// ];
|
||||
// }
|
||||
|
||||
const job = this.stratumV1JobsService.getOrCreateJob(
|
||||
const payoutIdentity = this.getPayoutIdentity(jobTemplate);
|
||||
const preStagedJob = (
|
||||
jobTemplate.blockData.jobType === 'empty'
|
||||
? this.stratumV1JobsService.activatePreStagedJob(
|
||||
jobTemplate,
|
||||
payoutIdentity,
|
||||
this.payoutMode,
|
||||
)
|
||||
: null
|
||||
);
|
||||
const job = preStagedJob ?? this.stratumV1JobsService.getOrCreateJob(
|
||||
this.network,
|
||||
payoutInformation,
|
||||
jobTemplate,
|
||||
this.getPayoutIdentity(jobTemplate),
|
||||
payoutIdentity,
|
||||
this.payoutMode,
|
||||
);
|
||||
const payload = job.responseBuffer(jobTemplate);
|
||||
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 };
|
||||
return {
|
||||
status: 'closed',
|
||||
bytes: 0,
|
||||
bufferedBytes: bufferedBeforeWrite,
|
||||
...(preStagedJob == null ? {} : { preStaged: true }),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const accepted = this.socket.write(payload);
|
||||
this.lastSentMiningJobTimestamp = jobTemplate.block.timestamp;
|
||||
this.lastSentMiningJobSignature = signature;
|
||||
this.lastSentMiningTipKey = jobTemplate.blockData.tipKey;
|
||||
this.lastSentMiningJobType = jobTemplate.blockData.jobType;
|
||||
const bufferedAfterWrite = this.socket.writableLength ?? 0;
|
||||
if (bufferedAfterWrite >= maximumBufferedBytes) {
|
||||
this.closeSocket();
|
||||
@@ -537,12 +611,14 @@ export class StratumV1Client {
|
||||
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();
|
||||
@@ -550,6 +626,7 @@ export class StratumV1Client {
|
||||
status: 'error',
|
||||
bytes: 0,
|
||||
bufferedBytes: this.socket.writableLength ?? 0,
|
||||
...(preStagedJob == null ? {} : { preStaged: true }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -620,6 +697,15 @@ export class StratumV1Client {
|
||||
);
|
||||
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.
|
||||
@@ -698,6 +784,15 @@ export class StratumV1Client {
|
||||
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') {
|
||||
@@ -993,33 +1088,31 @@ export class StratumV1Client {
|
||||
|
||||
private isDuplicateSubmission(submissionHash: string): boolean {
|
||||
const now = Date.now();
|
||||
const existingExpiry = this.miningSubmissionHashes.get(submissionHash);
|
||||
if (existingExpiry != null && existingExpiry > now) {
|
||||
return true;
|
||||
}
|
||||
if (existingExpiry != null) {
|
||||
this.miningSubmissionHashes.delete(submissionHash);
|
||||
this.pruneExpiredSubmissions(now);
|
||||
return this.miningSubmissionHashes.has(submissionHash);
|
||||
}
|
||||
|
||||
for (const [hash, expiresAt] of this.miningSubmissionHashes) {
|
||||
if (expiresAt <= now) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
this.miningSubmissionHashes.set(
|
||||
submissionHash,
|
||||
now + this.getSubmissionDedupTtlMs(),
|
||||
);
|
||||
const maxEntries = this.getSubmissionDedupMaxEntries();
|
||||
while (this.miningSubmissionHashes.size > maxEntries) {
|
||||
const oldestHash = this.miningSubmissionHashes.keys().next().value;
|
||||
if (oldestHash == null) {
|
||||
break;
|
||||
}
|
||||
this.miningSubmissionHashes.delete(oldestHash);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private getSubmissionDedupTtlMs(): number {
|
||||
@@ -1052,6 +1145,16 @@ export class StratumV1Client {
|
||||
: 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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -927,6 +927,41 @@ describe('StratumV2Client extended channels', () => {
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps enough retained SV2 jobs for repeated fast activation and full-template followup cycles', async () => {
|
||||
const { client, jobTemplate } = await createClient();
|
||||
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
|
||||
requestId: 1,
|
||||
userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker',
|
||||
nominalHashRate: 0,
|
||||
maxTarget: Buffer.alloc(32, 0xff),
|
||||
minExtranonceSize: 8,
|
||||
}));
|
||||
|
||||
let currentTemplate = jobTemplate;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
const activation = {
|
||||
...createActivationTemplate(currentTemplate),
|
||||
previousblockhash: (index + 1).toString(16).padStart(64, '0'),
|
||||
mintime: parseInt(MockRecording1.TIME, 16) + index + 1,
|
||||
curtime: parseInt(MockRecording1.TIME, 16) + index + 1,
|
||||
};
|
||||
await client.enqueueWorkActivation(activation as any);
|
||||
currentTemplate = {
|
||||
...createCanonicalFollowup(currentTemplate, activation),
|
||||
blockData: {
|
||||
...createCanonicalFollowup(currentTemplate, activation).blockData,
|
||||
id: `canonical-followup-${index}`,
|
||||
},
|
||||
};
|
||||
await client.enqueueCanonicalJob(currentTemplate);
|
||||
}
|
||||
|
||||
expect((client as any).socket.destroyed).toBe(false);
|
||||
const channel = (client as any).channels.get(1);
|
||||
expect(channel.extendedJobs.size).toBeLessThanOrEqual((client as any).maxRetainedJobsPerChannel);
|
||||
expect(channel.stagedFutureJobId).toBeDefined();
|
||||
});
|
||||
|
||||
it('activates a staged standard future and rejects incompatible activation versions', async () => {
|
||||
const { client, sentFrames, jobTemplate } = await createClient();
|
||||
await (client as any).handleOpenStandardMiningChannel(serializeOpenStandardMiningChannel({
|
||||
@@ -956,7 +991,7 @@ describe('StratumV2Client extended channels', () => {
|
||||
.toBe(parseInt(activation.bits, 16));
|
||||
});
|
||||
|
||||
it('validates fixed/rolling versions, required bits, and the advertised minimum nTime', async () => {
|
||||
it('validates BIP320 version rolling, required bits, and the advertised minimum nTime', async () => {
|
||||
const { client } = await createClient();
|
||||
const now = process.hrtime.bigint();
|
||||
const context = {
|
||||
@@ -972,11 +1007,12 @@ describe('StratumV2Client extended channels', () => {
|
||||
};
|
||||
|
||||
expect((client as any).isSubmissionHeaderValid(context, 0x20000004, 100)).toBe(true);
|
||||
expect((client as any).isSubmissionHeaderValid(context, 0x20002004, 100)).toBe(false);
|
||||
expect((client as any).isSubmissionHeaderValid(context, 0x20002004, 100)).toBe(true);
|
||||
expect((client as any).isSubmissionHeaderValid(context, 0x20000004, 99)).toBe(false);
|
||||
expect((client as any).isSubmissionHeaderValid(context, 0x20000004, 101)).toBe(true);
|
||||
expect((client as any).isSubmissionHeaderValid(context, 0x20000004, 0xffffffff)).toBe(true);
|
||||
expect((client as any).isSubmissionHeaderValid(context, 0x20000004, 0x1_0000_0000)).toBe(false);
|
||||
expect((client as any).isSubmissionHeaderValid(context, 0x00000004, 100)).toBe(false);
|
||||
|
||||
(client as any).versionRollingEnabled = true;
|
||||
expect((client as any).isSubmissionHeaderValid(context, 0x20002004, 100)).toBe(true);
|
||||
|
||||
@@ -78,7 +78,7 @@ const DEFAULT_DIFFICULTY_CHECK_INTERVAL_MS = 60 * 1000;
|
||||
const DEFAULT_CLIENT_HASHRATE_PERSIST_INTERVAL_MS = 60 * 1000;
|
||||
const FIXED_STANDARD_EXTRANONCE2 = '0000000000000000';
|
||||
const DEFAULT_SV2_JOB_RETENTION_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_SV2_MAX_RETAINED_JOBS_PER_CHANNEL = 16;
|
||||
const DEFAULT_SV2_MAX_RETAINED_JOBS_PER_CHANNEL = 64;
|
||||
const DEFAULT_SV2_MAX_QUEUED_JOB_OPERATIONS = 4;
|
||||
const DEFAULT_SV2_MAX_SOCKET_BUFFER_BYTES = 256 * 1024;
|
||||
const DEFAULT_SV2_SOCKET_WRITE_TIMEOUT_MS = 2 * 1000;
|
||||
@@ -2088,13 +2088,13 @@ export class StratumV2Client {
|
||||
): boolean {
|
||||
const submitted = submittedVersion >>> 0;
|
||||
const base = headerContext.baseVersion >>> 0;
|
||||
if (this.versionRollingEnabled) {
|
||||
// Some SV2 clients roll BIP320 general-purpose version bits even when
|
||||
// they did not explicitly require version rolling during setup. Those
|
||||
// bits are non-consensus signalling space; reject only changes outside
|
||||
// that mask and continue enforcing required bits below.
|
||||
if (((submitted ^ base) & BIP320_CONSENSUS_VERSION_MASK) !== 0) {
|
||||
return false;
|
||||
}
|
||||
} else if (submitted !== base) {
|
||||
return false;
|
||||
}
|
||||
if ((submitted & headerContext.requiredVersionBits) !== headerContext.requiredVersionBits) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,14 @@ export interface IBlockTemplate {
|
||||
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=')) {
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { }
|
||||
@@ -199,7 +199,7 @@ describe('BitcoinRpcService template publication', () => {
|
||||
.toEqual([true, true]);
|
||||
});
|
||||
|
||||
it('publishes a validated compact subsidy bridge before serializing the full template', async () => {
|
||||
it('publishes a consensus-subsidy bridge before traversing or serializing the full body', async () => {
|
||||
const order: string[] = [];
|
||||
const redis = createRedisMock(order);
|
||||
const template = createTemplate();
|
||||
@@ -236,6 +236,147 @@ describe('BitcoinRpcService template publication', () => {
|
||||
expect(order.indexOf('redis:publish:bridge:solo')).toBeLessThan(order.indexOf('redis:publish:solo'));
|
||||
});
|
||||
|
||||
it('publishes compact authoritative activation before canonical template work', async () => {
|
||||
const order: string[] = [];
|
||||
const redis = createRedisMock(order);
|
||||
redis.publishSv1PrestageActivation = jest.fn(async (activation: any) => {
|
||||
order.push(`redis:publish:activation:${activation.payoutMode}`);
|
||||
return true;
|
||||
});
|
||||
const template = createTemplateAtHeight(840_000, '68');
|
||||
const service = new BitcoinRpcService(
|
||||
createConfig({ NETWORK: 'mainnet' }),
|
||||
{ saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any,
|
||||
redis as any,
|
||||
{ createSnapshotForTemplate: jest.fn().mockResolvedValue(null) } as any,
|
||||
);
|
||||
service.miningInfo = { blocks: template.height - 1 } as any;
|
||||
jest.spyOn(service as any, 'fetchBlockTemplate').mockResolvedValue(template);
|
||||
|
||||
await service.getAndBroadcastLatestTemplate('new_block');
|
||||
|
||||
expect(redis.publishSv1PrestageActivation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
schemaVersion: 1,
|
||||
type: 'prestage-activation',
|
||||
height: template.height,
|
||||
previousBlockHash: template.previousblockhash,
|
||||
version: template.version,
|
||||
bits: template.bits,
|
||||
minTime: template.mintime,
|
||||
currentTime: template.curtime,
|
||||
subsidySats: calculateBlockSubsidySats(template.height, 'mainnet'),
|
||||
payoutMode: 'solo',
|
||||
requiredVersionBits: template.vbrequired >>> 0,
|
||||
}));
|
||||
expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled();
|
||||
expect(order.indexOf('redis:publish:activation:solo'))
|
||||
.toBeLessThan(order.indexOf('redis:set:solo'));
|
||||
});
|
||||
|
||||
it('falls back to the full empty bridge when compact activation delivery fails', async () => {
|
||||
const order: string[] = [];
|
||||
const redis = createRedisMock(order);
|
||||
redis.publishSv1PrestageActivation = jest.fn().mockResolvedValue(false);
|
||||
const template = createTemplateAtHeight(840_000, '69');
|
||||
const service = new BitcoinRpcService(
|
||||
createConfig({ NETWORK: 'mainnet' }),
|
||||
{ saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any,
|
||||
redis as any,
|
||||
{ createSnapshotForTemplate: jest.fn().mockResolvedValue(null) } as any,
|
||||
);
|
||||
service.miningInfo = { blocks: template.height - 1 } as any;
|
||||
jest.spyOn(service as any, 'fetchBlockTemplate').mockResolvedValue(template);
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await service.getAndBroadcastLatestTemplate('new_block');
|
||||
|
||||
expect(redis.publishSv1PrestageActivation).toHaveBeenCalledTimes(1);
|
||||
expect(redis.publishSv1BridgeUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'subsidy-bridge',
|
||||
template: expect.objectContaining({ payoutMode: 'solo', jobType: 'empty' }),
|
||||
}));
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('retries a timed-out compact activation through the normal Redis lane', async () => {
|
||||
const redis = createRedisMock([]);
|
||||
let resolveUrgent: () => void;
|
||||
const stalled = new Promise<void>(resolve => { resolveUrgent = resolve; });
|
||||
redis.publishSv1PrestageActivation = jest.fn(async (
|
||||
_activation: any,
|
||||
lane?: 'fallback',
|
||||
) => {
|
||||
if (lane == null) {
|
||||
await stalled;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const template = createTemplateAtHeight(840_000, '67');
|
||||
const service = new BitcoinRpcService(
|
||||
createConfig({ NETWORK: 'mainnet', SV1_BRIDGE_PUBLISH_BUDGET_MS: '1' }),
|
||||
{ saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any,
|
||||
redis as any,
|
||||
{ createSnapshotForTemplate: jest.fn().mockResolvedValue(null) } as any,
|
||||
);
|
||||
service.miningInfo = { blocks: template.height - 1 } as any;
|
||||
jest.spyOn(service as any, 'fetchBlockTemplate').mockResolvedValue(template);
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
await service.getAndBroadcastLatestTemplate('new_block');
|
||||
await flushPromises();
|
||||
|
||||
expect(redis.publishSv1PrestageActivation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'prestage-activation' }),
|
||||
'fallback',
|
||||
);
|
||||
expect(redis.setBlockTemplate).toHaveBeenCalled();
|
||||
resolveUrgent!();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects an urgent bridge when Core fee data cannot validate its subsidy', async () => {
|
||||
const redis = createRedisMock([]);
|
||||
const template = createTemplateAtHeight(840_000, '70');
|
||||
Object.defineProperty(template.transactions[0], 'fee', {
|
||||
get: () => {
|
||||
throw new Error('full transaction body was traversed');
|
||||
},
|
||||
});
|
||||
const service = new BitcoinRpcService(
|
||||
createConfig({ NETWORK: 'mainnet' }),
|
||||
{} as any,
|
||||
redis as any,
|
||||
);
|
||||
const trace = (service as any).startTrace('new_block', Date.now());
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await expect((service as any).publishSoloSubsidyBridge(template, trace))
|
||||
.resolves.toBe('failed');
|
||||
expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects an urgent bridge when Core coinbasevalue is not subsidy plus fees', async () => {
|
||||
const redis = createRedisMock([]);
|
||||
const template = createTemplateAtHeight(840_000, '79');
|
||||
template.coinbasevalue += 1;
|
||||
const service = new BitcoinRpcService(
|
||||
createConfig({ NETWORK: 'mainnet' }),
|
||||
{} as any,
|
||||
redis as any,
|
||||
);
|
||||
const trace = (service as any).startTrace('new_block', Date.now());
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await expect((service as any).publishSoloSubsidyBridge(template, trace))
|
||||
.resolves.toBe('failed');
|
||||
expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining(
|
||||
'does not equal subsidy',
|
||||
));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('precomputes an exact next-height PPLNS subsidy seed off the canonical path', async () => {
|
||||
const order: string[] = [];
|
||||
const canonical = createTemplateAtHeight(839_999, '66');
|
||||
@@ -500,7 +641,7 @@ describe('BitcoinRpcService template publication', () => {
|
||||
await expect((service as any).publishPplnsSubsidyBridge(
|
||||
nextTemplate,
|
||||
trace,
|
||||
)).resolves.toBe(false);
|
||||
)).resolves.toBe('skipped');
|
||||
expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
@@ -536,7 +677,7 @@ describe('BitcoinRpcService template publication', () => {
|
||||
await expect((service as any).publishPplnsSubsidyBridge(
|
||||
template,
|
||||
trace,
|
||||
)).resolves.toBe(false);
|
||||
)).resolves.toBe('skipped');
|
||||
|
||||
(service as any).storePplnsSubsidyBridgeSeed({
|
||||
...baseSeed,
|
||||
@@ -546,7 +687,7 @@ describe('BitcoinRpcService template publication', () => {
|
||||
await expect((service as any).publishPplnsSubsidyBridge(
|
||||
template,
|
||||
trace,
|
||||
)).resolves.toBe(false);
|
||||
)).resolves.toBe('failed');
|
||||
|
||||
(service as any).storePplnsSubsidyBridgeSeed({
|
||||
...baseSeed,
|
||||
@@ -556,7 +697,7 @@ describe('BitcoinRpcService template publication', () => {
|
||||
await expect((service as any).publishPplnsSubsidyBridge(
|
||||
template,
|
||||
trace,
|
||||
)).resolves.toBe(false);
|
||||
)).resolves.toBe('skipped');
|
||||
expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -569,6 +710,7 @@ describe('BitcoinRpcService template publication', () => {
|
||||
if (update.template.payoutMode === 'pplns') {
|
||||
throw new Error('PPLNS Redis unavailable');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const service = new BitcoinRpcService(
|
||||
createConfig({
|
||||
@@ -633,6 +775,102 @@ describe('BitcoinRpcService template publication', () => {
|
||||
expect((service as any).latestLongpollId).toBe('next-longpoll-id');
|
||||
});
|
||||
|
||||
it('accepts the first authoritative template from an independent auxiliary longpoll source', async () => {
|
||||
const redis = createRedisMock([]);
|
||||
const template = createTemplateAtHeight(840_000, '73');
|
||||
template.longpollid = 'aux-next-longpoll';
|
||||
const auxiliaryClient = {
|
||||
post: jest.fn().mockResolvedValue({
|
||||
data: { result: template, error: null },
|
||||
}),
|
||||
};
|
||||
const source = {
|
||||
name: 'aux-1',
|
||||
client: auxiliaryClient,
|
||||
latestLongpollId: 'aux-prior-longpoll',
|
||||
longpollLoopStarted: true,
|
||||
};
|
||||
const service = new BitcoinRpcService(
|
||||
createConfig({ NETWORK: 'mainnet', BLOCK_TEMPLATE_LONGPOLL_TIMEOUT_MS: '1000' }),
|
||||
{ saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any,
|
||||
redis as any,
|
||||
);
|
||||
service.miningInfo = { blocks: template.height - 1 } as any;
|
||||
const primaryPost = jest.fn().mockResolvedValue({
|
||||
data: { result: template.previousblockhash, error: null },
|
||||
});
|
||||
(service as any).client = { post: primaryPost };
|
||||
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
|
||||
await (service as any).getAndBroadcastLatestTemplateOnce(
|
||||
'longpoll',
|
||||
source.latestLongpollId,
|
||||
undefined,
|
||||
source,
|
||||
);
|
||||
|
||||
expect(auxiliaryClient.post).toHaveBeenCalledWith('', expect.objectContaining({
|
||||
method: 'getblocktemplate',
|
||||
params: [expect.objectContaining({ longpollid: 'aux-prior-longpoll' })],
|
||||
}), { timeout: 1000 });
|
||||
expect(source.latestLongpollId).toBe('aux-next-longpoll');
|
||||
expect(primaryPost).toHaveBeenCalledWith('', expect.objectContaining({
|
||||
method: 'getbestblockhash',
|
||||
}), undefined);
|
||||
expect(redis.publishSv1BridgeUpdate).toHaveBeenCalled();
|
||||
expect(redis.setBlockTemplate).toHaveBeenCalledWith(
|
||||
template.height - 1,
|
||||
expect.objectContaining({ previousblockhash: template.previousblockhash }),
|
||||
);
|
||||
const sourceLog = logSpy.mock.calls
|
||||
.map(call => call[0])
|
||||
.find(value => typeof value === 'string' && value.includes('block_source_notification'));
|
||||
expect(JSON.parse(sourceLog)).toEqual(expect.objectContaining({
|
||||
source: 'longpoll',
|
||||
templateSource: 'aux-1',
|
||||
}));
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects auxiliary work before urgent and canonical publication when primary Core has another tip', async () => {
|
||||
const redis = createRedisMock([]);
|
||||
const template = createTemplateAtHeight(840_000, '74');
|
||||
const source = {
|
||||
name: 'aux-1',
|
||||
client: {
|
||||
post: jest.fn().mockResolvedValue({
|
||||
data: { result: template, error: null },
|
||||
}),
|
||||
},
|
||||
latestLongpollId: 'aux-prior-longpoll',
|
||||
longpollLoopStarted: true,
|
||||
};
|
||||
const service = new BitcoinRpcService(
|
||||
createConfig({ NETWORK: 'mainnet' }),
|
||||
{ saveBlock: jest.fn(), getSavedBlockTemplate: jest.fn() } as any,
|
||||
redis as any,
|
||||
);
|
||||
service.miningInfo = { blocks: template.height - 1 } as any;
|
||||
(service as any).client = {
|
||||
post: jest.fn().mockResolvedValue({
|
||||
data: { result: '75'.repeat(32), error: null },
|
||||
}),
|
||||
};
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
await (service as any).getAndBroadcastLatestTemplateOnce(
|
||||
'longpoll',
|
||||
source.latestLongpollId,
|
||||
undefined,
|
||||
source,
|
||||
);
|
||||
|
||||
expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled();
|
||||
expect(redis.setBlockTemplate).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('aux_template_rejected'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('deduplicates the same authoritative template returned by ZMQ and longpoll', async () => {
|
||||
const redis = createRedisMock([]);
|
||||
const template = createTemplate();
|
||||
@@ -694,10 +932,16 @@ describe('BitcoinRpcService template publication', () => {
|
||||
const redis = createRedisMock(order);
|
||||
let resolveBridge: () => void;
|
||||
const stalledBridge = new Promise<void>(resolve => { resolveBridge = resolve; });
|
||||
redis.publishSv1BridgeUpdate.mockImplementation(async (update: { template: IBlockTemplate }) => {
|
||||
order.push(`redis:publish:bridge:${update.template.payoutMode}`);
|
||||
redis.publishSv1BridgeUpdate.mockImplementation((async (
|
||||
update: { template: IBlockTemplate },
|
||||
lane: 'fallback' | undefined,
|
||||
) => {
|
||||
order.push(`redis:publish:bridge:${update.template.payoutMode}:${lane ?? 'urgent'}`);
|
||||
if (lane == null) {
|
||||
await stalledBridge;
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}) as any);
|
||||
const template = createTemplateAtHeight(840_000, '71');
|
||||
const service = new BitcoinRpcService(
|
||||
createConfig({ NETWORK: 'mainnet' }),
|
||||
@@ -710,7 +954,12 @@ describe('BitcoinRpcService template publication', () => {
|
||||
|
||||
await service.getAndBroadcastLatestTemplate('new_block');
|
||||
|
||||
expect(redis.publishSv1BridgeUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(redis.publishSv1BridgeUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(redis.publishSv1BridgeUpdate).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ type: 'subsidy-bridge' }),
|
||||
'fallback',
|
||||
);
|
||||
expect(redis.setBlockTemplate).toHaveBeenCalledWith(
|
||||
template.height - 1,
|
||||
expect.objectContaining({ payoutMode: 'solo', jobType: 'full' }),
|
||||
@@ -718,12 +967,85 @@ describe('BitcoinRpcService template publication', () => {
|
||||
expect(redis.publishBlockTemplateUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payoutMode: 'solo',
|
||||
}));
|
||||
expect(order.indexOf('redis:publish:bridge:solo')).toBeLessThan(order.indexOf('redis:set:solo'));
|
||||
expect(order.indexOf('redis:publish:bridge:solo:fallback')).toBeLessThan(order.indexOf('redis:set:solo'));
|
||||
|
||||
resolveBridge!();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it('retries the bridge from the canonical path after an immediate Redis delivery failure', async () => {
|
||||
const redis = createRedisMock([]);
|
||||
redis.publishSv1BridgeUpdate
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true);
|
||||
const template = createTemplateAtHeight(840_000, '78');
|
||||
const service = new BitcoinRpcService(
|
||||
createConfig({ NETWORK: 'mainnet' }),
|
||||
{ saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any,
|
||||
redis as any,
|
||||
{ createSnapshotForTemplate: jest.fn().mockResolvedValue(null) } as any,
|
||||
);
|
||||
service.miningInfo = { blocks: template.height - 1 } as any;
|
||||
jest.spyOn(service as any, 'fetchBlockTemplate').mockResolvedValue(template);
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await service.getAndBroadcastLatestTemplate('new_block');
|
||||
|
||||
expect(redis.publishSv1BridgeUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(redis.setBlockTemplate).toHaveBeenCalledWith(
|
||||
template.height - 1,
|
||||
expect.objectContaining({ payoutMode: 'solo', jobType: 'full' }),
|
||||
);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['mainnet', 'main'],
|
||||
['testnet', 'test'],
|
||||
['regtest', 'regtest'],
|
||||
] as const)('accepts NETWORK=%s only for the matching Core chain', (network, chain) => {
|
||||
const service = new BitcoinRpcService(
|
||||
createConfig({ NETWORK: network }),
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
|
||||
expect(() => (service as any).validateConfiguredNetworkAgainstCore(chain))
|
||||
.not.toThrow();
|
||||
expect(() => (service as any).validateConfiguredNetworkAgainstCore(
|
||||
chain === 'main' ? 'test' : 'main',
|
||||
)).toThrow(`NETWORK=${network} does not match Bitcoin Core chain=`);
|
||||
});
|
||||
|
||||
it('publishes a durable next-height empty prestage after canonical work', async () => {
|
||||
const redis = createRedisMock([]);
|
||||
const template = createTemplateAtHeight(840_000, '72');
|
||||
const service = new BitcoinRpcService(
|
||||
createConfig({ NETWORK: 'mainnet' }),
|
||||
{ saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any,
|
||||
redis as any,
|
||||
{ createSnapshotForTemplate: jest.fn().mockResolvedValue(null) } as any,
|
||||
);
|
||||
service.miningInfo = { blocks: template.height - 1 } as any;
|
||||
jest.spyOn(service as any, 'fetchBlockTemplate').mockResolvedValue(template);
|
||||
|
||||
await service.getAndBroadcastLatestTemplate('new_block');
|
||||
await flushPromises();
|
||||
|
||||
expect(redis.publishSv1PrestageUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
schemaVersion: 1,
|
||||
type: 'subsidy-prestage',
|
||||
template: expect.objectContaining({
|
||||
height: template.height + 1,
|
||||
previousblockhash: '0'.repeat(64),
|
||||
payoutMode: 'solo',
|
||||
jobType: 'empty',
|
||||
transactions: [],
|
||||
coinbasevalue: calculateBlockSubsidySats(template.height + 1, 'mainnet'),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('verifies same-height reorgs against Core and never switches back to an orphan', async () => {
|
||||
const redis = createRedisMock([]);
|
||||
const first = createTemplateAtHeight(840_000, '31');
|
||||
@@ -895,6 +1217,61 @@ describe('BitcoinRpcService template publication', () => {
|
||||
expect(canonicals).toEqual([expect.objectContaining({ previousblockhash: canonical.previousblockhash })]);
|
||||
});
|
||||
|
||||
it('delivers fresh compact activations once and ignores stale or duplicate events', () => {
|
||||
const service = new BitcoinRpcService(
|
||||
createConfig({}),
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
service.miningInfo = { blocks: 900_000 } as any;
|
||||
const activations: any[] = [];
|
||||
service.newSv1PrestageActivation$.subscribe(activation => activations.push(activation));
|
||||
const activation = {
|
||||
schemaVersion: 1,
|
||||
type: 'prestage-activation',
|
||||
eventId: 'fresh-activation',
|
||||
height: 900_001,
|
||||
previousBlockHash: '61'.repeat(32),
|
||||
version: 0x20000000,
|
||||
bits: '17034219',
|
||||
minTime: 1_700_000_000,
|
||||
currentTime: 1_700_000_001,
|
||||
subsidySats: 312_500_000,
|
||||
payoutMode: 'solo',
|
||||
requiredVersionBits: 0,
|
||||
publishedAtMs: Date.now(),
|
||||
};
|
||||
|
||||
(service as any).handleSv1PrestageActivation({
|
||||
...activation,
|
||||
eventId: 'stale-activation',
|
||||
height: 899_999,
|
||||
});
|
||||
(service as any).handleSv1PrestageActivation(activation);
|
||||
(service as any).handleSv1PrestageActivation(activation);
|
||||
(service as any).emitCanonicalTemplate({
|
||||
...createTemplate(),
|
||||
height: activation.height,
|
||||
previousblockhash: activation.previousBlockHash,
|
||||
payoutMode: 'solo',
|
||||
jobType: 'full',
|
||||
notificationEventId: 'canonical-before-late-activation',
|
||||
notificationPublishedAtMs: activation.publishedAtMs + 1,
|
||||
});
|
||||
(service as any).handleSv1PrestageActivation({
|
||||
...activation,
|
||||
eventId: 'late-after-canonical',
|
||||
publishedAtMs: activation.publishedAtMs + 2,
|
||||
});
|
||||
|
||||
expect(activations).toEqual([
|
||||
expect.objectContaining({
|
||||
eventId: activation.eventId,
|
||||
workerReceivedAtMs: expect.any(Number),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects live bridge updates older than or redundant with active canonical work', async () => {
|
||||
const service = new BitcoinRpcService(
|
||||
createConfig({}),
|
||||
@@ -1263,6 +1640,11 @@ function createRedisMock(order: string[]) {
|
||||
}),
|
||||
publishSv1BridgeUpdate: jest.fn(async (update: { template: IBlockTemplate }) => {
|
||||
order.push(`redis:publish:bridge:${update.template.payoutMode}`);
|
||||
return true;
|
||||
}),
|
||||
publishSv1PrestageActivation: undefined as jest.Mock | undefined,
|
||||
publishSv1PrestageUpdate: jest.fn(async (update: { template: IBlockTemplate }) => {
|
||||
order.push(`redis:publish:prestage:${update.template.payoutMode}`);
|
||||
}),
|
||||
setLegacyBlockTemplate: jest.fn(async (_height: number, template: IBlockTemplate) => {
|
||||
order.push(`redis:set:legacy:${template.payoutMode}`);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,14 @@ describe('RedisMessagingService', () => {
|
||||
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),
|
||||
@@ -113,6 +120,19 @@ describe('RedisMessagingService', () => {
|
||||
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);
|
||||
@@ -175,6 +195,42 @@ describe('RedisMessagingService', () => {
|
||||
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);
|
||||
@@ -193,12 +249,86 @@ describe('RedisMessagingService', () => {
|
||||
};
|
||||
|
||||
await service.subscribeSv1BridgeUpdates(handler);
|
||||
await service.publishSv1BridgeUpdate(update);
|
||||
await expect(service.publishSv1BridgeUpdate(update)).resolves.toBe(true);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(update);
|
||||
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');
|
||||
@@ -213,6 +343,51 @@ describe('RedisMessagingService', () => {
|
||||
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');
|
||||
@@ -297,9 +472,33 @@ function createBridgeUpdate(payoutMode: 'solo' | 'pplns', eventId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -307,7 +506,10 @@ function createRedisClient() {
|
||||
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');
|
||||
}),
|
||||
@@ -326,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);
|
||||
@@ -360,11 +571,12 @@ function createRedisClient() {
|
||||
}),
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,18 @@ 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 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,
|
||||
@@ -26,6 +32,7 @@ const blockTemplateHeightPointerKey = (height: number, payoutMode: PayoutMode) =
|
||||
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;
|
||||
@@ -42,12 +49,61 @@ export interface Sv1BridgeUpdate {
|
||||
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(
|
||||
@@ -64,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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -78,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;
|
||||
}
|
||||
|
||||
@@ -136,16 +205,45 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
public async publishSv1BridgeUpdate(update: Sv1BridgeUpdate): Promise<void> {
|
||||
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.
|
||||
await this.publisher.publish(SV1_BRIDGE_CHANNEL, serialized);
|
||||
const publisher = lane === 'urgent' ? this.urgentPublisher : this.publisher;
|
||||
await publisher.publish(SV1_BRIDGE_CHANNEL, serialized);
|
||||
void Promise.all([
|
||||
this.publisher.setEx(
|
||||
sv1BridgeLatestKey(payoutMode),
|
||||
@@ -162,15 +260,52 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
||||
]).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.subscriber.subscribe(SV1_BRIDGE_CHANNEL, async message => {
|
||||
await this.urgentSubscriber.subscribe(SV1_BRIDGE_CHANNEL, async message => {
|
||||
try {
|
||||
await handler(this.parseSv1BridgeUpdate(message));
|
||||
const update = this.parseSv1BridgeUpdate(message);
|
||||
update.workerReceivedAtMs = Date.now();
|
||||
await handler(update);
|
||||
} catch (error) {
|
||||
console.error(`Invalid Redis SV1 bridge update: ${error.message}`);
|
||||
}
|
||||
@@ -201,6 +336,53 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -343,6 +525,41 @@ 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;
|
||||
@@ -411,6 +628,94 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
||||
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 {
|
||||
|
||||
@@ -5,11 +5,13 @@ 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 bridgeTemplate$: Subject<IBlockTemplate>;
|
||||
let bitcoinRpcService: { newBlockTemplate$: any, newSv1BridgeTemplate$: any, miningInfo: { blocks: number } };
|
||||
let prestageTemplate$: Subject<IBlockTemplate>;
|
||||
let bitcoinRpcService: { newBlockTemplate$: any, newSv1BridgeTemplate$: any, newSv1PrestageTemplate$: any, miningInfo: { blocks: number } };
|
||||
let service: StratumV1JobsService;
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
|
||||
@@ -26,9 +28,11 @@ describe('StratumV1JobsService', () => {
|
||||
|
||||
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);
|
||||
@@ -141,6 +145,162 @@ describe('StratumV1JobsService', () => {
|
||||
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 }];
|
||||
|
||||
@@ -4,9 +4,10 @@ import * as crypto from 'crypto';
|
||||
import { EMPTY, filter, map, merge, Observable, shareReplay, tap } from 'rxjs';
|
||||
|
||||
import { IBlockTemplate, IBlockTemplateTx } from '../models/bitcoin-rpc/IBlockTemplate';
|
||||
import { AddressObject, MiningJob } from '../models/MiningJob';
|
||||
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,
|
||||
@@ -29,7 +30,10 @@ export interface IJobTemplate {
|
||||
payoutMode: PayoutMode | 'all';
|
||||
notificationEventId?: string;
|
||||
sourceNotificationReceivedAtMs?: number;
|
||||
notificationPreparedAtMs?: number;
|
||||
notificationPublishedAtMs?: number;
|
||||
notificationWorkerReceivedAtMs?: number;
|
||||
notificationWorkerHandledAtMs?: number;
|
||||
payoutSnapshotId?: string;
|
||||
payoutOutputs?: AddressObject[];
|
||||
transactions?: IBlockTemplateTx[];
|
||||
@@ -49,6 +53,7 @@ export interface IJobSubmissionContext {
|
||||
|
||||
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,
|
||||
@@ -70,6 +75,8 @@ 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 } = {};
|
||||
@@ -79,7 +86,13 @@ export class StratumV1JobsService {
|
||||
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>()],
|
||||
@@ -152,7 +165,10 @@ export class StratumV1JobsService {
|
||||
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,
|
||||
@@ -161,7 +177,7 @@ export class StratumV1JobsService {
|
||||
};
|
||||
}),
|
||||
filter(next => next != null),
|
||||
map(({ prepared, timestamp, networkDifficulty, clearJobs, isNewBlock, notificationEventId, sourceNotificationReceivedAtMs, notificationPublishedAtMs, rawTransactions, sigoplimit, sizelimit, weightlimit, requiredVersionBits }) => {
|
||||
map(({ prepared, timestamp, networkDifficulty, clearJobs, isNewBlock, notificationEventId, sourceNotificationReceivedAtMs, notificationPreparedAtMs, notificationPublishedAtMs, notificationWorkerReceivedAtMs, notificationWorkerHandledAtMs, rawTransactions, sigoplimit, sizelimit, weightlimit, requiredVersionBits }) => {
|
||||
const block = new bitcoinjs.Block();
|
||||
|
||||
// Keep only a placeholder coinbase on the hot path. The full raw body
|
||||
@@ -201,7 +217,10 @@ export class StratumV1JobsService {
|
||||
payoutMode: prepared.payoutMode,
|
||||
notificationEventId,
|
||||
sourceNotificationReceivedAtMs,
|
||||
notificationPreparedAtMs,
|
||||
notificationPublishedAtMs,
|
||||
notificationWorkerReceivedAtMs,
|
||||
notificationWorkerHandledAtMs,
|
||||
payoutSnapshotId: prepared.coinbase.payoutSnapshotId,
|
||||
payoutOutputs: prepared.coinbase.payoutOutputs?.map(output => ({ ...output })),
|
||||
transactions: rawTransactions,
|
||||
@@ -244,9 +263,20 @@ export class StratumV1JobsService {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +302,162 @@ export class StratumV1JobsService {
|
||||
?? 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++;
|
||||
@@ -288,13 +474,11 @@ export class StratumV1JobsService {
|
||||
?? (jobTemplate.blockData.payoutMode === 'pplns' ? 'pplns' : 'solo');
|
||||
const effectivePayoutIdentity = payoutIdentity
|
||||
?? createPayoutOutputIdentity(effectivePayoutMode, payoutInformation);
|
||||
const cacheKey = [
|
||||
jobTemplate.blockData.id,
|
||||
jobTemplate.block.timestamp,
|
||||
jobTemplate.blockData.clearJobs,
|
||||
const cacheKey = this.getJobCacheKey(
|
||||
jobTemplate,
|
||||
effectivePayoutMode,
|
||||
effectivePayoutIdentity,
|
||||
].join(':');
|
||||
);
|
||||
const cached = this.cachedJobs.get(cacheKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
@@ -388,6 +572,110 @@ export class StratumV1JobsService {
|
||||
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];
|
||||
|
||||
@@ -12,6 +12,7 @@ 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;
|
||||
@@ -19,6 +20,8 @@ describe('StratumV1Service', () => {
|
||||
let stratumV2Service;
|
||||
let redisMessagingService;
|
||||
let miningJobs: Subject<any>;
|
||||
let prestageJobs: Subject<any>;
|
||||
let prestageActivations: Subject<any>;
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
|
||||
@@ -36,13 +39,18 @@ describe('StratumV1Service', () => {
|
||||
};
|
||||
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,
|
||||
{ newMiningJob$: miningJobs.asObservable() } as any,
|
||||
{
|
||||
newMiningJob$: miningJobs.asObservable(),
|
||||
sv1PrestageJob$: prestageJobs.asObservable(),
|
||||
} as any,
|
||||
{} as any,
|
||||
stratumV2Service as any,
|
||||
userAgentReportService as any,
|
||||
@@ -63,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();
|
||||
@@ -137,6 +146,7 @@ describe('StratumV1Service', () => {
|
||||
});
|
||||
|
||||
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 = () => {
|
||||
@@ -166,6 +176,8 @@ describe('StratumV1Service', () => {
|
||||
event: 'stratum_job_fanout',
|
||||
eventId: 'load-test',
|
||||
clients: clientCount,
|
||||
targetClientsPerWorker: 10_000,
|
||||
overTargetClients: 90_000,
|
||||
written: clientCount,
|
||||
}));
|
||||
expect(trace.milestoneMs).toEqual(expect.objectContaining({
|
||||
@@ -176,6 +188,78 @@ describe('StratumV1Service', () => {
|
||||
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({
|
||||
|
||||
@@ -15,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';
|
||||
@@ -41,6 +42,8 @@ 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;
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +60,11 @@ export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
|
||||
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,
|
||||
@@ -89,6 +97,11 @@ export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
|
||||
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$
|
||||
@@ -96,6 +109,10 @@ export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
|
||||
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(() => {
|
||||
@@ -130,6 +147,12 @@ export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
|
||||
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;
|
||||
@@ -459,8 +482,13 @@ export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
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)),
|
||||
@@ -473,6 +501,7 @@ export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
|
||||
let backpressured = 0;
|
||||
let closed = 0;
|
||||
let errors = 0;
|
||||
let preStaged = 0;
|
||||
let bytesQueued = 0;
|
||||
let maxBufferedBytes = 0;
|
||||
|
||||
@@ -481,6 +510,9 @@ export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
|
||||
visited++;
|
||||
try {
|
||||
const result = client.broadcastMiningJob(jobTemplate);
|
||||
if (result.preStaged) {
|
||||
preStaged++;
|
||||
}
|
||||
bytesQueued += result.bytes;
|
||||
maxBufferedBytes = Math.max(maxBufferedBytes, result.bufferedBytes);
|
||||
switch (result.status) {
|
||||
@@ -515,21 +547,47 @@ export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
|
||||
eventId: jobTemplate.blockData.notificationEventId,
|
||||
sourceToFanoutStartMs: jobTemplate.blockData.sourceNotificationReceivedAtMs == null
|
||||
? undefined
|
||||
: Date.now() - jobTemplate.blockData.sourceNotificationReceivedAtMs,
|
||||
: 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
|
||||
: Date.now() - jobTemplate.blockData.notificationPublishedAtMs,
|
||||
: 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,
|
||||
@@ -537,6 +595,96 @@ export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
|
||||
}));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
|
||||
@@ -156,6 +157,45 @@ describe('StratumV2Service canonical job broadcaster', () => {
|
||||
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();
|
||||
@@ -230,11 +270,13 @@ function createService(): {
|
||||
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)),
|
||||
@@ -248,6 +290,7 @@ function createService(): {
|
||||
};
|
||||
const bitcoinRpcService = {
|
||||
workActivationTemplate$: new Observable((subscriber) => activationSubscribeSpy(subscriber)),
|
||||
newSv1PrestageActivation$: compactActivations.asObservable(),
|
||||
};
|
||||
const service = new StratumV2Service(
|
||||
bitcoinRpcService as any,
|
||||
@@ -263,7 +306,7 @@ function createService(): {
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
return { service, templates, subscribeSpy, activations, activationSubscribeSpy };
|
||||
return { service, templates, subscribeSpy, activations, compactActivations, activationSubscribeSpy };
|
||||
}
|
||||
|
||||
function createClientMock(
|
||||
@@ -298,6 +341,27 @@ function createActivationTemplate(
|
||||
} 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',
|
||||
|
||||
@@ -30,7 +30,7 @@ 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 { 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';
|
||||
@@ -45,6 +45,7 @@ export class StratumV2Service implements OnModuleInit, OnModuleDestroy {
|
||||
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;
|
||||
@@ -98,6 +99,8 @@ export class StratumV2Service implements OnModuleInit, OnModuleDestroy {
|
||||
this.canonicalJobSubscription = null;
|
||||
this.workActivationSubscription?.unsubscribe();
|
||||
this.workActivationSubscription = null;
|
||||
this.compactActivationSubscription?.unsubscribe();
|
||||
this.compactActivationSubscription = null;
|
||||
|
||||
const clients = Array.from(this.clients);
|
||||
this.clients.clear();
|
||||
@@ -261,15 +264,58 @@ export class StratumV2Service implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
private startWorkActivationBroadcaster(): void {
|
||||
if (this.workActivationSubscription != null || this.bitcoinRpcService.workActivationTemplate$ == null) {
|
||||
return;
|
||||
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}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user