22 Commits
Author SHA1 Message Date
Ben 789b529bd5 Fix one-off hashrate calculation 2026-09-15 21:54:06 -04:00
Ben 5889e3af5b constrain suggest difficulty 2026-08-27 22:41:48 -04:00
Ben f10cef61ce Automate deployment of renewed TLS certificates 2026-08-18 08:52:25 -04:00
Ben 07a2b173d0 Harden Stratum V1 share validation 2026-08-05 16:34:43 -04:00
Ben 4c56839c02 Prevent accounting cache refresh stampedes 2026-08-04 23:47:41 -04:00
Ben 1cdf3fbf7e Cap concurrent API connections 2026-08-04 19:24:29 -04:00
Ben d1ebc55871 Bound public API connection lifetimes 2026-08-04 19:20:48 -04:00
Ben 7d96c06bbf Avoid retaining accounting summary promises 2026-08-04 12:30:29 -04:00
Ben dd39c6edcf Fix Timescale rollup index migration 2026-08-03 23:03:36 -04:00
Ben 86ad296189 Restore accepted share rollup lookup indexes 2026-08-03 22:44:49 -04:00
Ben f49e7b5b8f Accept BIP320 version rolling in SV2 shares 2026-08-03 10:58:23 -04:00
Ben 3cb4cbfbdf Isolate API worker module 2026-08-03 10:27:03 -04:00
Ben f31cdef7f0 Fix retained share high score ordering 2026-07-29 00:57:14 -04:00
Ben 943dd60eef Limit pool summary aggregate refresh window 2026-07-29 00:41:11 -04:00
Ben 4150d1e425 Add pool-level share summary aggregate 2026-07-29 00:24:48 -04:00
Ben fd226f206f Raise SV2 retained job limit 2026-07-14 22:55:36 -04:00
Ben 0b030fecca Split maintenance duties from notifier 2026-07-13 22:58:54 -04:00
Ben 448f7d4c52 Fix pool accounting summary fallback 2026-07-13 22:47:53 -04:00
Ben d830227178 Speed up prestaged block activation 2026-07-13 22:15:18 -04:00
Ben fe287c32c7 Switch block ZMQ notifications to hashblock 2026-07-13 13:00:26 -04:00
Ben 65101a98a2 Block found notifications 2026-07-13 12:20:37 -04:00
Ben c60264bbe5 Optimize SV1 block notify fanout 2026-07-13 11:55:26 -04:00
59 changed files with 4856 additions and 356 deletions
+40 -3
View File
@@ -15,9 +15,15 @@ BITCOIN_RPC_TIMEOUT=10000
BLOCK_TEMPLATE_LONGPOLL_TIMEOUT_MS=600000 BLOCK_TEMPLATE_LONGPOLL_TIMEOUT_MS=600000
BLOCK_TEMPLATE_RETRY_INITIAL_MS=25 BLOCK_TEMPLATE_RETRY_INITIAL_MS=25
BLOCK_TEMPLATE_RETRY_MAX_MS=1000 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: # 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 BITCOIN_ZMQ_HOST=tcp://192.168.1.100:3000
API_PORT=3334 API_PORT=3334
@@ -26,6 +32,14 @@ API_PORT=3334
API_BIND_HOST=127.0.0.1 API_BIND_HOST=127.0.0.1
API_PUBLIC_PORT=3334 API_PUBLIC_PORT=3334
API_WORKERS=4 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 json-file log rotation. Applies when using the compose files.
DOCKER_LOG_MAX_SIZE=100m DOCKER_LOG_MAX_SIZE=100m
@@ -43,7 +57,9 @@ SV2_AUTH_FAILURE_LOG_ENABLED=false
STRATUM_PORTS=3333,3332,3331,3330 STRATUM_PORTS=3333,3332,3331,3330
# Optional PPLNS-mode SV1/SV2 autodetect ports. # Optional PPLNS-mode SV1/SV2 autodetect ports.
#PPLNS_STRATUM_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 STRATUM_WORKER_MAX_MEMORY_RESTART=4096M
# High-byte base for pool-assigned SV2 extranonce prefixes. PM2 reserves two # 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 # 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 # Disconnect slow/non-reading SV1 clients before their per-socket write queue
# can grow without bound. Includes bytes already buffered by Node.js. # can grow without bound. Includes bytes already buffered by Node.js.
STRATUM_MAX_SOCKET_BUFFER_BYTES=262144 STRATUM_MAX_SOCKET_BUFFER_BYTES=262144
STRATUM_MAX_INBOUND_LINE_BYTES=65536
# Immediately publish a consensus-valid, subsidy-only solo job before the full # Immediately publish a consensus-valid, subsidy-only solo job before the full
# transaction template. SV2 uses the same activation to select its pre-staged # transaction template. SV2 uses the same activation to select its pre-staged
# future job; the full job then follows on the active tip. # future job; the full job then follows on the active tip.
SV1_SUBSIDY_BRIDGE_ENABLED=true 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; # Comma-separated: solo,pplns. PPLNS requires a fresh precomputed snapshot;
# solo remains the safe default and is always published first when both are set. # solo remains the safe default and is always published first when both are set.
SV1_SUBSIDY_BRIDGE_PAYOUT_MODES=solo 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 SV1_SUBSIDY_BRIDGE_PPLNS_SEED_MAX_AGE_MS=300000
STRATUM_JOB_RETENTION_MS=300000 STRATUM_JOB_RETENTION_MS=300000
STRATUM_SUBMISSION_DEDUP_TTL_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 # Per-client lifecycle bounds. Obsolete queued jobs are coalesced; clients are
# disconnected instead of dropping still-recoverable candidates or buffering forever. # disconnected instead of dropping still-recoverable candidates or buffering forever.
SV2_JOB_RETENTION_MS=300000 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_QUEUED_JOB_OPERATIONS=4
SV2_MAX_SOCKET_BUFFER_BYTES=262144 SV2_MAX_SOCKET_BUFFER_BYTES=262144
SV2_SOCKET_WRITE_TIMEOUT_MS=2000 SV2_SOCKET_WRITE_TIMEOUT_MS=2000
@@ -138,6 +171,10 @@ SHARE_ACCOUNTING_FLUSH_INTERVAL_MS=25
SHARE_ACCOUNTING_MAX_QUEUE_SIZE=50000 SHARE_ACCOUNTING_MAX_QUEUE_SIZE=50000
SHARE_ACCOUNTING_SUMMARY_CACHE_MS=2500 SHARE_ACCOUNTING_SUMMARY_CACHE_MS=2500
SHARE_ACCOUNTING_SUMMARY_CACHE_MAX=10000 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 CLIENT_HASHRATE_PERSIST_INTERVAL_MS=60000
SHARE_ROLLUP_ENABLED=true SHARE_ROLLUP_ENABLED=true
SHARE_ROLLUP_INTERVAL_MS=60000 SHARE_ROLLUP_INTERVAL_MS=60000
+2
View File
@@ -1,6 +1,8 @@
# compiled output # compiled output
/dist /dist
/node_modules /node_modules
__pycache__/
*.pyc
# Logs # Logs
logs logs
+63 -10
View File
@@ -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. 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. `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 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. 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 ### New-block notification path
The master keeps an authoritative Bitcoin Core `getblocktemplate` longpoll open; The master keeps an authoritative Bitcoin Core `getblocktemplate` longpoll open;
rawblock ZMQ remains a watchdog and duplicate results are discarded. On a new tip, hashblock ZMQ remains a watchdog and duplicate results are discarded. Optional
the master publishes a compact subsidy-only SV1 job before serializing the full endpoints in `BITCOIN_RPC_AUX_URLS` keep independent longpolls open and race the
transaction template. Workers fan that job out through one process-level socket primary source, reducing dependence on one node's block-relay peers. An auxiliary
broadcaster, then issue the full fee-paying job as a second clean switch. Payout template is only eligible after the primary Core's `getbestblockhash` exactly
snapshot creation and Postgres persistence run after the immediate solo publish. 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. 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 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. late network-target candidates remain recoverable without crediting stale shares.
Pending SV2 canonical jobs are coalesced per client, while a new-tip activation 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 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 outstanding socket writes, and a two-second write-callback deadline. A client is
disconnected if `SV2_MAX_RETAINED_JOBS_PER_CHANNEL`, disconnected if `SV2_MAX_RETAINED_JOBS_PER_CHANNEL`,
`SV2_MAX_QUEUED_JOB_OPERATIONS`, `SV2_MAX_SOCKET_BUFFER_BYTES`, or `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 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. 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 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 The Redis protocol remains rolling-deploy compatible: new workers retain the
legacy mining-info reload path, while the master writes the historical latest 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 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 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. on their prior job instead of being woken with a miner-address fallback job.
Two structured log events expose the end-to-end timing: 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. - `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 ## Docker
@@ -194,7 +247,7 @@ RPC block template table.
``` ```
rpcallowip=172.16.0.0/12 rpcallowip=172.16.0.0/12
zmqpubrawblock=tcp://0.0.0.0:3000 zmqpubhashblock=tcp://0.0.0.0:3000
``` ```
to your bitcoin.conf. to your bitcoin.conf.
+11 -1
View File
@@ -74,8 +74,14 @@ services:
API_PORT: ${API_PORT:-3334} API_PORT: ${API_PORT:-3334}
API_SECURE: ${API_SECURE:-false} API_SECURE: ${API_SECURE:-false}
API_WORKERS: ${API_WORKERS:-4} 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} PM2_ENABLED: ${PM2_ENABLED:-true}
STRATUM_WORKERS: ${STRATUM_WORKERS:-2} STRATUM_WORKERS: ${STRATUM_WORKERS:-auto}
STRATUM_PORTS: ${STRATUM_PORTS:-3333,3332,3331,3330} STRATUM_PORTS: ${STRATUM_PORTS:-3333,3332,3331,3330}
PPLNS_STRATUM_PORTS: ${PPLNS_STRATUM_PORTS:-} PPLNS_STRATUM_PORTS: ${PPLNS_STRATUM_PORTS:-}
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1} 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_MAX_QUEUE_SIZE: ${SHARE_ACCOUNTING_MAX_QUEUE_SIZE:-50000}
SHARE_ACCOUNTING_SUMMARY_CACHE_MS: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MS:-2500} SHARE_ACCOUNTING_SUMMARY_CACHE_MS: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MS:-2500}
SHARE_ACCOUNTING_SUMMARY_CACHE_MAX: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MAX:-10000} 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_ENABLED: ${SHARE_ROLLUP_ENABLED:-true}
SHARE_ROLLUP_INTERVAL_MS: ${SHARE_ROLLUP_INTERVAL_MS:-60000} SHARE_ROLLUP_INTERVAL_MS: ${SHARE_ROLLUP_INTERVAL_MS:-60000}
SHARE_ROLLUP_SAFETY_LAG_SECONDS: ${SHARE_ROLLUP_SAFETY_LAG_SECONDS:-30} SHARE_ROLLUP_SAFETY_LAG_SECONDS: ${SHARE_ROLLUP_SAFETY_LAG_SECONDS:-30}
+11 -1
View File
@@ -104,8 +104,18 @@ services:
API_PORT: ${API_PORT:-3334} API_PORT: ${API_PORT:-3334}
API_SECURE: ${API_SECURE:-false} API_SECURE: ${API_SECURE:-false}
API_WORKERS: ${API_WORKERS:-4} 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} PM2_ENABLED: ${PM2_ENABLED:-true}
STRATUM_WORKERS: ${STRATUM_WORKERS:-2} STRATUM_WORKERS: ${STRATUM_WORKERS:-auto}
STRATUM_PORTS: ${STRATUM_PORTS:-3333,3332,3331,3330} STRATUM_PORTS: ${STRATUM_PORTS:-3333,3332,3331,3330}
PPLNS_STRATUM_PORTS: ${PPLNS_STRATUM_PORTS:-} PPLNS_STRATUM_PORTS: ${PPLNS_STRATUM_PORTS:-}
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1} STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
+84 -44
View File
@@ -1,54 +1,94 @@
const { availableParallelism, cpus } = require('os');
const dockerLogConfig = { const dockerLogConfig = {
out_file: '/dev/stdout', out_file: '/dev/stdout',
error_file: '/dev/stderr', error_file: '/dev/stderr',
merge_logs: true, 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 = { module.exports = {
apps: [ apps: [
// API instance // API instance
{ {
...dockerLogConfig, ...dockerLogConfig,
name: 'api', name: 'api',
script: './dist/main.js', script: './dist/main.js',
instances: parseInt(process.env.API_WORKERS || '4', 10), instances: apiWorkers,
exec_mode: 'cluster', exec_mode: 'cluster',
env: { env: {
MASTER: 'false', MASTER: 'false',
API_ONLY: 'true', API_ONLY: 'true',
API_ENABLED: 'true', API_ENABLED: 'true',
NODE_CLUSTER_SCHED_POLICY: 'none', NODE_CLUSTER_SCHED_POLICY: 'none',
},
time: true
}, },
// Master instance time: true,
{ },
...dockerLogConfig, // Minimal authoritative template/notifier instance. This entrypoint avoids
name: 'master', // initializing API, Stratum, reporting, and notification integrations.
script: './dist/main.js', {
instances: 1, ...dockerLogConfig,
exec_mode: 'fork', name: 'master',
env: { script: './dist/notifier-main.js',
MASTER: 'true', instances: 1,
API_ENABLED: 'false', exec_mode: 'fork',
NODE_CLUSTER_SCHED_POLICY: 'none', env: {
}, MASTER: 'true',
time: true API_ENABLED: 'false',
NODE_CLUSTER_SCHED_POLICY: 'none',
}, },
// Worker instances time: true,
{ },
...dockerLogConfig, // Non-hot-path master duties: notifications, reporting, and cleanup.
name: 'workers', {
script: './dist/main.js', ...dockerLogConfig,
instances: parseInt(process.env.STRATUM_WORKERS || '2', 10), name: 'maintenance',
exec_mode: "cluster", script: './dist/maintenance-main.js',
max_memory_restart: process.env.STRATUM_WORKER_MAX_MEMORY_RESTART || '4096M', instances: 1,
env: { exec_mode: 'fork',
MASTER: 'false', env: {
API_ENABLED: 'false', MASTER: 'true',
NODE_CLUSTER_SCHED_POLICY: 'none', API_ENABLED: 'false',
}, NODE_CLUSTER_SCHED_POLICY: 'none',
time: true
}, },
], time: true,
}; },
// Worker instances
{
...dockerLogConfig,
name: 'workers',
script: './dist/main.js',
instances: stratumWorkers,
exec_mode: 'cluster',
max_memory_restart:
process.env.STRATUM_WORKER_MAX_MEMORY_RESTART || '4096M',
env: {
MASTER: 'false',
API_ENABLED: 'false',
NODE_CLUSTER_SCHED_POLICY: 'none',
},
time: true,
},
],
};
+1 -1
View File
@@ -19,7 +19,7 @@ rpcuser=bitcoin
server=1 server=1
# notify pool master about new blocks # notify pool master about new blocks
zmqpubrawblock=tcp://0.0.0.0:3000 zmqpubhashblock=tcp://0.0.0.0:3000
[main] [main]
[test] [test]
+1 -1
View File
@@ -22,4 +22,4 @@ rpcuser=bitcoin
prune=550 prune=550
# notify pool master about new blocks # notify pool master about new blocks
zmqpubrawblock=tcp://0.0.0.0:3000 zmqpubhashblock=tcp://0.0.0.0:3000
+1 -1
View File
@@ -19,7 +19,7 @@ rpcuser=bitcoin
server=1 server=1
# notify pool master about new blocks # notify pool master about new blocks
zmqpubrawblock=tcp://0.0.0.0:3000 zmqpubhashblock=tcp://0.0.0.0:3000
[main] [main]
[test] [test]
+10
View File
@@ -108,6 +108,16 @@ services:
DB_DATABASE: public_pool_mainnet DB_DATABASE: public_pool_mainnet
REDIS_URL: redis://redis:6379 REDIS_URL: redis://redis:6379
API_WORKERS: ${API_WORKERS:-4} 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" PM2_ENABLED: "true"
STRATUM_WORKERS: ${STRATUM_WORKERS:-2} STRATUM_WORKERS: ${STRATUM_WORKERS:-2}
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1} STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
+1
View File
@@ -4,6 +4,7 @@ BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=8332 BITCOIN_RPC_PORT=8332
BITCOIN_RPC_TIMEOUT=10000 BITCOIN_RPC_TIMEOUT=10000
BITCOIN_ZMQ_TOPIC=hashblock
BITCOIN_ZMQ_HOST=tcp://bitcoin:3000 BITCOIN_ZMQ_HOST=tcp://bitcoin:3000
API_PORT=3334 API_PORT=3334
+1
View File
@@ -4,6 +4,7 @@ BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=28332 BITCOIN_RPC_PORT=28332
BITCOIN_RPC_TIMEOUT=10000 BITCOIN_RPC_TIMEOUT=10000
BITCOIN_ZMQ_TOPIC=hashblock
BITCOIN_ZMQ_HOST=tcp://bitcoin-regtest:3000 BITCOIN_ZMQ_HOST=tcp://bitcoin-regtest:3000
API_PORT=23334 API_PORT=23334
+1
View File
@@ -4,6 +4,7 @@ BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=18332 BITCOIN_RPC_PORT=18332
BITCOIN_RPC_TIMEOUT=10000 BITCOIN_RPC_TIMEOUT=10000
BITCOIN_ZMQ_TOPIC=hashblock
BITCOIN_ZMQ_HOST=tcp://bitcoin-testnet:3000 BITCOIN_ZMQ_HOST=tcp://bitcoin-testnet:3000
API_PORT=13334 API_PORT=13334
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
set -eu
lineage=${RENEWED_LINEAGE:-/etc/letsencrypt/live/public-pool.io}
secrets_dir=${PUBLIC_POOL_SECRETS_DIR:-/home/ben/public-pool-timescaledb-test/secrets}
container=${PUBLIC_POOL_CONTAINER:-public-pool}
owner=${PUBLIC_POOL_CERT_OWNER:-ben}
group=${PUBLIC_POOL_CERT_GROUP:-ben}
cert_source="$lineage/fullchain.pem"
key_source="$lineage/privkey.pem"
test -r "$cert_source"
test -r "$key_source"
openssl x509 -in "$cert_source" -noout >/dev/null
openssl pkey -in "$key_source" -noout >/dev/null
cert_temp=$(mktemp "$secrets_dir/.cert.pem.XXXXXX")
key_temp=$(mktemp "$secrets_dir/.key.pem.XXXXXX")
trap 'rm -f "$cert_temp" "$key_temp"' EXIT HUP INT TERM
cat "$cert_source" > "$cert_temp"
cat "$key_source" > "$key_temp"
chown "$owner:$group" "$cert_temp" "$key_temp"
chmod 0644 "$cert_temp"
chmod 0600 "$key_temp"
mv -f "$cert_temp" "$secrets_dir/cert.pem"
mv -f "$key_temp" "$secrets_dir/key.pem"
# HTTPS can reload its secure context, but the Stratum TLS listeners currently
# read their certificate only when they start.
docker restart --time 30 "$container" >/dev/null
@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AcceptedShare10mLookupIndexes1781403000000 implements MigrationInterface {
public name = 'AcceptedShare10mLookupIndexes1781403000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_10m_bucket"
ON "accepted_share_10m" ("bucket" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_10m_address_bucket"
ON "accepted_share_10m" ("address", "bucket" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_10m_address_mode_bucket"
ON "accepted_share_10m" ("address", "payoutMode", "bucket" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_10m_group_bucket"
ON "accepted_share_10m" ("address", "clientName", "bucket" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_10m_group_mode_bucket"
ON "accepted_share_10m" ("address", "clientName", "payoutMode", "bucket" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_10m_client_bucket"
ON "accepted_share_10m" ("clientId", "bucket" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_10m_client_mode_bucket"
ON "accepted_share_10m" ("clientId", "payoutMode", "bucket" DESC)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_10m_client_mode_bucket"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_10m_group_mode_bucket"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_10m_address_mode_bucket"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_10m_client_bucket"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_10m_group_bucket"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_10m_address_bucket"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_10m_bucket"`);
}
}
@@ -0,0 +1,250 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AcceptedShareHighScoreNumericRetention1781402000000 implements MigrationInterface {
public name = 'AcceptedShareHighScoreNumericRetention1781402000000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_block_10m_best"
ON "accepted_share_block_10m" ("bestSubmissionDifficulty" DESC, "bucket" DESC)
WHERE "bestSubmissionDifficulty" > 0
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_block_10m_mode_best"
ON "accepted_share_block_10m" ("payoutMode", "bestSubmissionDifficulty" DESC, "bucket" DESC)
WHERE "bestSubmissionDifficulty" > 0
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_client_best_difficulty_mode"
ON "client_entity" ("payoutMode", "bestDifficulty" DESC, "updatedAt" DESC)
WHERE "bestDifficulty" > 0
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_address_settings_best_difficulty"
ON "address_settings_entity" ("bestDifficulty" DESC, "updatedAt" DESC)
WHERE "bestDifficulty" > 0
`);
await this.backfillRollupAllTime(queryRunner);
await this.backfillClientAllTime(queryRunner);
await this.backfillAddressSettingsAllTime(queryRunner);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_address_settings_best_difficulty"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_client_best_difficulty_mode"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_block_10m_mode_best"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_block_10m_best"`);
}
private async backfillRollupAllTime(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
WITH candidates AS (
SELECT
"payoutMode",
"bucket",
"blockHeight",
"bestSubmissionDifficulty"
FROM "accepted_share_block_10m"
WHERE "bestSubmissionDifficulty" IS NOT NULL
AND "bestSubmissionDifficulty" > 0
UNION ALL
SELECT
'all' AS "payoutMode",
"bucket",
"blockHeight",
"bestSubmissionDifficulty"
FROM "accepted_share_block_10m"
WHERE "bestSubmissionDifficulty" IS NOT NULL
AND "bestSubmissionDifficulty" > 0
),
best_rows AS (
SELECT DISTINCT ON ("payoutMode")
"payoutMode",
"bucket",
"blockHeight",
"bestSubmissionDifficulty"
FROM candidates
ORDER BY "payoutMode", "bestSubmissionDifficulty" DESC, "bucket" DESC
)
INSERT INTO "accepted_share_high_score" (
"scope",
"payoutMode",
"bucketDate",
"submissionDifficulty",
"acceptedAt",
"bucket",
"blockHeight",
"address",
"clientName",
"protocol"
)
SELECT
'all_time',
"payoutMode",
DATE '1970-01-01',
"bestSubmissionDifficulty",
"bucket",
"bucket",
"blockHeight",
NULL,
NULL,
NULL
FROM best_rows
ON CONFLICT ("scope", "payoutMode", "bucketDate")
DO UPDATE SET
"submissionDifficulty" = EXCLUDED."submissionDifficulty",
"acceptedAt" = EXCLUDED."acceptedAt",
"bucket" = EXCLUDED."bucket",
"blockHeight" = EXCLUDED."blockHeight",
"address" = EXCLUDED."address",
"clientName" = EXCLUDED."clientName",
"protocol" = EXCLUDED."protocol",
"updatedAt" = NOW()
WHERE EXCLUDED."submissionDifficulty" > "accepted_share_high_score"."submissionDifficulty"
OR (
EXCLUDED."submissionDifficulty" = "accepted_share_high_score"."submissionDifficulty"
AND EXCLUDED."acceptedAt" > COALESCE("accepted_share_high_score"."acceptedAt", '-infinity'::timestamptz)
)
`);
}
private async backfillClientAllTime(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
WITH candidates AS (
SELECT
"payoutMode",
"bestDifficulty",
"updatedAt",
"address",
"clientName",
"userAgent"
FROM "client_entity"
WHERE "bestDifficulty" IS NOT NULL
AND "bestDifficulty" > 0
UNION ALL
SELECT
'all' AS "payoutMode",
"bestDifficulty",
"updatedAt",
"address",
"clientName",
"userAgent"
FROM "client_entity"
WHERE "bestDifficulty" IS NOT NULL
AND "bestDifficulty" > 0
),
best_rows AS (
SELECT DISTINCT ON ("payoutMode")
"payoutMode",
"bestDifficulty",
"updatedAt",
"address",
"clientName",
"userAgent"
FROM candidates
ORDER BY "payoutMode", "bestDifficulty" DESC, "updatedAt" DESC
)
INSERT INTO "accepted_share_high_score" (
"scope",
"payoutMode",
"bucketDate",
"submissionDifficulty",
"acceptedAt",
"bucket",
"blockHeight",
"address",
"clientName",
"protocol"
)
SELECT
'all_time',
"payoutMode",
DATE '1970-01-01',
"bestDifficulty",
"updatedAt",
NULL,
NULL,
"address",
"clientName",
"userAgent"
FROM best_rows
ON CONFLICT ("scope", "payoutMode", "bucketDate")
DO UPDATE SET
"submissionDifficulty" = EXCLUDED."submissionDifficulty",
"acceptedAt" = EXCLUDED."acceptedAt",
"bucket" = EXCLUDED."bucket",
"blockHeight" = EXCLUDED."blockHeight",
"address" = EXCLUDED."address",
"clientName" = EXCLUDED."clientName",
"protocol" = EXCLUDED."protocol",
"updatedAt" = NOW()
WHERE EXCLUDED."submissionDifficulty" > "accepted_share_high_score"."submissionDifficulty"
OR (
EXCLUDED."submissionDifficulty" = "accepted_share_high_score"."submissionDifficulty"
AND EXCLUDED."acceptedAt" > COALESCE("accepted_share_high_score"."acceptedAt", '-infinity'::timestamptz)
)
`);
}
private async backfillAddressSettingsAllTime(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
WITH best_row AS (
SELECT
"bestDifficulty",
"updatedAt",
"address",
"bestDifficultyUserAgent"
FROM "address_settings_entity"
WHERE "bestDifficulty" IS NOT NULL
AND "bestDifficulty" > 0
ORDER BY "bestDifficulty" DESC, "updatedAt" DESC
LIMIT 1
)
INSERT INTO "accepted_share_high_score" (
"scope",
"payoutMode",
"bucketDate",
"submissionDifficulty",
"acceptedAt",
"bucket",
"blockHeight",
"address",
"clientName",
"protocol"
)
SELECT
'all_time',
'all',
DATE '1970-01-01',
"bestDifficulty",
"updatedAt",
NULL,
NULL,
"address",
NULL,
"bestDifficultyUserAgent"
FROM best_row
ON CONFLICT ("scope", "payoutMode", "bucketDate")
DO UPDATE SET
"submissionDifficulty" = EXCLUDED."submissionDifficulty",
"acceptedAt" = EXCLUDED."acceptedAt",
"bucket" = EXCLUDED."bucket",
"blockHeight" = EXCLUDED."blockHeight",
"address" = EXCLUDED."address",
"clientName" = EXCLUDED."clientName",
"protocol" = EXCLUDED."protocol",
"updatedAt" = NOW()
WHERE EXCLUDED."submissionDifficulty" > "accepted_share_high_score"."submissionDifficulty"
OR (
EXCLUDED."submissionDifficulty" = "accepted_share_high_score"."submissionDifficulty"
AND EXCLUDED."acceptedAt" > COALESCE("accepted_share_high_score"."acceptedAt", '-infinity'::timestamptz)
)
`);
}
}
@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PoolSummaryContinuousAggregate1781400000000 implements MigrationInterface {
public name = 'PoolSummaryContinuousAggregate1781400000000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_pool_10m"
WITH (timescaledb.continuous) AS
SELECT
time_bucket(INTERVAL '10 minutes', "acceptedAt") AS "bucket",
"payoutMode",
SUM("creditedDifficulty") AS "shares",
COUNT(*) AS "acceptedCount"
FROM "accepted_share_entity"
GROUP BY "bucket", "payoutMode"
WITH NO DATA
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_pool_10m',
start_offset => INTERVAL '25 hours',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => TRUE
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_pool_10m_bucket"
ON "accepted_share_pool_10m" ("bucket" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_pool_10m_mode_bucket"
ON "accepted_share_pool_10m" ("payoutMode", "bucket" DESC)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_pool_10m_mode_bucket"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_pool_10m_bucket"`);
await queryRunner.query(`
SELECT remove_continuous_aggregate_policy(
'accepted_share_pool_10m',
if_exists => TRUE
)
`);
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "accepted_share_pool_10m"`);
}
}
@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PoolSummaryRefreshWindow1781401000000 implements MigrationInterface {
public name = 'PoolSummaryRefreshWindow1781401000000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
SELECT remove_continuous_aggregate_policy(
'accepted_share_pool_10m',
if_exists => TRUE
)
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_pool_10m',
start_offset => INTERVAL '25 hours',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => TRUE
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
SELECT remove_continuous_aggregate_policy(
'accepted_share_pool_10m',
if_exists => TRUE
)
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_pool_10m',
start_offset => INTERVAL '2 days',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => TRUE
)
`);
}
}
@@ -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 () => { it('should use Redis cache for share accounting summaries across API workers', async () => {
process.env.SHARE_ACCOUNTING_SUMMARY_CACHE_MS = '0'; process.env.SHARE_ACCOUNTING_SUMMARY_CACHE_MS = '0';
const repository = { const repository = {
@@ -309,11 +365,65 @@ describe('ShareAccountingService', () => {
expect(repository.query).toHaveBeenCalledTimes(1); expect(repository.query).toHaveBeenCalledTimes(1);
expect(redis.setJsonCache).toHaveBeenCalledWith( expect(redis.setJsonCache).toHaveBeenCalledWith(
expect.stringContaining('accounting:summary:'), expect.stringContaining('accounting:summary:'),
expect.objectContaining({ totalAcceptedShares: 1 }), expect.objectContaining({
30000, 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 () => { it('should refresh pool summaries from completed rollup buckets and current round rollups', async () => {
const redis = { const redis = {
getJsonCache: jest.fn().mockResolvedValue(null), getJsonCache: jest.fn().mockResolvedValue(null),
@@ -357,6 +467,11 @@ describe('ShareAccountingService', () => {
networkDifficultyPercent: 35.2, networkDifficultyPercent: 35.2,
latestShareAt: '2026-06-07T12:10:00.000Z', latestShareAt: '2026-06-07T12:10:00.000Z',
})); }));
expect(repository.query).toHaveBeenNthCalledWith(
1,
expect.stringContaining('FROM "accepted_share_pool_10m"'),
[],
);
expect(repository.query).toHaveBeenNthCalledWith( expect(repository.query).toHaveBeenNthCalledWith(
2, 2,
expect.stringContaining('FROM "accepted_share_block_10m"'), expect.stringContaining('FROM "accepted_share_block_10m"'),
@@ -429,8 +544,8 @@ describe('ShareAccountingService', () => {
}; };
const service = new ShareAccountingService(repository as any); const service = new ShareAccountingService(repository as any);
await service.getPoolSummary(); await service.getAddressSummary('bc1qcached');
await service.getPoolSummary(); await service.getAddressSummary('bc1qcached');
expect(repository.query).toHaveBeenCalledTimes(1); expect(repository.query).toHaveBeenCalledTimes(1);
}); });
@@ -1,5 +1,6 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { AcceptedShareEntity } from '../accepted-share/accepted-share.entity'; 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_MAX_QUEUE_SIZE = 50000;
const DEFAULT_SUMMARY_CACHE_MS = 2500; const DEFAULT_SUMMARY_CACHE_MS = 2500;
const DEFAULT_SUMMARY_CACHE_MAX = 10000; 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_INTERVAL_MS = 60000;
const DEFAULT_ROLLUP_SAFETY_LAG_SECONDS = 10; const DEFAULT_ROLLUP_SAFETY_LAG_SECONDS = 10;
const DEFAULT_ROLLUP_MAX_SHARES_PER_BATCH = 5000000; 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 rollupTimer: NodeJS.Timeout | null = null;
private activeRollup: Promise<void> | null = null; private activeRollup: Promise<void> | null = null;
private summaryCache = new Map<string, SummaryCacheEntry>(); 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 poolSummaryCacheKey = 'accounting:pool-summary';
private readonly batchSize = this.readPositiveInt('SHARE_ACCOUNTING_BATCH_SIZE', DEFAULT_BATCH_SIZE); 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); 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 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 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 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 shareRollupEnabled = this.readBoolean('SHARE_ROLLUP_ENABLED', true);
private readonly shareRollupIntervalMs = this.readPositiveInt('SHARE_ROLLUP_INTERVAL_MS', DEFAULT_ROLLUP_INTERVAL_MS); 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); 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; return cached;
} }
if (process.env.API_ONLY === 'true') { return this.withPoolRollupOverlay(await this.getSummary({ payoutMode: mode }), mode);
return this.emptySummary();
}
return this.getSummary({ payoutMode: mode });
} }
public async refreshPoolSummary(payoutMode?: PayoutMode): Promise<ShareAccountingSummary> { 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> { private async withPoolRollupOverlay(summary: ShareAccountingSummary, payoutMode?: PayoutMode): Promise<ShareAccountingSummary> {
if (process.env.API_ONLY === 'true') {
return summary;
}
const [currentRoundRow] = await this.acceptedShareRepository.query(` const [currentRoundRow] = await this.acceptedShareRepository.query(`
WITH latest_found_block AS ( WITH latest_found_block AS (
SELECT COALESCE(MAX("height"), 0) AS "height" SELECT COALESCE(MAX("height"), 0) AS "height"
@@ -573,46 +577,219 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
return cached.value; return cached.value;
} }
const value = this.loadCachedSummary(filter, cacheKey).catch(error => { if (cached != null) {
this.summaryCache.delete(cacheKey); this.summaryCache.delete(cacheKey);
throw error;
});
if (this.summaryCacheMs > 0) {
this.summaryCache.set(cacheKey, {
expiresAt: now + this.summaryCacheMs,
value,
});
this.trimSummaryCache();
} }
const inFlight = this.summaryInFlight.get(cacheKey);
if (inFlight != null) {
return inFlight;
}
const value = this.loadCachedSummary(filter, cacheKey)
.then(summary => {
if (this.summaryCacheMs > 0) {
this.summaryCache.set(cacheKey, {
expiresAt: Date.now() + this.summaryCacheMs,
value: summary,
});
this.trimSummaryCache();
}
return summary;
})
.finally(() => {
this.summaryInFlight.delete(cacheKey);
});
this.summaryInFlight.set(cacheKey, value);
return value; return value;
} }
private async loadCachedSummary(filter: AccountingFilter, cacheKey: string): Promise<ShareAccountingSummary> { private async loadCachedSummary(filter: AccountingFilter, cacheKey: string): Promise<ShareAccountingSummary> {
const redisCacheKey = `accounting:summary:${cacheKey}`; 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 const cached = await this.redisMessagingService
?.getJsonCache<ShareAccountingSummary>(redisCacheKey) ?.getJsonCache<RedisSummaryCacheEntry | ShareAccountingSummary>(redisCacheKey)
.catch(error => { .catch(error => {
console.error(`Share accounting summary cache read failed: ${error.message}`); console.error(`Share accounting summary cache read failed: ${error.message}`);
return null; return null;
}); });
if (cached != null) { if (cached == null) {
return null;
}
if (this.isRedisSummaryCacheEntry(cached)) {
return cached; return cached;
} }
// Cache entries written by older workers have no timestamp. Their old
// short Redis TTL still bounds how long they can be treated as fresh.
return {
schemaVersion: 1,
refreshedAtMs: Date.now(),
value: cached,
};
}
private async loadColdRedisSummaryCache(
filter: AccountingFilter,
redisCacheKey: string,
): Promise<ShareAccountingSummary> {
if (!this.supportsRedisSummaryLock()) {
const summary = await this.loadSummary(filter);
await this.storeRedisSummaryCache(redisCacheKey, summary);
return summary;
}
const owner = randomUUID();
const acquired = await this.tryAcquireRedisSummaryLock(redisCacheKey, owner);
if (acquired === true) {
return this.loadAndStoreRedisSummary(filter, redisCacheKey, owner);
}
if (acquired == null) {
const summary = await this.loadSummary(filter);
await this.storeRedisSummaryCache(redisCacheKey, summary);
return summary;
}
const deadline = Date.now() + this.redisSummaryLockWaitMs;
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 50));
const cached = await this.readRedisSummaryCache(redisCacheKey);
if (cached != null) {
return cached.value;
}
}
// Redis locking is an optimization, not an availability dependency.
// If a lock holder died or a refresh exceeded its budget, fail open.
const summary = await this.loadSummary(filter); const summary = await this.loadSummary(filter);
await this.storeRedisSummaryCache(redisCacheKey, summary);
await this.redisMessagingService
?.setJsonCache(redisCacheKey, summary, this.redisSummaryCacheMs)
.catch(error => {
console.error(`Share accounting summary cache write failed: ${error.message}`);
});
return 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> { 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 { whereSql, params } = this.buildWhereClause(filter);
const [summary] = await this.acceptedShareRepository.query(` const [summary] = await this.acceptedShareRepository.query(`
WITH clock AS MATERIALIZED ( WITH clock AS MATERIALIZED (
@@ -657,6 +834,59 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
FROM filtered_rows FROM filtered_rows
`, params); `, 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 { return {
totalAcceptedShares: this.toNumber(summary?.totalAcceptedShares), totalAcceptedShares: this.toNumber(summary?.totalAcceptedShares),
totalCreditedDifficulty: this.toNumber(summary?.totalCreditedDifficulty), totalCreditedDifficulty: this.toNumber(summary?.totalCreditedDifficulty),
@@ -677,11 +907,17 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
blockCandidateCount: 0, blockCandidateCount: 0,
latestShareAt: summary?.latestShareAt == null latestShareAt: summary?.latestShareAt == null
? null ? null
: new Date(summary.latestShareAt).toISOString(), : new Date(summary.latestShareAt as string | Date).toISOString(),
protocolBreakdown: [], protocolBreakdown: [],
}; };
} }
private isPoolSummaryFilter(filter: AccountingFilter): boolean {
return filter.address == null
&& filter.clientName == null
&& filter.clientId == null;
}
private scheduleFlush(): void { private scheduleFlush(): void {
if (this.flushTimer != null || this.activeFlush != null) { if (this.flushTimer != null || this.activeFlush != null) {
return; return;
@@ -804,6 +1040,13 @@ export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
} }
private trimSummaryCache(): void { 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) { while (this.summaryCache.size > this.summaryCacheMax) {
const firstKey = this.summaryCache.keys().next().value; const firstKey = this.summaryCache.keys().next().value;
if (firstKey == null) { if (firstKey == null) {
@@ -840,5 +1083,11 @@ interface PendingShare {
interface SummaryCacheEntry { interface SummaryCacheEntry {
expiresAt: number; 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; return updatedRows;
} }
@@ -167,10 +172,130 @@ export class ShareHighScoreService implements OnModuleInit, OnModuleDestroy {
"blockHeight"::text AS "blockHeight", "blockHeight"::text AS "blockHeight",
"bestSubmissionDifficulty"::text AS "bestSubmissionDifficulty" "bestSubmissionDifficulty"::text AS "bestSubmissionDifficulty"
FROM candidate_rows FROM candidate_rows
ORDER BY "bucketDate", "bestSubmissionDifficulty" DESC, "bucket" DESC ORDER BY "bucketDate", "bestSubmissionDifficulty"::numeric DESC, "bucket" DESC
`, params); `, 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( private async loadExactHighScore(
manager: EntityManager, manager: EntityManager,
candidate: HighScoreCandidate, candidate: HighScoreCandidate,
+62
View File
@@ -0,0 +1,62 @@
import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AddressController } from './controllers/address/address.controller';
import { ClientController } from './controllers/client/client.controller';
import { UserAgentReportModule } from './ORM/_views/user-agent-report/user-agent-report.module';
import { AddressSettingsModule } from './ORM/address-settings/address-settings.module';
import { BlocksModule } from './ORM/blocks/blocks.module';
import { ClientStatisticsModule } from './ORM/client-statistics/client-statistics.module';
import { ClientModule } from './ORM/client/client.module';
import { PayoutSnapshotModule } from './ORM/payout-snapshot/payout-snapshot.module';
import { RpcBlocksModule } from './ORM/rpc-block/rpc-block.module';
import { ShareAccountingModule } from './ORM/share-accounting/share-accounting.module';
import { createDatabaseOptions } from './database.config';
import { BitcoinAddressValidator } from './models/validators/bitcoin-address.validator';
import { BitcoinRpcService } from './services/bitcoin-rpc.service';
import { RedisMessagingModule } from './services/redis-messaging.module';
import { Sv2AuthorityService } from './services/sv2-authority.service';
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => createDatabaseOptions({
...process.env,
DB_HOST: configService.get('DB_HOST'),
DB_PORT: configService.get('DB_PORT'),
DB_USERNAME: configService.get('DB_USERNAME'),
DB_PASSWORD: configService.get('DB_PASSWORD'),
DB_DATABASE: configService.get('DB_DATABASE'),
DB_LOGGING: configService.get('DB_LOGGING'),
DB_POOL_SIZE: configService.get('DB_POOL_SIZE'),
}),
imports: [ConfigModule],
inject: [ConfigService],
}),
CacheModule.register(),
RedisMessagingModule,
ClientStatisticsModule,
ClientModule,
AddressSettingsModule,
BlocksModule,
RpcBlocksModule,
UserAgentReportModule,
ShareAccountingModule,
PayoutSnapshotModule,
],
controllers: [
AppController,
ClientController,
AddressController,
],
providers: [
BitcoinRpcService,
BitcoinAddressValidator,
Sv2AuthorityService,
],
})
export class ApiModule {}
+2 -2
View File
@@ -6,7 +6,7 @@ describe('AppController', () => {
blocksService?: any; blocksService?: any;
addressSettingsService?: any; addressSettingsService?: any;
userAgentReportService?: any; userAgentReportService?: any;
stratumV2Service?: any; sv2AuthorityService?: any;
redisMessagingService?: any; redisMessagingService?: any;
} = {}) => { } = {}) => {
return new AppController( return new AppController(
@@ -26,7 +26,7 @@ describe('AppController', () => {
overrides.userAgentReportService ?? { overrides.userAgentReportService ?? {
getReport: jest.fn().mockResolvedValue([]), getReport: jest.fn().mockResolvedValue([]),
}, },
overrides.stratumV2Service ?? { overrides.sv2AuthorityService ?? {
getPoolAuthorityPublicKey: jest.fn().mockResolvedValue({ getPoolAuthorityPublicKey: jest.fn().mockResolvedValue({
publicKey: '', publicKey: '',
configured: false, configured: false,
+3 -3
View File
@@ -10,9 +10,9 @@ import { ClientStatisticsService } from './ORM/client-statistics/client-statisti
import { ClientService } from './ORM/client/client.service'; import { ClientService } from './ORM/client/client.service';
import { BitcoinRpcService } from './services/bitcoin-rpc.service'; import { BitcoinRpcService } from './services/bitcoin-rpc.service';
import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view'; 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 { ShareAccountingService } from './ORM/share-accounting/share-accounting.service';
import { RedisMessagingService } from './services/redis-messaging.service'; import { RedisMessagingService } from './services/redis-messaging.service';
import { Sv2AuthorityService } from './services/sv2-authority.service';
import { normalizePayoutMode } from './types/payout-mode'; import { normalizePayoutMode } from './types/payout-mode';
@Controller() @Controller()
@@ -30,7 +30,7 @@ export class AppController {
private readonly bitcoinRpcService: BitcoinRpcService, private readonly bitcoinRpcService: BitcoinRpcService,
private readonly addressSettingsService: AddressSettingsService, private readonly addressSettingsService: AddressSettingsService,
private readonly userAgentReportService: UserAgentReportService, private readonly userAgentReportService: UserAgentReportService,
private readonly stratumV2Service: StratumV2Service, private readonly sv2AuthorityService: Sv2AuthorityService,
private readonly shareAccountingService: ShareAccountingService, private readonly shareAccountingService: ShareAccountingService,
private readonly redisMessagingService: RedisMessagingService private readonly redisMessagingService: RedisMessagingService
) { } ) { }
@@ -78,7 +78,7 @@ export class AppController {
const [blockData, highScores, poolAuthority, userAgentReport] = await Promise.all([ const [blockData, highScores, poolAuthority, userAgentReport] = await Promise.all([
withInfoTimeout('found blocks', this.blocksService.getFoundBlocks(), staleInfo?.blockData ?? []), withInfoTimeout('found blocks', this.blocksService.getFoundBlocks(), staleInfo?.blockData ?? []),
withInfoTimeout('high scores', this.addressSettingsService.getHighScores(), staleInfo?.highScores ?? []), withInfoTimeout('high scores', this.addressSettingsService.getHighScores(), staleInfo?.highScores ?? []),
withInfoTimeout('SV2 authority', this.stratumV2Service.getPoolAuthorityPublicKey(), { withInfoTimeout('SV2 authority', this.sv2AuthorityService.getPoolAuthorityPublicKey(), {
publicKey: staleInfo?.sv2?.poolAuthorityPublicKey ?? '', publicKey: staleInfo?.sv2?.poolAuthorityPublicKey ?? '',
configured: staleInfo?.sv2?.authorityKeyConfigured ?? false configured: staleInfo?.sv2?.authorityKeyConfigured ?? false
}), }),
+2
View File
@@ -33,6 +33,7 @@ import { StratumV1Service } from './services/stratum-v1.service';
import { Sv2JobDeclarationRegistryService } from './services/sv2-job-declaration-registry.service'; import { Sv2JobDeclarationRegistryService } from './services/sv2-job-declaration-registry.service';
import { Sv2JobDeclarationService } from './services/sv2-job-declaration.service'; import { Sv2JobDeclarationService } from './services/sv2-job-declaration.service';
import { Sv2TemplateDistributionService } from './services/sv2-template-distribution.service'; import { Sv2TemplateDistributionService } from './services/sv2-template-distribution.service';
import { Sv2AuthorityService } from './services/sv2-authority.service';
import { StratumV2Service } from './services/stratum-v2.service'; import { StratumV2Service } from './services/stratum-v2.service';
import { TelegramService } from './services/telegram.service'; import { TelegramService } from './services/telegram.service';
import { TemplateProviderService } from './services/template-provider.service'; import { TemplateProviderService } from './services/template-provider.service';
@@ -94,6 +95,7 @@ const ORMModules = [
Sv2JobDeclarationRegistryService, Sv2JobDeclarationRegistryService,
Sv2JobDeclarationService, Sv2JobDeclarationService,
Sv2TemplateDistributionService, Sv2TemplateDistributionService,
Sv2AuthorityService,
CustomWorkService, CustomWorkService,
DatumService, DatumService,
BTCPayService, BTCPayService,
+8
View File
@@ -18,6 +18,10 @@ import { PayoutModes1781300000000 } from './ORM/_migrations/PayoutModes178130000
import { ShareRollupStoragePolicy1781305000000 } from './ORM/_migrations/ShareRollupStoragePolicy1781305000000'; import { ShareRollupStoragePolicy1781305000000 } from './ORM/_migrations/ShareRollupStoragePolicy1781305000000';
import { AcceptedShareHighScores1781309000000 } from './ORM/_migrations/AcceptedShareHighScores1781309000000'; import { AcceptedShareHighScores1781309000000 } from './ORM/_migrations/AcceptedShareHighScores1781309000000';
import { UserAgentReportNonzeroHashrate1781313000000 } from './ORM/_migrations/UserAgentReportNonzeroHashrate1781313000000'; 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 { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
import { AcceptedShareEntity } from './ORM/accepted-share/accepted-share.entity'; import { AcceptedShareEntity } from './ORM/accepted-share/accepted-share.entity';
import { AddressSettingsEntity } from './ORM/address-settings/address-settings.entity'; import { AddressSettingsEntity } from './ORM/address-settings/address-settings.entity';
@@ -62,6 +66,10 @@ export const databaseMigrations = [
ShareRollupStoragePolicy1781305000000, ShareRollupStoragePolicy1781305000000,
AcceptedShareHighScores1781309000000, AcceptedShareHighScores1781309000000,
UserAgentReportNonzeroHashrate1781313000000, UserAgentReportNonzeroHashrate1781313000000,
PoolSummaryContinuousAggregate1781400000000,
PoolSummaryRefreshWindow1781401000000,
AcceptedShareHighScoreNumericRetention1781402000000,
AcceptedShare10mLookupIndexes1781403000000,
]; ];
export function createDatabaseOptions(env: NodeJS.ProcessEnv): TypeOrmModuleOptions & DataSourceOptions { export function createDatabaseOptions(env: NodeJS.ProcessEnv): TypeOrmModuleOptions & DataSourceOptions {
+77
View File
@@ -0,0 +1,77 @@
/* eslint-disable @typescript-eslint/no-var-requires */
describe('PM2 worker sizing', () => {
const originalApiWorkers = process.env.API_WORKERS;
const originalStratumWorkers = process.env.STRATUM_WORKERS;
afterEach(() => {
restoreEnv('API_WORKERS', originalApiWorkers);
restoreEnv('STRATUM_WORKERS', originalStratumWorkers);
jest.resetModules();
jest.unmock('os');
});
it('assigns remaining CPUs to Stratum workers in auto mode', () => {
process.env.API_WORKERS = '4';
process.env.STRATUM_WORKERS = 'auto';
jest.doMock('os', () => ({
availableParallelism: () => 14,
cpus: () => Array.from({ length: 14 }),
}));
const config = require('../ecosystem.config.js');
expect(config.apps.find((app) => app.name === 'api').instances).toBe(4);
expect(config.apps.find((app) => app.name === 'workers').instances).toBe(9);
});
it('preserves an explicit Stratum worker count', () => {
process.env.API_WORKERS = '2';
process.env.STRATUM_WORKERS = '7';
const config = require('../ecosystem.config.js');
expect(config.apps.find((app) => app.name === 'workers').instances).toBe(7);
});
it('runs the master through the isolated notifier entrypoint', () => {
const config = require('../ecosystem.config.js');
expect(config.apps.find((app) => app.name === 'master')).toEqual(
expect.objectContaining({
script: './dist/notifier-main.js',
instances: 1,
exec_mode: 'fork',
env: expect.objectContaining({ MASTER: 'true', API_ENABLED: 'false' }),
}),
);
});
it('runs maintenance duties outside the isolated notifier process', () => {
const config = require('../ecosystem.config.js');
expect(config.apps.find((app) => app.name === 'maintenance')).toEqual(
expect.objectContaining({
script: './dist/maintenance-main.js',
instances: 1,
exec_mode: 'fork',
env: expect.objectContaining({ MASTER: 'true', API_ENABLED: 'false' }),
}),
);
});
it('rejects invalid fixed worker counts instead of silently starting no workers', () => {
process.env.STRATUM_WORKERS = 'many';
expect(() => require('../ecosystem.config.js')).toThrow(
'STRATUM_WORKERS must be a positive integer',
);
});
});
function restoreEnv(key: string, value: string | undefined) {
if (value == null) {
delete process.env[key];
return;
}
process.env[key] = value;
}
+35 -10
View File
@@ -7,8 +7,21 @@ import { readFileSync, watch } from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as ecc from 'tiny-secp256k1'; import * as ecc from 'tiny-secp256k1';
import { ApiModule } from './api.module';
import { AppModule } from './app.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() { async function bootstrap() {
if (process.env.API_PORT == null) { if (process.env.API_PORT == null) {
console.error('It appears your environment is not configured, create and populate an .env file.'); 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 keyPath = path.join(currentDirectory, 'secrets', 'key.pem');
const certPath = path.join(currentDirectory, 'secrets', 'cert.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) { if (secure) {
options = { options.https = {
https: { key: readFileSync(keyPath),
key: readFileSync(keyPath), cert: readFileSync(certPath),
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.setGlobalPrefix('api');
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
@@ -54,7 +73,7 @@ async function bootstrap() {
}); });
app.enableCors(); app.enableCors();
useContainer(app.select(AppModule), { fallbackOnErrors: true }); useContainer(app.select(appModule), { fallbackOnErrors: true });
// Taproot // Taproot
bitcoinjs.initEccLib(ecc); bitcoinjs.initEccLib(ecc);
@@ -73,11 +92,17 @@ async function bootstrap() {
console.log(`API listening on ${address}`); 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 --- // --- Live-reload TLS certs/keys when they change on disk ---
if (secure) { if (secure) {
// Fastify's underlying Node https server // Fastify's underlying Node https server
const server: any = app.getHttpServer();
// Guard: only HTTPS servers expose setSecureContext // Guard: only HTTPS servers expose setSecureContext
if (typeof server?.setSecureContext === 'function') { if (typeof server?.setSecureContext === 'function') {
let reloadTimer: NodeJS.Timeout | null = null; let reloadTimer: NodeJS.Timeout | null = null;
+16
View File
@@ -0,0 +1,16 @@
import { NestFactory } from '@nestjs/core';
import { MaintenanceModule } from './maintenance.module';
async function bootstrap(): Promise<void> {
process.env.MASTER = 'true';
process.env.API_ENABLED = 'false';
const application = await NestFactory.createApplicationContext(
MaintenanceModule,
);
application.enableShutdownHooks();
console.log('Maintenance services started');
}
void bootstrap();
+53
View File
@@ -0,0 +1,53 @@
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserAgentReportModule } from './ORM/_views/user-agent-report/user-agent-report.module';
import { ClientModule } from './ORM/client/client.module';
import { RpcBlocksModule } from './ORM/rpc-block/rpc-block.module';
import { ShareAccountingModule } from './ORM/share-accounting/share-accounting.module';
import { TelegramSubscriptionsModule } from './ORM/telegram-subscriptions/telegram-subscriptions.module';
import { createDatabaseOptions } from './database.config';
import { AppService } from './services/app.service';
import { DiscordService } from './services/discord.service';
import { NotificationService } from './services/notification.service';
import { RedisMessagingModule } from './services/redis-messaging.module';
import { TelegramService } from './services/telegram.service';
/**
* Background process for non-hot-path master duties. Keep this separate from
* NotifierModule so chat integrations, reporting, and cleanup timers cannot
* delay block-template notification.
*/
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => createDatabaseOptions({
...process.env,
DB_HOST: configService.get('DB_HOST'),
DB_PORT: configService.get('DB_PORT'),
DB_USERNAME: configService.get('DB_USERNAME'),
DB_PASSWORD: configService.get('DB_PASSWORD'),
DB_DATABASE: configService.get('DB_DATABASE'),
DB_LOGGING: configService.get('DB_LOGGING'),
DB_POOL_SIZE: configService.get('DB_POOL_SIZE'),
}),
}),
RedisMessagingModule,
ClientModule,
RpcBlocksModule,
UserAgentReportModule,
ShareAccountingModule,
TelegramSubscriptionsModule,
],
providers: [
AppService,
DiscordService,
NotificationService,
TelegramService,
],
})
export class MaintenanceModule { }
+128 -9
View File
@@ -20,10 +20,29 @@ export interface MiningJobOwnership {
payoutIdentity: string; 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 { export class MiningJob {
private static readonly paymentScriptCache = new Map<string, Buffer>(); private static readonly paymentScriptCache = new Map<string, Buffer>();
private static readonly paymentScriptCacheMaxEntries = 250_000; private static readonly paymentScriptCacheMaxEntries = 250_000;
private static readonly placeholderPrevHash = Buffer.alloc(32, 0);
private coinbaseTransaction: bitcoinjs.Transaction; private coinbaseTransaction: bitcoinjs.Transaction;
private coinbasePart1: string; private coinbasePart1: string;
@@ -33,6 +52,7 @@ export class MiningJob {
private merkleBranchBuffers: Buffer[]; private merkleBranchBuffers: Buffer[];
private miningNotifyResponse: string; private miningNotifyResponse: string;
private miningNotifyResponseBuffer: Buffer; private miningNotifyResponseBuffer: Buffer;
private preStagedNotifyLayout: PreStagedNotifyLayout;
public jobTemplateId: string; public jobTemplateId: string;
public tipKey: string; public tipKey: string;
@@ -50,6 +70,7 @@ export class MiningJob {
this.creation = new Date().getTime(); this.creation = new Date().getTime();
this.jobTemplateId = jobTemplate.blockData.id; this.jobTemplateId = jobTemplate.blockData.id;
this.tipKey = jobTemplate.blockData.tipKey; this.tipKey = jobTemplate.blockData.tipKey;
this.networkDifficulty = jobTemplate.blockData.networkDifficulty;
this.merkleBranchBuffers = jobTemplate.merkle_branch.map(branch => Buffer.from(branch, 'hex')); this.merkleBranchBuffers = jobTemplate.merkle_branch.map(branch => Buffer.from(branch, 'hex'));
this.coinbaseTransaction = this.createCoinbaseTransaction(payoutInformation, jobTemplate.blockData.coinbasevalue); this.coinbaseTransaction = this.createCoinbaseTransaction(payoutInformation, jobTemplate.blockData.coinbasevalue);
@@ -104,6 +125,60 @@ export class MiningJob {
return Buffer.from(this.coinbasePart2Buffer); 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 { public buildCoinbaseMerkleRoot(extraNonce: string, extraNonce2: string): Buffer {
const coinbaseBuffer = Buffer.concat([ const coinbaseBuffer = Buffer.concat([
this.coinbasePart1Buffer, this.coinbasePart1Buffer,
@@ -317,25 +392,32 @@ export class MiningJob {
return paymentScript; return paymentScript;
} }
public response(jobTemplate: IJobTemplate): string { public response(
jobTemplate: IJobTemplate,
headerFields = MiningJob.createNotifyHeaderFields(jobTemplate),
): string {
if (this.miningNotifyResponse != null) { if (this.miningNotifyResponse != null) {
return this.miningNotifyResponse; return this.miningNotifyResponse;
} }
if (this.miningNotifyResponseBuffer != null) {
this.miningNotifyResponse = this.miningNotifyResponseBuffer.toString();
return this.miningNotifyResponse;
}
const job: IMiningNotify = { const job: IMiningNotify = {
id: null, id: null,
method: eResponseMethod.MINING_NOTIFY, method: eResponseMethod.MINING_NOTIFY,
params: [ params: [
this.jobId, this.jobId,
this.swapEndianWords(jobTemplate.block.prevHash).toString('hex'), headerFields.previousBlockHash,
this.coinbasePart1, this.coinbasePart1,
this.coinbasePart2, this.coinbasePart2,
jobTemplate.merkle_branch, jobTemplate.merkle_branch,
jobTemplate.block.version.toString(16), headerFields.version,
jobTemplate.block.bits.toString(16), headerFields.bits,
jobTemplate.block.timestamp.toString(16), headerFields.timestamp,
jobTemplate.blockData.clearJobs headerFields.cleanJobs
] ]
}; };
@@ -344,15 +426,52 @@ export class MiningJob {
return this.miningNotifyResponse; return this.miningNotifyResponse;
} }
public responseBuffer(jobTemplate: IJobTemplate): Buffer { public responseBuffer(
jobTemplate: IJobTemplate,
headerFields = MiningJob.createNotifyHeaderFields(jobTemplate),
): Buffer {
if (this.miningNotifyResponseBuffer == null) { if (this.miningNotifyResponseBuffer == null) {
this.response(jobTemplate); this.response(jobTemplate, headerFields);
} }
return this.miningNotifyResponseBuffer; 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); const swappedBuffer = Buffer.alloc(buffer.length);
for (let i = 0; i < buffer.length; i += 4) { for (let i = 0; i < buffer.length; i += 4) {
+146 -36
View File
@@ -169,6 +169,59 @@ describe('StratumV1Client', () => {
expect(socket.on).toHaveBeenCalled(); 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 () => { it('should clean up socket state only once when destroyed repeatedly', async () => {
const timer = setInterval(() => undefined, 1000); const timer = setInterval(() => undefined, 1000);
const removeListenerSpy = jest.spyOn(socket, 'removeListener'); 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)); 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 () => { it('should reject suggested difficulty below the protocol 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;
});
jest.spyOn(socket, 'write').mockImplementation((data) => true); jest.spyOn(socket, 'write').mockImplementation((data) => true);
emitMessage(`{"id":4,"method":"mining.suggest_difficulty","params":[0]}`); emitMessage(`{"id":4,"method":"mining.suggest_difficulty","params":[0]}`);
await new Promise((r) => setTimeout(r, 1)); 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 () => { it('should set difficulty', async () => {
@@ -482,7 +558,7 @@ describe('StratumV1Client', () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT); emitMessage(MockRecording1.MINING_SUBMIT);
@@ -510,7 +586,7 @@ describe('StratumV1Client', () => {
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
@@ -536,7 +612,7 @@ describe('StratumV1Client', () => {
sessionId: MockRecording1.EXTRA_NONCE, sessionId: MockRecording1.EXTRA_NONCE,
jobId: '1', jobId: '1',
jobTemplateId: '1', jobTemplateId: '1',
creditedDifficulty: 0, creditedDifficulty: 1e-9,
isBlockCandidate: false, isBlockCandidate: false,
})); }));
}); });
@@ -547,7 +623,7 @@ describe('StratumV1Client', () => {
const fullBlockSpy = jest.spyOn(MiningJob.prototype, 'copyAndUpdateBlock'); const fullBlockSpy = jest.spyOn(MiningJob.prototype, 'copyAndUpdateBlock');
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
@@ -563,7 +639,7 @@ describe('StratumV1Client', () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
@@ -597,7 +673,7 @@ describe('StratumV1Client', () => {
const clientUpdateIfHigherSpy = jest.spyOn(clientService as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 }); const clientUpdateIfHigherSpy = jest.spyOn(clientService as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 });
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
@@ -671,7 +747,7 @@ describe('StratumV1Client', () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
@@ -693,7 +769,7 @@ describe('StratumV1Client', () => {
}); });
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
@@ -712,7 +788,7 @@ describe('StratumV1Client', () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
@@ -737,7 +813,7 @@ describe('StratumV1Client', () => {
const calculateDifficultySpy = jest.spyOn(client as any, 'calculateDifficulty'); const calculateDifficultySpy = jest.spyOn(client as any, 'calculateDifficulty');
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
@@ -775,7 +851,7 @@ describe('StratumV1Client', () => {
}); });
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
@@ -800,11 +876,12 @@ describe('StratumV1Client', () => {
jest.spyOn(socket, 'write').mockImplementation(() => true); jest.spyOn(socket, 'write').mockImplementation(() => true);
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
expect((client as any).isDuplicateSubmission('old-tip-share')).toBe(false); expect((client as any).isDuplicateSubmission('old-tip-share')).toBe(false);
expect((client as any).rememberSubmission('old-tip-share', '1')).toBe(true);
const nextTip = { const nextTip = {
...MockRecording1.BLOCK_TEMPLATE, ...MockRecording1.BLOCK_TEMPLATE,
previousblockhash: '11'.repeat(32), previousblockhash: '11'.repeat(32),
@@ -817,7 +894,9 @@ describe('StratumV1Client', () => {
expect((client as any).isDuplicateSubmission('old-tip-share')).toBe(true); 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) => { (configService.get as jest.Mock).mockImplementation((key: string) => {
if (key === 'STRATUM_SUBMISSION_DEDUP_TTL_MS') return '1000'; if (key === 'STRATUM_SUBMISSION_DEDUP_TTL_MS') return '1000';
if (key === 'STRATUM_SUBMISSION_DEDUP_MAX_ENTRIES') return '2'; 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).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('two')).toBe(false);
expect((client as any).isDuplicateSubmission('three')).toBe(false); expect((client as any).rememberSubmission('two', '1')).toBe(true);
expect([...(client as any).miningSubmissionHashes.keys()]).toEqual(['two', 'three']); expect((client as any).rememberSubmission('three', '1')).toBe(false);
expect([...(client as any).miningSubmissionHashes.keys()]).toEqual(['one', 'two']);
jest.advanceTimersByTime(1001); 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).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 () => { 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'); const buildHeaderSpy = jest.spyOn(MiningJob.prototype, 'buildHeaderBuffer');
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
@@ -876,7 +960,7 @@ describe('StratumV1Client', () => {
}); });
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
@@ -913,7 +997,7 @@ describe('StratumV1Client', () => {
}); });
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
@@ -939,7 +1023,7 @@ describe('StratumV1Client', () => {
const hashSpy = jest.spyOn(MiningSubmitMessage.prototype, 'hash'); const hashSpy = jest.spyOn(MiningSubmitMessage.prototype, 'hash');
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
@@ -956,7 +1040,7 @@ describe('StratumV1Client', () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
stratumV1JobsService.blocks = {}; 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((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[23,"Difficulty too low",""]}\n`);
expect(await clientService.connectedClientCount()).toBe(1); expect(await clientService.connectedClientCount()).toBe(1);
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled(); 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 () => { it('rejects version rolling masks outside the negotiated BIP320 range', async () => {
jest.spyOn(client as any, 'write').mockImplementation(() => Promise.resolve(true)); jest.spyOn(client as any, 'write').mockImplementation(() => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
@@ -1114,7 +1224,7 @@ describe('StratumV1Client', () => {
jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined); jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined);
emitMessage(MockRecording1.MINING_SUBSCRIBE); 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); emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
+136 -33
View File
@@ -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_TTL_MS = 5 * 60 * 1000;
const DEFAULT_SUBMISSION_DEDUP_MAX_ENTRIES = 10_000; const DEFAULT_SUBMISSION_DEDUP_MAX_ENTRIES = 10_000;
const DEFAULT_MAX_SOCKET_BUFFER_BYTES = 256 * 1024; 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; const VERSION_ROLLING_MASK = 0x1fffe000;
export interface MiningJobBroadcastResult { export interface MiningJobBroadcastResult {
status: 'written' | 'backpressured' | 'skipped' | 'closed' | 'error'; status: 'written' | 'backpressured' | 'skipped' | 'closed' | 'error';
bytes: number; bytes: number;
bufferedBytes: number; bufferedBytes: number;
preStaged?: boolean;
}
interface SubmissionDedupEntry {
expiresAt: number;
jobId: string;
} }
export class StratumV1Client { export class StratumV1Client {
@@ -84,11 +92,14 @@ export class StratumV1Client {
private connectionClosed = false; private connectionClosed = false;
private lastSentMiningJobTimestamp: number = null; private lastSentMiningJobTimestamp: number = null;
private lastSentMiningJobSignature: string = null; private lastSentMiningJobSignature: string = null;
private lastSentMiningTipKey: string = null;
private lastSentMiningJobType: 'full' | 'empty' | null = null;
private lastHashRatePersistedAt = 0; private lastHashRatePersistedAt = 0;
private readonly network: bitcoinjs.Network; private readonly network: bitcoinjs.Network;
private readonly maxSocketBufferBytes: number; private readonly maxSocketBufferBytes: number;
private readonly maxInboundLineBytes: number;
private miningSubmissionHashes = new Map<string, number>(); private miningSubmissionHashes = new Map<string, SubmissionDedupEntry>();
constructor( constructor(
public readonly socket: Socket, public readonly socket: Socket,
@@ -112,6 +123,7 @@ export class StratumV1Client {
this.socket.on('data', this.socketDataHandler); this.socket.on('data', this.socketDataHandler);
this.network = this.getNetwork(); this.network = this.getNetwork();
this.maxSocketBufferBytes = this.readMaxSocketBufferBytes(); this.maxSocketBufferBytes = this.readMaxSocketBufferBytes();
this.maxInboundLineBytes = this.readMaxInboundLineBytes();
} }
@@ -147,9 +159,15 @@ export class StratumV1Client {
return; return;
} }
this.buffer += data.toString(); const lines = `${this.buffer}${data.toString()}`.split('\n');
const lines = this.buffer.split('\n'); const incompleteLine = lines.pop() || '';
this.buffer = lines.pop() || ''; // Save the last part of the data (incomplete line) to the buffer 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)) { for (const m of lines.filter(l => l.length > 0)) {
if (this.connectionClosed || this.socket.destroyed || this.socket.writableEnded) { if (this.connectionClosed || this.socket.destroyed || this.socket.writableEnded) {
@@ -440,10 +458,17 @@ export class StratumV1Client {
this.stratumInitialized = true; this.stratumInitialized = true;
const latestJobTemplate = await this.getLatestPayoutJobTemplate(); const latestJobTemplate = await this.getLatestPayoutJobTemplate();
this.broadcastMiningJob(latestJobTemplate); this.broadcastMiningJob(latestJobTemplate);
const latestPrestage = this.stratumV1JobsService.getLatestPrestageJobTemplate(this.payoutMode);
if (latestPrestage != null) {
this.preStageMiningJob(latestPrestage);
}
this.backgroundWork.push( this.backgroundWork.push(
setInterval(async () => { setInterval(() => {
await this.checkDifficulty(); void this.checkDifficulty().catch((error) => {
console.error('Stratum difficulty check failed; closing client connection', error);
this.closeSocket();
});
}, 60 * 1000) }, 60 * 1000)
); );
@@ -461,6 +486,29 @@ export class StratumV1Client {
&& !this.socket.writableEnded; && !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 { public broadcastMiningJob(jobTemplate: IJobTemplate, force = false): MiningJobBroadcastResult {
if (!this.isReadyForMiningJobs()) { if (!this.isReadyForMiningJobs()) {
return { status: 'skipped', bytes: 0, bufferedBytes: this.socket.writableLength ?? 0 }; return { status: 'skipped', bytes: 0, bufferedBytes: this.socket.writableLength ?? 0 };
@@ -478,6 +526,12 @@ export class StratumV1Client {
if (!force && signature === this.lastSentMiningJobSignature) { if (!force && signature === this.lastSentMiningJobSignature) {
return { status: 'skipped', bytes: 0, bufferedBytes: this.socket.writableLength ?? 0 }; 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 maximumBufferedBytes = this.maxSocketBufferBytes;
const bufferedBeforeBuild = this.socket.writableLength ?? 0; 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, this.network,
payoutInformation, payoutInformation,
jobTemplate, jobTemplate,
this.getPayoutIdentity(jobTemplate), payoutIdentity,
this.payoutMode, this.payoutMode,
); );
const payload = job.responseBuffer(jobTemplate); const payload = job.responseBuffer(
jobTemplate,
this.stratumV1JobsService.getNotifyHeaderFields?.(jobTemplate),
);
const bufferedBeforeWrite = this.socket.writableLength ?? 0; const bufferedBeforeWrite = this.socket.writableLength ?? 0;
if (bufferedBeforeWrite >= maximumBufferedBytes if (bufferedBeforeWrite >= maximumBufferedBytes
|| payload.length >= maximumBufferedBytes - bufferedBeforeWrite) { || payload.length >= maximumBufferedBytes - bufferedBeforeWrite) {
this.closeSocket(); this.closeSocket();
return { status: 'closed', bytes: 0, bufferedBytes: bufferedBeforeWrite }; return {
status: 'closed',
bytes: 0,
bufferedBytes: bufferedBeforeWrite,
...(preStagedJob == null ? {} : { preStaged: true }),
};
} }
try { try {
const accepted = this.socket.write(payload); const accepted = this.socket.write(payload);
this.lastSentMiningJobTimestamp = jobTemplate.block.timestamp; this.lastSentMiningJobTimestamp = jobTemplate.block.timestamp;
this.lastSentMiningJobSignature = signature; this.lastSentMiningJobSignature = signature;
this.lastSentMiningTipKey = jobTemplate.blockData.tipKey;
this.lastSentMiningJobType = jobTemplate.blockData.jobType;
const bufferedAfterWrite = this.socket.writableLength ?? 0; const bufferedAfterWrite = this.socket.writableLength ?? 0;
if (bufferedAfterWrite >= maximumBufferedBytes) { if (bufferedAfterWrite >= maximumBufferedBytes) {
this.closeSocket(); this.closeSocket();
@@ -537,12 +611,14 @@ export class StratumV1Client {
status: 'closed', status: 'closed',
bytes: payload.length, bytes: payload.length,
bufferedBytes: bufferedAfterWrite, bufferedBytes: bufferedAfterWrite,
...(preStagedJob == null ? {} : { preStaged: true }),
}; };
} }
return { return {
status: accepted ? 'written' : 'backpressured', status: accepted ? 'written' : 'backpressured',
bytes: payload.length, bytes: payload.length,
bufferedBytes: bufferedAfterWrite, bufferedBytes: bufferedAfterWrite,
...(preStagedJob == null ? {} : { preStaged: true }),
}; };
} catch (error) { } catch (error) {
this.closeSocket(); this.closeSocket();
@@ -550,6 +626,7 @@ export class StratumV1Client {
status: 'error', status: 'error',
bytes: 0, bytes: 0,
bufferedBytes: this.socket.writableLength ?? 0, bufferedBytes: this.socket.writableLength ?? 0,
...(preStagedJob == null ? {} : { preStaged: true }),
}; };
} }
} }
@@ -620,6 +697,15 @@ export class StratumV1Client {
); );
return false; 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 // The optional BIP310 field contains replacement bits, not an XOR
// delta. A legacy five-field submission leaves the advertised version // delta. A legacy five-field submission leaves the advertised version
// unchanged, including any bits already set inside the rolling mask. // unchanged, including any bits already set inside the rolling mask.
@@ -698,6 +784,15 @@ export class StratumV1Client {
const creditedDifficulty = isBlockCandidate && !meetsSessionTarget const creditedDifficulty = isBlockCandidate && !meetsSessionTarget
? Math.min(this.sessionDifficulty, submissionDifficulty) ? Math.min(this.sessionDifficulty, submissionDifficulty)
: this.sessionDifficulty; : 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; let blockSubmissionResult: string = null;
if (status === 'stale') { if (status === 'stale') {
@@ -993,33 +1088,31 @@ export class StratumV1Client {
private isDuplicateSubmission(submissionHash: string): boolean { private isDuplicateSubmission(submissionHash: string): boolean {
const now = Date.now(); const now = Date.now();
const existingExpiry = this.miningSubmissionHashes.get(submissionHash); this.pruneExpiredSubmissions(now);
if (existingExpiry != null && existingExpiry > now) { return this.miningSubmissionHashes.has(submissionHash);
return true; }
}
if (existingExpiry != null) {
this.miningSubmissionHashes.delete(submissionHash);
}
for (const [hash, expiresAt] of this.miningSubmissionHashes) { private rememberSubmission(submissionHash: string, jobId: string): boolean {
if (expiresAt <= now) { 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.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 { private getSubmissionDedupTtlMs(): number {
@@ -1052,6 +1145,16 @@ export class StratumV1Client {
: DEFAULT_MAX_SOCKET_BUFFER_BYTES; : 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 { private getValidationErrorSignature(errors: ValidationError[]): string {
if (errors.length === 0) { if (errors.length === 0) {
return 'unknown'; return 'unknown';
+42 -3
View File
@@ -32,7 +32,30 @@ describe('StratumV1ClientStatistics', () => {
await statistics.addShares(client, 64); 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', () => { 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); 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 () => { it('should decrease difficulty for slow submissions', async () => {
@@ -60,7 +99,7 @@ describe('StratumV1ClientStatistics', () => {
await statistics.addShares(client, 64); 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', () => { it('should not suggest a difficulty below the configured minimum', () => {
+46 -28
View File
@@ -16,9 +16,9 @@ export class StratumV1ClientStatistics {
} }
public async addShares(_client: ClientEntity, targetDifficulty: number) { 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.submissionCacheDifficultySum -= this.submissionCache[0].difficulty;
this.submissionCache.shift(); this.submissionCache.shift();
} }
@@ -28,9 +28,12 @@ export class StratumV1ClientStatistics {
}); });
this.submissionCacheDifficultySum += targetDifficulty; this.submissionCacheDifficultySum += targetDifficulty;
const time = new Date().getTime() - this.submissionCache[0].time.getTime(); const elapsedSeconds = (date.getTime() - this.submissionCache[0].time.getTime()) / 1000;
if(time > 60000 && this.submissionCache.length > 2) { if (elapsedSeconds > 60) {
this.hashRate = (this.submissionCacheDifficultySum * 4294967296) / (time / 1000); 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 diffSeconds = (this.submissionCache[this.submissionCache.length - 1].time.getTime() - this.submissionCache[0].time.getTime()) / 1000;
const difficultyPerSecond = this.getDifficultyPerSecond(diffSeconds);
const difficultyPerSecond = sum / diffSeconds; if (difficultyPerSecond == null) {
return null;
}
const targetDifficulty = difficultyPerSecond * this.targetSubmitShareEveryNSeconds; const targetDifficulty = difficultyPerSecond * this.targetSubmitShareEveryNSeconds;
if (!Number.isFinite(targetDifficulty) || targetDifficulty <= 0) {
return null;
}
if ((clientDifficulty * 2) < targetDifficulty || (clientDifficulty / 2) > targetDifficulty) { if ((clientDifficulty * 2) < targetDifficulty || (clientDifficulty / 2) > targetDifficulty) {
return this.nearestPowerOfTwo(targetDifficulty) return this.nearestPowerOfTwo(targetDifficulty)
@@ -63,27 +67,41 @@ export class StratumV1ClientStatistics {
return null; 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; 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; return this.minDifficulty;
} }
let x = val | (val >> 1); const result = 2 ** Math.floor(Math.log2(val));
x = x | (x >> 2); return Number.isFinite(result) ? Math.max(this.minDifficulty, result) : null;
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;
} }
} }
+38 -2
View File
@@ -927,6 +927,41 @@ describe('StratumV2Client extended channels', () => {
)).toBe(true); )).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 () => { it('activates a staged standard future and rejects incompatible activation versions', async () => {
const { client, sentFrames, jobTemplate } = await createClient(); const { client, sentFrames, jobTemplate } = await createClient();
await (client as any).handleOpenStandardMiningChannel(serializeOpenStandardMiningChannel({ await (client as any).handleOpenStandardMiningChannel(serializeOpenStandardMiningChannel({
@@ -956,7 +991,7 @@ describe('StratumV2Client extended channels', () => {
.toBe(parseInt(activation.bits, 16)); .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 { client } = await createClient();
const now = process.hrtime.bigint(); const now = process.hrtime.bigint();
const context = { 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, 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, 99)).toBe(false);
expect((client as any).isSubmissionHeaderValid(context, 0x20000004, 101)).toBe(true); 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, 0xffffffff)).toBe(true);
expect((client as any).isSubmissionHeaderValid(context, 0x20000004, 0x1_0000_0000)).toBe(false); 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; (client as any).versionRollingEnabled = true;
expect((client as any).isSubmissionHeaderValid(context, 0x20002004, 100)).toBe(true); expect((client as any).isSubmissionHeaderValid(context, 0x20002004, 100)).toBe(true);
+6 -6
View File
@@ -78,7 +78,7 @@ const DEFAULT_DIFFICULTY_CHECK_INTERVAL_MS = 60 * 1000;
const DEFAULT_CLIENT_HASHRATE_PERSIST_INTERVAL_MS = 60 * 1000; const DEFAULT_CLIENT_HASHRATE_PERSIST_INTERVAL_MS = 60 * 1000;
const FIXED_STANDARD_EXTRANONCE2 = '0000000000000000'; const FIXED_STANDARD_EXTRANONCE2 = '0000000000000000';
const DEFAULT_SV2_JOB_RETENTION_MS = 5 * 60 * 1000; 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_QUEUED_JOB_OPERATIONS = 4;
const DEFAULT_SV2_MAX_SOCKET_BUFFER_BYTES = 256 * 1024; const DEFAULT_SV2_MAX_SOCKET_BUFFER_BYTES = 256 * 1024;
const DEFAULT_SV2_SOCKET_WRITE_TIMEOUT_MS = 2 * 1000; const DEFAULT_SV2_SOCKET_WRITE_TIMEOUT_MS = 2 * 1000;
@@ -2088,11 +2088,11 @@ export class StratumV2Client {
): boolean { ): boolean {
const submitted = submittedVersion >>> 0; const submitted = submittedVersion >>> 0;
const base = headerContext.baseVersion >>> 0; const base = headerContext.baseVersion >>> 0;
if (this.versionRollingEnabled) { // Some SV2 clients roll BIP320 general-purpose version bits even when
if (((submitted ^ base) & BIP320_CONSENSUS_VERSION_MASK) !== 0) { // they did not explicitly require version rolling during setup. Those
return false; // bits are non-consensus signalling space; reject only changes outside
} // that mask and continue enforcing required bits below.
} else if (submitted !== base) { if (((submitted ^ base) & BIP320_CONSENSUS_VERSION_MASK) !== 0) {
return false; return false;
} }
if ((submitted & headerContext.requiredVersionBits) !== headerContext.requiredVersionBits) { if ((submitted & headerContext.requiredVersionBits) !== headerContext.requiredVersionBits) {
+7
View File
@@ -56,7 +56,14 @@ export interface IBlockTemplate {
notificationEventId?: string; notificationEventId?: string;
/** Wall-clock time when the master first observed the source block notification. */ /** Wall-clock time when the master first observed the source block notification. */
sourceNotificationReceivedAtMs?: number; 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; 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. */ /** Timestamp of the rolling PPLNS snapshot seed used by an empty bridge. */
payoutBridgeSeedCreatedAtMs?: number; payoutBridgeSeedCreatedAtMs?: number;
@@ -1,9 +1,10 @@
import { Expose, Transform } from 'class-transformer'; 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 { eRequestMethod } from '../enums/eRequestMethod';
import { IsBitcoinAddress } from '../validators/bitcoin-address.validator'; import { IsBitcoinAddress } from '../validators/bitcoin-address.validator';
import { StratumBaseMessage } from './StratumBaseMessage'; import { StratumBaseMessage } from './StratumBaseMessage';
import { MAX_STRATUM_DIFFICULTY } from './SuggestDifficultyMessage';
export class AuthorizationMessage extends StratumBaseMessage { export class AuthorizationMessage extends StratumBaseMessage {
@@ -35,6 +36,8 @@ export class AuthorizationMessage extends StratumBaseMessage {
@Expose() @Expose()
@IsNumber() @IsNumber()
@IsPositive()
@Max(MAX_STRATUM_DIFFICULTY)
@Transform(({ value, key, obj, type }) => { @Transform(({ value, key, obj, type }) => {
const password: string | null = obj.params[1]; const password: string | null = obj.params[1];
if (password?.includes('d=')) { if (password?.includes('d=')) {
@@ -69,4 +72,4 @@ export class AuthorizationMessage extends StratumBaseMessage {
result: true result: true
}; };
} }
} }
@@ -1,10 +1,12 @@
import { Expose, Transform } from 'class-transformer'; 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 { eRequestMethod } from '../enums/eRequestMethod';
import { eResponseMethod } from '../enums/eResponseMethod'; import { eResponseMethod } from '../enums/eResponseMethod';
import { StratumBaseMessage } from './StratumBaseMessage'; import { StratumBaseMessage } from './StratumBaseMessage';
export const MAX_STRATUM_DIFFICULTY = 2 ** 32;
export class SuggestDifficulty extends StratumBaseMessage { export class SuggestDifficulty extends StratumBaseMessage {
@IsArray() @IsArray()
@ArrayMinSize(1) @ArrayMinSize(1)
@@ -16,6 +18,8 @@ export class SuggestDifficulty extends StratumBaseMessage {
@Expose() @Expose()
@IsNumber() @IsNumber()
@IsPositive()
@Max(MAX_STRATUM_DIFFICULTY)
@Transform(({ value, key, obj, type }) => { @Transform(({ value, key, obj, type }) => {
return Number(obj.params[0]); return Number(obj.params[0]);
}) })
@@ -37,4 +41,3 @@ export class SuggestDifficulty extends StratumBaseMessage {
} }
} }
} }
+15
View File
@@ -0,0 +1,15 @@
import { NestFactory } from '@nestjs/core';
import { NotifierModule } from './notifier.module';
async function bootstrap(): Promise<void> {
process.env.MASTER = 'true';
process.env.API_ENABLED = 'false';
const application = await NestFactory.createApplicationContext(
NotifierModule,
);
application.enableShutdownHooks();
console.log('Authoritative block notifier started');
}
void bootstrap();
+41
View File
@@ -0,0 +1,41 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { createDatabaseOptions } from './database.config';
import { PayoutSnapshotModule } from './ORM/payout-snapshot/payout-snapshot.module';
import { RpcBlocksModule } from './ORM/rpc-block/rpc-block.module';
import { BitcoinRpcService } from './services/bitcoin-rpc.service';
import { RedisMessagingModule } from './services/redis-messaging.module';
/**
* Minimal process graph for new-tip notification. Keeping API, Stratum,
* reporting, chat integrations, and accounting providers out of this Nest
* context prevents their timers and callbacks from delaying the Core longpoll.
*/
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => createDatabaseOptions({
...process.env,
DB_HOST: configService.get('DB_HOST'),
DB_PORT: configService.get('DB_PORT'),
DB_USERNAME: configService.get('DB_USERNAME'),
DB_PASSWORD: configService.get('DB_PASSWORD'),
DB_DATABASE: configService.get('DB_DATABASE'),
DB_LOGGING: configService.get('DB_LOGGING'),
DB_POOL_SIZE: configService.get('DB_POOL_SIZE'),
}),
}),
RedisMessagingModule,
RpcBlocksModule,
PayoutSnapshotModule,
],
providers: [
BitcoinRpcService,
],
})
export class NotifierModule { }
+393 -11
View File
@@ -199,7 +199,7 @@ describe('BitcoinRpcService template publication', () => {
.toEqual([true, true]); .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 order: string[] = [];
const redis = createRedisMock(order); const redis = createRedisMock(order);
const template = createTemplate(); const template = createTemplate();
@@ -236,6 +236,147 @@ describe('BitcoinRpcService template publication', () => {
expect(order.indexOf('redis:publish:bridge:solo')).toBeLessThan(order.indexOf('redis:publish:solo')); 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 () => { it('precomputes an exact next-height PPLNS subsidy seed off the canonical path', async () => {
const order: string[] = []; const order: string[] = [];
const canonical = createTemplateAtHeight(839_999, '66'); const canonical = createTemplateAtHeight(839_999, '66');
@@ -500,7 +641,7 @@ describe('BitcoinRpcService template publication', () => {
await expect((service as any).publishPplnsSubsidyBridge( await expect((service as any).publishPplnsSubsidyBridge(
nextTemplate, nextTemplate,
trace, trace,
)).resolves.toBe(false); )).resolves.toBe('skipped');
expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled(); expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled();
consoleSpy.mockRestore(); consoleSpy.mockRestore();
}); });
@@ -536,7 +677,7 @@ describe('BitcoinRpcService template publication', () => {
await expect((service as any).publishPplnsSubsidyBridge( await expect((service as any).publishPplnsSubsidyBridge(
template, template,
trace, trace,
)).resolves.toBe(false); )).resolves.toBe('skipped');
(service as any).storePplnsSubsidyBridgeSeed({ (service as any).storePplnsSubsidyBridgeSeed({
...baseSeed, ...baseSeed,
@@ -546,7 +687,7 @@ describe('BitcoinRpcService template publication', () => {
await expect((service as any).publishPplnsSubsidyBridge( await expect((service as any).publishPplnsSubsidyBridge(
template, template,
trace, trace,
)).resolves.toBe(false); )).resolves.toBe('failed');
(service as any).storePplnsSubsidyBridgeSeed({ (service as any).storePplnsSubsidyBridgeSeed({
...baseSeed, ...baseSeed,
@@ -556,7 +697,7 @@ describe('BitcoinRpcService template publication', () => {
await expect((service as any).publishPplnsSubsidyBridge( await expect((service as any).publishPplnsSubsidyBridge(
template, template,
trace, trace,
)).resolves.toBe(false); )).resolves.toBe('skipped');
expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled(); expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled();
}); });
@@ -569,6 +710,7 @@ describe('BitcoinRpcService template publication', () => {
if (update.template.payoutMode === 'pplns') { if (update.template.payoutMode === 'pplns') {
throw new Error('PPLNS Redis unavailable'); throw new Error('PPLNS Redis unavailable');
} }
return true;
}); });
const service = new BitcoinRpcService( const service = new BitcoinRpcService(
createConfig({ createConfig({
@@ -633,6 +775,102 @@ describe('BitcoinRpcService template publication', () => {
expect((service as any).latestLongpollId).toBe('next-longpoll-id'); 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 () => { it('deduplicates the same authoritative template returned by ZMQ and longpoll', async () => {
const redis = createRedisMock([]); const redis = createRedisMock([]);
const template = createTemplate(); const template = createTemplate();
@@ -694,10 +932,16 @@ describe('BitcoinRpcService template publication', () => {
const redis = createRedisMock(order); const redis = createRedisMock(order);
let resolveBridge: () => void; let resolveBridge: () => void;
const stalledBridge = new Promise<void>(resolve => { resolveBridge = resolve; }); const stalledBridge = new Promise<void>(resolve => { resolveBridge = resolve; });
redis.publishSv1BridgeUpdate.mockImplementation(async (update: { template: IBlockTemplate }) => { redis.publishSv1BridgeUpdate.mockImplementation((async (
order.push(`redis:publish:bridge:${update.template.payoutMode}`); update: { template: IBlockTemplate },
await stalledBridge; 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 template = createTemplateAtHeight(840_000, '71');
const service = new BitcoinRpcService( const service = new BitcoinRpcService(
createConfig({ NETWORK: 'mainnet' }), createConfig({ NETWORK: 'mainnet' }),
@@ -710,7 +954,12 @@ describe('BitcoinRpcService template publication', () => {
await service.getAndBroadcastLatestTemplate('new_block'); 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( expect(redis.setBlockTemplate).toHaveBeenCalledWith(
template.height - 1, template.height - 1,
expect.objectContaining({ payoutMode: 'solo', jobType: 'full' }), expect.objectContaining({ payoutMode: 'solo', jobType: 'full' }),
@@ -718,12 +967,85 @@ describe('BitcoinRpcService template publication', () => {
expect(redis.publishBlockTemplateUpdate).toHaveBeenCalledWith(expect.objectContaining({ expect(redis.publishBlockTemplateUpdate).toHaveBeenCalledWith(expect.objectContaining({
payoutMode: 'solo', 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!(); resolveBridge!();
await flushPromises(); 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 () => { it('verifies same-height reorgs against Core and never switches back to an orphan', async () => {
const redis = createRedisMock([]); const redis = createRedisMock([]);
const first = createTemplateAtHeight(840_000, '31'); const first = createTemplateAtHeight(840_000, '31');
@@ -895,6 +1217,61 @@ describe('BitcoinRpcService template publication', () => {
expect(canonicals).toEqual([expect.objectContaining({ previousblockhash: canonical.previousblockhash })]); 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 () => { it('rejects live bridge updates older than or redundant with active canonical work', async () => {
const service = new BitcoinRpcService( const service = new BitcoinRpcService(
createConfig({}), createConfig({}),
@@ -1263,6 +1640,11 @@ function createRedisMock(order: string[]) {
}), }),
publishSv1BridgeUpdate: jest.fn(async (update: { template: IBlockTemplate }) => { publishSv1BridgeUpdate: jest.fn(async (update: { template: IBlockTemplate }) => {
order.push(`redis:publish:bridge:${update.template.payoutMode}`); 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) => { setLegacyBlockTemplate: jest.fn(async (_height: number, template: IBlockTemplate) => {
order.push(`redis:set:legacy:${template.payoutMode}`); order.push(`redis:set:legacy:${template.payoutMode}`);
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -124,7 +124,7 @@ export class DiscordService implements OnModuleInit {
} }
} }
public async notifySubscribersBlockFound(height: number, block: Block, message: string) { public async notifySubscribersBlockFound(height: number, block: Block | undefined, message: string) {
if (process.env.MASTER == 'true') { if (process.env.MASTER == 'true') {
if (this.bot == null) { if (this.bot == null) {
return; return;
@@ -135,4 +135,4 @@ export class DiscordService implements OnModuleInit {
channel.send(`Block Found! Result: ${message}, Height: ${height}`); channel.send(`Block Found! Result: ${message}, Height: ${height}`);
} }
} }
} }
+147
View File
@@ -0,0 +1,147 @@
import { Block } from 'bitcoinjs-lib';
import { NotificationService } from './notification.service';
describe('NotificationService', () => {
const originalMaster = process.env.MASTER;
let telegramService: { notifySubscribersBlockFound: jest.Mock };
let discordService: {
notifyRestarted: jest.Mock;
notifySubscribersBlockFound: jest.Mock;
};
let redisMessagingService: {
publishBlockFoundNotification: jest.Mock;
subscribeBlockFoundNotifications: jest.Mock;
};
beforeEach(() => {
telegramService = {
notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined),
};
discordService = {
notifyRestarted: jest.fn().mockResolvedValue(undefined),
notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined),
};
redisMessagingService = {
publishBlockFoundNotification: jest.fn().mockResolvedValue(true),
subscribeBlockFoundNotifications: jest.fn().mockResolvedValue(undefined),
};
});
afterEach(() => {
process.env.MASTER = originalMaster;
jest.clearAllMocks();
});
it('publishes block found notifications from worker processes', async () => {
process.env.MASTER = 'false';
const service = createService();
await service.notifySubscribersBlockFound(
'bc1qminer',
900001,
createBlock('11'.repeat(32)),
'accepted',
);
expect(redisMessagingService.publishBlockFoundNotification).toHaveBeenCalledWith({
schemaVersion: 1,
eventId: 'block-found:900001:1111111111111111111111111111111111111111111111111111111111111111:bc1qminer',
address: 'bc1qminer',
height: 900001,
blockHash: '11'.repeat(32),
message: 'accepted',
publishedAtMs: expect.any(Number),
});
expect(discordService.notifySubscribersBlockFound).not.toHaveBeenCalled();
expect(telegramService.notifySubscribersBlockFound).not.toHaveBeenCalled();
});
it('subscribes on the master process and dispatches received notifications', async () => {
process.env.MASTER = 'true';
const service = createService();
await service.onModuleInit();
const handler = redisMessagingService.subscribeBlockFoundNotifications.mock.calls[0][0];
await handler({
schemaVersion: 1,
eventId: 'block-found:900001:blockhash:bc1qminer',
address: 'bc1qminer',
height: 900001,
blockHash: 'blockhash',
message: 'accepted',
publishedAtMs: 123,
});
expect(redisMessagingService.subscribeBlockFoundNotifications).toHaveBeenCalledTimes(1);
expect(discordService.notifyRestarted).toHaveBeenCalledTimes(1);
expect(discordService.notifySubscribersBlockFound).toHaveBeenCalledWith(
900001,
undefined,
'accepted',
);
expect(telegramService.notifySubscribersBlockFound).toHaveBeenCalledWith(
'bc1qminer',
900001,
undefined,
'accepted',
);
});
it('deduplicates repeated master notifications for the same block event', async () => {
process.env.MASTER = 'true';
const service = createService();
const notification = {
schemaVersion: 1 as const,
eventId: 'block-found:900001:blockhash:bc1qminer',
address: 'bc1qminer',
height: 900001,
blockHash: 'blockhash',
message: 'accepted',
publishedAtMs: 123,
};
await service.onModuleInit();
const handler = redisMessagingService.subscribeBlockFoundNotifications.mock.calls[0][0];
await handler(notification);
await handler(notification);
expect(discordService.notifySubscribersBlockFound).toHaveBeenCalledTimes(1);
expect(telegramService.notifySubscribersBlockFound).toHaveBeenCalledTimes(1);
});
it('dispatches directly when called in the master process', async () => {
process.env.MASTER = 'true';
const service = createService();
const block = createBlock('22'.repeat(32));
await service.notifySubscribersBlockFound('bc1qminer', 900002, block, 'accepted');
expect(redisMessagingService.publishBlockFoundNotification).not.toHaveBeenCalled();
expect(discordService.notifySubscribersBlockFound).toHaveBeenCalledWith(
900002,
block,
'accepted',
);
expect(telegramService.notifySubscribersBlockFound).toHaveBeenCalledWith(
'bc1qminer',
900002,
block,
'accepted',
);
});
function createService(): NotificationService {
return new NotificationService(
telegramService as any,
discordService as any,
redisMessagingService as any,
);
}
});
function createBlock(blockHash: string): Block {
return {
getId: () => blockHash,
} as unknown as Block;
}
+91 -3
View File
@@ -2,15 +2,20 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
import { Block } from 'bitcoinjs-lib'; import { Block } from 'bitcoinjs-lib';
import { DiscordService } from './discord.service'; import { DiscordService } from './discord.service';
import { BlockFoundNotification, RedisMessagingService } from './redis-messaging.service';
import { TelegramService } from './telegram.service'; import { TelegramService } from './telegram.service';
const BLOCK_NOTIFICATION_DEDUPE_TTL_MS = 10 * 60 * 1000;
const BLOCK_NOTIFICATION_DEDUPE_MAX_ENTRIES = 1000;
@Injectable() @Injectable()
export class NotificationService implements OnModuleInit { export class NotificationService implements OnModuleInit {
private readonly processedBlockNotifications = new Map<string, number>();
constructor( constructor(
private readonly telegramService: TelegramService, private readonly telegramService: TelegramService,
private readonly discordService: DiscordService private readonly discordService: DiscordService,
private readonly redisMessagingService: RedisMessagingService
) { } ) { }
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
@@ -18,11 +23,94 @@ export class NotificationService implements OnModuleInit {
return; return;
} }
await this.redisMessagingService.subscribeBlockFoundNotifications(async notification => {
await this.dispatchBlockFoundNotification(notification);
});
await this.discordService.notifyRestarted(); await this.discordService.notifyRestarted();
} }
public async notifySubscribersBlockFound(address: string, height: number, block: Block, message: string) { public async notifySubscribersBlockFound(address: string, height: number, block: Block, message: string) {
await this.discordService.notifySubscribersBlockFound(height, block, message); const blockHash = this.getBlockHash(block);
await this.telegramService.notifySubscribersBlockFound(address, height, block, message); 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;
} }
} }
+221 -9
View File
@@ -17,7 +17,14 @@ describe('RedisMessagingService', () => {
subscriptions.clear(); subscriptions.clear();
clientsByRole.publisher = null; clientsByRole.publisher = null;
clientsByRole.subscriber = 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()); (createClient as jest.Mock).mockImplementation(() => clients.shift());
service = new RedisMessagingService({ service = new RedisMessagingService({
get: jest.fn((key: string) => key === 'REDIS_URL' ? 'redis://test-redis:6379' : null), get: jest.fn((key: string) => key === 'REDIS_URL' ? 'redis://test-redis:6379' : null),
@@ -113,6 +120,19 @@ describe('RedisMessagingService', () => {
consoleSpy.mockRestore(); 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 () => { it('stores payout variants and same-height reorg templates under distinct tip keys', async () => {
await service.connect(); await service.connect();
const firstHash = '11'.repeat(32); const firstHash = '11'.repeat(32);
@@ -175,6 +195,42 @@ describe('RedisMessagingService', () => {
consoleSpy.mockRestore(); 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 () => { it('stores, publishes, and replays compact SV1 bridge updates', async () => {
await service.connect(); await service.connect();
const handler = jest.fn().mockResolvedValue(undefined); const handler = jest.fn().mockResolvedValue(undefined);
@@ -193,12 +249,86 @@ describe('RedisMessagingService', () => {
}; };
await service.subscribeSv1BridgeUpdates(handler); 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); 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 () => { it('stores and replays the latest SV1 bridge independently by payout mode', async () => {
await service.connect(); await service.connect();
const solo = createBridgeUpdate('solo', 'solo-bridge'); const solo = createBridgeUpdate('solo', 'solo-bridge');
@@ -213,6 +343,51 @@ describe('RedisMessagingService', () => {
expect(store.get('sv1-bridge:latest:pplns')).toBe(JSON.stringify(pplns)); 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 () => { it('rejects PPLNS bridges without an explicit snapshot and fixed-value outputs', async () => {
await service.connect(); await service.connect();
const valid = createBridgeUpdate('pplns', 'pplns-valid'); 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 store = new Map<string, string>();
const sets = new Map<string, Set<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>>(); const subscriptions = new Map<string, (message: string) => Promise<void>>();
function createRedisClient() { function createRedisClient() {
@@ -307,7 +506,10 @@ function createRedisClient() {
connect: jest.fn().mockResolvedValue(undefined), connect: jest.fn().mockResolvedValue(undefined),
quit: jest.fn().mockResolvedValue(undefined), quit: jest.fn().mockResolvedValue(undefined),
on: jest.fn(), 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); store.set(key, value);
return Promise.resolve('OK'); return Promise.resolve('OK');
}), }),
@@ -326,6 +528,15 @@ function createRedisClient() {
}); });
return Promise.resolve(deleted); 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) => { sAdd: jest.fn((key: string, value: string) => {
const set = sets.get(key) ?? new Set<string>(); const set = sets.get(key) ?? new Set<string>();
set.add(value); set.add(value);
@@ -360,11 +571,12 @@ function createRedisClient() {
}), }),
}; };
if (clientsByRole.publisher == null) { const role = (['publisher', 'subscriber', 'urgentPublisher', 'urgentSubscriber'] as const)
clientsByRole.publisher = client; .find(candidate => clientsByRole[candidate] == null);
} else { if (role == null) {
clientsByRole.subscriber = client; throw new Error('Unexpected extra Redis test client');
} }
clientsByRole[role] = client;
return client; return client;
} }
+310 -5
View File
@@ -9,12 +9,18 @@ import { PayoutMode } from '../types/payout-mode';
const MINING_INFO_CHANNEL = 'mining-info.updated'; const MINING_INFO_CHANNEL = 'mining-info.updated';
const BLOCK_TEMPLATE_CHANNEL = 'block-template.updated'; const BLOCK_TEMPLATE_CHANNEL = 'block-template.updated';
const SV1_BRIDGE_CHANNEL = 'sv1-bridge.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 MINING_INFO_KEY = 'mining-info:latest';
const BLOCK_TEMPLATE_LATEST_KEY = 'block-template:latest'; const BLOCK_TEMPLATE_LATEST_KEY = 'block-template:latest';
const SV1_BRIDGE_LATEST_KEY = 'sv1-bridge: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 BLOCK_TEMPLATE_CACHE_TTL_SECONDS = 60 * 60;
const sv1BridgeLatestKey = (payoutMode: PayoutMode) => const sv1BridgeLatestKey = (payoutMode: PayoutMode) =>
`${SV1_BRIDGE_LATEST_KEY}:${payoutMode}`; `${SV1_BRIDGE_LATEST_KEY}:${payoutMode}`;
const sv1PrestageLatestKey = (payoutMode: PayoutMode) =>
`${SV1_PRESTAGE_LATEST_KEY}:${payoutMode}`;
const blockTemplateLegacyKey = (height: number) => `block-template:${height}`; const blockTemplateLegacyKey = (height: number) => `block-template:${height}`;
const blockTemplateKey = ( const blockTemplateKey = (
height: number, height: number,
@@ -26,6 +32,7 @@ const blockTemplateHeightPointerKey = (height: number, payoutMode: PayoutMode) =
const blockTemplateLatestPointerKey = (payoutMode: PayoutMode) => const blockTemplateLatestPointerKey = (payoutMode: PayoutMode) =>
`${BLOCK_TEMPLATE_LATEST_KEY}:${payoutMode}`; `${BLOCK_TEMPLATE_LATEST_KEY}:${payoutMode}`;
const jsonCacheKey = (key: string) => `json-cache:${key}`; const jsonCacheKey = (key: string) => `json-cache:${key}`;
const jsonCacheLockKey = (key: string) => `json-cache-lock:${key}`;
export interface BlockTemplateUpdate { export interface BlockTemplateUpdate {
schemaVersion: 1; schemaVersion: 1;
@@ -42,12 +49,61 @@ export interface Sv1BridgeUpdate {
eventId: string; eventId: string;
template: IBlockTemplate; template: IBlockTemplate;
publishedAtMs: number; 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() @Injectable()
export class RedisMessagingService implements OnModuleInit, OnModuleDestroy { export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
private publisher: RedisClientType; private publisher: RedisClientType;
private subscriber: 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; private connected = false;
constructor( constructor(
@@ -64,6 +120,8 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
await Promise.all([ await Promise.all([
this.publisher?.quit().catch(() => undefined), this.publisher?.quit().catch(() => undefined),
this.subscriber?.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.publisher = createClient({ url, socket });
this.subscriber = 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.publisher.on('error', error => console.error(`Redis publisher error: ${error.message}`));
this.subscriber.on('error', error => console.error(`Redis subscriber 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.publisher.on('end', () => { this.connected = false; });
this.subscriber.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; 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()) { if (!await this.ensureConnected()) {
return; 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 serialized = JSON.stringify(update);
const validated = this.parseSv1BridgeUpdate(serialized); const validated = this.parseSv1BridgeUpdate(serialized);
const payoutMode = validated.template.payoutMode === 'pplns' ? 'pplns' : 'solo'; const payoutMode = validated.template.payoutMode === 'pplns' ? 'pplns' : 'solo';
// The bridge envelope is self-contained. Deliver it before spending a // The bridge envelope is self-contained. Deliver it before spending a
// second Redis round trip on best-effort replay metadata. // 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([ void Promise.all([
this.publisher.setEx( this.publisher.setEx(
sv1BridgeLatestKey(payoutMode), sv1BridgeLatestKey(payoutMode),
@@ -162,15 +260,52 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
]).catch(error => { ]).catch(error => {
console.error(`Unable to cache latest SV1 bridge: ${error.message}`); 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> { public async subscribeSv1BridgeUpdates(handler: (update: Sv1BridgeUpdate) => Promise<void>): Promise<void> {
if (!await this.ensureConnected()) { if (!await this.ensureConnected()) {
return; return;
} }
await this.subscriber.subscribe(SV1_BRIDGE_CHANNEL, async message => { await this.urgentSubscriber.subscribe(SV1_BRIDGE_CHANNEL, async message => {
try { try {
await handler(this.parseSv1BridgeUpdate(message)); const update = this.parseSv1BridgeUpdate(message);
update.workerReceivedAtMs = Date.now();
await handler(update);
} catch (error) { } catch (error) {
console.error(`Invalid Redis SV1 bridge update: ${error.message}`); 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) { public async setLatestMiningInfo(miningInfo: IMiningInfo) {
if (!await this.ensureConnected()) { if (!await this.ensureConnected()) {
return; 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> { private async readBlockTemplatePointer(value: string | null): Promise<IBlockTemplate | null> {
if (value == null) { if (value == null) {
return null; return null;
@@ -411,6 +628,94 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
return update as Sv1BridgeUpdate; 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> { private async ensureConnected(): Promise<boolean> {
if (!this.connected) { if (!this.connected) {
try { try {
+161 -1
View File
@@ -5,11 +5,13 @@ import { MockRecording1 } from '../../test/models/MockRecording1';
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate'; import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
import { createPreparedMiningJob } from './prepared-mining-job.factory'; import { createPreparedMiningJob } from './prepared-mining-job.factory';
import { StratumV1JobsService } from './stratum-v1-jobs.service'; import { StratumV1JobsService } from './stratum-v1-jobs.service';
import { EMPTY_DEFAULT_WITNESS_COMMITMENT } from './subsidy-only-template.factory';
describe('StratumV1JobsService', () => { describe('StratumV1JobsService', () => {
let blockTemplate$: BehaviorSubject<IBlockTemplate>; let blockTemplate$: BehaviorSubject<IBlockTemplate>;
let bridgeTemplate$: Subject<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 service: StratumV1JobsService;
let consoleLogSpy: jest.SpyInstance; let consoleLogSpy: jest.SpyInstance;
@@ -26,9 +28,11 @@ describe('StratumV1JobsService', () => {
blockTemplate$ = new BehaviorSubject(createTemplate()); blockTemplate$ = new BehaviorSubject(createTemplate());
bridgeTemplate$ = new Subject<IBlockTemplate>(); bridgeTemplate$ = new Subject<IBlockTemplate>();
prestageTemplate$ = new Subject<IBlockTemplate>();
bitcoinRpcService = { bitcoinRpcService = {
newBlockTemplate$: blockTemplate$.asObservable(), newBlockTemplate$: blockTemplate$.asObservable(),
newSv1BridgeTemplate$: bridgeTemplate$.asObservable(), newSv1BridgeTemplate$: bridgeTemplate$.asObservable(),
newSv1PrestageTemplate$: prestageTemplate$.asObservable(),
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height } miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height }
}; };
service = new StratumV1JobsService(bitcoinRpcService as any); service = new StratumV1JobsService(bitcoinRpcService as any);
@@ -141,6 +145,162 @@ describe('StratumV1JobsService', () => {
expect(full.merkle_branch.length).toBeGreaterThan(0); 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 () => { 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 bridgeTemplate = await firstValueFrom(service.newMiningJob$);
const payout = [{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }]; const payout = [{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }];
+295 -7
View File
@@ -4,9 +4,10 @@ import * as crypto from 'crypto';
import { EMPTY, filter, map, merge, Observable, shareReplay, tap } from 'rxjs'; import { EMPTY, filter, map, merge, Observable, shareReplay, tap } from 'rxjs';
import { IBlockTemplate, IBlockTemplateTx } from '../models/bitcoin-rpc/IBlockTemplate'; 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 { PayoutMode } from '../types/payout-mode';
import { BitcoinRpcService } from './bitcoin-rpc.service'; import { BitcoinRpcService } from './bitcoin-rpc.service';
import { Sv1PrestageActivation } from './redis-messaging.service';
import { import {
createPreparedMiningJob, createPreparedMiningJob,
PreparedMiningJobBodyReference, PreparedMiningJobBodyReference,
@@ -29,7 +30,10 @@ export interface IJobTemplate {
payoutMode: PayoutMode | 'all'; payoutMode: PayoutMode | 'all';
notificationEventId?: string; notificationEventId?: string;
sourceNotificationReceivedAtMs?: number; sourceNotificationReceivedAtMs?: number;
notificationPreparedAtMs?: number;
notificationPublishedAtMs?: number; notificationPublishedAtMs?: number;
notificationWorkerReceivedAtMs?: number;
notificationWorkerHandledAtMs?: number;
payoutSnapshotId?: string; payoutSnapshotId?: string;
payoutOutputs?: AddressObject[]; payoutOutputs?: AddressObject[];
transactions?: IBlockTemplateTx[]; transactions?: IBlockTemplateTx[];
@@ -49,6 +53,7 @@ export interface IJobSubmissionContext {
const DEFAULT_JOB_RETENTION_MS = 5 * 60 * 1000; const DEFAULT_JOB_RETENTION_MS = 5 * 60 * 1000;
const PAYOUT_MODES: readonly PayoutMode[] = ['solo', 'pplns']; const PAYOUT_MODES: readonly PayoutMode[] = ['solo', 'pplns'];
const PLACEHOLDER_PREV_HASH = Buffer.alloc(32, 0);
export function createPayoutOutputIdentity( export function createPayoutOutputIdentity(
payoutMode: PayoutMode, payoutMode: PayoutMode,
@@ -70,6 +75,8 @@ export class StratumV1JobsService {
public newMiningJob$: Observable<IJobTemplate>; public newMiningJob$: Observable<IJobTemplate>;
/** Ordered SV1-only stream: subsidy bridge first, canonical full job second. */ /** Ordered SV1-only stream: subsidy bridge first, canonical full job second. */
public sv1MiningJob$: Observable<IJobTemplate>; public sv1MiningJob$: Observable<IJobTemplate>;
/** Detached next-height empty jobs prepared without changing active work. */
public sv1PrestageJob$: Observable<IJobTemplate>;
public latestJobId: number = 1; public latestJobId: number = 1;
public latestJobTemplateId: number = 1; public latestJobTemplateId: number = 1;
public jobs: { [jobId: string]: MiningJob } = {}; public jobs: { [jobId: string]: MiningJob } = {};
@@ -79,7 +86,13 @@ export class StratumV1JobsService {
private readonly lastWorkSignatures = new Map<PayoutMode, string>(); private readonly lastWorkSignatures = new Map<PayoutMode, string>();
private readonly currentTipKeys = new Map<PayoutMode, string>(); private readonly currentTipKeys = new Map<PayoutMode, string>();
private readonly cachedJobs = new Map<string, MiningJob>(); private readonly cachedJobs = new Map<string, MiningJob>();
private readonly notifyHeaderFields = new WeakMap<IJobTemplate, MiningNotifyHeaderFields>();
private readonly latestJobTemplates = new Map<PayoutMode | 'all', IJobTemplate>(); 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>>([ private readonly pinnedCurrentTipTemplateIds = new Map<PayoutMode, Set<string>>([
['solo', new Set<string>()], ['solo', new Set<string>()],
['pplns', new Set<string>()], ['pplns', new Set<string>()],
@@ -152,7 +165,10 @@ export class StratumV1JobsService {
isNewBlock, isNewBlock,
notificationEventId: blockTemplate.notificationEventId, notificationEventId: blockTemplate.notificationEventId,
sourceNotificationReceivedAtMs: blockTemplate.sourceNotificationReceivedAtMs, sourceNotificationReceivedAtMs: blockTemplate.sourceNotificationReceivedAtMs,
notificationPreparedAtMs: blockTemplate.notificationPreparedAtMs,
notificationPublishedAtMs: blockTemplate.notificationPublishedAtMs, notificationPublishedAtMs: blockTemplate.notificationPublishedAtMs,
notificationWorkerReceivedAtMs: blockTemplate.notificationWorkerReceivedAtMs,
notificationWorkerHandledAtMs: blockTemplate.notificationWorkerHandledAtMs,
rawTransactions: blockTemplate.transactions, rawTransactions: blockTemplate.transactions,
sigoplimit: blockTemplate.sigoplimit, sigoplimit: blockTemplate.sigoplimit,
sizelimit: blockTemplate.sizelimit, sizelimit: blockTemplate.sizelimit,
@@ -161,7 +177,7 @@ export class StratumV1JobsService {
}; };
}), }),
filter(next => next != null), 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(); const block = new bitcoinjs.Block();
// Keep only a placeholder coinbase on the hot path. The full raw body // Keep only a placeholder coinbase on the hot path. The full raw body
@@ -201,7 +217,10 @@ export class StratumV1JobsService {
payoutMode: prepared.payoutMode, payoutMode: prepared.payoutMode,
notificationEventId, notificationEventId,
sourceNotificationReceivedAtMs, sourceNotificationReceivedAtMs,
notificationPreparedAtMs,
notificationPublishedAtMs, notificationPublishedAtMs,
notificationWorkerReceivedAtMs,
notificationWorkerHandledAtMs,
payoutSnapshotId: prepared.coinbase.payoutSnapshotId, payoutSnapshotId: prepared.coinbase.payoutSnapshotId,
payoutOutputs: prepared.coinbase.payoutOutputs?.map(output => ({ ...output })), payoutOutputs: prepared.coinbase.payoutOutputs?.map(output => ({ ...output })),
transactions: rawTransactions, transactions: rawTransactions,
@@ -244,9 +263,20 @@ export class StratumV1JobsService {
this.sv1MiningJob$ = merge(bridgeMiningJob$, this.newMiningJob$).pipe( this.sv1MiningJob$ = merge(bridgeMiningJob$, this.newMiningJob$).pipe(
shareReplay({ refCount: true, bufferSize: 1 }), 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') { if (process.env.API_ONLY !== 'true' && process.env.MASTER !== 'true') {
this.sv1MiningJob$.subscribe(); this.sv1MiningJob$.subscribe();
this.sv1PrestageJob$.subscribe();
} }
} }
@@ -272,6 +302,162 @@ export class StratumV1JobsService {
?? null; ?? 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) { public addJob(job: MiningJob) {
this.jobs[job.jobId] = job; this.jobs[job.jobId] = job;
this.latestJobId++; this.latestJobId++;
@@ -288,13 +474,11 @@ export class StratumV1JobsService {
?? (jobTemplate.blockData.payoutMode === 'pplns' ? 'pplns' : 'solo'); ?? (jobTemplate.blockData.payoutMode === 'pplns' ? 'pplns' : 'solo');
const effectivePayoutIdentity = payoutIdentity const effectivePayoutIdentity = payoutIdentity
?? createPayoutOutputIdentity(effectivePayoutMode, payoutInformation); ?? createPayoutOutputIdentity(effectivePayoutMode, payoutInformation);
const cacheKey = [ const cacheKey = this.getJobCacheKey(
jobTemplate.blockData.id, jobTemplate,
jobTemplate.block.timestamp,
jobTemplate.blockData.clearJobs,
effectivePayoutMode, effectivePayoutMode,
effectivePayoutIdentity, effectivePayoutIdentity,
].join(':'); );
const cached = this.cachedJobs.get(cacheKey); const cached = this.cachedJobs.get(cacheKey);
if (cached != null) { if (cached != null) {
return cached; return cached;
@@ -388,6 +572,110 @@ export class StratumV1JobsService {
return payoutMode === 'all' ? PAYOUT_MODES : [payoutMode]; return payoutMode === 'all' ? PAYOUT_MODES : [payoutMode];
} }
private createDetachedPrestageJobTemplate(blockTemplate: IBlockTemplate): IJobTemplate {
const prepared = createPreparedMiningJob(blockTemplate);
if (prepared.jobType !== 'empty'
|| prepared.header.previousBlockHash !== '0'.repeat(64)
|| (prepared.payoutMode !== 'solo' && prepared.payoutMode !== 'pplns')) {
throw new Error('SV1 prestage must be a payout-specific empty template with a placeholder prevhash');
}
const block = new bitcoinjs.Block();
const tempCoinbaseTx = new bitcoinjs.Transaction();
tempCoinbaseTx.version = 2;
tempCoinbaseTx.addInput(Buffer.alloc(32, 0), 0xffffffff, 0xffffffff);
tempCoinbaseTx.ins[0].witness = [Buffer.alloc(32, 0)];
block.prevHash = Buffer.alloc(32, 0);
block.version = prepared.header.version;
block.bits = prepared.header.bits;
block.timestamp = Math.max(
prepared.header.minTime,
Math.floor(Date.now() / 1000),
);
block.transactions = [tempCoinbaseTx];
block.merkleRoot = tempCoinbaseTx.getHash(false);
block.witnessCommit = Buffer.from(prepared.coinbase.witnessCommitmentHash, 'hex');
const id = `prestage-${this.getNextTemplateId()}`;
this.latestJobTemplateId++;
return {
block,
merkle_branch: [],
blockData: {
id,
creation: Date.now(),
coinbasevalue: prepared.coinbase.valueSats,
networkDifficulty: this.calculateNetworkDifficulty(prepared.header.bits),
height: prepared.height,
tipKey: `prestage:${prepared.height}`,
clearJobs: true,
isNewBlock: false,
jobType: 'empty',
payoutMode: prepared.payoutMode,
notificationEventId: blockTemplate.notificationEventId,
notificationPreparedAtMs: blockTemplate.notificationPreparedAtMs,
payoutSnapshotId: prepared.coinbase.payoutSnapshotId,
payoutOutputs: prepared.coinbase.payoutOutputs?.map(output => ({ ...output })),
transactions: [],
bodyReference: prepared.body,
sigoplimit: blockTemplate.sigoplimit,
sizelimit: blockTemplate.sizelimit,
weightlimit: blockTemplate.weightlimit,
requiredVersionBits: blockTemplate.vbrequired >>> 0,
},
};
}
private getJobCacheKey(
jobTemplate: IJobTemplate,
payoutMode: PayoutMode,
payoutIdentity: string,
): string {
return [
jobTemplate.blockData.id,
jobTemplate.block.timestamp,
jobTemplate.blockData.clearJobs,
payoutMode,
payoutIdentity,
].join(':');
}
public getNotifyHeaderFields(jobTemplate: IJobTemplate): MiningNotifyHeaderFields {
const cached = this.notifyHeaderFields.get(jobTemplate);
if (cached != null) {
return cached;
}
const fields = MiningJob.createNotifyHeaderFields(jobTemplate);
this.notifyHeaderFields.set(jobTemplate, fields);
return fields;
}
private getPrestageJobKey(
jobTemplate: IJobTemplate,
payoutIdentity: string,
payoutMode: PayoutMode,
): string {
return [
jobTemplate.blockData.height,
jobTemplate.blockData.coinbasevalue,
jobTemplate.block.witnessCommit.toString('hex'),
payoutMode,
payoutIdentity,
jobTemplate.blockData.payoutSnapshotId ?? '',
].join(':');
}
private cleanupPrestageJobs(latestCandidateHeight: number): void {
for (const key of this.stagedJobs.keys()) {
const height = Number(key.slice(0, key.indexOf(':')));
if (height < latestCandidateHeight) {
// Activated jobs remain available through `jobs`/`cachedJobs` for
// candidate reconstruction and normal retention. The staging
// index is only needed through that height's fanout and would
// otherwise retain every miner payout identity forever.
this.stagedJobs.delete(key);
}
}
}
private getJobPayoutModes(job: MiningJob, jobTemplate: IJobTemplate): readonly PayoutMode[] { private getJobPayoutModes(job: MiningJob, jobTemplate: IJobTemplate): readonly PayoutMode[] {
if (job.ownership?.payoutMode != null) { if (job.ownership?.payoutMode != null) {
return [job.ownership.payoutMode]; return [job.ownership.payoutMode];
+86 -2
View File
@@ -12,6 +12,7 @@ describe('StratumV1Service', () => {
const originalTlsHandshakeTimeoutMs = process.env.STRATUM_TLS_HANDSHAKE_TIMEOUT_MS; const originalTlsHandshakeTimeoutMs = process.env.STRATUM_TLS_HANDSHAKE_TIMEOUT_MS;
const originalSocketTimeoutMs = process.env.STRATUM_SOCKET_TIMEOUT_MS; const originalSocketTimeoutMs = process.env.STRATUM_SOCKET_TIMEOUT_MS;
const originalTcpKeepAliveInitialDelayMs = process.env.STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_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 service: StratumV1Service;
let clientService; let clientService;
@@ -19,6 +20,8 @@ describe('StratumV1Service', () => {
let stratumV2Service; let stratumV2Service;
let redisMessagingService; let redisMessagingService;
let miningJobs: Subject<any>; let miningJobs: Subject<any>;
let prestageJobs: Subject<any>;
let prestageActivations: Subject<any>;
let consoleLogSpy: jest.SpyInstance; let consoleLogSpy: jest.SpyInstance;
let consoleWarnSpy: jest.SpyInstance; let consoleWarnSpy: jest.SpyInstance;
@@ -36,13 +39,18 @@ describe('StratumV1Service', () => {
}; };
redisMessagingService = {}; redisMessagingService = {};
miningJobs = new Subject(); miningJobs = new Subject();
prestageJobs = new Subject();
prestageActivations = new Subject();
service = new StratumV1Service( service = new StratumV1Service(
{} as any, { newSv1PrestageActivation$: prestageActivations.asObservable() } as any,
clientService, clientService,
{} as any, {} as any,
{} as any, {} as any,
{} as any, {} as any,
{ newMiningJob$: miningJobs.asObservable() } as any, {
newMiningJob$: miningJobs.asObservable(),
sv1PrestageJob$: prestageJobs.asObservable(),
} as any,
{} as any, {} as any,
stratumV2Service as any, stratumV2Service as any,
userAgentReportService as any, userAgentReportService as any,
@@ -63,6 +71,7 @@ describe('StratumV1Service', () => {
restoreEnv('STRATUM_TLS_HANDSHAKE_TIMEOUT_MS', originalTlsHandshakeTimeoutMs); restoreEnv('STRATUM_TLS_HANDSHAKE_TIMEOUT_MS', originalTlsHandshakeTimeoutMs);
restoreEnv('STRATUM_SOCKET_TIMEOUT_MS', originalSocketTimeoutMs); restoreEnv('STRATUM_SOCKET_TIMEOUT_MS', originalSocketTimeoutMs);
restoreEnv('STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS', originalTcpKeepAliveInitialDelayMs); restoreEnv('STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS', originalTcpKeepAliveInitialDelayMs);
restoreEnv('STRATUM_FANOUT_TARGET_CLIENTS_PER_WORKER', originalFanoutTargetClientsPerWorker);
consoleLogSpy.mockRestore(); consoleLogSpy.mockRestore();
consoleWarnSpy.mockRestore(); consoleWarnSpy.mockRestore();
jest.useRealTimers(); jest.useRealTimers();
@@ -137,6 +146,7 @@ describe('StratumV1Service', () => {
}); });
it('enqueues a 100,000-client fanout without per-client async serialization', () => { 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; const clientCount = 100_000;
let writes = 0; let writes = 0;
const broadcastMiningJob = () => { const broadcastMiningJob = () => {
@@ -166,6 +176,8 @@ describe('StratumV1Service', () => {
event: 'stratum_job_fanout', event: 'stratum_job_fanout',
eventId: 'load-test', eventId: 'load-test',
clients: clientCount, clients: clientCount,
targetClientsPerWorker: 10_000,
overTargetClients: 90_000,
written: clientCount, written: clientCount,
})); }));
expect(trace.milestoneMs).toEqual(expect.objectContaining({ expect(trace.milestoneMs).toEqual(expect.objectContaining({
@@ -176,6 +188,78 @@ describe('StratumV1Service', () => {
expect(trace.totalMs).toBeLessThan(1_000); 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', () => { it('does not log routine non-new-block fanout unless explicitly enabled', () => {
(service as any).clients.add({ (service as any).clients.add({
broadcastMiningJob: jest.fn().mockReturnValue({ broadcastMiningJob: jest.fn().mockReturnValue({
+150 -2
View File
@@ -15,6 +15,7 @@ import { ShareAccountingService } from '../ORM/share-accounting/share-accounting
import { BitcoinRpcService } from './bitcoin-rpc.service'; import { BitcoinRpcService } from './bitcoin-rpc.service';
import { NotificationService } from './notification.service'; import { NotificationService } from './notification.service';
import { RedisMessagingService } from './redis-messaging.service'; import { RedisMessagingService } from './redis-messaging.service';
import { Sv1PrestageActivation } from './redis-messaging.service';
import { StratumV1JobsService } from './stratum-v1-jobs.service'; import { StratumV1JobsService } from './stratum-v1-jobs.service';
import { StratumV2Service } from './stratum-v2.service'; import { StratumV2Service } from './stratum-v2.service';
import { parsePayoutModePorts, PayoutMode } from '../types/payout-mode'; 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_TLS_HANDSHAKE_TIMEOUT_MS = 10000;
const DEFAULT_SOCKET_TIMEOUT_MS = 1000 * 60 * 60; const DEFAULT_SOCKET_TIMEOUT_MS = 1000 * 60 * 60;
const DEFAULT_TCP_KEEPALIVE_INITIAL_DELAY_MS = 1000 * 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 healthyBackpressureChecks = 0;
private readonly clients = new Set<StratumV1Client>(); private readonly clients = new Set<StratumV1Client>();
private jobBroadcastSubscription: Subscription | null = null; 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( constructor(
private readonly bitcoinRpcService: BitcoinRpcService, private readonly bitcoinRpcService: BitcoinRpcService,
@@ -89,6 +97,11 @@ export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
return; 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.jobBroadcastSubscription = (
this.stratumV1JobsService.sv1MiningJob$ this.stratumV1JobsService.sv1MiningJob$
?? this.stratumV1JobsService.newMiningJob$ ?? this.stratumV1JobsService.newMiningJob$
@@ -96,6 +109,10 @@ export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
next: jobTemplate => this.broadcastMiningJob(jobTemplate), next: jobTemplate => this.broadcastMiningJob(jobTemplate),
error: error => console.error(`SV1 job broadcast subscription failed: ${error.message}`), 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 // wait for all the other processes to init for an even connection distribution
setTimeout(() => { setTimeout(() => {
@@ -130,6 +147,12 @@ export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
public onModuleDestroy(): void { public onModuleDestroy(): void {
this.jobBroadcastSubscription?.unsubscribe(); this.jobBroadcastSubscription?.unsubscribe();
this.jobBroadcastSubscription = null; this.jobBroadcastSubscription = null;
this.jobActivationSubscription?.unsubscribe();
this.jobActivationSubscription = null;
this.jobPrestageSubscription?.unsubscribe();
this.jobPrestageSubscription = null;
this.prestageGeneration++;
this.pendingPrestageJobs.clear();
if (this.backpressureMonitor != null) { if (this.backpressureMonitor != null) {
clearInterval(this.backpressureMonitor); clearInterval(this.backpressureMonitor);
this.backpressureMonitor = null; this.backpressureMonitor = null;
@@ -459,8 +482,13 @@ export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
} }
private broadcastMiningJob(jobTemplate: import('./stratum-v1-jobs.service').IJobTemplate): void { private broadcastMiningJob(jobTemplate: import('./stratum-v1-jobs.service').IJobTemplate): void {
const fanoutStartedAtMs = Date.now();
const startedAt = process.hrtime.bigint(); const startedAt = process.hrtime.bigint();
const totalClients = this.clients.size; const totalClients = this.clients.size;
const targetClientsPerWorker = this.getPositiveIntegerEnv(
'STRATUM_FANOUT_TARGET_CLIENTS_PER_WORKER',
DEFAULT_FANOUT_TARGET_CLIENTS_PER_WORKER,
);
const milestoneIndexes = { const milestoneIndexes = {
p50: Math.max(1, Math.ceil(totalClients * 0.5)), p50: Math.max(1, Math.ceil(totalClients * 0.5)),
p95: Math.max(1, Math.ceil(totalClients * 0.95)), p95: Math.max(1, Math.ceil(totalClients * 0.95)),
@@ -473,6 +501,7 @@ export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
let backpressured = 0; let backpressured = 0;
let closed = 0; let closed = 0;
let errors = 0; let errors = 0;
let preStaged = 0;
let bytesQueued = 0; let bytesQueued = 0;
let maxBufferedBytes = 0; let maxBufferedBytes = 0;
@@ -481,6 +510,9 @@ export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
visited++; visited++;
try { try {
const result = client.broadcastMiningJob(jobTemplate); const result = client.broadcastMiningJob(jobTemplate);
if (result.preStaged) {
preStaged++;
}
bytesQueued += result.bytes; bytesQueued += result.bytes;
maxBufferedBytes = Math.max(maxBufferedBytes, result.bufferedBytes); maxBufferedBytes = Math.max(maxBufferedBytes, result.bufferedBytes);
switch (result.status) { switch (result.status) {
@@ -515,21 +547,47 @@ export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
eventId: jobTemplate.blockData.notificationEventId, eventId: jobTemplate.blockData.notificationEventId,
sourceToFanoutStartMs: jobTemplate.blockData.sourceNotificationReceivedAtMs == null sourceToFanoutStartMs: jobTemplate.blockData.sourceNotificationReceivedAtMs == null
? undefined ? 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 redisToFanoutStartMs: jobTemplate.blockData.notificationPublishedAtMs == null
? undefined ? undefined
: Date.now() - jobTemplate.blockData.notificationPublishedAtMs, : fanoutStartedAtMs - jobTemplate.blockData.notificationPublishedAtMs,
templateId: jobTemplate.blockData.id, templateId: jobTemplate.blockData.id,
height: jobTemplate.blockData.height, height: jobTemplate.blockData.height,
jobType: jobTemplate.blockData.jobType, jobType: jobTemplate.blockData.jobType,
isNewBlock: jobTemplate.blockData.isNewBlock, isNewBlock: jobTemplate.blockData.isNewBlock,
cleanJobs: jobTemplate.blockData.clearJobs, cleanJobs: jobTemplate.blockData.clearJobs,
clients: totalClients, clients: totalClients,
targetClientsPerWorker,
overTargetClients: Math.max(0, totalClients - targetClientsPerWorker),
written, written,
skipped, skipped,
backpressured, backpressured,
closed, closed,
errors, errors,
preStaged,
bytesQueued, bytesQueued,
maxBufferedBytes, maxBufferedBytes,
milestoneMs, 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 { private shouldLogJobFanout(isNewBlock: boolean, errors: number): boolean {
if (errors > 0 || isNewBlock) { if (errors > 0 || isNewBlock) {
return true; return true;
+65 -1
View File
@@ -4,6 +4,7 @@ import { Socket } from 'net';
import { Observable, Subject } from 'rxjs'; import { Observable, Subject } from 'rxjs';
import { StratumV2Client } from '../models/StratumV2Client'; import { StratumV2Client } from '../models/StratumV2Client';
import { Sv1PrestageActivation } from './redis-messaging.service';
import { IJobTemplate } from './stratum-v1-jobs.service'; import { IJobTemplate } from './stratum-v1-jobs.service';
import { StratumV2Service } from './stratum-v2.service'; import { StratumV2Service } from './stratum-v2.service';
@@ -156,6 +157,45 @@ describe('StratumV2Service canonical job broadcaster', () => {
await service.onModuleDestroy(); 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 () => { it('registers created clients and unregisters them during client destruction', async () => {
const { service } = createService(); const { service } = createService();
(service as any).noiseConfig = createNoiseConfig(); (service as any).noiseConfig = createNoiseConfig();
@@ -230,11 +270,13 @@ function createService(): {
templates: Subject<IJobTemplate>; templates: Subject<IJobTemplate>;
subscribeSpy: jest.Mock; subscribeSpy: jest.Mock;
activations: Subject<any>; activations: Subject<any>;
compactActivations: Subject<Sv1PrestageActivation>;
activationSubscribeSpy: jest.Mock; activationSubscribeSpy: jest.Mock;
} { } {
const templates = new Subject<IJobTemplate>(); const templates = new Subject<IJobTemplate>();
const subscribeSpy = jest.fn((observer) => templates.subscribe(observer)); const subscribeSpy = jest.fn((observer) => templates.subscribe(observer));
const activations = new Subject<any>(); const activations = new Subject<any>();
const compactActivations = new Subject<Sv1PrestageActivation>();
const activationSubscribeSpy = jest.fn((observer) => activations.subscribe(observer)); const activationSubscribeSpy = jest.fn((observer) => activations.subscribe(observer));
const jobsService = { const jobsService = {
newMiningJob$: new Observable((subscriber) => subscribeSpy(subscriber)), newMiningJob$: new Observable((subscriber) => subscribeSpy(subscriber)),
@@ -248,6 +290,7 @@ function createService(): {
}; };
const bitcoinRpcService = { const bitcoinRpcService = {
workActivationTemplate$: new Observable((subscriber) => activationSubscribeSpy(subscriber)), workActivationTemplate$: new Observable((subscriber) => activationSubscribeSpy(subscriber)),
newSv1PrestageActivation$: compactActivations.asObservable(),
}; };
const service = new StratumV2Service( const service = new StratumV2Service(
bitcoinRpcService as any, bitcoinRpcService as any,
@@ -263,7 +306,7 @@ function createService(): {
{} as any, {} as any,
{} as any, {} as any,
); );
return { service, templates, subscribeSpy, activations, activationSubscribeSpy }; return { service, templates, subscribeSpy, activations, compactActivations, activationSubscribeSpy };
} }
function createClientMock( function createClientMock(
@@ -298,6 +341,27 @@ function createActivationTemplate(
} as any; } 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( function createJobTemplate(
id: string, id: string,
payoutMode: 'solo' | 'pplns' | 'all', payoutMode: 'solo' | 'pplns' | 'all',
+53 -7
View File
@@ -30,7 +30,7 @@ import {
import { BitcoinRpcService } from './bitcoin-rpc.service'; import { BitcoinRpcService } from './bitcoin-rpc.service';
import { CustomWorkService } from './custom-work.service'; import { CustomWorkService } from './custom-work.service';
import { NotificationService } from './notification.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 { IJobTemplate, StratumV1JobsService } from './stratum-v1-jobs.service';
import { Sv2JobDeclarationRegistryService } from './sv2-job-declaration-registry.service'; import { Sv2JobDeclarationRegistryService } from './sv2-job-declaration-registry.service';
import { parsePayoutModePorts, PayoutMode } from '../types/payout-mode'; 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 readonly latestCanonicalJobs = new Map<PayoutMode | 'all', IJobTemplate>();
private canonicalJobSubscription: Subscription = null; private canonicalJobSubscription: Subscription = null;
private workActivationSubscription: Subscription = null; private workActivationSubscription: Subscription = null;
private compactActivationSubscription: Subscription = null;
private latestWorkActivationTemplate: IBlockTemplate = null; private latestWorkActivationTemplate: IBlockTemplate = null;
private latestWorkActivationKey: string = null; private latestWorkActivationKey: string = null;
private authorityPrivKey: Buffer; private authorityPrivKey: Buffer;
@@ -98,6 +99,8 @@ export class StratumV2Service implements OnModuleInit, OnModuleDestroy {
this.canonicalJobSubscription = null; this.canonicalJobSubscription = null;
this.workActivationSubscription?.unsubscribe(); this.workActivationSubscription?.unsubscribe();
this.workActivationSubscription = null; this.workActivationSubscription = null;
this.compactActivationSubscription?.unsubscribe();
this.compactActivationSubscription = null;
const clients = Array.from(this.clients); const clients = Array.from(this.clients);
this.clients.clear(); this.clients.clear();
@@ -261,14 +264,57 @@ export class StratumV2Service implements OnModuleInit, OnModuleDestroy {
} }
private startWorkActivationBroadcaster(): void { private startWorkActivationBroadcaster(): void {
if (this.workActivationSubscription != null || this.bitcoinRpcService.workActivationTemplate$ == null) { if (
return; 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}`),
});
} }
this.workActivationSubscription = this.bitcoinRpcService.workActivationTemplate$.subscribe({ if (this.workActivationSubscription == null && this.bitcoinRpcService.workActivationTemplate$ != null) {
next: template => this.broadcastWorkActivation(template), this.workActivationSubscription = this.bitcoinRpcService.workActivationTemplate$.subscribe({
error: error => console.error(`SV2 work activation subscription failed: ${error.message}`), 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 { private broadcastWorkActivation(template: IBlockTemplate): void {
+37
View File
@@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
import { encodeSv2AuthorityPublicKey } from '../models/sv2/sv2-authority-key';
import { xOnlyPubKeyFromPriv } from '../models/sv2/sv2-noise';
@Injectable()
export class Sv2AuthorityService {
private authorityPublicKey: string | null = null;
private authorityKeyConfigured = false;
constructor(
private readonly configService: ConfigService,
) {}
public async getPoolAuthorityPublicKey(): Promise<{ publicKey: string; configured: boolean }> {
if (this.authorityPublicKey != null) {
return {
publicKey: this.authorityPublicKey,
configured: this.authorityKeyConfigured,
};
}
const configuredAuthorityKey = this.configService.get<string>('SV2_AUTHORITY_PRIVKEY');
this.authorityKeyConfigured = configuredAuthorityKey?.length === 64;
const authorityPrivKey = configuredAuthorityKey?.length === 64
? Buffer.from(configuredAuthorityKey, 'hex')
: crypto.randomBytes(32);
this.authorityPublicKey = encodeSv2AuthorityPublicKey(xOnlyPubKeyFromPriv(authorityPrivKey));
return {
publicKey: this.authorityPublicKey,
configured: this.authorityKeyConfigured,
};
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ export class TelegramService implements OnModuleInit {
}, 2000); }, 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) { if (this.bot == null) {
return; return;
} }
+13 -1
View File
@@ -76,7 +76,7 @@ describe('TimescaleDB and Redis integration', () => {
const aggregates = await dataSource.query(` const aggregates = await dataSource.query(`
SELECT view_name SELECT view_name
FROM timescaledb_information.continuous_aggregates 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 ORDER BY view_name
`); `);
expect(aggregates.map(row => row.view_name)).toEqual([ expect(aggregates.map(row => row.view_name)).toEqual([
@@ -84,6 +84,7 @@ describe('TimescaleDB and Redis integration', () => {
'accepted_share_1d', 'accepted_share_1d',
'accepted_share_1h', 'accepted_share_1h',
'accepted_share_block_10m', 'accepted_share_block_10m',
'accepted_share_pool_10m',
]); ]);
const legacyTables = await dataSource.query(` 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_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_block_10m', NULL, NULL)`);
await dataSource.query(`CALL refresh_continuous_aggregate('accepted_share_pool_10m', NULL, NULL)`);
const aggregateRows = await dataSource.query(` const aggregateRows = await dataSource.query(`
SELECT "shares"::float AS shares, "acceptedCount"::int AS "acceptedCount" SELECT "shares"::float AS shares, "acceptedCount"::int AS "acceptedCount"
FROM accepted_share_10m FROM accepted_share_10m
@@ -250,6 +252,16 @@ describe('TimescaleDB and Redis integration', () => {
expect(blockAggregateRows).toEqual(expect.arrayContaining([ expect(blockAggregateRows).toEqual(expect.arrayContaining([
expect.objectContaining({ shares: 96, acceptedCount: 2, networkDifficulty: 100000 }), 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 () => { it('should retain daily and all-time best share from completed rollup buckets', async () => {