181 Commits
Author SHA1 Message Date
Ben efbd95e464 Keep SV1 client entity during cleanup 2026-06-09 12:45:33 -04:00
Ben bb0acf0395 Pass Stratum backpressure setting to container 2026-06-09 12:42:27 -04:00
Ben 9b99c5153a Harden Stratum V1 cleanup 2026-06-09 12:30:03 -04:00
Ben a693501648 Optimize latest completed accounting bucket lookup 2026-06-08 18:49:30 -04:00
Ben f259af947e Remove accepted share retention policy 2026-06-08 18:38:42 -04:00
Ben f29081de36 Use latest available completed accounting bucket 2026-06-08 18:21:44 -04:00
Ben 0acc1910db Use completed share rollups for pool accounting 2026-06-08 18:17:49 -04:00
Ben d847e2666a Align accounting last ten minute rollup window 2026-06-08 13:54:31 -04:00
Ben 294c4a6825 Expose fallback best share in client accounting 2026-06-08 13:47:43 -04:00
Ben 8252111c32 Harden accounting and stratum production settings 2026-06-08 13:27:24 -04:00
Ben deaf3e56bd Serve client accounting from rollup cache 2026-06-08 01:42:17 -04:00
Ben 9b73e33b13 Add current round best share index migration 2026-06-08 01:32:59 -04:00
Ben 0560b874dc Use current round best share for dashboard 2026-06-08 01:32:19 -04:00
Ben 7f6f42c5c9 Restore live pool accounting dashboard values 2026-06-08 01:09:42 -04:00
Ben 4c59797886 Avoid client accounting queries in API workers 2026-06-08 00:59:40 -04:00
Ben 4c91e69ca8 Use share rollups for API accounting 2026-06-08 00:55:51 -04:00
Ben 7b7dc87242 Add API timing diagnostics 2026-06-08 00:47:11 -04:00
Ben df35549f27 Avoid repeated cold info refreshes 2026-06-08 00:31:56 -04:00
Ben 8c795a3fa2 Run API as an API-only cluster 2026-06-08 00:26:48 -04:00
Ben ac7827a944 Restore in-memory API cache priority 2026-06-08 00:19:56 -04:00
Ben 17ba4afc1a Revert "Harden API process under production load"
This reverts commit 817f416b79.
2026-06-08 00:17:15 -04:00
Ben b612b50f3e Revert "Relax API connection hardening defaults"
This reverts commit b6a951bdee.
2026-06-08 00:17:15 -04:00
Ben b6a951bdee Relax API connection hardening defaults 2026-06-08 00:14:58 -04:00
Ben 817f416b79 Harden API process under production load 2026-06-08 00:10:37 -04:00
Ben d05d5f0b37 Bound info endpoint cold cache work 2026-06-07 23:32:49 -04:00
Ben 3da95b964b Restore lightweight info endpoint 2026-06-07 23:29:08 -04:00
Ben 4155335bb5 Limit background services by process role 2026-06-07 23:18:00 -04:00
Ben 13106347b2 Avoid raw pool summary on API process 2026-06-07 23:13:14 -04:00
Ben 96e0e36755 Keep API process out of SV2 sockets 2026-06-07 23:09:44 -04:00
Ben 93dde1a899 Cache pool accounting summary 2026-06-07 23:02:57 -04:00
Ben eac0fe65ab Cache live user agent report off request path 2026-06-07 22:58:00 -04:00
Ben f14d76c2ea Isolate API process from mining workers 2026-06-07 22:53:15 -04:00
Ben 51e3afb7e7 Fix client presence cleanup index 2026-06-07 22:46:46 -04:00
Ben 3c33fcd3c4 Serve API only from master process 2026-06-07 22:40:45 -04:00
Ben 78fbf12f8f Make share index migration online safe 2026-06-07 22:13:08 -04:00
Ben 3e1c303fa0 Add accepted share order index 2026-06-07 22:03:32 -04:00
Ben 4f51a4d05c Omit current chart bucket 2026-06-07 21:37:23 -04:00
Ben 2d6b01691c Cache hot API responses in Redis 2026-06-07 21:32:03 -04:00
Ben 4a5d4bbb71 Reduce dashboard Timescale query load 2026-06-07 21:26:22 -04:00
Ben c0604aaf2a Expose configurable Docker API publish port 2026-06-07 21:08:18 -04:00
Ben ec69f5fb6c Migrate pool storage to TimescaleDB and Redis 2026-06-07 20:33:13 -04:00
Ben fb367ac5f0 sv1-sv2 routing 2026-06-05 23:44:00 -04:00
Ben 3d6c8b3134 sv2 debug 2026-06-05 23:41:22 -04:00
Ben 2a37cb1f06 Stratum V2 initial commit 2026-06-05 13:14:09 -04:00
Ben f2d2283b47 fix possible dup work on diff change 2026-06-05 09:51:06 -04:00
Ben 4cab56a1a0 prevent half connections staying open 2026-05-30 19:24:34 -04:00
Ben c5169e105e add stratum worker backpressure 2026-05-07 23:56:55 -04:00
Ben 2d6d2afacb dead code 2026-05-07 23:52:56 -04:00
Ben 327be48ade update postgresql branch 2026-05-07 23:45:07 -04:00
Ben cc0ca3979e extranonce2 8 bytes 2026-05-06 18:18:34 -04:00
Benjamin WilsonandWantClue 9bf3dde862 validateHeaderCompliance (#145)
Co-authored-by: WantClue <wantclue@users.noreply.github.com>
2026-01-24 14:53:05 -05:00
Ben 8b2bea702a enable stratum TLS 2025-11-29 11:45:44 -05:00
Ben 63a1618f26 killDeadClients 2025-11-21 20:56:27 -05:00
Ben 1b0f3f52d8 index 2025-11-21 20:52:52 -05:00
Ben 78c00fd25c killDeadClients 2025-11-21 20:34:00 -05:00
Ben dd45c60eff killDeadClients 2025-11-21 20:01:55 -05:00
Ben 0481dfda6e dead clients 2025-11-21 18:56:29 -05:00
Ben 6f6abe5e2d batch kill dead clients 2025-11-21 18:42:00 -05:00
Ben ff84d8284f watch cert and reload 2025-08-22 19:01:46 -04:00
Ben cdf6eb807b other filtering 2025-04-29 15:31:28 -04:00
Benjamin Wilson d9c89347e1 remove logging 2025-04-27 13:13:05 -04:00
Ben c1d8341c22 Prevent duplicate share submissions - this is why we can't have nice things 2025-04-27 13:07:24 -04:00
Benjamin Wilson f39c0dc059 Increase miner compatibility by allowing, but stripping unknown message values rather than rejecting as validation errors 2025-04-19 13:50:50 -04:00
Benjamin Wilson 3dab14e3b7 increase timeout to 15min 2025-03-03 20:29:31 -05:00
Benjamin Wilson 5060345809 better way to calculate hashrate 2025-02-27 16:22:48 -05:00
Benjamin Wilson b44f09848e don't calculate hashrate for 1 min 2025-02-27 12:15:20 -05:00
Benjamin Wilson 9b5402f1b0 refactor hashrate calculation 2025-02-27 12:10:43 -05:00
Benjamin Wilson 17881f70d6 heartbeat 2025-02-26 20:20:37 -05:00
Benjamin Wilson e9949f4038 utc string 2025-02-26 20:15:24 -05:00
Benjamin Wilson 7c3b31f7b3 timezone 2025-02-26 19:59:33 -05:00
Benjamin Wilson 78deb3a375 logging 2025-02-26 17:52:32 -05:00
Benjamin Wilson c5c8810da6 heartbeat updates 2025-02-26 17:48:27 -05:00
Benjamin Wilson 8584c442dc bug 2025-02-26 17:07:04 -05:00
Benjamin Wilson fd24807c55 cleanup 2025-02-26 09:50:42 -05:00
Benjamin Wilson bd5333a725 fix data type 2025-02-26 09:16:46 -05:00
Benjamin Wilson 419eb09853 logging 2025-02-26 00:32:43 -05:00
Benjamin Wilson 74f5783dc3 logging 2025-02-26 00:24:34 -05:00
Benjamin Wilson 34a27d0f2f update all stats 2025-02-26 00:18:25 -05:00
Benjamin Wilson d8e087f720 server start setTimeout 2025-02-26 00:08:20 -05:00
Benjamin Wilson 34eb0ca0f2 logging 2025-02-26 00:06:25 -05:00
Benjamin Wilson a33d7e07b3 logging 2025-02-25 23:59:29 -05:00
Benjamin Wilson f8d1c61c1c bug 2025-02-25 23:40:50 -05:00
Benjamin Wilson 326a5a7b33 typeo 2025-02-25 19:27:34 -05:00
Benjamin Wilson 903aa2628c query 2025-02-25 19:17:13 -05:00
Benjamin Wilson 19f49b74e6 sql camel case 2025-02-25 19:11:10 -05:00
Benjamin Wilson 864867ffd4 remove socket server delay 2025-02-25 19:06:33 -05:00
Benjamin Wilson f465b837f5 bulk client stat updates 2025-02-25 19:02:38 -05:00
Remco Ros 637d30f1a5 patch rpc-bitcoin to be compatible with bitcoin core 28.0 (#66) 2025-02-24 19:37:47 -05:00
Benjamin Wilson df87214de9 Online device list compaction 2025-02-23 12:57:38 -05:00
Benjamin Wilson 66ce822faa Fix kill dead clients tx wantclue 2025-02-22 17:47:46 -05:00
Benjamin Wilson 78ef48b805 logging 2025-02-10 00:07:14 -05:00
Benjamin Wilson b545e74d4f delete old templates and jobs 2025-02-10 00:01:55 -05:00
Benjamin Wilson 5702e20dc0 reduce the user agent list 2025-02-09 19:38:08 -05:00
Benjamin Wilson 74dd5ba66f Use pg pub/sub for stateless app notification 2025-02-09 17:04:31 -05:00
Benjamin Wilson 674b02ca9d socket cleanup 2025-02-03 15:39:03 -05:00
Benjamin Wilson 40ee610600 logs 2025-02-01 12:36:07 -05:00
Benjamin Wilson 7e0de263f2 Revert "Fix home graph hashrate calculation (around restarts)"
This reverts commit 220a1d1558.
2025-02-01 12:35:55 -05:00
Benjamin Wilson 65232da901 block save => upsert 2025-02-01 11:57:34 -05:00
Benjamin Wilson 220a1d1558 Fix home graph hashrate calculation (around restarts) 2025-01-02 23:34:43 -05:00
Benjamin Wilson ae7ed021a3 logging 2025-01-02 11:15:46 -05:00
Benjamin Wilson 056fe7af4f logging 2025-01-01 23:06:19 -05:00
Benjamin Wilson 912ae9fe85 tweak timeout and starting diff 2024-12-22 10:03:03 -05:00
Benjamin Wilson d513dab932 user agent cleanup 2024-10-16 13:53:49 -04:00
Benjamin Wilson 3a1f3bba10 Ensure mintime is considered for jobs 2024-09-27 13:00:52 -04:00
Benjamin Wilson 8fdf1e839e save block 2024-09-16 19:48:46 -04:00
Benjamin Wilson 76d92d84b4 debug 2024-09-16 19:43:24 -04:00
Benjamin Wilson 4928f34daf debug 2024-09-16 19:39:56 -04:00
Benjamin Wilson 88e7f5f3fb Saving block error 2024-09-16 19:35:13 -04:00
Benjamin Wilson d97843af3d getBlockTemplate 2024-09-16 19:31:40 -04:00
Benjamin Wilson c1ab6a152e loadBlockTemplate 2024-09-16 19:28:00 -04:00
Benjamin Wilson 717a5c7878 getBlockTemplate fix 2024-09-16 19:24:34 -04:00
Benjamin Wilson 6038af8448 fix getBlockTemplate 2024-09-16 19:16:38 -04:00
Benjamin Wilson d6863fad28 timing adjustments 2024-09-16 18:32:43 -04:00
Benjamin Wilson b9b5fe2cf5 cluster 2024-09-09 20:27:17 -04:00
Benjamin Wilson 9db0afd9df removed NODE_APP_INSTANCE 0 dependency 2024-09-09 20:18:37 -04:00
Benjamin Wilson fb8715c23d multi port 2024-09-07 23:56:09 -04:00
Benjamin Wilson ff6d446c12 validation handling 2024-08-22 15:18:40 -04:00
Benjamin Wilson 02d718934d initialize hashrate to 0 2024-07-17 17:24:04 -04:00
Benjamin Wilson d40742ef71 bug 2024-07-15 09:16:55 -04:00
Benjamin Wilson 38ecbdc7ec logging 2024-07-14 11:55:49 -04:00
Benjamin Wilson 8905409880 null jobTemplate 2024-07-10 22:40:02 -04:00
Benjamin Wilson 2bfbc2d79a logging 2024-07-10 21:51:46 -04:00
Benjamin Wilson 2ee82a269c loadBlockTemplate 2024-07-05 20:22:06 -04:00
Benjamin Wilson fe4d58a60d StratumBaseMessage validation 2024-07-05 20:21:47 -04:00
Benjamin Wilson b858a594b8 open stratum base message to accept number or string 2024-07-05 08:19:54 -04:00
Benjamin Wilson 3e4cb4ff5d always clear jobs on diff change 2024-07-05 08:13:55 -04:00
Benjamin Wilson 14f95a7fa4 tweaks 2024-07-04 21:03:51 -04:00
Ben Wilson 522ef126d9 updated at, hashrate calculation 2024-07-04 20:42:37 -04:00
Benjamin Wilson 0c34f23284 ensure block template is fresh 2024-07-04 20:06:48 -04:00
Benjamin Wilson e52d14b0a9 handle partial socket data 2024-07-04 17:19:58 -04:00
Benjamin Wilson 3727773a2e getBlockTemplate improvements 2024-07-04 16:46:57 -04:00
Benjamin Wilson ad3035b908 ensure getblocktemplate always returns a value 2024-07-04 16:46:44 -04:00
Benjamin Wilson 3e279ee662 remove dead code 2024-07-04 16:43:02 -04:00
Ben Wilson deeb40967e decrease submission rate 2024-04-30 16:45:08 -04:00
Ben Wilson f1221651f6 diff fix 2024-03-29 12:17:30 -04:00
Ben Wilson 32a604e745 allow client to suggest diff in password field "d=1234" 2024-03-27 17:02:11 -04:00
Georges 6442bb45e0 stratum: set_difficulty should always be a notification (#32) 2024-02-23 12:54:16 -05:00
Ben Wilson f2930681d3 optional password 2024-02-23 12:54:16 -05:00
Ben Wilson 3d3d1a55a8 share stats 2024-02-18 14:38:45 -05:00
Ben Wilson 01adb0a997 ClientStatistics updatedAt 2024-02-18 12:21:52 -05:00
Ben Wilson 5ead6c605b remove server start delay 2024-02-18 10:52:28 -05:00
Ben Wilson 4fd8d9ceb6 change coinbase tag 2024-02-17 20:31:23 -05:00
Ben Wilson 9d4c3ad35e lastSave 2024-02-17 20:12:37 -05:00
Ben Wilson aab69b65fc clientid 2024-02-17 19:01:24 -05:00
Ben Wilson 9895e80a01 share updates 2024-02-17 18:40:34 -05:00
Ben Wilson 9617ec5685 statistics saving 2024-02-17 18:25:14 -05:00
Ben Wilson aefb180fb4 fix pool info 2024-02-17 16:27:37 -05:00
Ben Wilson 87508bfb69 user agent report 2024-02-16 23:49:47 -05:00
Ben Wilson 39e537a00c bug 2024-02-16 17:18:21 -05:00
Ben Wilson 9ecfc68155 bug 2024-02-16 17:17:19 -05:00
Ben Wilson 3e2de62259 db refactor 2024-02-16 17:12:36 -05:00
Ben Wilson 8e977c21a1 Merge remote-tracking branch 'origin/master' into postgresql 2024-02-14 08:38:05 -05:00
Ben Wilson 5fd9f98807 index, dead code 2024-01-22 17:32:34 -05:00
Ben Wilson 6bc42defef separate home graph into it's own table 2024-01-16 19:37:29 -05:00
Ben Wilson bbc14e2272 bug 2024-01-15 20:04:26 -05:00
Ben Wilson fc767fa770 reduce heartbeat updates 2024-01-14 14:10:47 -05:00
Ben Wilson 40e32d6e1a stratum jobs array to dictionary 2023-12-15 00:56:22 -05:00
Ben Wilson df6ec07c58 batch save share statistics 2023-12-15 00:25:01 -05:00
Ben Wilson df245f396e parameterized typeorm 2023-12-14 23:54:45 -05:00
Ben Wilson 42fc863c7c column names 2023-12-14 23:47:51 -05:00
Ben Wilson e46bb24155 raw query 2023-12-14 23:44:41 -05:00
Ben Wilson cb4a173733 add back index with time 2023-12-14 22:45:12 -05:00
Ben Wilson 83c769849d removed hash from client stats table, connection pool size 2023-12-14 22:30:55 -05:00
Ben Wilson ee5fea1df5 wrap save in a transaction 2023-12-10 17:23:08 -05:00
Ben Wilson 06acad6770 example 2023-12-08 00:12:25 -05:00
Ben Wilson f7b22bb310 delete old stats sooner 2023-12-07 09:57:20 -05:00
Ben Wilson 6cf6a007d9 remove read UNCOMMITTED 2023-12-07 09:55:36 -05:00
Ben Wilson 5b71632f8a postgres doesn't support read UNCOMMITTED 2023-12-07 09:55:07 -05:00
Ben Wilson c5ff8521ac Merge branch 'postgresql' of https://github.com/benjamin-wilson/public-pool into postgresql 2023-12-07 09:53:40 -05:00
Ben Wilson 53ee510a1e fix chart, caching 2023-12-07 09:53:16 -05:00
Ben Wilson 7be554a358 fix chart, caching 2023-12-07 09:40:32 -05:00
Ben Wilson eb5d434157 few optimizations 2023-12-07 09:07:37 -05:00
Ben Wilson 893c7c2003 revert 2023-12-07 08:33:43 -05:00
Ben Wilson 4736eadf7b row names 2023-12-07 08:19:51 -05:00
Ben Wilson 1089e6210c deadlock? 2023-12-07 08:16:48 -05:00
Ben Wilson 017978d250 userAgent 2023-12-04 08:52:49 -05:00
Ben Wilson 5a17528b8f bug 2023-12-04 08:33:47 -05:00
Ben Wilson 0b147ea5db fix numbers returned from query as string 2023-12-04 08:26:47 -05:00
Ben Wilson bffcf6c74c config 2023-12-04 01:07:45 -05:00
Ben Wilson 96a38f9d9b bug 2023-12-04 01:06:53 -05:00
Ben Wilson 39c4c033e2 postgres init 2023-12-04 00:34:33 -05:00
122 changed files with 23263 additions and 9314 deletions
+52 -8
View File
@@ -10,12 +10,41 @@ BITCOIN_RPC_PASSWORD=
BITCOIN_RPC_PORT=8332
BITCOIN_RPC_TIMEOUT=10000
# Enable in bitcoin.conf with
# zmqpubrawblock=tcp://*:3000
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
# Enable in bitcoin.conf with:
# zmqpubrawblock=tcp://0.0.0.0:3000
BITCOIN_ZMQ_HOST=tcp://192.168.1.100:3000
API_PORT=3334
STRATUM_PORT=3333
# Docker host/IP and public host port for the API listener.
# Keep API_BIND_HOST as 127.0.0.1 when a reverse proxy terminates public traffic.
API_BIND_HOST=127.0.0.1
API_PUBLIC_PORT=3334
API_WORKERS=4
# Docker json-file log rotation. Applies when using the compose files.
DOCKER_LOG_MAX_SIZE=100m
DOCKER_LOG_MAX_FILES=5
# Plain TCP Stratum ports accept both SV1 JSON-RPC and SV2 Noise/binary traffic.
STRATUM_PORTS=3333,3332,3331,3330
STRATUM_WORKERS=2
STRATUM_WORKER_MAX_MEMORY_RESTART=4096M
STRATUM_MIN_DIFFICULTY=1
STRATUM_SOCKET_TIMEOUT_MS=3600000
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS=60000
# Optional additional SV2-only mining ports. Usually unnecessary when STRATUM_PORTS are exposed.
#STRATUM_V2_PORTS=
#SV2_START_DIFFICULTY=100000
#SV2_TARGET_SHARES_PER_MINUTE=2
#SV2_DIFFICULTY_CHECK_INTERVAL_MS=60000
#STRATUM_V2_SOCKET_TIMEOUT_MS=3600000
#SV2_AUTHORITY_PRIVKEY=
STRATUM_SECURE=true
SECURE_STRATUM_PORTS=4333,4332,4331,4330
STRATUM_MAX_CONNECTIONS_PER_LISTENER=10000
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS=10000
#optional telegram bot
#TELEGRAM_BOT_TOKEN=
@@ -32,9 +61,24 @@ NETWORK=mainnet
API_SECURE=false
ENABLE_SOLO=false
ENABLE_PROXY=true
#postgresql
DB_HOST=timescaledb
DB_PORT=5432
DB_USERNAME=public_pool
DB_PASSWORD=public_pool
DB_DATABASE=public_pool
DB_POOL_SIZE=10
DB_SSL=false
DB_SSL_REJECT_UNAUTHORIZED=true
BRAIINS_ACCESS_TOKEN=
#accepted share accounting
SHARE_ACCOUNTING_BATCH_SIZE=500
SHARE_ACCOUNTING_FLUSH_INTERVAL_MS=25
SHARE_ACCOUNTING_MAX_QUEUE_SIZE=50000
SHARE_ACCOUNTING_SUMMARY_CACHE_MS=2500
SHARE_ACCOUNTING_SUMMARY_CACHE_MAX=10000
PROXY_PORT=3333
#redis
REDIS_URL=redis://redis:6379
PRODUCTION=false
+3
View File
@@ -13,10 +13,13 @@ lerna-debug.log*
# OS
.DS_Store
._*
# Tests
/coverage
/.nyc_output
/.baseline-*.json
/.test-artifacts
# IDEs and editors
/.idea
+1
View File
@@ -0,0 +1 @@
omit=optional
+6 -4
View File
@@ -2,7 +2,7 @@
# Docker build environment #
############################
FROM node:18.16.1-bookworm-slim AS build
FROM node:24.16.0-bookworm-slim AS build
# Upgrade all packages and install dependencies
RUN apt-get update \
@@ -11,6 +11,8 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
python3 \
build-essential \
cmake \
curl \
ca-certificates \
&& apt clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
WORKDIR /build
@@ -18,13 +20,13 @@ WORKDIR /build
COPY . .
# Build Public Pool using NPM
RUN npm i && npm run build
RUN npm ci && npm run build && npm prune --omit=dev
############################
# Docker final environment #
############################
FROM node:18.16.1-bookworm-slim
FROM node:24.16.0-bookworm-slim
# Expose ports for Stratum and Bitcoin RPC
EXPOSE 3333 3334 8332
@@ -35,4 +37,4 @@ WORKDIR /public-pool
COPY --from=build /build .
#COPY .env.example .env
CMD ["/usr/local/bin/node", "dist/main"]
CMD ["npm", "run", "start:docker"]
+19
View File
@@ -0,0 +1,19 @@
FROM node:24.16.0-bookworm
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
python3 \
build-essential \
cmake \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["npm", "run", "test:integration"]
+93 -17
View File
@@ -39,51 +39,127 @@ See [public-pool-ui](https://github.com/benjamin-wilson/public-pool-ui)
## Deployment
Install pm2 (https://pm2.keymetrics.io/)
The production Docker image runs database migrations and then starts PM2
automatically. For a manual host deployment, install pm2
(https://pm2.keymetrics.io/), run migrations, then start the app.
```bash
$ npm run build
$ npm run migration:run:prod
$ pm2 start dist/main.js
```
When running the worker app in PM2 cluster mode, start the PM2 daemon with OS-level
connection scheduling. The environment variable must be present when the PM2 daemon
starts, not only in the worker configuration.
```bash
$ NODE_CLUSTER_SCHED_POLICY=none pm2 start ecosystem.config.js
```
Cluster-mode connection dropping requires Node.js `22.12.0` or newer.
`STRATUM_MAX_CONNECTIONS_PER_LISTENER` is enforced per worker and Stratum port.
Size it using the busiest port: `worker count * limit`. For example, 28 workers
with the default limit of `10000` allow up to `280000` connections on one port.
## Docker
Build container:
The default compose stack includes Public Pool, TimescaleDB, and Redis. TimescaleDB
stores normal Postgres tables plus immutable accepted-share rows in a hypertable.
Chart and hashrate APIs read from Timescale continuous aggregates plus recent raw
shares for realtime buckets. Redis is used only for process messaging and latest
mining-template replay.
Start the stack:
```bash
$ docker build -t public-pool .
$ docker compose up --build -d
```
Run container:
Use an external TimescaleDB/Postgres server:
```bash
$ docker container run --name public-pool --rm -p 3333:3333 -p 3334:3334 -p 8332:8332 -v .env:/public-pool/.env public-pool
$ DB_HOST=postgres.example.com \
DB_PORT=5432 \
DB_USERNAME=public_pool \
DB_PASSWORD='change-me' \
DB_DATABASE=public_pool \
DB_SSL=false \
docker compose -f docker-compose.external-db.yml up --build -d
```
### Docker Compose
The external database must be TimescaleDB-compatible and reachable from the
Public Pool container. The startup script waits for the remote `DB_HOST:DB_PORT`,
runs migrations, then starts PM2. Redis still runs locally in this compose file
unless `REDIS_URL` is pointed at an external Redis instance.
Set `DB_SSL=true` if the external database requires TLS. For private CA or
self-signed test deployments, `DB_SSL_REJECT_UNAUTHORIZED=false` disables
certificate verification; do not use that setting for normal internet-facing
production databases.
For a fresh external database, create the role/database first:
```sql
CREATE USER public_pool WITH PASSWORD 'change-me';
CREATE DATABASE public_pool OWNER public_pool;
\c public_pool
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
```
If TimescaleDB is managed by another operator, make sure the `public_pool` role
can create tables, indexes, continuous aggregates, and Timescale policies in the
target database.
Run only the database migrations:
Build container:
```bash
$ docker compose build
$ docker compose run --rm public-pool npm run migration:run:prod
```
Run container:
Watch logs:
```bash
$ docker compose up -d
$ docker compose logs --tail 100 -f public-pool
```
The docker-compose binds to `127.0.0.1` by default. To expose the Stratum services on your server change:
```diff
ports:
- - "127.0.0.1:3333:3333/tcp"
- - "127.0.0.1:3334:3334/tcp"
+ - "3333"
+ - "3334"
Back up TimescaleDB:
```bash
$ docker compose exec timescaledb pg_dump -U public_pool public_pool > public-pool.sql
```
Redis does not hold durable accounting data. Losing Redis requires workers to
replay the latest template from Redis after reconnect or fall back to the saved
RPC block template table.
**note**: To successfully connect to the bitcoin RPC you will need to add
```
rpcallowip=172.16.0.0/12
zmqpubrawblock=tcp://0.0.0.0:3000
```
to your bitcoin.conf.
## Testing
Baseline unit regression capture:
```bash
$ npm run test:baseline
```
Unit tests:
```bash
$ npm test
```
Integration tests against real TimescaleDB and Redis:
```bash
$ docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
```
+86
View File
@@ -0,0 +1,86 @@
x-log-limits: &log-limits
driver: json-file
options:
max-size: ${DOCKER_LOG_MAX_SIZE:-100m}
max-file: "${DOCKER_LOG_MAX_FILES:-5}"
services:
redis:
image: redis:8-alpine
container_name: public-pool-redis
restart: unless-stopped
logging: *log-limits
ports:
- "127.0.0.1:${REDIS_PORT:-6379}:6379/tcp"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
public-pool:
container_name: public-pool
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
logging: *log-limits
depends_on:
redis:
condition: service_healthy
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "0.0.0.0:3333:3333/tcp"
- "0.0.0.0:3332:3332/tcp"
- "0.0.0.0:3331:3331/tcp"
- "0.0.0.0:3330:3330/tcp"
- "0.0.0.0:4333:4333/tcp"
- "0.0.0.0:4332:4332/tcp"
- "0.0.0.0:4331:4331/tcp"
- "0.0.0.0:4330:4330/tcp"
- "${API_BIND_HOST:-127.0.0.1}:${API_PUBLIC_PORT:-3334}:3334/tcp"
volumes:
- "./.env:/public-pool/.env:ro"
- "./secrets:/public-pool/secrets:ro"
environment:
NODE_ENV: production
DB_HOST: ${DB_HOST:?set DB_HOST to your external TimescaleDB/Postgres host}
DB_PORT: ${DB_PORT:-5432}
DB_USERNAME: ${DB_USERNAME:-public_pool}
DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD for your external database}
DB_DATABASE: ${DB_DATABASE:-public_pool}
DB_POOL_SIZE: ${DB_POOL_SIZE:-10}
DB_SSL: ${DB_SSL:-false}
DB_SSL_REJECT_UNAUTHORIZED: ${DB_SSL_REJECT_UNAUTHORIZED:-true}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
API_PORT: ${API_PORT:-3334}
API_SECURE: ${API_SECURE:-false}
API_WORKERS: ${API_WORKERS:-4}
PM2_ENABLED: ${PM2_ENABLED:-true}
STRATUM_WORKERS: ${STRATUM_WORKERS:-2}
STRATUM_PORTS: ${STRATUM_PORTS:-3333,3332,3331,3330}
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
STRATUM_SOCKET_TIMEOUT_MS: ${STRATUM_SOCKET_TIMEOUT_MS:-3600000}
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS: ${STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS:-60000}
STRATUM_SECURE: ${STRATUM_SECURE:-true}
SECURE_STRATUM_PORTS: ${SECURE_STRATUM_PORTS:-4333,4332,4331,4330}
STRATUM_MAX_CONNECTIONS_PER_LISTENER: ${STRATUM_MAX_CONNECTIONS_PER_LISTENER:-10000}
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS: ${STRATUM_TLS_HANDSHAKE_TIMEOUT_MS:-10000}
STRATUM_BACKPRESSURE_ENABLED: "${STRATUM_BACKPRESSURE_ENABLED:-false}"
STRATUM_V2_PORTS: ${STRATUM_V2_PORTS:-}
SHARE_ACCOUNTING_BATCH_SIZE: ${SHARE_ACCOUNTING_BATCH_SIZE:-500}
SHARE_ACCOUNTING_FLUSH_INTERVAL_MS: ${SHARE_ACCOUNTING_FLUSH_INTERVAL_MS:-25}
SHARE_ACCOUNTING_MAX_QUEUE_SIZE: ${SHARE_ACCOUNTING_MAX_QUEUE_SIZE:-50000}
SHARE_ACCOUNTING_SUMMARY_CACHE_MS: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MS:-2500}
SHARE_ACCOUNTING_SUMMARY_CACHE_MAX: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MAX:-10000}
healthcheck:
test: ["CMD-SHELL", "node -e \"const http=require('http'); const https=require('https'); const secure=process.env.API_SECURE==='true'; const client=secure?https:http; const req=client.get({hostname:'127.0.0.1',port:process.env.API_PORT||3334,path:'/api/network',rejectUnauthorized:false},res=>process.exit(res.statusCode<500?0:1)); req.on('error',()=>process.exit(1)); req.setTimeout(5000,()=>{req.destroy(); process.exit(1);});\""]
interval: 30s
timeout: 5s
retries: 10
volumes:
redis-data:
+65
View File
@@ -0,0 +1,65 @@
name: public-pool-test
x-log-limits: &log-limits
driver: json-file
options:
max-size: ${DOCKER_LOG_MAX_SIZE:-100m}
max-file: "${DOCKER_LOG_MAX_FILES:-5}"
services:
timescaledb:
image: timescale/timescaledb:latest-pg17
logging: *log-limits
environment:
POSTGRES_DB: public_pool_test
POSTGRES_USER: public_pool
POSTGRES_PASSWORD: public_pool
command:
- postgres
- -c
- max_wal_size=16GB
- -c
- min_wal_size=2GB
- -c
- checkpoint_timeout=15min
- -c
- checkpoint_completion_target=0.9
- -c
- wal_compression=on
ports:
- "127.0.0.1:15432:5432/tcp"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U public_pool -d public_pool_test"]
interval: 5s
timeout: 5s
retries: 20
redis:
image: redis:8-alpine
logging: *log-limits
ports:
- "127.0.0.1:16379:6379/tcp"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 20
integration-tests:
build:
context: .
dockerfile: Dockerfile.test
logging: *log-limits
depends_on:
timescaledb:
condition: service_healthy
redis:
condition: service_healthy
environment:
NODE_ENV: integration
DB_HOST: timescaledb
DB_PORT: 5432
DB_USERNAME: public_pool
DB_PASSWORD: public_pool
DB_DATABASE: public_pool_test
REDIS_URL: redis://redis:6379
+96 -5
View File
@@ -1,20 +1,111 @@
version: '3.8'
x-log-limits: &log-limits
driver: json-file
options:
max-size: ${DOCKER_LOG_MAX_SIZE:-100m}
max-file: "${DOCKER_LOG_MAX_FILES:-5}"
services:
timescaledb:
image: timescale/timescaledb:latest-pg17
container_name: public-pool-timescaledb
restart: unless-stopped
logging: *log-limits
environment:
POSTGRES_DB: ${DB_DATABASE:-public_pool}
POSTGRES_USER: ${DB_USERNAME:-public_pool}
POSTGRES_PASSWORD: ${DB_PASSWORD:-public_pool}
command:
- postgres
- -c
- max_wal_size=16GB
- -c
- min_wal_size=2GB
- -c
- checkpoint_timeout=15min
- -c
- checkpoint_completion_target=0.9
- -c
- wal_compression=on
ports:
- "127.0.0.1:${DB_PORT:-5432}:5432/tcp"
volumes:
- timescaledb-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-public_pool} -d ${DB_DATABASE:-public_pool}"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:8-alpine
container_name: public-pool-redis
restart: unless-stopped
logging: *log-limits
ports:
- "127.0.0.1:${REDIS_PORT:-6379}:6379/tcp"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
public-pool:
container_name: public-pool
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
logging: *log-limits
depends_on:
timescaledb:
condition: service_healthy
redis:
condition: service_healthy
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "127.0.0.1:${STRATUM_PORT}:${STRATUM_PORT}/tcp"
- "127.0.0.1:${API_PORT}:${API_PORT}/tcp"
- "0.0.0.0:3333:3333/tcp"
- "0.0.0.0:3332:3332/tcp"
- "0.0.0.0:3331:3331/tcp"
- "0.0.0.0:3330:3330/tcp"
- "0.0.0.0:4333:4333/tcp"
- "0.0.0.0:4332:4332/tcp"
- "0.0.0.0:4331:4331/tcp"
- "0.0.0.0:4330:4330/tcp"
- "${API_BIND_HOST:-127.0.0.1}:${API_PUBLIC_PORT:-3334}:3334/tcp"
volumes:
- "./${NETWORK}-DB:/public-pool/DB"
- "./.env:/public-pool/.env:ro"
- "./secrets:/public-pool/secrets:ro"
environment:
- NODE_ENV=production
NODE_ENV: production
DB_HOST: timescaledb
DB_PORT: 5432
DB_USERNAME: ${DB_USERNAME:-public_pool}
DB_PASSWORD: ${DB_PASSWORD:-public_pool}
DB_DATABASE: ${DB_DATABASE:-public_pool}
REDIS_URL: redis://redis:6379
API_PORT: ${API_PORT:-3334}
API_SECURE: ${API_SECURE:-false}
API_WORKERS: ${API_WORKERS:-4}
PM2_ENABLED: ${PM2_ENABLED:-true}
STRATUM_WORKERS: ${STRATUM_WORKERS:-2}
STRATUM_PORTS: ${STRATUM_PORTS:-3333,3332,3331,3330}
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
STRATUM_SOCKET_TIMEOUT_MS: ${STRATUM_SOCKET_TIMEOUT_MS:-3600000}
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS: ${STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS:-60000}
STRATUM_SECURE: ${STRATUM_SECURE:-true}
SECURE_STRATUM_PORTS: ${SECURE_STRATUM_PORTS:-4333,4332,4331,4330}
STRATUM_MAX_CONNECTIONS_PER_LISTENER: ${STRATUM_MAX_CONNECTIONS_PER_LISTENER:-10000}
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS: ${STRATUM_TLS_HANDSHAKE_TIMEOUT_MS:-10000}
STRATUM_V2_PORTS: ${STRATUM_V2_PORTS:-}
healthcheck:
test: ["CMD-SHELL", "node -e \"const http=require('http'); const https=require('https'); const secure=process.env.API_SECURE==='true'; const client=secure?https:http; const req=client.get({hostname:'127.0.0.1',port:process.env.API_PORT||3334,path:'/api/network',rejectUnauthorized:false},res=>process.exit(res.statusCode<500?0:1)); req.on('error',()=>process.exit(1)); req.setTimeout(5000,()=>{req.destroy(); process.exit(1);});\""]
interval: 30s
timeout: 5s
retries: 10
volumes:
timescaledb-data:
redis-data:
+54
View File
@@ -0,0 +1,54 @@
const dockerLogConfig = {
out_file: '/dev/stdout',
error_file: '/dev/stderr',
merge_logs: true,
};
module.exports = {
apps: [
// API instance
{
...dockerLogConfig,
name: 'api',
script: './dist/main.js',
instances: parseInt(process.env.API_WORKERS || '4', 10),
exec_mode: 'cluster',
env: {
MASTER: 'false',
API_ONLY: 'true',
API_ENABLED: 'true',
NODE_CLUSTER_SCHED_POLICY: 'none',
},
time: true
},
// Master instance
{
...dockerLogConfig,
name: 'master',
script: './dist/main.js',
instances: 1,
exec_mode: 'fork',
env: {
MASTER: 'true',
API_ENABLED: 'false',
NODE_CLUSTER_SCHED_POLICY: 'none',
},
time: true
},
// Worker instances
{
...dockerLogConfig,
name: 'workers',
script: './dist/main.js',
instances: parseInt(process.env.STRATUM_WORKERS || '2', 10),
exec_mode: "cluster",
max_memory_restart: process.env.STRATUM_WORKER_MAX_MEMORY_RESTART || '4096M',
env: {
MASTER: 'false',
API_ENABLED: 'false',
NODE_CLUSTER_SCHED_POLICY: 'none',
},
time: true
},
],
};
+25 -1
View File
@@ -1,6 +1,7 @@
# Full Setup for public pool
This setup provides a docker-compose setup consisting of Bitcoin Core Node and Public-Pool running in Mainnet or Testnet.
This setup provides Docker Compose stacks for Bitcoin Core, Public Pool,
TimescaleDB, and Redis on mainnet, testnet, and regtest.
It exposes following ports:
@@ -8,6 +9,7 @@ It exposes following ports:
- `8333/18333` Bitcoin peering on `0.0.0.0`
- `3333/13333` Public-Pool Stratum port on `0.0.0.0`
- `3334/13334` Public-Pool API port on `localhost`
- TimescaleDB and Redis only on the internal compose network
The docker-compose setups for Mainnet and Testnet can be run in parallel without any problems.
@@ -42,6 +44,9 @@ There are 4 config files for Mainnet and Testnet
- `bitcoin-testnet.conf`
**note: pruning (`prune=550`) is enabled by default in the config**
**note: Bitcoin ZMQ raw block publishing is enabled on the internal compose
network so the Public Pool master can refresh templates immediately after new
blocks.**
# Running the setup
To start the setup in foreground mode:
@@ -65,6 +70,25 @@ To stop the setup use:
docker compose -f docker-compose-mainnet.yml down
```
Database and Redis data are stored under `full-setup/data/<network>/`. To remove
all runtime data, stop the stack and delete the relevant `bitcoin`,
`timescaledb`, and `redis` directories.
# Migrations and backups
The Public Pool container waits for TimescaleDB and Redis, runs migrations, and
then starts PM2. To run migrations manually:
```bash
docker compose -f docker-compose-mainnet.yml run --rm public-pool npm run migration:run:prod
```
To back up mainnet accounting data:
```bash
docker compose -f docker-compose-mainnet.yml exec timescaledb pg_dump -U public_pool public_pool_mainnet > public-pool-mainnet.sql
```
# Regtest
After running the `regtest` setup a couple of blocks need to be generated:
+3
View File
@@ -18,6 +18,9 @@ rpcuser=bitcoin
# enable server
server=1
# notify pool master about new blocks
zmqpubrawblock=tcp://0.0.0.0:3000
[main]
[test]
rpcbind=0.0.0.0
+3
View File
@@ -20,3 +20,6 @@ rpcuser=bitcoin
# set pruning
prune=550
# notify pool master about new blocks
zmqpubrawblock=tcp://0.0.0.0:3000
+3
View File
@@ -18,6 +18,9 @@ rpcuser=bitcoin
# enable server
server=1
# notify pool master about new blocks
zmqpubrawblock=tcp://0.0.0.0:3000
[main]
[test]
rpcbind=0.0.0.0
+80 -4
View File
@@ -1,4 +1,8 @@
version: '3.8'
x-log-limits: &log-limits
driver: json-file
options:
max-size: ${DOCKER_LOG_MAX_SIZE:-100m}
max-file: "${DOCKER_LOG_MAX_FILES:-5}"
services:
bitcoin:
@@ -8,6 +12,7 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
stop_grace_period: 30s
logging: *log-limits
networks:
- bitcoin
ports:
@@ -20,6 +25,52 @@ services:
- "./data/mainnet/bitcoin:/app/data"
- "./bitcoin-mainnet.conf:/app/data/bitcoin.conf:ro"
timescaledb:
image: timescale/timescaledb:latest-pg17
container_name: public-pool-mainnet-timescaledb
restart: unless-stopped
logging: *log-limits
networks:
- bitcoin
environment:
POSTGRES_DB: public_pool_mainnet
POSTGRES_USER: public_pool
POSTGRES_PASSWORD: public_pool
command:
- postgres
- -c
- max_wal_size=16GB
- -c
- min_wal_size=2GB
- -c
- checkpoint_timeout=15min
- -c
- checkpoint_completion_target=0.9
- -c
- wal_compression=on
volumes:
- "./data/mainnet/timescaledb:/var/lib/postgresql/data"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U public_pool -d public_pool_mainnet"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:8-alpine
container_name: public-pool-mainnet-redis
restart: unless-stopped
logging: *log-limits
networks:
- bitcoin
volumes:
- "./data/mainnet/redis:/data"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
public-pool:
container_name: public-pool
build:
@@ -27,16 +78,41 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
stop_grace_period: 30s
logging: *log-limits
depends_on:
timescaledb:
condition: service_healthy
redis:
condition: service_healthy
networks:
- bitcoin
ports:
- "0.0.0.0:3333:3333/tcp"
- "127.0.0.1:3334:3334/tcp"
- "0.0.0.0:3332:3332/tcp"
- "0.0.0.0:3331:3331/tcp"
- "0.0.0.0:3330:3330/tcp"
- "0.0.0.0:4333:4333/tcp"
- "0.0.0.0:4332:4332/tcp"
- "0.0.0.0:4331:4331/tcp"
- "0.0.0.0:4330:4330/tcp"
- "${API_BIND_HOST:-127.0.0.1}:${API_PUBLIC_PORT:-3334}:3334/tcp"
volumes:
- "./data/mainnet/public-pool:/public-pool/DB"
- "./public-pool-mainnet.env:/public-pool/.env:ro"
- "../secrets:/public-pool/secrets:ro"
environment:
- NODE_ENV=production
NODE_ENV: production
DB_HOST: timescaledb
DB_PORT: 5432
DB_USERNAME: public_pool
DB_PASSWORD: public_pool
DB_DATABASE: public_pool_mainnet
REDIS_URL: redis://redis:6379
API_WORKERS: ${API_WORKERS:-4}
PM2_ENABLED: "true"
STRATUM_WORKERS: ${STRATUM_WORKERS:-2}
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
STRATUM_SOCKET_TIMEOUT_MS: ${STRATUM_SOCKET_TIMEOUT_MS:-3600000}
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS: ${STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS:-60000}
networks:
bitcoin:
+74 -3
View File
@@ -1,4 +1,8 @@
version: '3.8'
x-log-limits: &log-limits
driver: json-file
options:
max-size: ${DOCKER_LOG_MAX_SIZE:-100m}
max-file: "${DOCKER_LOG_MAX_FILES:-5}"
services:
bitcoin-regtest:
@@ -8,6 +12,7 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
stop_grace_period: 30s
logging: *log-limits
networks:
- bitcoin-regtest
ports:
@@ -22,6 +27,52 @@ services:
- "./data/regtest/bitcoin:/app/data"
- "./bitcoin-regtest.conf:/app/data/bitcoin.conf:ro"
timescaledb-regtest:
image: timescale/timescaledb:latest-pg17
container_name: public-pool-regtest-timescaledb
restart: unless-stopped
logging: *log-limits
networks:
- bitcoin-regtest
environment:
POSTGRES_DB: public_pool_regtest
POSTGRES_USER: public_pool
POSTGRES_PASSWORD: public_pool
command:
- postgres
- -c
- max_wal_size=16GB
- -c
- min_wal_size=2GB
- -c
- checkpoint_timeout=15min
- -c
- checkpoint_completion_target=0.9
- -c
- wal_compression=on
volumes:
- "./data/regtest/timescaledb:/var/lib/postgresql/data"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U public_pool -d public_pool_regtest"]
interval: 10s
timeout: 5s
retries: 10
redis-regtest:
image: redis:8-alpine
container_name: public-pool-regtest-redis
restart: unless-stopped
logging: *log-limits
networks:
- bitcoin-regtest
volumes:
- "./data/regtest/redis:/data"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
public-pool-regtest:
container_name: public-pool-regtest
build:
@@ -29,16 +80,36 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
stop_grace_period: 30s
logging: *log-limits
depends_on:
timescaledb-regtest:
condition: service_healthy
redis-regtest:
condition: service_healthy
networks:
- bitcoin-regtest
ports:
- "0.0.0.0:23333:23333/tcp"
- "0.0.0.0:23332:23332/tcp"
- "0.0.0.0:23331:23331/tcp"
- "0.0.0.0:23330:23330/tcp"
- "127.0.0.1:23334:23334/tcp"
volumes:
- "./data/regtest/public-pool:/public-pool/DB"
- "./public-pool-regtest.env:/public-pool/.env:ro"
- "../secrets:/public-pool/secrets:ro"
environment:
- NODE_ENV=production
NODE_ENV: production
DB_HOST: timescaledb-regtest
DB_PORT: 5432
DB_USERNAME: public_pool
DB_PASSWORD: public_pool
DB_DATABASE: public_pool_regtest
REDIS_URL: redis://redis-regtest:6379
PM2_ENABLED: "true"
STRATUM_WORKERS: ${STRATUM_WORKERS:-2}
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
STRATUM_SOCKET_TIMEOUT_MS: ${STRATUM_SOCKET_TIMEOUT_MS:-3600000}
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS: ${STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS:-60000}
networks:
bitcoin-regtest:
+74 -3
View File
@@ -1,4 +1,8 @@
version: '3.8'
x-log-limits: &log-limits
driver: json-file
options:
max-size: ${DOCKER_LOG_MAX_SIZE:-100m}
max-file: "${DOCKER_LOG_MAX_FILES:-5}"
services:
bitcoin-testnet:
@@ -8,6 +12,7 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
stop_grace_period: 30s
logging: *log-limits
networks:
- bitcoin-testnet
ports:
@@ -21,6 +26,52 @@ services:
- "./data/testnet/bitcoin:/app/data"
- "./bitcoin-testnet.conf:/app/data/bitcoin.conf:ro"
timescaledb-testnet:
image: timescale/timescaledb:latest-pg17
container_name: public-pool-testnet-timescaledb
restart: unless-stopped
logging: *log-limits
networks:
- bitcoin-testnet
environment:
POSTGRES_DB: public_pool_testnet
POSTGRES_USER: public_pool
POSTGRES_PASSWORD: public_pool
command:
- postgres
- -c
- max_wal_size=16GB
- -c
- min_wal_size=2GB
- -c
- checkpoint_timeout=15min
- -c
- checkpoint_completion_target=0.9
- -c
- wal_compression=on
volumes:
- "./data/testnet/timescaledb:/var/lib/postgresql/data"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U public_pool -d public_pool_testnet"]
interval: 10s
timeout: 5s
retries: 10
redis-testnet:
image: redis:8-alpine
container_name: public-pool-testnet-redis
restart: unless-stopped
logging: *log-limits
networks:
- bitcoin-testnet
volumes:
- "./data/testnet/redis:/data"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
public-pool-testnet:
container_name: public-pool-testnet
build:
@@ -28,16 +79,36 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
stop_grace_period: 30s
logging: *log-limits
depends_on:
timescaledb-testnet:
condition: service_healthy
redis-testnet:
condition: service_healthy
networks:
- bitcoin-testnet
ports:
- "0.0.0.0:13333:13333/tcp"
- "0.0.0.0:13332:13332/tcp"
- "0.0.0.0:13331:13331/tcp"
- "0.0.0.0:13330:13330/tcp"
- "127.0.0.1:13334:13334/tcp"
volumes:
- "./data/testnet/public-pool:/public-pool/DB"
- "./public-pool-testnet.env:/public-pool/.env:ro"
- "../secrets:/public-pool/secrets:ro"
environment:
- NODE_ENV=production
NODE_ENV: production
DB_HOST: timescaledb-testnet
DB_PORT: 5432
DB_USERNAME: public_pool
DB_PASSWORD: public_pool
DB_DATABASE: public_pool_testnet
REDIS_URL: redis://redis-testnet:6379
PM2_ENABLED: "true"
STRATUM_WORKERS: ${STRATUM_WORKERS:-2}
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
STRATUM_SOCKET_TIMEOUT_MS: ${STRATUM_SOCKET_TIMEOUT_MS:-3600000}
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS: ${STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS:-60000}
networks:
bitcoin-testnet:
+13 -10
View File
@@ -4,12 +4,14 @@ BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=8332
BITCOIN_RPC_TIMEOUT=10000
# Enable in bitcoin.conf with
# zmqpubrawblock=tcp://*:3000
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
BITCOIN_ZMQ_HOST=tcp://bitcoin:3000
API_PORT=3334
STRATUM_PORT=3333
STRATUM_PORTS=3333,3332,3331,3330
STRATUM_SECURE=true
SECURE_STRATUM_PORTS=4333,4332,4331,4330
STRATUM_MAX_CONNECTIONS_PER_LISTENER=10000
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS=10000
#optional telegram bot
#TELEGRAM_BOT_TOKEN=
@@ -26,9 +28,10 @@ NETWORK=mainnet
API_SECURE=false
ENABLE_SOLO=true
ENABLE_PROXY=false
BRAIINS_ACCESS_TOKEN=
PROXY_PORT=3333
DB_HOST=timescaledb
DB_PORT=5432
DB_USERNAME=public_pool
DB_PASSWORD=public_pool
DB_DATABASE=public_pool_mainnet
REDIS_URL=redis://redis:6379
PRODUCTION=true
+13 -10
View File
@@ -4,12 +4,14 @@ BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=28332
BITCOIN_RPC_TIMEOUT=10000
# Enable in bitcoin.conf with
# zmqpubrawblock=tcp://*:3000
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
BITCOIN_ZMQ_HOST=tcp://bitcoin-regtest:3000
API_PORT=23334
STRATUM_PORT=23333
STRATUM_PORTS=23333,23332,23331,23330
STRATUM_SECURE=false
SECURE_STRATUM_PORTS=
STRATUM_MAX_CONNECTIONS_PER_LISTENER=10000
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS=10000
#optional telegram bot
#TELEGRAM_BOT_TOKEN=
@@ -26,9 +28,10 @@ NETWORK=regtest
API_SECURE=false
ENABLE_SOLO=true
ENABLE_PROXY=false
BRAIINS_ACCESS_TOKEN=
PROXY_PORT=3333
DB_HOST=timescaledb-regtest
DB_PORT=5432
DB_USERNAME=public_pool
DB_PASSWORD=public_pool
DB_DATABASE=public_pool_regtest
REDIS_URL=redis://redis-regtest:6379
PRODUCTION=true
+13 -10
View File
@@ -4,12 +4,14 @@ BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=18332
BITCOIN_RPC_TIMEOUT=10000
# Enable in bitcoin.conf with
# zmqpubrawblock=tcp://*:3000
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
BITCOIN_ZMQ_HOST=tcp://bitcoin-testnet:3000
API_PORT=13334
STRATUM_PORT=13333
STRATUM_PORTS=13333,13332,13331,13330
STRATUM_SECURE=false
SECURE_STRATUM_PORTS=
STRATUM_MAX_CONNECTIONS_PER_LISTENER=10000
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS=10000
#optional telegram bot
#TELEGRAM_BOT_TOKEN=
@@ -26,9 +28,10 @@ NETWORK=testnet
API_SECURE=false
ENABLE_SOLO=true
ENABLE_PROXY=false
BRAIINS_ACCESS_TOKEN=
PROXY_PORT=3333
DB_HOST=timescaledb-testnet
DB_PORT=5432
DB_USERNAME=public_pool
DB_PASSWORD=public_pool
DB_DATABASE=public_pool_testnet
REDIS_URL=redis://redis-testnet:6379
PRODUCTION=true
+9566 -7897
View File
File diff suppressed because it is too large Load Diff
+65 -42
View File
@@ -5,74 +5,87 @@
"author": "",
"private": true,
"license": "UNLICENSED",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"postinstall": "patch-package",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"start:docker": "node dist/scripts/start-docker.js",
"migration:run": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d src/data-source.ts migration:run",
"migration:run:prod": "node dist/scripts/run-migrations.js",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:baseline": "jest --runInBand --json --outputFile=.baseline-jest-results.json",
"test:integration": "jest --config ./test/jest-integration.json --runInBand",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/axios": "^3.0.0",
"@nestjs/cache-manager": "^2.0.1",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.2",
"@nestjs/core": "^9.0.0",
"@nestjs/platform-fastify": "^9.4.2",
"@nestjs/schedule": "^3.0.1",
"@nestjs/typeorm": "^10.0.0",
"axios": "^1.4.0",
"big.js": "^6.2.1",
"bitcoin-address-validation": "^2.2.1",
"bitcoinjs-lib": "^6.1.3",
"bitcoinjs-message": "^2.2.0",
"@nestjs/axios": "^4.0.1",
"@nestjs/cache-manager": "^3.1.2",
"@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/platform-fastify": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/typeorm": "^11.0.1",
"@scure/btc-signer": "^2.2.0",
"axios": "^1.16.0",
"bitcoin-address-validation": "^2.2.3",
"bitcoinjs-lib": "^6.1.7",
"bs58": "^5.0.0",
"cache-manager": "^5.2.3",
"cache-manager": "^7.2.8",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"discord.js": "^14.11.0",
"class-validator": "^0.14.4",
"discord.js": "^14.26.4",
"dotenv": "^16.6.1",
"merkle-lib": "^2.0.10",
"node-telegram-bot-api": "^0.61.0",
"reflect-metadata": "^0.1.13",
"rpc-bitcoin": "^2.0.0",
"rxjs": "^7.2.0",
"sqlite3": "^5.1.6",
"tiny-secp256k1": "^2.2.3",
"typeorm": "^0.3.17",
"pg": "^8.20.0",
"pm2": "^7.0.1",
"redis": "^5.12.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"tiny-secp256k1": "^2.2.4",
"typeorm": "^0.3.28",
"zeromq": "6.0.0-beta.19"
},
"devDependencies": {
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
"@types/big.js": "^6.1.6",
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19",
"@types/cron": "^2.0.1",
"@types/express": "^4.17.13",
"@types/jest": "29.5.1",
"@types/node": "^18.16.12",
"@types/node-telegram-bot-api": "^0.61.6",
"@types/supertest": "^2.0.11",
"@types/express": "^4.17.25",
"@types/jest": "^29.5.14",
"@types/node": "^18.19.130",
"@types/supertest": "^7.2.0",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "29.5.0",
"prettier": "^2.3.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"jest": "^29.7.0",
"patch-package": "^8.0.1",
"prettier": "^3.8.3",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "29.1.0",
"ts-loader": "^9.2.3",
"ts-node": "^10.0.0",
"supertest": "^7.2.2",
"ts-jest": "^29.4.9",
"ts-loader": "^9.5.7",
"ts-node": "^10.9.2",
"tsconfig-paths": "4.2.0",
"typescript": "^5.0.0"
"typescript": "^5.9.3"
},
"overrides": {
"@fastify/middie": "^9.3.2",
"fastify": "^5.8.5",
"path-to-regexp": "^8.4.2"
},
"jest": {
"moduleFileExtensions": [
@@ -83,8 +96,18 @@
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
"^.+\\.(t|j)s$": [
"ts-jest",
{
"tsconfig": {
"allowJs": true
}
}
]
},
"transformIgnorePatterns": [
"node_modules/(?!(@scure|@noble|micro-packed))"
],
"collectCoverageFrom": [
"**/*.(t|j)s"
],
@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AcceptedShareIndex1780862400000 implements MigrationInterface {
public name = 'AcceptedShareIndex1780862400000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE SEQUENCE IF NOT EXISTS "accepted_share_index_seq"`);
await queryRunner.query(`ALTER TABLE "accepted_share_entity" ADD COLUMN IF NOT EXISTS "shareIndex" bigint`);
await queryRunner.query(`
SELECT setval(
'accepted_share_index_seq',
GREATEST(
(SELECT COUNT(*) FROM "accepted_share_entity") + 1,
(SELECT COALESCE(MAX("shareIndex"), 0) + 1 FROM "accepted_share_entity"),
1
),
false
)
`);
await queryRunner.query(`
ALTER TABLE "accepted_share_entity"
ALTER COLUMN "shareIndex" SET DEFAULT nextval('accepted_share_index_seq')
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_order"
ON "accepted_share_entity" ("shareIndex" DESC)
WHERE "shareIndex" IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_order"`);
await queryRunner.query(`
ALTER TABLE "accepted_share_entity"
ALTER COLUMN "shareIndex" DROP DEFAULT
`);
await queryRunner.query(`ALTER TABLE "accepted_share_entity" DROP COLUMN IF EXISTS "shareIndex"`);
await queryRunner.query(`DROP SEQUENCE IF EXISTS "accepted_share_index_seq"`);
}
}
@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AcceptedShareRollupIndexes1780865400000 implements MigrationInterface {
public name = 'AcceptedShareRollupIndexes1780865400000';
public transaction = false;
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_group_bucket"
ON "accepted_share_10m" ("address", "clientName", "bucket" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_10m_client_bucket"
ON "accepted_share_10m" ("clientId", "bucket" DESC)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ActiveOnlyUserAgentReport1780860200000 implements MigrationInterface {
name = 'ActiveOnlyUserAgentReport1780860200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "user_agent_report_view"`);
await queryRunner.query(`
CREATE MATERIALIZED VIEW "user_agent_report_view" AS
SELECT
"userAgent",
COUNT("userAgent") AS "count",
MAX("bestDifficulty") AS "bestDifficulty",
SUM("hashRate") AS "totalHashRate"
FROM "client_entity"
WHERE "deletedAt" IS NULL
GROUP BY "userAgent"
ORDER BY "totalHashRate" DESC
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "user_agent_report_view"`);
await queryRunner.query(`
CREATE MATERIALIZED VIEW "user_agent_report_view" AS
SELECT
"userAgent",
COUNT("userAgent") AS "count",
MAX("bestDifficulty") AS "bestDifficulty",
SUM("hashRate") AS "totalHashRate"
FROM "client_entity"
GROUP BY "userAgent"
ORDER BY "totalHashRate" DESC
`);
}
}
@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CurrentRoundBestShareIndex1780897600000 implements MigrationInterface {
public name = 'CurrentRoundBestShareIndex1780897600000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_address_settings_best_difficulty"`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_round_best"
ON "accepted_share_entity" ("submissionDifficulty" DESC, "acceptedAt" DESC, "blockHeight")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_round_best"`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_address_settings_best_difficulty"
ON "address_settings_entity" ("bestDifficulty" DESC)
`);
}
}
@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CurrentRoundWorkRollup1780899000000 implements MigrationInterface {
public name = 'CurrentRoundWorkRollup1780899000000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_block_10m"
WITH (timescaledb.continuous) AS
SELECT
time_bucket(INTERVAL '10 minutes', "acceptedAt") AS "bucket",
"blockHeight",
SUM("creditedDifficulty") AS "shares",
COUNT(*) AS "acceptedCount",
MAX("networkDifficulty") AS "networkDifficulty",
MAX("submissionDifficulty") AS "bestSubmissionDifficulty"
FROM "accepted_share_entity"
GROUP BY "bucket", "blockHeight"
WITH NO DATA
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_block_10m',
start_offset => INTERVAL '180 days',
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_block_10m_height_bucket"
ON "accepted_share_block_10m" ("blockHeight" DESC, "bucket" DESC)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_block_10m_height_bucket"`);
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "accepted_share_block_10m"`);
}
}
@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class FixClientSessionActiveIndex1780863600000 implements MigrationInterface {
public name = 'FixClientSessionActiveIndex1780863600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_unique_nonce"`);
await queryRunner.query(`
DELETE FROM "client_entity"
WHERE ctid IN (
SELECT ctid
FROM (
SELECT
ctid,
row_number() OVER (
PARTITION BY "sessionId"
ORDER BY "createdAt" DESC, "id" DESC
) AS rn
FROM "client_entity"
WHERE "deletedAt" IS NULL
) duplicates
WHERE rn > 1
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_unique_nonce"
ON "client_entity" ("sessionId")
WHERE "deletedAt" IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_unique_nonce"`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_unique_nonce"
ON "client_entity" ("sessionId")
WHERE "deletedAt" IS NOT NULL
`);
}
}
@@ -0,0 +1,215 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class InitialTimescaleSchema1780859300000 implements MigrationInterface {
public name = 'InitialTimescaleSchema1780859300000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS timescaledb`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "address_settings_entity" (
"address" varchar(62) PRIMARY KEY,
"shares" integer NOT NULL DEFAULT 0,
"bestDifficulty" numeric NOT NULL DEFAULT 0,
"bestDifficultyUserAgent" varchar,
"miscCoinbaseScriptData" varchar,
"deletedAt" timestamptz,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "client_entity" (
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
"address" varchar(62) NOT NULL,
"clientName" varchar(64) NOT NULL,
"sessionId" varchar(8) NOT NULL,
"userAgent" varchar(128),
"startTime" timestamptz NOT NULL,
"bestDifficulty" numeric NOT NULL DEFAULT 0,
"hashRate" numeric NOT NULL DEFAULT 0,
"deletedAt" timestamptz,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_client_entity_address" ON "client_entity" ("address")`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_client_cleanup" ON "client_entity" ("id") WHERE "deletedAt" IS NULL`);
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_unique_nonce" ON "client_entity" ("sessionId") WHERE "deletedAt" IS NULL`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "accepted_share_entity" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"acceptedAt" timestamptz NOT NULL,
"protocol" varchar(8) NOT NULL,
"address" varchar(62) NOT NULL,
"clientName" varchar NOT NULL,
"sessionId" varchar(8) NOT NULL,
"clientId" uuid NOT NULL,
"jobId" varchar NOT NULL,
"jobTemplateId" varchar NOT NULL,
"blockHeight" bigint NOT NULL,
"creditedDifficulty" numeric NOT NULL,
"submissionDifficulty" numeric NOT NULL,
"networkDifficulty" numeric NOT NULL,
"nonce" varchar NOT NULL,
"ntime" varchar NOT NULL,
"version" varchar NOT NULL,
"extraNonce2" varchar NOT NULL,
"isBlockCandidate" boolean NOT NULL DEFAULT false,
"blockSubmissionResult" text,
"createdAt" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id", "acceptedAt")
)
`);
await queryRunner.query(`
SELECT create_hypertable(
'"accepted_share_entity"',
'acceptedAt',
chunk_time_interval => INTERVAL '1 day',
if_not_exists => TRUE
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_accepted_share_accounting_lookup" ON "accepted_share_entity" ("address", "clientName", "acceptedAt" DESC)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_accepted_share_client_lookup" ON "accepted_share_entity" ("clientId", "acceptedAt" DESC)`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_accepted_share_unique_submission"
ON "accepted_share_entity" ("acceptedAt", "protocol", "sessionId", "jobId", "nonce", "ntime", "version", "extraNonce2")
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "blocks_entity" (
"id" bigserial PRIMARY KEY,
"height" integer NOT NULL,
"minerAddress" varchar(62) NOT NULL,
"worker" varchar NOT NULL,
"sessionId" varchar(8) NOT NULL,
"blockData" varchar NOT NULL,
"deletedAt" timestamptz,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "rpc_block_entity" (
"blockHeight" integer PRIMARY KEY,
"data" varchar
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "telegram_subscriptions_entity" (
"id" bigserial PRIMARY KEY,
"address" varchar(62) NOT NULL,
"telegramChatId" integer NOT NULL,
"deletedAt" timestamptz,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_telegram_subscriptions_address" ON "telegram_subscriptions_entity" ("address")`);
await queryRunner.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS "user_agent_report_view" AS
SELECT
"userAgent",
COUNT("userAgent") AS "count",
MAX("bestDifficulty") AS "bestDifficulty",
SUM("hashRate") AS "totalHashRate"
FROM "client_entity"
WHERE "deletedAt" IS NULL
GROUP BY "userAgent"
ORDER BY "totalHashRate" DESC
`);
await queryRunner.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_10m"
WITH (timescaledb.continuous) AS
SELECT
time_bucket(INTERVAL '10 minutes', "acceptedAt") AS "bucket",
"address",
"clientName",
"sessionId",
"clientId",
SUM("creditedDifficulty") AS "shares",
COUNT(*) AS "acceptedCount",
ROUND(((SUM("creditedDifficulty") * 4294967296) / 600)) AS "hashRate"
FROM "accepted_share_entity"
GROUP BY "bucket", "address", "clientName", "sessionId", "clientId"
WITH NO DATA
`);
await queryRunner.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_1h"
WITH (timescaledb.continuous) AS
SELECT
time_bucket(INTERVAL '1 hour', "acceptedAt") AS "bucket",
"address",
"clientName",
SUM("creditedDifficulty") AS "shares",
COUNT(*) AS "acceptedCount"
FROM "accepted_share_entity"
GROUP BY "bucket", "address", "clientName"
WITH NO DATA
`);
await queryRunner.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_1d"
WITH (timescaledb.continuous) AS
SELECT
time_bucket(INTERVAL '1 day', "acceptedAt") AS "bucket",
"address",
SUM("creditedDifficulty") AS "shares",
COUNT(*) AS "acceptedCount"
FROM "accepted_share_entity"
GROUP BY "bucket", "address"
WITH NO DATA
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_10m',
start_offset => INTERVAL '2 days',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => TRUE
)
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_1h',
start_offset => INTERVAL '30 days',
end_offset => INTERVAL '10 minutes',
schedule_interval => INTERVAL '10 minutes',
if_not_exists => TRUE
)
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_1d',
start_offset => INTERVAL '365 days',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour',
if_not_exists => TRUE
)
`);
await queryRunner.query(`SELECT add_retention_policy('accepted_share_entity', INTERVAL '90 days', if_not_exists => TRUE)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "accepted_share_1d"`);
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "accepted_share_1h"`);
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "accepted_share_10m"`);
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "user_agent_report_view"`);
await queryRunner.query(`DROP TABLE IF EXISTS "accepted_share_entity" CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS "telegram_subscriptions_entity"`);
await queryRunner.query(`DROP TABLE IF EXISTS "rpc_block_entity"`);
await queryRunner.query(`DROP TABLE IF EXISTS "blocks_entity"`);
await queryRunner.query(`DROP TABLE IF EXISTS "client_entity"`);
await queryRunner.query(`DROP TABLE IF EXISTS "address_settings_entity"`);
}
}
@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PoolAccountingDashboardIndexes1780867200000 implements MigrationInterface {
public name = 'PoolAccountingDashboardIndexes1780867200000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_accepted_at"
ON "accepted_share_entity" ("acceptedAt" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_round_best"
ON "accepted_share_entity" ("submissionDifficulty" DESC, "acceptedAt" DESC, "blockHeight")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_round_best"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_accepted_at"`);
}
}
@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveAcceptedShareRetention1780959000000 implements MigrationInterface {
public name = 'RemoveAcceptedShareRetention1780959000000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`SELECT remove_retention_policy('accepted_share_entity', if_exists => TRUE)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`SELECT add_retention_policy('accepted_share_entity', INTERVAL '30 days', if_not_exists => TRUE)`);
}
}
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class TimescaleOperationalHardening1780861200000 implements MigrationInterface {
public name = 'TimescaleOperationalHardening1780861200000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "accepted_share_entity" SET (
timescaledb.compress,
timescaledb.compress_orderby = '"acceptedAt" DESC',
timescaledb.compress_segmentby = '"address","clientName"'
)
`);
await queryRunner.query(`
SELECT add_compression_policy(
'accepted_share_entity',
INTERVAL '1 day',
if_not_exists => TRUE
)
`);
await queryRunner.query(`SELECT remove_retention_policy('accepted_share_entity', if_exists => TRUE)`);
await queryRunner.query(`SELECT add_retention_policy('accepted_share_entity', INTERVAL '30 days', if_not_exists => TRUE)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`SELECT remove_compression_policy('accepted_share_entity', if_exists => TRUE)`);
await queryRunner.query(`SELECT remove_retention_policy('accepted_share_entity', if_exists => TRUE)`);
await queryRunner.query(`SELECT add_retention_policy('accepted_share_entity', INTERVAL '90 days', if_not_exists => TRUE)`);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UniqueNonceIndex implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_unique_nonce" ON "client_entity" ("sessionId") WHERE "deletedAt" IS NULL`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_unique_nonce"`);
}
}
@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserAgentReportService } from './user-agent-report.service';
import { UserAgentReportView } from './user-agent-report.view';
import { ClientEntity } from '../../client/client.entity';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([UserAgentReportView, ClientEntity])],
providers: [UserAgentReportService],
exports: [TypeOrmModule, UserAgentReportService],
})
export class UserAgentReportModule { }
@@ -0,0 +1,111 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ClientEntity } from '../../client/client.entity';
import { UserAgentReportView } from './user-agent-report.view';
import { RedisMessagingService } from '../../../services/redis-messaging.service';
@Injectable()
export class UserAgentReportService {
private readonly liveReportCacheKey = 'presence:user-agent-report';
private liveRefreshPromise: Promise<UserAgentReportView[]> | null = null;
constructor(
@InjectRepository(UserAgentReportView)
private userAgentReport: Repository<UserAgentReportView>,
@InjectRepository(ClientEntity)
private clientRepository: Repository<ClientEntity>,
private redisMessagingService: RedisMessagingService,
) {
}
public async getReport() {
const cachedReport = await this.redisMessagingService
.getJsonCache<UserAgentReportView[]>(this.liveReportCacheKey)
.catch(error => {
console.error(`Live user-agent report cache read failed: ${error.message}`);
return null;
});
if (cachedReport != null) {
return cachedReport;
}
if (process.env.API_ONLY == 'true') {
return await this.userAgentReport.find();
}
return await this.refreshLiveReport();
}
public async refreshLiveReport() {
if (this.liveRefreshPromise != null) {
return this.liveRefreshPromise;
}
this.liveRefreshPromise = this.buildLiveReport()
.finally(() => {
this.liveRefreshPromise = null;
});
return this.liveRefreshPromise;
}
private async buildLiveReport() {
const presences = await this.redisMessagingService.getAllClientPresence();
const rows = new Map<string, {
userAgent: string;
count: number;
bestDifficulty: number;
totalHashRate: number;
}>();
presences.forEach(presence => {
const userAgent = presence.userAgent == null || presence.userAgent.length === 0
? 'Other'
: presence.userAgent;
const row = rows.get(userAgent) ?? {
userAgent,
count: 0,
bestDifficulty: 0,
totalHashRate: 0,
};
row.count++;
row.bestDifficulty = Math.max(
row.bestDifficulty,
Number(presence.bestDifficulty ?? 0),
);
row.totalHashRate += Number(presence.hashRate ?? 0);
rows.set(userAgent, row);
});
const report = [...rows.values()]
.sort((left, right) => right.totalHashRate - left.totalHashRate)
.map(row => ({
userAgent: row.userAgent,
count: row.count.toString(),
bestDifficulty: row.bestDifficulty,
totalHashRate: row.totalHashRate.toString(),
}));
await this.redisMessagingService
.setJsonCache(this.liveReportCacheKey, report, 60 * 1000)
.catch(error => {
console.error(`Live user-agent report cache write failed: ${error.message}`);
});
return report;
}
public async refreshReport() {
try {
await this.userAgentReport.query(`REFRESH MATERIALIZED VIEW user_agent_report_view`);
return await this.refreshLiveReport();
} catch (e) {
console.log(e)
}
}
}
@@ -0,0 +1,28 @@
import { DataSource, ViewColumn, ViewEntity } from 'typeorm';
import { ClientEntity } from '../../client/client.entity';
@ViewEntity({
materialized: true,
expression: (dataSource: DataSource) =>
dataSource
.createQueryBuilder()
.select('client.userAgent as "userAgent"')
.addSelect('COUNT(client.userAgent)', 'count')
.addSelect('MAX(client.bestDifficulty)', 'bestDifficulty')
.addSelect('SUM(client.hashRate)', 'totalHashRate')
.from(ClientEntity, 'client')
.where('client.deletedAt IS NULL')
.groupBy('client.userAgent')
.orderBy('"totalHashRate"', 'DESC')
})
export class UserAgentReportView {
@ViewColumn()
userAgent: string;
@ViewColumn()
count: string;
@ViewColumn()
bestDifficulty: number;
@ViewColumn()
totalHashRate: string;
}
@@ -0,0 +1,71 @@
import { Column, Entity, Index, PrimaryColumn, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
@Index('IDX_accepted_share_accounting_lookup', ['address', 'clientName', 'acceptedAt'])
@Index('IDX_accepted_share_client_lookup', ['clientId', 'acceptedAt'])
@Index('IDX_accepted_share_order', ['shareIndex'])
@Index('IDX_accepted_share_unique_submission', ['acceptedAt', 'protocol', 'sessionId', 'jobId', 'nonce', 'ntime', 'version', 'extraNonce2'], { unique: true })
export class AcceptedShareEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@PrimaryColumn({ type: 'timestamptz' })
acceptedAt: Date;
@Column({ type: 'bigint', default: () => `nextval('accepted_share_index_seq')` })
shareIndex: number;
@Column({ length: 8, type: 'varchar' })
protocol: 'sv1' | 'sv2';
@Column({ length: 62, type: 'varchar' })
address: string;
@Column()
clientName: string;
@Column({ length: 8, type: 'varchar' })
sessionId: string;
@Column({ type: 'uuid' })
clientId: string;
@Column()
jobId: string;
@Column()
jobTemplateId: string;
@Column({ type: 'bigint' })
blockHeight: number;
@Column({ type: 'decimal' })
creditedDifficulty: number;
@Column({ type: 'decimal' })
submissionDifficulty: number;
@Column({ type: 'decimal' })
networkDifficulty: number;
@Column()
nonce: string;
@Column()
ntime: string;
@Column()
version: string;
@Column()
extraNonce2: string;
@Column({ default: false })
isBlockCandidate: boolean;
@Column({ type: 'text', nullable: true })
blockSubmissionResult?: string;
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
}
@@ -11,9 +11,12 @@ export class AddressSettingsEntity extends TrackedEntity {
@Column({ default: 0 })
shares: number;
@Column({ type: 'real', default: 0 })
@Column({ type: 'decimal', default: 0 })
bestDifficulty: number;
@Column({ nullable: true })
bestDifficultyUserAgent: string;
@Column({ nullable: true })
miscCoinbaseScriptData: string;
@@ -17,33 +17,68 @@ export class AddressSettingsService {
public async getSettings(address: string, createIfNotFound: boolean) {
const settings = await this.addressSettingsRepository.findOne({ where: { address } });
if (createIfNotFound == true && settings == null) {
return await this.createNew(address);
// It's possible to have a race condition here so if we get a PK violation, fetch it
try {
return await this.createNew(address);
} catch (e) {
return await this.addressSettingsRepository.findOne({ where: { address } });
}
}
return settings;
}
public async updateBestDifficulty(address: string, bestDifficulty: number) {
return await this.addressSettingsRepository.update({ address }, { bestDifficulty });
public async updateBestDifficulty(address: string, bestDifficulty: number, bestDifficultyUserAgent: string) {
return await this.addressSettingsRepository.update({ address }, { bestDifficulty, bestDifficultyUserAgent });
}
public async updateBestDifficultyIfHigher(address: string, bestDifficulty: number, bestDifficultyUserAgent: string) {
await this.addressSettingsRepository
.createQueryBuilder()
.insert()
.into(AddressSettingsEntity)
.values({ address })
.orIgnore()
.execute();
return await this.addressSettingsRepository
.createQueryBuilder()
.update(AddressSettingsEntity)
.set({ bestDifficulty, bestDifficultyUserAgent })
.where('address = :address', { address })
.andWhere('"bestDifficulty" < :bestDifficulty', { bestDifficulty })
.execute();
}
public async createNew(address: string) {
return await this.addressSettingsRepository.save({ address });
}
public async addShares(address: string, shares: number) {
return await this.addressSettingsRepository.createQueryBuilder()
// public async addShares(address: string, shares: number) {
// return await this.addressSettingsRepository.createQueryBuilder()
// .update(AddressSettingsEntity)
// .set({
// shares: () => `"shares" + ${shares}` // Use the actual value of shares here
// })
// .where('address = :address', { address })
// .execute();
// }
public async resetBestDifficultyAndShares() {
return await this.addressSettingsRepository
.createQueryBuilder()
.update(AddressSettingsEntity)
.set({
shares: () => `"shares" + ${shares}` // Use the actual value of shares here
shares: 0,
bestDifficulty: 0
})
.where('address = :address', { address })
.execute();
}
public async resetBestDifficultyAndShares() {
return await this.addressSettingsRepository.update({}, {
shares: 0,
bestDifficulty: 0
});
public async getHighScores() {
return await this.addressSettingsRepository.createQueryBuilder()
.select('"updatedAt", "bestDifficulty", "bestDifficultyUserAgent"')
.orderBy('"bestDifficulty"', 'DESC')
.limit(10)
.execute();
}
}
+3 -2
View File
@@ -1,11 +1,12 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { Column, Entity } from 'typeorm';
import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn';
import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Entity()
export class BlocksEntity extends TrackedEntity {
@PrimaryGeneratedColumn()
@PrimaryGeneratedBigIntColumn()
id: number;
@Column()
@@ -1,35 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Entity()
//Index for getHashRateForSession
@Index(["address", "clientName", "sessionId"])
//Index for statistics save
@Index(["address", "clientName", "sessionId", "time"])
export class ClientStatisticsEntity extends TrackedEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 62, type: 'varchar' })
address: string;
@Column()
clientName: string;
@Column({ length: 8, type: 'varchar' })
sessionId: string;
@Index()
@Column({ type: 'integer' })
time: number;
@Column({ type: 'real' })
shares: number;
@Column({ default: 0, type: 'integer' })
acceptedCount: number;
}
@@ -1,14 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClientStatisticsEntity } from './client-statistics.entity';
import { ClientStatisticsService } from './client-statistics.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([ClientStatisticsEntity])],
providers: [ClientStatisticsService],
exports: [TypeOrmModule, ClientStatisticsService],
exports: [ClientStatisticsService],
})
export class ClientStatisticsModule { }
@@ -1,260 +1,104 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ClientStatisticsEntity } from './client-statistics.entity';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
const HASHES_PER_DIFFICULTY = 4294967296;
const CHART_BUCKET_SECONDS = 600;
const CHART_WINDOW = '24 hours';
const SITE_CHART_WINDOW = '7 days';
@Injectable()
export class ClientStatisticsService {
constructor(
@InjectRepository(ClientStatisticsEntity)
private clientStatisticsRepository: Repository<ClientStatisticsEntity>,
@InjectDataSource()
private dataSource: DataSource,
) {
}
public async save(clientStatistic: Partial<ClientStatisticsEntity>) {
// Attempt to update the existing record
const updateResult = await this.clientStatisticsRepository
.createQueryBuilder()
.update(ClientStatisticsEntity)
.set({
shares: () => `"shares" + :sharesIncrement`,
acceptedCount: () => `"acceptedCount" + 1`
})
.where('address = :address AND clientName = :clientName AND sessionId = :sessionId AND time = :time', {
address: clientStatistic.address,
clientName: clientStatistic.clientName,
sessionId: clientStatistic.sessionId,
time: clientStatistic.time,
sharesIncrement: clientStatistic.shares
})
.execute();
// Check if the update affected any rows
if (updateResult.affected === 0) {
// If no rows were updated, insert a new record
await this.clientStatisticsRepository.insert(clientStatistic);
}
public async getChartDataForSite(limit: number = 144 * 7) {
return this.getAcceptedShareChartData('', [], limit, SITE_CHART_WINDOW);
}
public async deleteOldStatistics() {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
return await this.clientStatisticsRepository
.createQueryBuilder()
.delete()
.from(ClientStatisticsEntity)
.where('time < :time', { time: oneDayAgo.getTime() })
.execute();
}
public async getChartDataForSite() {
var yesterday = new Date(new Date().getTime() - (24 * 60 * 60 * 1000));
const query = `
SELECT
time AS label,
ROUND(((SUM(shares) * 4294967296) / 600)) AS data
FROM
client_statistics_entity AS entry
WHERE
entry.time > ${yesterday.getTime()}
GROUP BY
time
ORDER BY
time
LIMIT 144;
`;
const result: any[] = await this.clientStatisticsRepository.query(query);
return result.map(res => {
res.label = new Date(res.label).toISOString();
return res;
}).slice(0, result.length - 1)
}
// public async getHashRateForAddress(address: string) {
// const oneHour = new Date(new Date().getTime() - (60 * 60 * 1000));
// const query = `
// SELECT
// SUM(entry.shares) AS difficultySum
// FROM
// client_statistics_entity AS entry
// WHERE
// entry.address = ? AND entry.time > ${oneHour}
// `;
// const result = await this.clientStatisticsRepository.query(query, [address]);
// const difficultySum = result[0].difficultySum;
// return (difficultySum * 4294967296) / (600);
// }
public async getChartDataForAddress(address: string) {
var yesterday = new Date(new Date().getTime() - (24 * 60 * 60 * 1000));
const query = `
SELECT
time label,
(SUM(shares) * 4294967296) / 600 AS data
FROM
client_statistics_entity AS entry
WHERE
entry.address = ? AND entry.time > ${yesterday.getTime()}
GROUP BY
time
ORDER BY
time
LIMIT 144;
`;
const result = await this.clientStatisticsRepository.query(query, [address]);
return result.map(res => {
res.label = new Date(res.label).toISOString();
return res;
}).slice(0, result.length - 1);
return this.getAcceptedShareChartData(
'AND "address" = $1',
[address],
144,
CHART_WINDOW,
);
}
public async getHashRateForGroup(address: string, clientName: string) {
var oneHour = new Date(new Date().getTime() - (60 * 60 * 1000));
const query = `
const result = await this.dataSource.query(`
SELECT
SUM(entry.shares) AS difficultySum
FROM
client_statistics_entity AS entry
WHERE
entry.address = ? AND entry.clientName = ? AND entry.time > ${oneHour.getTime()}
`;
const result = await this.clientStatisticsRepository.query(query, [address, clientName]);
const difficultySum = result[0].difficultySum;
return (difficultySum * 4294967296) / (600);
COALESCE((SUM("creditedDifficulty") * ${HASHES_PER_DIFFICULTY}) / ${CHART_BUCKET_SECONDS}, 0) AS "hashRate"
FROM "accepted_share_entity"
WHERE "address" = $1
AND "clientName" = $2
AND "acceptedAt" > NOW() - INTERVAL '1 hour'
`, [address, clientName]);
return parseFloat(result[0]?.hashRate ?? '0');
}
public async getChartDataForGroup(address: string, clientName: string) {
var yesterday = new Date(new Date().getTime() - (24 * 60 * 60 * 1000));
return this.getAcceptedShareChartData(
'AND "address" = $1 AND "clientName" = $2',
[address, clientName],
144,
CHART_WINDOW,
);
}
public async getChartDataForSession(clientId: string) {
return this.getAcceptedShareChartData(
'AND "clientId" = $1',
[clientId],
144,
CHART_WINDOW,
);
}
private async getAcceptedShareChartData(filterSql: string, params: unknown[], limit: number, windowSql: string) {
const query = `
WITH bounds AS (
SELECT
NOW() - INTERVAL '${windowSql}' AS since,
time_bucket(INTERVAL '10 minutes', NOW()) AS current_bucket
)
SELECT
time label,
(SUM(shares) * 4294967296) / 600 AS data
FROM
client_statistics_entity AS entry
WHERE
entry.address = ? AND entry.clientName = ? AND entry.time > ${yesterday.getTime()}
GROUP BY
time
ORDER BY
time
LIMIT 144;
"label",
"data",
"shares",
"acceptedCount"
FROM (
SELECT
"bucket" AS "label",
ROUND((SUM("shares") * ${HASHES_PER_DIFFICULTY}) / ${CHART_BUCKET_SECONDS}) AS "data",
SUM("shares") AS "shares",
SUM("acceptedCount") AS "acceptedCount"
FROM "accepted_share_10m", bounds
WHERE "bucket" > bounds.since
AND "bucket" < bounds.current_bucket
${filterSql}
GROUP BY "bucket"
ORDER BY "bucket" DESC
LIMIT ${limit}
) AS limited_rows
ORDER BY "label"
`;
const result = await this.clientStatisticsRepository.query(query, [address, clientName]);
const result = await this.dataSource.query(query, params);
return result.map(res => {
res.label = new Date(res.label).toISOString();
return res;
}).slice(0, result.length - 1);
}
public async getHashRateForSession(address: string, clientName: string, sessionId: string) {
const query = `
SELECT
createdAt,
updatedAt,
shares
FROM
client_statistics_entity AS entry
WHERE
entry.address = ? AND entry.clientName = ? AND entry.sessionId = ?
ORDER BY time DESC
LIMIT 2;
`;
const result = await this.clientStatisticsRepository.query(query, [address, clientName, sessionId]);
if (result.length < 1) {
return 0;
}
const latestStat = result[0];
if (result.length < 2) {
const time = new Date(latestStat.updatedAt).getTime() - new Date(latestStat.createdAt).getTime();
if (time < 1) {
return 0;
}
return (latestStat.shares * 4294967296) / (time / 1000);
} else {
const secondLatestStat = result[1];
const time = new Date(latestStat.updatedAt).getTime() - new Date(secondLatestStat.createdAt).getTime();
if (time < 1) {
return 0;
}
return ((latestStat.shares + secondLatestStat.shares) * 4294967296) / (time / 1000);
}
}
public async getChartDataForSession(address: string, clientName: string, sessionId: string) {
var yesterday = new Date(new Date().getTime() - (24 * 60 * 60 * 1000));
const query = `
SELECT
time label,
(SUM(shares) * 4294967296) / 600 AS data
FROM
client_statistics_entity AS entry
WHERE
entry.address = ? AND entry.clientName = ? AND entry.sessionId = ? AND entry.time > ${yesterday.getTime()}
GROUP BY
time
ORDER BY
time
LIMIT 144;
`;
const result = await this.clientStatisticsRepository.query(query, [address, clientName, sessionId]);
return result.map(res => {
res.label = new Date(res.label).toISOString();
return res;
}).slice(0, result.length - 1);
}
public async deleteAll() {
return await this.clientStatisticsRepository.delete({})
return {
label: new Date(res.label).toISOString(),
data: res.data,
shares: Number(res.shares ?? 0),
acceptedCount: Number(res.acceptedCount ?? 0),
};
});
}
}
+16 -16
View File
@@ -1,25 +1,27 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { DateTimeTransformer } from '../utils/DateTimeTransformer';
import { TrackedEntity } from '../utils/TrackedEntity.entity';
//https://www.sqlite.org/withoutrowid.html
//The WITHOUT ROWID optimization is likely to be helpful for tables that have non-integer
// or composite (multi-column) PRIMARY KEYs and that do not store large strings or BLOBs.
//WITHOUT ROWID tables work best when individual rows are not too large.
@Entity({ withoutRowid: true })
@Index(['address', 'clientName', 'sessionId'], { unique: true })
@Entity()
@Index("IDX_unique_nonce", { synchronize: false })
@Index('idx_client_cleanup', ['id'], {
where: '"deletedAt" IS NULL',
// This is a partial (filtered) index — only indexes active clients
})
export class ClientEntity extends TrackedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@PrimaryColumn({ length: 62, type: 'varchar' })
@Index()
@Column({ length: 62, type: 'varchar' })
address: string;
@PrimaryColumn({ length: 64, type: 'varchar' })
@Column({ length: 64, type: 'varchar' })
clientName: string;
@PrimaryColumn({ length: 8, type: 'varchar' })
@Column({ length: 8, type: 'varchar', })
sessionId: string;
@@ -27,15 +29,13 @@ export class ClientEntity extends TrackedEntity {
userAgent: string;
@Column({ type: 'datetime', transformer: new DateTimeTransformer() })
@Column()
startTime: Date;
@Column({ type: 'real', default: 0 })
@Column({ type: 'decimal', default: 0 })
bestDifficulty: number
@Column({ default: 0 })
@Column({ default: 0, type: 'decimal' })
hashRate: number;
}
+35 -65
View File
@@ -1,8 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { ObjectLiteral, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { ClientEntity } from './client.entity';
@@ -11,10 +9,6 @@ import { ClientEntity } from './client.entity';
@Injectable()
export class ClientService {
public insertQueue: { result: BehaviorSubject<ObjectLiteral | null>, partialClient: Partial<ClientEntity> }[] = [];
constructor(
@InjectRepository(ClientEntity)
private clientRepository: Repository<ClientEntity>
@@ -22,59 +16,19 @@ export class ClientService {
}
@Interval(1000 * 5)
public async insertClients() {
const queueCopy = [...this.insertQueue];
this.insertQueue = [];
const results = await this.clientRepository.insert(queueCopy.map(c => c.partialClient));
queueCopy.forEach((c, index) => {
c.result.next(results.generatedMaps[index]);
});
}
public async killDeadClients() {
var fiveMinutes = new Date(new Date().getTime() - (5 * 60 * 1000)).toISOString();
return await this.clientRepository
.createQueryBuilder()
.update(ClientEntity)
.set({ deletedAt: () => "DATETIME('now')" })
.where("deletedAt IS NULL AND updatedAt < DATETIME(:fiveMinutes)", { fiveMinutes })
.execute();
}
public async heartbeat(address: string, clientName: string, sessionId: string, hashRate: number, updatedAt: Date) {
return await this.clientRepository.update({ address, clientName, sessionId }, { hashRate, deletedAt: null, updatedAt });
}
// public async save(client: Partial<ClientEntity>) {
// return await this.clientRepository.save(client);
// }
public async insert(partialClient: Partial<ClientEntity>): Promise<ClientEntity> {
const result = new BehaviorSubject(null);
this.insertQueue.push({ result, partialClient });
// const insertResult = await this.clientRepository.insert(partialClient);
const generatedMap = await firstValueFrom(result);
const insertResult = await this.clientRepository.insert(partialClient);
const client = {
...partialClient,
...generatedMap
...insertResult.generatedMaps[0]
};
return client as ClientEntity;
}
public async delete(sessionId: string) {
return await this.clientRepository.softDelete({ sessionId });
public async delete(id: string) {
return await this.clientRepository.softDelete({ id });
}
public async deleteOldClients() {
@@ -90,9 +44,20 @@ export class ClientService {
}
public async updateBestDifficulty(sessionId: string, bestDifficulty: number) {
return await this.clientRepository.update({ sessionId }, { bestDifficulty });
public async updateBestDifficulty(id: string, bestDifficulty: number) {
return await this.clientRepository.update({ id }, { bestDifficulty });
}
public async updateBestDifficultyIfHigher(id: string, bestDifficulty: number) {
return await this.clientRepository
.createQueryBuilder()
.update(ClientEntity)
.set({ bestDifficulty })
.where('id = :id', { id })
.andWhere('"bestDifficulty" < :bestDifficulty', { bestDifficulty })
.execute();
}
public async connectedClientCount(): Promise<number> {
return await this.clientRepository.count();
}
@@ -126,19 +91,24 @@ export class ClientService {
}
public async deleteAll() {
return await this.clientRepository.softDelete({})
return await this.clientRepository
.createQueryBuilder()
.delete()
.from(ClientEntity)
.where('"deletedAt" IS NULL')
.execute();
}
public async getUserAgents() {
const result = await this.clientRepository.createQueryBuilder('client')
.select('client.userAgent as userAgent')
.addSelect('COUNT(client.userAgent)', 'count')
.addSelect('MAX(client.bestDifficulty)', 'bestDifficulty')
.addSelect('SUM(client.hashRate)', 'totalHashRate')
.groupBy('client.userAgent')
.orderBy('count', 'DESC')
.getRawMany();
return result;
}
// public async getUserAgents() {
// const result = await this.clientRepository.createQueryBuilder('client')
// .select('client.userAgent as "userAgent"')
// .addSelect('COUNT(client.userAgent)', 'count')
// .addSelect('MAX(client.bestDifficulty)', 'bestDifficulty')
// .addSelect('SUM(client.hashRate)', 'totalHashRate')
// .groupBy('client.userAgent')
// .orderBy('"totalHashRate"', 'DESC')
// .getRawMany();
// return result;
// }
}
-3
View File
@@ -6,9 +6,6 @@ export class RpcBlockEntity {
@PrimaryColumn()
blockHeight: number;
@Column({ nullable: true })
lockedBy?: string;
@Column({ nullable: true })
data?: string;
}
+17 -6
View File
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InsertResult, Repository } from 'typeorm';
import { RpcBlockEntity } from './rpc-block.entity';
@@ -12,17 +12,28 @@ export class RpcBlockService {
) {
}
public getBlock(blockHeight: number) {
public getSavedBlockTemplate(blockHeight: number) {
return this.rpcBlockRepository.findOne({
where: { blockHeight }
});
}
public lockBlock(blockHeight: number, process: string) {
return this.rpcBlockRepository.save({ blockHeight, data: null, lockedBy: process });
public saveBlock(blockHeight: number, data: string): Promise<InsertResult> {
return this.rpcBlockRepository.upsert({ blockHeight, data }, ['blockHeight']);
}
public saveBlock(blockHeight: number, data: string) {
return this.rpcBlockRepository.update(blockHeight, { data })
public async deleteOldBlocks() {
const result = await this.rpcBlockRepository.createQueryBuilder('entity')
.select('MAX(entity.blockHeight)', 'maxNumber')
.getRawOne();
const newestBlock = result ? result.maxNumber : null;
await this.rpcBlockRepository.createQueryBuilder()
.delete()
.where('"blockHeight" < :newestBlock', { newestBlock })
.execute();
return;
}
}
@@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AcceptedShareEntity } from '../accepted-share/accepted-share.entity';
import { ShareAccountingService } from './share-accounting.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([AcceptedShareEntity])],
providers: [ShareAccountingService],
exports: [TypeOrmModule, ShareAccountingService],
})
export class ShareAccountingModule { }
@@ -0,0 +1,351 @@
import { ShareAccountingService } from './share-accounting.service';
describe('ShareAccountingService', () => {
const originalEnv = process.env;
beforeEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
process.env = { ...originalEnv };
});
afterAll(() => {
process.env = originalEnv;
});
it('should normalize and save accepted share records', async () => {
const repository = {
create: jest.fn(value => value),
insert: jest.fn().mockResolvedValue({}),
};
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '1';
const service = new ShareAccountingService(repository as any);
const acceptedAt = new Date('2026-06-07T12:00:00Z');
await expect(service.recordAcceptedShare({
protocol: 'sv1',
acceptedAt,
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'worker',
sessionId: '57a6f098',
clientId: '6df7beea-921a-46cb-b7dc-e8075f916232',
jobId: '1',
jobTemplateId: '2',
blockHeight: 900000,
creditedDifficulty: 1024,
submissionDifficulty: 2048,
networkDifficulty: 100000,
nonce: 123,
ntime: 456,
version: 536870912,
extraNonce2: 'c708000000000000',
isBlockCandidate: false,
blockSubmissionResult: null,
})).resolves.toEqual(expect.objectContaining({
protocol: 'sv1',
creditedDifficulty: 1024,
}));
expect(repository.create).toHaveBeenCalledWith(expect.objectContaining({
acceptedAt,
nonce: '123',
ntime: '456',
version: '536870912',
blockSubmissionResult: null,
}));
expect(repository.insert).toHaveBeenCalledWith([expect.objectContaining({
protocol: 'sv1',
creditedDifficulty: 1024,
})]);
});
it('should batch accepted share inserts until the batch size is reached', async () => {
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '2';
process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '1000';
const repository = {
create: jest.fn(value => value),
insert: jest.fn().mockResolvedValue({}),
};
const service = new ShareAccountingService(repository as any);
const first = service.recordAcceptedShare(buildRecord('1'));
expect(repository.insert).not.toHaveBeenCalled();
const second = service.recordAcceptedShare(buildRecord('2'));
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
expect(repository.insert).toHaveBeenCalledTimes(1);
expect(repository.insert).toHaveBeenCalledWith([
expect.objectContaining({ jobId: '1' }),
expect.objectContaining({ jobId: '2' }),
]);
});
it('should flush partial batches on the flush interval', async () => {
jest.useFakeTimers();
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '10';
process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '25';
const repository = {
create: jest.fn(value => value),
insert: jest.fn().mockResolvedValue({}),
};
const service = new ShareAccountingService(repository as any);
const pending = service.recordAcceptedShare(buildRecord('interval'));
expect(repository.insert).not.toHaveBeenCalled();
jest.advanceTimersByTime(25);
await expect(pending).resolves.toEqual(expect.objectContaining({ jobId: 'interval' }));
expect(repository.insert).toHaveBeenCalledTimes(1);
});
it('should reject accepted shares when the accounting queue is full', async () => {
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '10';
process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '1000';
process.env.SHARE_ACCOUNTING_MAX_QUEUE_SIZE = '1';
const repository = {
create: jest.fn(value => value),
insert: jest.fn().mockResolvedValue({}),
};
const service = new ShareAccountingService(repository as any);
const queued = service.recordAcceptedShare(buildRecord('queued'));
await expect(service.recordAcceptedShare(buildRecord('overflow')))
.rejects
.toThrow('Share accounting queue is full');
await service.flushPendingShares();
await expect(queued).resolves.toEqual(expect.objectContaining({ jobId: 'queued' }));
});
it('should return numeric accounting summaries from the share rollup', async () => {
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '3',
totalCreditedDifficulty: '96',
acceptedSharesLast10Minutes: '2',
creditedDifficultyLast10Minutes: '64',
acceptedSharesLastHour: '3',
creditedDifficultyLastHour: '96',
acceptedSharesLastDay: '3',
creditedDifficultyLastDay: '96',
hashRateLast10Minutes: '458129844.9',
hashRateLastHour: '114532461.2',
latestShareAt: new Date('2026-06-07T12:10:00Z'),
}]),
};
const service = new ShareAccountingService(repository as any);
await expect(service.getAddressSummary('bc1qtest')).resolves.toEqual({
totalAcceptedShares: 3,
totalCreditedDifficulty: 96,
acceptedSharesLast10Minutes: 2,
creditedDifficultyLast10Minutes: 64,
acceptedSharesLastHour: 3,
creditedDifficultyLastHour: 96,
acceptedSharesLastDay: 3,
creditedDifficultyLastDay: 96,
hashRateLast10Minutes: 458129844.9,
hashRateLastHour: 114532461.2,
bestSubmissionDifficulty: 0,
bestSubmissionDifficultyAt: null,
workSinceLastBlock: 0,
currentRoundAcceptedShares: 0,
currentRoundNetworkDifficulty: 0,
networkDifficultyPercent: 0,
blockCandidateCount: 0,
latestShareAt: '2026-06-07T12:10:00.000Z',
protocolBreakdown: [],
});
expect(repository.query).toHaveBeenNthCalledWith(
1,
expect.stringContaining('"accepted_share_10m"'),
['bc1qtest'],
);
expect(repository.query).toHaveBeenCalledTimes(1);
});
it('should serve API-only address accounting from the share rollup instead of empty summaries', async () => {
process.env.API_ONLY = 'true';
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'),
}]),
};
const service = new ShareAccountingService(repository as any);
await expect(service.getAddressSummary('bc1qapi')).resolves.toEqual(expect.objectContaining({
totalAcceptedShares: 12,
totalCreditedDifficulty: 384,
acceptedSharesLast10Minutes: 4,
creditedDifficultyLast10Minutes: 128,
hashRateLast10Minutes: 916259689.8,
latestShareAt: '2026-06-07T12:30:00.000Z',
}));
expect(repository.query).toHaveBeenCalledWith(
expect.stringContaining('FROM "accepted_share_10m"'),
['bc1qapi'],
);
expect(repository.query).not.toHaveBeenCalledWith(
expect.stringContaining('FROM "accepted_share_entity"'),
expect.anything(),
);
});
it('should use Redis cache for share accounting summaries across API workers', async () => {
process.env.SHARE_ACCOUNTING_SUMMARY_CACHE_MS = '0';
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '1',
totalCreditedDifficulty: '32',
acceptedSharesLast10Minutes: '1',
creditedDifficultyLast10Minutes: '32',
acceptedSharesLastHour: '1',
creditedDifficultyLastHour: '32',
acceptedSharesLastDay: '1',
creditedDifficultyLastDay: '32',
hashRateLast10Minutes: '1',
hashRateLastHour: '1',
latestShareAt: null,
}]),
};
const redis = {
getJsonCache: jest.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
...new ShareAccountingService(repository as any).emptySummary(),
totalAcceptedShares: 1,
}),
setJsonCache: jest.fn().mockResolvedValue(undefined),
};
const service = new ShareAccountingService(repository as any, redis as any);
await service.getAddressSummary('bc1qcached');
await expect(service.getAddressSummary('bc1qcached')).resolves.toEqual(expect.objectContaining({
totalAcceptedShares: 1,
}));
expect(repository.query).toHaveBeenCalledTimes(1);
expect(redis.setJsonCache).toHaveBeenCalledWith(
expect.stringContaining('accounting:summary:'),
expect.objectContaining({ totalAcceptedShares: 1 }),
30000,
);
});
it('should refresh pool summaries from completed rollup buckets and current round rollups', async () => {
const redis = {
getJsonCache: jest.fn().mockResolvedValue(null),
setJsonCache: jest.fn().mockResolvedValue(undefined),
};
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '3',
totalCreditedDifficulty: '96',
acceptedSharesLast10Minutes: '2',
creditedDifficultyLast10Minutes: '64',
acceptedSharesLastHour: '3',
creditedDifficultyLastHour: '96',
acceptedSharesLastDay: '3',
creditedDifficultyLastDay: '96',
hashRateLast10Minutes: '458129844.9',
hashRateLastHour: '114532461.2',
latestShareAt: new Date('2026-06-07T12:10:00Z'),
}])
.mockResolvedValueOnce([{
bestSubmissionDifficulty: '4096',
bestSubmissionDifficultyAt: new Date('2026-06-07T12:10:00Z'),
currentRoundAcceptedShares: '11',
workSinceLastBlock: '352',
currentRoundNetworkDifficulty: '1000',
}]),
};
const service = new ShareAccountingService(repository as any, redis as any);
await expect(service.refreshPoolSummary()).resolves.toEqual(expect.objectContaining({
acceptedSharesLast10Minutes: 2,
creditedDifficultyLast10Minutes: 64,
hashRateLast10Minutes: 458129844.9,
bestSubmissionDifficulty: 4096,
bestSubmissionDifficultyAt: '2026-06-07T12:10:00.000Z',
workSinceLastBlock: 352,
currentRoundAcceptedShares: 11,
currentRoundNetworkDifficulty: 1000,
networkDifficultyPercent: 35.2,
latestShareAt: '2026-06-07T12:10:00.000Z',
}));
expect(repository.query).toHaveBeenNthCalledWith(
2,
expect.stringContaining('FROM "accepted_share_block_10m"'),
);
expect(repository.query).not.toHaveBeenCalledWith(
expect.stringContaining('FROM "accepted_share_entity"'),
);
});
it('should cache accounting summaries briefly to protect hot dashboard endpoints', async () => {
process.env.SHARE_ACCOUNTING_SUMMARY_CACHE_MS = '1000';
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '1',
totalCreditedDifficulty: '32',
acceptedSharesLast10Minutes: '1',
creditedDifficultyLast10Minutes: '32',
acceptedSharesLastHour: '1',
creditedDifficultyLastHour: '32',
acceptedSharesLastDay: '1',
creditedDifficultyLastDay: '32',
hashRateLast10Minutes: '1',
hashRateLastHour: '1',
latestShareAt: null,
}]),
};
const service = new ShareAccountingService(repository as any);
await service.getPoolSummary();
await service.getPoolSummary();
expect(repository.query).toHaveBeenCalledTimes(1);
});
});
function buildRecord(jobId: string) {
return {
protocol: 'sv1' as const,
acceptedAt: new Date('2026-06-07T12:00:00Z'),
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'worker',
sessionId: '57a6f098',
clientId: '6df7beea-921a-46cb-b7dc-e8075f916232',
jobId,
jobTemplateId: 'template',
blockHeight: 900000,
creditedDifficulty: 1024,
submissionDifficulty: 2048,
networkDifficulty: 100000,
nonce: 123,
ntime: 456,
version: 536870912,
extraNonce2: 'c708000000000000',
isBlockCandidate: false,
blockSubmissionResult: null,
};
}
@@ -0,0 +1,546 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AcceptedShareEntity } from '../accepted-share/accepted-share.entity';
import { RedisMessagingService } from '../../services/redis-messaging.service';
export interface AcceptedShareRecord {
protocol: 'sv1' | 'sv2';
acceptedAt?: Date;
address: string;
clientName: string;
sessionId: string;
clientId: string;
jobId: string;
jobTemplateId: string;
blockHeight: number;
creditedDifficulty: number;
submissionDifficulty: number;
networkDifficulty: number;
nonce: string | number;
ntime: string | number;
version: string | number;
extraNonce2: string;
isBlockCandidate: boolean;
blockSubmissionResult?: string | null;
}
export interface ShareAccountingSummary {
totalAcceptedShares: number;
totalCreditedDifficulty: number;
acceptedSharesLast10Minutes: number;
creditedDifficultyLast10Minutes: number;
acceptedSharesLastHour: number;
creditedDifficultyLastHour: number;
acceptedSharesLastDay: number;
creditedDifficultyLastDay: number;
hashRateLast10Minutes: number;
hashRateLastHour: number;
bestSubmissionDifficulty: number;
bestSubmissionDifficultyAt: string | null;
workSinceLastBlock: number;
currentRoundAcceptedShares: number;
currentRoundNetworkDifficulty: number;
networkDifficultyPercent: number;
blockCandidateCount: number;
latestShareAt: string | null;
protocolBreakdown: {
protocol: string;
acceptedShares: number;
creditedDifficulty: number;
}[];
}
export interface SessionShareSummary {
clientId: string;
latestShareAt: string | null;
hashRateLast10Minutes: number;
bestSubmissionDifficulty: number;
}
interface AccountingFilter {
address?: string;
clientName?: string;
clientId?: string;
}
const HASHES_PER_DIFFICULTY = 4294967296;
const ROLLUP_BUCKET_SECONDS = 600;
const DEFAULT_BATCH_SIZE = 500;
const DEFAULT_FLUSH_INTERVAL_MS = 25;
const DEFAULT_MAX_QUEUE_SIZE = 50000;
const DEFAULT_SUMMARY_CACHE_MS = 2500;
const DEFAULT_SUMMARY_CACHE_MAX = 10000;
const DEFAULT_REDIS_SUMMARY_CACHE_MS = 30000;
@Injectable()
export class ShareAccountingService implements OnModuleDestroy {
private pendingShares: PendingShare[] = [];
private flushTimer: NodeJS.Timeout | null = null;
private activeFlush: Promise<void> | null = null;
private summaryCache = new Map<string, SummaryCacheEntry>();
private readonly poolSummaryCacheKey = 'accounting:pool-summary';
private readonly batchSize = this.readPositiveInt('SHARE_ACCOUNTING_BATCH_SIZE', DEFAULT_BATCH_SIZE);
private readonly flushIntervalMs = this.readPositiveInt('SHARE_ACCOUNTING_FLUSH_INTERVAL_MS', DEFAULT_FLUSH_INTERVAL_MS);
private readonly maxQueueSize = this.readPositiveInt('SHARE_ACCOUNTING_MAX_QUEUE_SIZE', DEFAULT_MAX_QUEUE_SIZE);
private readonly summaryCacheMs = this.readNonNegativeInt('SHARE_ACCOUNTING_SUMMARY_CACHE_MS', DEFAULT_SUMMARY_CACHE_MS);
private readonly summaryCacheMax = this.readPositiveInt('SHARE_ACCOUNTING_SUMMARY_CACHE_MAX', DEFAULT_SUMMARY_CACHE_MAX);
private readonly redisSummaryCacheMs = this.readNonNegativeInt('SHARE_ACCOUNTING_REDIS_SUMMARY_CACHE_MS', DEFAULT_REDIS_SUMMARY_CACHE_MS);
constructor(
@InjectRepository(AcceptedShareEntity)
private readonly acceptedShareRepository: Repository<AcceptedShareEntity>,
private readonly redisMessagingService?: RedisMessagingService,
) { }
public async recordAcceptedShare(record: AcceptedShareRecord): Promise<AcceptedShareEntity> {
const acceptedShare = this.acceptedShareRepository.create({
...record,
acceptedAt: record.acceptedAt ?? new Date(),
nonce: record.nonce.toString(),
ntime: record.ntime.toString(),
version: record.version.toString(),
blockSubmissionResult: record.blockSubmissionResult == null
? null
: record.blockSubmissionResult.toString(),
});
if (this.pendingShares.length >= this.maxQueueSize) {
throw new Error(`Share accounting queue is full (${this.maxQueueSize})`);
}
return await new Promise<AcceptedShareEntity>((resolve, reject) => {
this.pendingShares.push({ entity: acceptedShare, resolve, reject });
if (this.pendingShares.length >= this.batchSize) {
this.startFlush();
return;
}
this.scheduleFlush();
});
}
public async flushPendingShares(): Promise<void> {
while (this.pendingShares.length > 0 || this.activeFlush != null) {
if (this.activeFlush != null) {
await this.activeFlush;
continue;
}
this.startFlush();
}
}
public async onModuleDestroy(): Promise<void> {
await this.flushPendingShares();
}
public async getPoolSummary(): Promise<ShareAccountingSummary> {
const cached = await this.redisMessagingService
?.getJsonCache<ShareAccountingSummary>(this.poolSummaryCacheKey)
.catch(error => {
console.error(`Pool accounting summary cache read failed: ${error.message}`);
return null;
});
if (cached != null) {
return cached;
}
if (process.env.API_ONLY === 'true') {
return this.emptySummary();
}
return this.getSummary({});
}
public async refreshPoolSummary(): Promise<ShareAccountingSummary> {
const summary = await this.withPoolRollupOverlay(await this.getSummary({}));
await this.redisMessagingService
?.setJsonCache(this.poolSummaryCacheKey, summary, 10 * 60 * 1000)
.catch(error => {
console.error(`Pool accounting summary cache write failed: ${error.message}`);
});
return summary;
}
private async withPoolRollupOverlay(summary: ShareAccountingSummary): Promise<ShareAccountingSummary> {
if (process.env.API_ONLY === 'true') {
return summary;
}
const [currentRoundRow] = await this.acceptedShareRepository.query(`
WITH latest_found_block AS (
SELECT COALESCE(MAX("height"), 0) AS "height"
FROM "blocks_entity"
),
filtered_rows AS (
SELECT "accepted_share_block_10m".*
FROM "accepted_share_block_10m", latest_found_block
WHERE "blockHeight" > latest_found_block."height"
),
best_share AS (
SELECT
"bestSubmissionDifficulty",
"bucket"
FROM filtered_rows
ORDER BY "bestSubmissionDifficulty" DESC, "bucket" DESC
LIMIT 1
)
SELECT
COALESCE(SUM("acceptedCount"), 0)::int AS "currentRoundAcceptedShares",
COALESCE(SUM("shares"), 0)::float AS "workSinceLastBlock",
COALESCE(MAX("networkDifficulty"), 0)::float AS "currentRoundNetworkDifficulty",
COALESCE((SELECT "bestSubmissionDifficulty" FROM best_share), 0)::float AS "bestSubmissionDifficulty",
(SELECT "bucket" FROM best_share) AS "bestSubmissionDifficultyAt"
FROM filtered_rows
`);
const currentRoundNetworkDifficulty = this.toNumber(currentRoundRow?.currentRoundNetworkDifficulty);
const workSinceLastBlock = this.toNumber(currentRoundRow?.workSinceLastBlock);
return {
...summary,
bestSubmissionDifficulty: this.toNumber(currentRoundRow?.bestSubmissionDifficulty),
bestSubmissionDifficultyAt: currentRoundRow?.bestSubmissionDifficultyAt == null
? null
: new Date(currentRoundRow.bestSubmissionDifficultyAt).toISOString(),
workSinceLastBlock,
currentRoundAcceptedShares: this.toNumber(currentRoundRow?.currentRoundAcceptedShares),
currentRoundNetworkDifficulty,
networkDifficultyPercent: currentRoundNetworkDifficulty > 0
? this.roundPercent((workSinceLastBlock / currentRoundNetworkDifficulty) * 100)
: 0,
};
}
public emptySummary(): ShareAccountingSummary {
return {
totalAcceptedShares: 0,
totalCreditedDifficulty: 0,
acceptedSharesLast10Minutes: 0,
creditedDifficultyLast10Minutes: 0,
acceptedSharesLastHour: 0,
creditedDifficultyLastHour: 0,
acceptedSharesLastDay: 0,
creditedDifficultyLastDay: 0,
hashRateLast10Minutes: 0,
hashRateLastHour: 0,
bestSubmissionDifficulty: 0,
bestSubmissionDifficultyAt: null,
workSinceLastBlock: 0,
currentRoundAcceptedShares: 0,
currentRoundNetworkDifficulty: 0,
networkDifficultyPercent: 0,
blockCandidateCount: 0,
latestShareAt: null,
protocolBreakdown: [],
};
}
public async getAddressSummary(address: string): Promise<ShareAccountingSummary> {
return this.getSummary({ address });
}
public async getWorkerGroupSummary(address: string, clientName: string): Promise<ShareAccountingSummary> {
return this.getSummary({ address, clientName });
}
public async getSessionSummary(clientId: string): Promise<ShareAccountingSummary> {
return this.getSummary({ clientId });
}
public async getSessionSummaries(clientIds: string[]): Promise<Map<string, SessionShareSummary>> {
const uniqueClientIds = [...new Set(clientIds.filter(clientId => clientId != null))];
const summaries = new Map<string, SessionShareSummary>();
if (uniqueClientIds.length === 0 || process.env.API_ONLY === 'true') {
return summaries;
}
const rows = 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_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_10m".*, bounds."latestCompletedBucket"
FROM "accepted_share_10m", bounds
WHERE "clientId" = ANY($1::uuid[])
)
SELECT
"clientId",
MAX("bucket") AS "latestShareAt",
COALESCE((SUM("shares") FILTER (WHERE "bucket" = "latestCompletedBucket") * ${HASHES_PER_DIFFICULTY}) / ${ROLLUP_BUCKET_SECONDS}, 0)::float AS "hashRateLast10Minutes"
FROM filtered_rows
GROUP BY "clientId"
`, [uniqueClientIds]);
rows.forEach(row => {
summaries.set(row.clientId, {
clientId: row.clientId,
latestShareAt: row.latestShareAt == null
? null
: new Date(row.latestShareAt).toISOString(),
hashRateLast10Minutes: this.toNumber(row.hashRateLast10Minutes),
bestSubmissionDifficulty: 0,
});
});
return summaries;
}
private async getSummary(filter: AccountingFilter): Promise<ShareAccountingSummary> {
const cacheKey = this.getSummaryCacheKey(filter);
const cached = this.summaryCache.get(cacheKey);
const now = Date.now();
if (cached != null && cached.expiresAt > now) {
return cached.value;
}
const value = this.loadCachedSummary(filter, cacheKey).catch(error => {
this.summaryCache.delete(cacheKey);
throw error;
});
if (this.summaryCacheMs > 0) {
this.summaryCache.set(cacheKey, {
expiresAt: now + this.summaryCacheMs,
value,
});
this.trimSummaryCache();
}
return value;
}
private async loadCachedSummary(filter: AccountingFilter, cacheKey: string): Promise<ShareAccountingSummary> {
const redisCacheKey = `accounting:summary:${cacheKey}`;
const cached = await this.redisMessagingService
?.getJsonCache<ShareAccountingSummary>(redisCacheKey)
.catch(error => {
console.error(`Share accounting summary cache read failed: ${error.message}`);
return null;
});
if (cached != null) {
return cached;
}
const summary = await this.loadSummary(filter);
await this.redisMessagingService
?.setJsonCache(redisCacheKey, summary, this.redisSummaryCacheMs)
.catch(error => {
console.error(`Share accounting summary cache write failed: ${error.message}`);
});
return summary;
}
private async loadSummary(filter: AccountingFilter): Promise<ShareAccountingSummary> {
const { whereSql, params } = this.buildWhereClause(filter);
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_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_10m".*, bounds."currentBucket", bounds."latestCompletedBucket"
FROM "accepted_share_10m", bounds
${whereSql.length > 0
? `${whereSql} AND "accepted_share_10m"."bucket" <= bounds."latestCompletedBucket"`
: `WHERE "accepted_share_10m"."bucket" <= bounds."latestCompletedBucket"`}
)
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 {
totalAcceptedShares: this.toNumber(summary?.totalAcceptedShares),
totalCreditedDifficulty: this.toNumber(summary?.totalCreditedDifficulty),
acceptedSharesLast10Minutes: this.toNumber(summary?.acceptedSharesLast10Minutes),
creditedDifficultyLast10Minutes: this.toNumber(summary?.creditedDifficultyLast10Minutes),
acceptedSharesLastHour: this.toNumber(summary?.acceptedSharesLastHour),
creditedDifficultyLastHour: this.toNumber(summary?.creditedDifficultyLastHour),
acceptedSharesLastDay: this.toNumber(summary?.acceptedSharesLastDay),
creditedDifficultyLastDay: this.toNumber(summary?.creditedDifficultyLastDay),
hashRateLast10Minutes: this.toNumber(summary?.hashRateLast10Minutes),
hashRateLastHour: this.toNumber(summary?.hashRateLastHour),
bestSubmissionDifficulty: 0,
bestSubmissionDifficultyAt: null,
workSinceLastBlock: 0,
currentRoundAcceptedShares: 0,
currentRoundNetworkDifficulty: 0,
networkDifficultyPercent: 0,
blockCandidateCount: 0,
latestShareAt: summary?.latestShareAt == null
? null
: new Date(summary.latestShareAt).toISOString(),
protocolBreakdown: [],
};
}
private scheduleFlush(): void {
if (this.flushTimer != null || this.activeFlush != null) {
return;
}
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
this.startFlush();
}, this.flushIntervalMs);
this.flushTimer.unref?.();
}
private startFlush(): void {
if (this.activeFlush != null) {
return;
}
if (this.flushTimer != null) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
const batch = this.pendingShares.splice(0, this.batchSize);
if (batch.length === 0) {
return;
}
this.activeFlush = this.flushBatch(batch).finally(() => {
this.activeFlush = null;
if (this.pendingShares.length >= this.batchSize) {
this.startFlush();
return;
}
if (this.pendingShares.length > 0) {
this.scheduleFlush();
}
});
}
private async flushBatch(batch: PendingShare[]): Promise<void> {
try {
await this.acceptedShareRepository.insert(batch.map(item => item.entity));
batch.forEach(item => item.resolve(item.entity));
} catch (error) {
batch.forEach(item => item.reject(error));
}
}
private buildWhereClause(filter: AccountingFilter): { whereSql: string; params: string[] } {
const where: string[] = [];
const params: string[] = [];
if (filter.address != null) {
params.push(filter.address);
where.push(`"address" = $${params.length}`);
}
if (filter.clientName != null) {
params.push(filter.clientName);
where.push(`"clientName" = $${params.length}`);
}
if (filter.clientId != null) {
params.push(filter.clientId);
where.push(`"clientId" = $${params.length}`);
}
return {
whereSql: where.length > 0 ? `WHERE ${where.join(' AND ')}` : '',
params,
};
}
private toNumber(value: unknown): number {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
}
private roundPercent(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}
private getSummaryCacheKey(filter: AccountingFilter): string {
return JSON.stringify({
address: filter.address ?? null,
clientName: filter.clientName ?? null,
clientId: filter.clientId ?? null,
});
}
private trimSummaryCache(): void {
while (this.summaryCache.size > this.summaryCacheMax) {
const firstKey = this.summaryCache.keys().next().value;
if (firstKey == null) {
return;
}
this.summaryCache.delete(firstKey);
}
}
private readPositiveInt(name: string, defaultValue: number): number {
const value = Number(process.env[name]);
return Number.isInteger(value) && value > 0 ? value : defaultValue;
}
private readNonNegativeInt(name: string, defaultValue: number): number {
const value = Number(process.env[name]);
return Number.isInteger(value) && value >= 0 ? value : defaultValue;
}
}
interface PendingShare {
entity: AcceptedShareEntity;
resolve: (entity: AcceptedShareEntity) => void;
reject: (error: unknown) => void;
}
interface SummaryCacheEntry {
expiresAt: number;
value: Promise<ShareAccountingSummary>;
}
@@ -1,11 +1,12 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { Column, Entity, Index } from 'typeorm';
import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn';
import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Entity()
export class TelegramSubscriptionsEntity extends TrackedEntity {
@PrimaryGeneratedColumn()
@PrimaryGeneratedBigIntColumn()
id: number;
@Index()
@@ -0,0 +1,5 @@
import { PrimaryGeneratedColumn } from 'typeorm';
export const PrimaryGeneratedBigIntColumn = () => PrimaryGeneratedColumn({
type: process.env.NODE_ENV === 'test' ? 'integer' : 'bigint'
});
+3 -5
View File
@@ -1,14 +1,12 @@
import { CreateDateColumn, DeleteDateColumn, UpdateDateColumn } from 'typeorm';
import { DateTimeTransformer } from './DateTimeTransformer';
export abstract class TrackedEntity {
@DeleteDateColumn({ nullable: true, type: 'datetime', transformer: new DateTimeTransformer() })
@DeleteDateColumn({ nullable: true })
public deletedAt?: Date;
@CreateDateColumn({ type: 'datetime', transformer: new DateTimeTransformer() })
@CreateDateColumn()
public createdAt?: Date
@UpdateDateColumn({ type: 'datetime', transformer: new DateTimeTransformer() })
@UpdateDateColumn()
public updatedAt?: Date
}
+192 -22
View File
@@ -3,67 +3,163 @@ import { Controller, Get, Inject } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { firstValueFrom } from 'rxjs';
import { UserAgentReportService } from './ORM/_views/user-agent-report/user-agent-report.service';
import { AddressSettingsService } from './ORM/address-settings/address-settings.service';
import { BlocksService } from './ORM/blocks/blocks.service';
import { ClientStatisticsService } from './ORM/client-statistics/client-statistics.service';
import { ClientService } from './ORM/client/client.service';
import { BitcoinRpcService } from './services/bitcoin-rpc.service';
import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
import { StratumV2Service } from './services/stratum-v2.service';
import { ShareAccountingService } from './ORM/share-accounting/share-accounting.service';
import { RedisMessagingService } from './services/redis-messaging.service';
@Controller()
export class AppController {
private uptime = new Date();
private siteInfoRefreshPromise: Promise<SiteInfoResponse> | null = null;
constructor(
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
private readonly clientService: ClientService,
private readonly clientStatisticsService: ClientStatisticsService,
private readonly blocksService: BlocksService,
private readonly bitcoinRpcService: BitcoinRpcService
private readonly bitcoinRpcService: BitcoinRpcService,
private readonly addressSettingsService: AddressSettingsService,
private readonly userAgentReportService: UserAgentReportService,
private readonly stratumV2Service: StratumV2Service,
private readonly shareAccountingService: ShareAccountingService,
private readonly redisMessagingService: RedisMessagingService
) { }
@Get('info')
public async info() {
const CACHE_KEY = 'SITE_INFO';
const cachedResult = await this.cacheManager.get(CACHE_KEY);
const STALE_CACHE_KEY = 'SITE_INFO_STALE';
const cachedResult = await this.getCached(CACHE_KEY, 5 * 60 * 1000);
if (cachedResult != null) {
return cachedResult;
}
const staleResult = await this.getCached<SiteInfoResponse>(STALE_CACHE_KEY, 60 * 60 * 1000);
if (staleResult != null) {
void this.refreshSiteInfo(staleResult);
return staleResult;
}
const blockData = await this.blocksService.getFoundBlocks();
const userAgents = await this.clientService.getUserAgents();
return await this.refreshSiteInfo(null);
const data = {
}
private async refreshSiteInfo(staleInfo: SiteInfoResponse | null): Promise<SiteInfoResponse> {
if (this.siteInfoRefreshPromise != null) {
return this.siteInfoRefreshPromise;
}
this.siteInfoRefreshPromise = this.loadSiteInfo(staleInfo)
.finally(() => {
this.siteInfoRefreshPromise = null;
});
return this.siteInfoRefreshPromise;
}
private async loadSiteInfo(staleInfo: SiteInfoResponse | null): Promise<SiteInfoResponse> {
const CACHE_KEY = 'SITE_INFO';
const STALE_CACHE_KEY = 'SITE_INFO_STALE';
const withInfoTimeout = async <T>(label: string, promise: Promise<T>, fallback: T): Promise<T> => {
return await this.withTimeout(label, promise, fallback);
};
const [blockData, highScores, poolAuthority, userAgentReport] = await Promise.all([
withInfoTimeout('found blocks', this.blocksService.getFoundBlocks(), staleInfo?.blockData ?? []),
withInfoTimeout('high scores', this.addressSettingsService.getHighScores(), staleInfo?.highScores ?? []),
withInfoTimeout('SV2 authority', this.stratumV2Service.getPoolAuthorityPublicKey(), {
publicKey: staleInfo?.sv2?.poolAuthorityPublicKey ?? '',
configured: staleInfo?.sv2?.authorityKeyConfigured ?? false
}),
withInfoTimeout<UserAgentReportView[]>('user agent report', this.userAgentReportService.getReport(), staleInfo?.userAgents ?? []),
]);
const other: {
count: number,
bestDifficulty: number,
totalHashRate: number;
} = {
count: 0,
bestDifficulty: 0,
totalHashRate: 0
};
const userAgents: UserAgentReportView[] = userAgentReport.reduce((pre, cur, idx, arr) => {
// If less than 10Th/s and less than 100 devices, add to 'other'
if (parseInt(cur.totalHashRate) < 10000000000000 && parseInt(cur.count) < 200) {
other.totalHashRate += parseFloat(cur.totalHashRate);
other.count += parseInt(cur.count);
if (other.bestDifficulty < cur.bestDifficulty) {
other.bestDifficulty = cur.bestDifficulty;
}
} else {
pre.push(cur);
}
return pre;
}, []);
if (other.count > 0) {
userAgents.push({ userAgent: 'Other', count: other.count.toString(), bestDifficulty: other.bestDifficulty, totalHashRate: other.totalHashRate.toString() })
}
const data: SiteInfoResponse = {
blockData,
userAgents,
highScores,
sv2: {
poolAuthorityPublicKey: poolAuthority.publicKey,
authorityKeyConfigured: poolAuthority.configured
},
uptime: this.uptime
};
//1 min
await this.cacheManager.set(CACHE_KEY, data, 1 * 60 * 1000);
// Cache a complete response even when one slow component falls back to stale data.
// A short retry loop here causes repeated DB work because timed-out TypeORM queries are not cancelled.
await this.setCached(CACHE_KEY, data, 5 * 60 * 1000);
await this.setCached(STALE_CACHE_KEY, data, 60 * 60 * 1000);
return data;
}
@Get('info/accounting')
public async infoAccounting() {
const CACHE_KEY = 'SITE_ACCOUNTING';
const cachedResult = await this.getCached(CACHE_KEY, 15 * 1000);
if (cachedResult != null) {
return cachedResult;
}
const data = await this.shareAccountingService.getPoolSummary();
//15 sec
await this.setCached(CACHE_KEY, data, 15 * 1000);
return data;
}
@Get('pool')
public async pool() {
const CACHE_KEY = 'POOL_INFO';
const cachedResult = await this.cacheManager.get(CACHE_KEY);
const cachedResult = await this.getCached(CACHE_KEY, 15 * 1000);
if (cachedResult != null) {
return cachedResult;
}
const userAgents = await this.userAgentReportService.getReport();
const userAgents = await this.clientService.getUserAgents();
const totalHashRate = userAgents.reduce((acc, userAgent) => acc + parseFloat(userAgent.totalHashRate), 0);
const totalMiners = userAgents.reduce((acc, userAgent) => acc + parseInt(userAgent.count), 0);
const blockHeight = (await firstValueFrom(this.bitcoinRpcService.newBlock$)).blocks;
const totalMiners = userAgents.reduce((acc, userAgent) => acc + parseFloat(userAgent.count), 0);
const blockHeight = this.bitcoinRpcService.miningInfo.blocks;
const blocksFound = await this.blocksService.getFoundBlocks();
const data = {
@@ -74,24 +170,21 @@ export class AppController {
fee: 0
}
//5 min
await this.cacheManager.set(CACHE_KEY, data, 5 * 60 * 1000);
// Keep online miner counts responsive after reconnect cleanup.
await this.setCached(CACHE_KEY, data, 15 * 1000);
return data;
}
@Get('network')
public async network() {
const miningInfo = await firstValueFrom(this.bitcoinRpcService.newBlock$);
return miningInfo;
return this.bitcoinRpcService.miningInfo ?? {};
}
@Get('info/chart')
public async infoChart() {
const CACHE_KEY = 'SITE_HASHRATE_GRAPH';
const cachedResult = await this.cacheManager.get(CACHE_KEY);
const cachedResult = await this.getCached(CACHE_KEY, 10 * 60 * 1000);
if (cachedResult != null) {
return cachedResult;
@@ -100,11 +193,88 @@ export class AppController {
const chartData = await this.clientStatisticsService.getChartDataForSite();
//10 min
await this.cacheManager.set(CACHE_KEY, chartData, 10 * 60 * 1000);
await this.setCached(CACHE_KEY, chartData, 10 * 60 * 1000);
return chartData;
}
private async getCached<T>(key: string, localTtlMs: number): Promise<T | null> {
const local = await this.cacheManager.get<T>(key);
if (local != null) {
return local;
}
const shared = await this.withSecondaryCacheTimeout(
this.redisMessagingService.getJsonCache<T>(`api:${key}`).catch(error => {
console.error(`Shared API cache read failed for ${key}: ${error.message}`);
return null;
}),
100
);
if (shared != null) {
await this.cacheManager.set(key, shared, localTtlMs);
return shared;
}
return null;
}
private async setCached(key: string, value: unknown, ttlMs: number): Promise<void> {
await this.cacheManager.set(key, value, ttlMs);
void this.redisMessagingService.setJsonCache(`api:${key}`, value, ttlMs).catch(error => {
console.error(`Shared API cache write failed for ${key}: ${error.message}`);
});
}
private async withTimeout<T>(
label: string,
promise: Promise<T>,
fallback: T,
onTimeout: () => void = () => undefined,
timeoutMs = 1500
): Promise<T> {
let timeout: NodeJS.Timeout;
const timeoutPromise = new Promise<T>(resolve => {
timeout = setTimeout(() => {
onTimeout();
console.error(`/api/info ${label} timed out after ${timeoutMs}ms`);
resolve(fallback);
}, timeoutMs);
timeout.unref?.();
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
clearTimeout(timeout);
}
}
private async withSecondaryCacheTimeout<T>(promise: Promise<T | null>, timeoutMs: number): Promise<T | null> {
let timeout: NodeJS.Timeout;
const timeoutPromise = new Promise<null>(resolve => {
timeout = setTimeout(() => resolve(null), timeoutMs);
timeout.unref?.();
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
clearTimeout(timeout);
}
}
}
interface SiteInfoResponse {
blockData: unknown[];
userAgents: UserAgentReportView[];
highScores: unknown[];
sv2: {
poolAuthorityPublicKey: string;
authorityKeyConfigured: boolean;
};
uptime: Date;
}
+26 -13
View File
@@ -1,7 +1,7 @@
import { HttpModule } from '@nestjs/axios';
import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
@@ -9,21 +9,25 @@ import { AppController } from './app.controller';
import { AddressController } from './controllers/address/address.controller';
import { ClientController } from './controllers/client/client.controller';
import { BitcoinAddressValidator } from './models/validators/bitcoin-address.validator';
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 { 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 { BitcoinRpcService } from './services/bitcoin-rpc.service';
import { BraiinsService } from './services/braiins.service';
import { BTCPayService } from './services/btc-pay.service';
import { DiscordService } from './services/discord.service';
import { NotificationService } from './services/notification.service';
import { ProxyService } from './services/proxy.service';
import { RedisMessagingModule } from './services/redis-messaging.module';
import { StratumV1JobsService } from './services/stratum-v1-jobs.service';
import { StratumV1Service } from './services/stratum-v1.service';
import { StratumV2Service } from './services/stratum-v2.service';
import { TelegramService } from './services/telegram.service';
@@ -33,25 +37,34 @@ const ORMModules = [
AddressSettingsModule,
TelegramSubscriptionsModule,
BlocksModule,
RpcBlocksModule
RpcBlocksModule,
UserAgentReportModule,
ShareAccountingModule
]
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forRoot({
type: 'sqlite',
database: './DB/public-pool.sqlite',
synchronize: true,
autoLoadEntities: true,
logging: false,
enableWAL: true,
busyTimeout: 30 * 1000,
TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => {
return 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(),
ScheduleModule.forRoot(),
HttpModule,
RedisMessagingModule,
...ORMModules
],
controllers: [
@@ -68,7 +81,7 @@ const ORMModules = [
NotificationService,
BitcoinAddressValidator,
StratumV1JobsService,
ProxyService,
StratumV2Service,
BTCPayService,
BraiinsService
],
+1 -12
View File
@@ -1,5 +1,4 @@
import { Controller, Patch } from '@nestjs/common';
import * as bitcoinMessage from 'bitcoinjs-message';
@Controller('address')
@@ -7,16 +6,6 @@ export class AddressController {
@Patch('settings')
async settings() {
const publicKey = '...'; // Public key corresponding to the private key used for signing
const message = '...'; // The message that was signed
const signature = '...'; // The signature of the message
const isValid: boolean = bitcoinMessage.verify(message, publicKey, signature);
if (isValid) {
console.log('Signature is valid!');
} else {
console.log('Signature is not valid!');
}
return;
}
}
@@ -1,37 +1,103 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AddressSettingsModule } from '../../ORM/address-settings/address-settings.module';
import { ClientStatisticsModule } from '../../ORM/client-statistics/client-statistics.module';
import { ClientModule } from '../../ORM/client/client.module';
import { AddressSettingsService } from '../../ORM/address-settings/address-settings.service';
import { ClientStatisticsService } from '../../ORM/client-statistics/client-statistics.service';
import { ClientService } from '../../ORM/client/client.service';
import { ShareAccountingService } from '../../ORM/share-accounting/share-accounting.service';
import { RedisMessagingService } from '../../services/redis-messaging.service';
import { ClientController } from './client.controller';
describe('ClientController', () => {
let controller: ClientController;
let addressSettingsService: { getSettings: jest.Mock };
let shareAccountingService: {
getAddressSummary: jest.Mock;
getWorkerGroupSummary: jest.Mock;
getSessionSummary: jest.Mock;
getSessionSummaries: jest.Mock;
};
let redisMessagingService: { getClientPresenceByAddress: jest.Mock };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: './DB/public-pool.test.sqlite',
synchronize: true,
autoLoadEntities: true,
cache: true,
logging: false
}),
AddressSettingsModule,
ClientModule,
ClientStatisticsModule
],
controllers: [ClientController],
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ClientController],
providers: [
{
provide: ClientService,
useValue: {
getByAddress: jest.fn(),
getByName: jest.fn(),
getBySessionId: jest.fn(),
},
},
{
provide: ClientStatisticsService,
useValue: {
getChartDataForAddress: jest.fn(),
getChartDataForGroup: jest.fn(),
getChartDataForSession: jest.fn(),
},
},
{
provide: AddressSettingsService,
useValue: {
getSettings: jest.fn(),
},
},
{
provide: ShareAccountingService,
useValue: {
getAddressSummary: jest.fn(),
getWorkerGroupSummary: jest.fn(),
getSessionSummary: jest.fn(),
getSessionSummaries: jest.fn().mockResolvedValue(new Map()),
},
},
{
provide: RedisMessagingService,
useValue: {
getClientPresenceByAddress: jest.fn().mockResolvedValue([]),
},
},
],
}).compile();
}).compile();
controller = module.get<ClientController>(ClientController);
addressSettingsService = module.get(AddressSettingsService);
shareAccountingService = module.get(ShareAccountingService);
redisMessagingService = module.get(RedisMessagingService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('should expose the existing address best difficulty in accounting when rollup best is empty', async () => {
redisMessagingService.getClientPresenceByAddress.mockResolvedValue([
{
clientId: '92f5302f-5e32-487e-af67-f56fd78b13c7',
sessionId: 'abcd1234',
clientName: 'worker',
bestDifficulty: 64,
hashRate: 1024,
startTime: '2026-06-08T12:00:00.000Z',
lastSeen: '2026-06-08T12:10:00.000Z',
},
]);
addressSettingsService.getSettings.mockResolvedValue({ bestDifficulty: 4096 });
shareAccountingService.getSessionSummaries.mockResolvedValue(new Map());
shareAccountingService.getAddressSummary.mockResolvedValue({
totalAcceptedShares: 10,
totalCreditedDifficulty: 100,
bestSubmissionDifficulty: 0,
});
await expect(controller.getClientInfo('bc1qtest')).resolves.toEqual(expect.objectContaining({
bestDifficulty: 4096,
accounting: expect.objectContaining({
bestSubmissionDifficulty: 4096,
}),
}));
});
});
+77 -18
View File
@@ -3,6 +3,8 @@ import { Controller, Get, NotFoundException, Param } from '@nestjs/common';
import { AddressSettingsService } from '../../ORM/address-settings/address-settings.service';
import { ClientStatisticsService } from '../../ORM/client-statistics/client-statistics.service';
import { ClientService } from '../../ORM/client/client.service';
import { ShareAccountingService } from '../../ORM/share-accounting/share-accounting.service';
import { RedisMessagingService } from '../../services/redis-messaging.service';
@Controller('client')
@@ -11,45 +13,63 @@ export class ClientController {
constructor(
private readonly clientService: ClientService,
private readonly clientStatisticsService: ClientStatisticsService,
private readonly addressSettingsService: AddressSettingsService
private readonly addressSettingsService: AddressSettingsService,
private readonly shareAccountingService: ShareAccountingService,
private readonly redisMessagingService: RedisMessagingService
) { }
@Get(':address')
async getClientInfo(@Param('address') address: string) {
const workers = await this.redisMessagingService.getClientPresenceByAddress(address);
const sessionSummaries = await this.shareAccountingService.getSessionSummaries(workers.map(worker => worker.clientId));
const workers = await this.clientService.getByAddress(address);
const addressSettings = process.env.API_ONLY === 'true'
? null
: await this.addressSettingsService.getSettings(address, false);
const bestDifficulty = addressSettings?.bestDifficulty ?? workers.reduce((best, worker) => {
return Math.max(best, Number(worker.bestDifficulty ?? 0));
}, 0);
const accounting = this.withBestSubmissionDifficulty(
await this.shareAccountingService.getAddressSummary(address),
bestDifficulty,
);
const addressSettings = await this.addressSettingsService.getSettings(address, false);
return {
bestDifficulty: addressSettings?.bestDifficulty,
const response = {
bestDifficulty,
workersCount: workers.length,
accounting,
workers: await Promise.all(
workers.map(async (worker) => {
const sessionSummary = sessionSummaries.get(worker.clientId);
const bestDifficulty = Math.max(
Number(worker.bestDifficulty ?? 0),
Number(sessionSummary?.bestSubmissionDifficulty ?? 0),
);
return {
sessionId: worker.sessionId,
name: worker.clientName,
bestDifficulty: worker.bestDifficulty.toFixed(2),
hashRate: worker.hashRate,
bestDifficulty: bestDifficulty.toFixed(2),
hashRate: sessionSummary?.hashRateLast10Minutes ?? worker.hashRate,
startTime: worker.startTime,
lastSeen: worker.updatedAt
lastSeen: sessionSummary?.latestShareAt ?? worker.lastSeen
};
})
)
}
return response;
}
@Get(':address/chart')
async getClientInfoChart(@Param('address') address: string) {
const chartData = await this.clientStatisticsService.getChartDataForAddress(address);
return chartData;
return await this.clientStatisticsService.getChartDataForAddress(address);
}
@Get(':address/:workerName')
async getWorkerGroupInfo(@Param('address') address: string, @Param('workerName') workerName: string) {
const workers = await this.clientService.getByName(address, workerName);
const addressWorkers = await this.redisMessagingService.getClientPresenceByAddress(address);
const workers = addressWorkers
.filter(worker => worker.clientName === workerName);
const bestDifficulty = workers.reduce((pre, cur, idx, arr) => {
if (cur.bestDifficulty > pre) {
@@ -59,30 +79,69 @@ export class ClientController {
}, 0);
const chartData = await this.clientStatisticsService.getChartDataForGroup(address, workerName);
return {
const accounting = this.withBestSubmissionDifficulty(
await this.shareAccountingService.getWorkerGroupSummary(address, workerName),
bestDifficulty,
);
const response = {
name: workerName,
bestDifficulty: Math.floor(bestDifficulty),
accounting,
chartData: chartData,
}
return response;
}
@Get(':address/:workerName/:sessionId')
async getWorkerInfo(@Param('address') address: string, @Param('workerName') workerName: string, @Param('sessionId') sessionId: string) {
const worker = await this.clientService.getBySessionId(address, workerName, sessionId);
const addressWorkers = await this.redisMessagingService.getClientPresenceByAddress(address);
const presenceWorker = addressWorkers
.find(worker => worker.clientName === workerName && worker.sessionId === sessionId);
const worker = presenceWorker == null
? await this.clientService.getBySessionId(address, workerName, sessionId)
: {
id: presenceWorker.clientId,
sessionId: presenceWorker.sessionId,
clientName: presenceWorker.clientName,
bestDifficulty: presenceWorker.bestDifficulty,
startTime: presenceWorker.startTime,
};
if (worker == null) {
return new NotFoundException();
}
const chartData = await this.clientStatisticsService.getChartDataForSession(worker.address, worker.clientName, worker.sessionId);
const chartData = await this.clientStatisticsService.getChartDataForSession(worker.id);
const accounting = this.withBestSubmissionDifficulty(
await this.shareAccountingService.getSessionSummary(worker.id),
worker.bestDifficulty,
);
return {
const response = {
sessionId: worker.sessionId,
name: worker.clientName,
bestDifficulty: Math.floor(worker.bestDifficulty),
accounting,
chartData: chartData,
startTime: worker.startTime
}
return response;
}
private withBestSubmissionDifficulty<T extends { bestSubmissionDifficulty?: number }>(
accounting: T,
fallbackBestDifficulty: unknown,
): T {
const existing = Number(accounting?.bestSubmissionDifficulty ?? 0);
const fallback = Number(fallbackBestDifficulty ?? 0);
if (!Number.isFinite(fallback) || fallback <= existing) {
return accounting;
}
return {
...accounting,
bestSubmissionDifficulty: fallback,
};
}
}
+7
View File
@@ -0,0 +1,7 @@
import 'dotenv/config';
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { createDatabaseOptions } from './database.config';
export const AppDataSource = new DataSource(createDatabaseOptions(process.env));
+59
View File
@@ -0,0 +1,59 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { DataSourceOptions } from 'typeorm';
import { InitialTimescaleSchema1780859300000 } from './ORM/_migrations/InitialTimescaleSchema1780859300000';
import { ActiveOnlyUserAgentReport1780860200000 } from './ORM/_migrations/ActiveOnlyUserAgentReport1780860200000';
import { TimescaleOperationalHardening1780861200000 } from './ORM/_migrations/TimescaleOperationalHardening1780861200000';
import { AcceptedShareIndex1780862400000 } from './ORM/_migrations/AcceptedShareIndex1780862400000';
import { AcceptedShareRollupIndexes1780865400000 } from './ORM/_migrations/AcceptedShareRollupIndexes1780865400000';
import { PoolAccountingDashboardIndexes1780867200000 } from './ORM/_migrations/PoolAccountingDashboardIndexes1780867200000';
import { CurrentRoundBestShareIndex1780897600000 } from './ORM/_migrations/CurrentRoundBestShareIndex1780897600000';
import { CurrentRoundWorkRollup1780899000000 } from './ORM/_migrations/CurrentRoundWorkRollup1780899000000';
import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
import { AcceptedShareEntity } from './ORM/accepted-share/accepted-share.entity';
import { AddressSettingsEntity } from './ORM/address-settings/address-settings.entity';
import { BlocksEntity } from './ORM/blocks/blocks.entity';
import { ClientEntity } from './ORM/client/client.entity';
import { RpcBlockEntity } from './ORM/rpc-block/rpc-block.entity';
import { TelegramSubscriptionsEntity } from './ORM/telegram-subscriptions/telegram-subscriptions.entity';
export const databaseEntities = [
ClientEntity,
AddressSettingsEntity,
BlocksEntity,
RpcBlockEntity,
TelegramSubscriptionsEntity,
UserAgentReportView,
AcceptedShareEntity,
];
export const databaseMigrations = [
InitialTimescaleSchema1780859300000,
ActiveOnlyUserAgentReport1780860200000,
TimescaleOperationalHardening1780861200000,
AcceptedShareIndex1780862400000,
AcceptedShareRollupIndexes1780865400000,
PoolAccountingDashboardIndexes1780867200000,
CurrentRoundBestShareIndex1780897600000,
CurrentRoundWorkRollup1780899000000,
];
export function createDatabaseOptions(env: NodeJS.ProcessEnv): TypeOrmModuleOptions & DataSourceOptions {
return {
type: 'postgres',
host: env.DB_HOST,
port: parseInt(env.DB_PORT ?? '5432', 10),
username: env.DB_USERNAME,
password: env.DB_PASSWORD,
database: env.DB_DATABASE,
entities: databaseEntities,
synchronize: false,
logging: env.DB_LOGGING === 'true',
poolSize: parseInt(env.DB_POOL_SIZE ?? '10', 10),
ssl: env.DB_SSL === 'true'
? { rejectUnauthorized: env.DB_SSL_REJECT_UNAUTHORIZED !== 'false' }
: undefined,
migrations: databaseMigrations,
migrationsTransactionMode: 'none',
};
}
+62 -11
View File
@@ -3,38 +3,43 @@ import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import * as bitcoinjs from 'bitcoinjs-lib';
import { useContainer } from 'class-validator';
import { readFileSync } from 'fs';
import { readFileSync, watch } from 'fs';
import * as path from 'path';
import * as ecc from 'tiny-secp256k1';
import { AppModule } from './app.module';
async function bootstrap() {
if (process.env.API_PORT == null) {
console.error('It appears your environment is not configured, create and populate an .env file.');
return;
}
let options = {};
const secure = process.env.API_SECURE?.toLowerCase() == 'true';
const apiEnabled = process.env.API_ENABLED?.toLowerCase();
const serveApi = apiEnabled == null ? process.env.MASTER !== 'false' : apiEnabled === 'true';
const secure = serveApi && process.env.API_SECURE?.toLowerCase() === 'true';
const currentDirectory = process.cwd();
const keyPath = path.join(currentDirectory, 'secrets', 'key.pem');
const certPath = path.join(currentDirectory, 'secrets', 'cert.pem');
let options: any = {};
if (secure) {
const currentDirectory = process.cwd();
options = {
https: {
key: readFileSync(`${currentDirectory}/secrets/key.pem`),
cert: readFileSync(`${currentDirectory}/secrets/cert.pem`),
key: readFileSync(keyPath),
cert: readFileSync(certPath),
}
};
}
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(options));
app.setGlobalPrefix('api')
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true
//forbidNonWhitelisted: true,
//forbidUnknownValues: true
}),
);
@@ -51,13 +56,59 @@ async function bootstrap() {
app.enableCors();
useContainer(app.select(AppModule), { fallbackOnErrors: true });
//Taproot
// Taproot
bitcoinjs.initEccLib(ecc);
if (!serveApi) {
await app.init();
console.log('Worker process skipping API listener');
return;
}
await app.listen(process.env.API_PORT, '0.0.0.0', (err, address) => {
if (err) {
console.error(err);
process.exit(1);
}
console.log(`API listening on ${address}`);
});
// --- Live-reload TLS certs/keys when they change on disk ---
if (secure) {
// Fastify's underlying Node https server
const server: any = app.getHttpServer();
// Guard: only HTTPS servers expose setSecureContext
if (typeof server?.setSecureContext === 'function') {
let reloadTimer: NodeJS.Timeout | null = null;
const scheduleReload = () => {
if (reloadTimer) clearTimeout(reloadTimer);
// Debounce multiple fs events during a single write/replace
reloadTimer = setTimeout(() => {
try {
const key = readFileSync(keyPath);
const cert = readFileSync(certPath);
server.setSecureContext({ key, cert });
console.log(`[TLS] Reloaded certificate @ ${new Date().toISOString()}`);
} catch (e) {
console.error('[TLS] Failed to reload certificate:', e);
}
}, 500);
};
// Watch both files; handle 'change' and 'rename' (rename often fired on atomic replace)
try {
watch(keyPath, { persistent: true }, scheduleReload);
watch(certPath, { persistent: true }, scheduleReload);
console.log('[TLS] Watching cert/key for changes');
} catch (e) {
console.error('[TLS] Failed to watch cert/key files:', e);
}
} else {
console.warn('[TLS] Dynamic cert reload not available (non-HTTPS server?)');
}
}
}
bootstrap();
+128
View File
@@ -0,0 +1,128 @@
import * as bitcoinjs from 'bitcoinjs-lib';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { MiningJob } from './MiningJob';
describe('MiningJob', () => {
let jobTemplate;
let job: MiningJob;
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
const blockTemplate$ = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
const bitcoinRpcService = {
newBlockTemplate$: blockTemplate$.asObservable(),
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height }
};
jest.spyOn(console, 'log').mockImplementation(() => undefined);
const jobsService = new StratumV1JobsService(bitcoinRpcService as any);
jobTemplate = await firstValueFrom(jobsService.newMiningJob$);
job = new MiningJob(
bitcoinjs.networks.testnet,
'1',
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
jobTemplate
);
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
it('should split coinbase around 12 bytes of extranonce space', () => {
const notify = JSON.parse(job.response(jobTemplate));
const coinbasePart1 = notify.params[2];
const coinbasePart2 = notify.params[3];
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const coinbase = bitcoinjs.Transaction.fromHex(`${coinbasePart1}${extraNonce1}${extraNonce2}${coinbasePart2}`);
expect(Buffer.byteLength(extraNonce1 + extraNonce2, 'hex')).toBe(12);
expect(coinbase.ins[0].script.toString('hex')).toContain(`${extraNonce1}${extraNonce2}`);
expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
});
it('should expose coinbase prefix and suffix buffers for SV2 extended jobs', () => {
const notify = JSON.parse(job.response(jobTemplate));
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const fromNotify = Buffer.from(`${notify.params[2]}${extraNonce1}${extraNonce2}${notify.params[3]}`, 'hex');
const fromBuffers = Buffer.concat([
job.getCoinbasePrefixBuffer(),
Buffer.from(`${extraNonce1}${extraNonce2}`, 'hex'),
job.getCoinbaseSuffixBuffer(),
]);
expect(fromBuffers).toEqual(fromNotify);
expect(bitcoinjs.Transaction.fromBuffer(fromBuffers).ins[0].script.toString('hex'))
.toContain(`${extraNonce1}${extraNonce2}`);
});
it('should update block nonce, timestamp, version mask, and coinbase script', () => {
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const timestamp = parseInt(MockRecording1.TIME, 16);
const originalMerkleRoot = Buffer.from(jobTemplate.block.merkleRoot);
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
parseInt('00002000', 16),
parseInt('ed460d91', 16),
extraNonce1,
extraNonce2,
timestamp
);
expect(updatedBlock.nonce).toBe(parseInt('ed460d91', 16));
expect(updatedBlock.timestamp).toBe(timestamp);
expect(updatedBlock.version).toBe(jobTemplate.block.version ^ parseInt('00002000', 16));
expect(updatedBlock.transactions[0].ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
expect(updatedBlock.merkleRoot.equals(originalMerkleRoot)).toBe(false);
});
it('should leave block version unchanged without a version mask', () => {
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
0,
parseInt('ed460d91', 16),
'57a6f098',
'c708000000000000',
parseInt(MockRecording1.TIME, 16)
);
expect(updatedBlock.version).toBe(jobTemplate.block.version);
});
it('should build the same header as the full block update path', () => {
const versionMask = parseInt('00002000', 16);
const nonce = parseInt('ed460d91', 16);
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const timestamp = parseInt(MockRecording1.TIME, 16);
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
versionMask,
nonce,
extraNonce1,
extraNonce2,
timestamp
);
const fastHeader = job.buildHeaderBuffer(
jobTemplate,
versionMask,
nonce,
extraNonce1,
extraNonce2,
timestamp
);
expect(fastHeader.equals(updatedBlock.toBuffer(true))).toBe(true);
});
});
+54 -9
View File
@@ -4,6 +4,7 @@ import * as bitcoinjs from 'bitcoinjs-lib';
import { IJobTemplate } from '../services/stratum-v1-jobs.service';
import { eResponseMethod } from './enums/eResponseMethod';
import { IMiningNotify } from './stratum-messages/IMiningNotify';
import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants';
interface AddressObject {
@@ -15,10 +16,13 @@ export class MiningJob {
private coinbaseTransaction: bitcoinjs.Transaction;
private coinbasePart1: string;
private coinbasePart2: string;
private coinbasePart1Buffer: Buffer;
private coinbasePart2Buffer: Buffer;
private merkleBranchBuffers: Buffer[];
public jobTemplateId: string;
public networkDifficulty: number;
public creation: number;
constructor(
private network: bitcoinjs.networks.Network,
@@ -27,7 +31,9 @@ export class MiningJob {
jobTemplate: IJobTemplate
) {
this.creation = new Date().getTime();
this.jobTemplateId = jobTemplate.blockData.id;
this.merkleBranchBuffers = jobTemplate.merkle_branch.map(branch => Buffer.from(branch, 'hex'));
this.coinbaseTransaction = this.createCoinbaseTransaction(payoutInformation, jobTemplate.blockData.coinbasevalue);
@@ -39,7 +45,7 @@ export class MiningJob {
// 32-byte - Commitment hash: Double-SHA256(witness root hash|witness reserved value)
// 39th byte onwards: Optional data with no consensus meaning
const extra = Buffer.from('\\public-pool\\');
const extra = Buffer.from('Public-Pool');
// Encode the block height
// https://github.com/bitcoin/bips/blob/master/bip-0034.mediawiki
@@ -49,7 +55,7 @@ export class MiningJob {
const blockHeightLengthByte = Buffer.from([blockHeightEncoded.length]);
// generate padding and take length of encode blockHeight into account
const padding = Buffer.alloc(8 + (3 - blockHeightEncoded.length), 0)
const padding = Buffer.alloc(TOTAL_EXTRANONCE_SIZE_BYTES + (3 - blockHeightEncoded.length), 0)
// build the script
this.coinbaseTransaction.ins[0].script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, extra, padding])
@@ -64,12 +70,51 @@ export class MiningJob {
const partOneIndex = serializedCoinbaseTx.indexOf(inputScript) + inputScript.length;
this.coinbasePart1 = serializedCoinbaseTx.slice(0, partOneIndex - 16);
this.coinbasePart1 = serializedCoinbaseTx.slice(0, partOneIndex - (TOTAL_EXTRANONCE_SIZE_BYTES * 2));
this.coinbasePart2 = serializedCoinbaseTx.slice(partOneIndex);
this.coinbasePart1Buffer = Buffer.from(this.coinbasePart1, 'hex');
this.coinbasePart2Buffer = Buffer.from(this.coinbasePart2, 'hex');
}
public cloneCoinbaseTransaction(): bitcoinjs.Transaction {
return bitcoinjs.Transaction.fromBuffer(this.coinbaseTransaction.toBuffer());
}
public getCoinbasePrefixBuffer(): Buffer {
return Buffer.from(this.coinbasePart1Buffer);
}
public getCoinbaseSuffixBuffer(): Buffer {
return Buffer.from(this.coinbasePart2Buffer);
}
public buildHeaderBuffer(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): Buffer {
const coinbaseBuffer = Buffer.concat([
this.coinbasePart1Buffer,
Buffer.from(`${extraNonce}${extraNonce2}`, 'hex'),
this.coinbasePart2Buffer,
]);
const coinbaseHash = bitcoinjs.crypto.hash256(coinbaseBuffer);
const merkleRoot = this.calculateMerkleRootHash(coinbaseHash, this.merkleBranchBuffers);
let version = jobTemplate.block.version;
if (versionMask !== undefined && versionMask != 0) {
version = version ^ versionMask;
}
const header = Buffer.alloc(80);
header.writeInt32LE(version, 0);
jobTemplate.block.prevHash.copy(header, 4);
merkleRoot.copy(header, 36);
header.writeUInt32LE(timestamp, 68);
header.writeUInt32LE(jobTemplate.block.bits, 72);
header.writeUInt32LE(nonce, 76);
return header;
}
public copyAndUpdateBlock(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): bitcoinjs.Block {
const testBlock = Object.assign(new bitcoinjs.Block(), jobTemplate.block);
@@ -77,7 +122,7 @@ export class MiningJob {
return Object.assign(new bitcoinjs.Transaction(), tx);
});
testBlock.transactions[0] = this.coinbaseTransaction;
testBlock.transactions[0] = this.cloneCoinbaseTransaction();
testBlock.nonce = nonce;
@@ -89,10 +134,10 @@ export class MiningJob {
// set the nonces
const nonceScript = testBlock.transactions[0].ins[0].script.toString('hex');
testBlock.transactions[0].ins[0].script = Buffer.from(`${nonceScript.substring(0, nonceScript.length - 16)}${extraNonce}${extraNonce2}`, 'hex');
testBlock.transactions[0].ins[0].script = Buffer.from(`${nonceScript.substring(0, nonceScript.length - (TOTAL_EXTRANONCE_SIZE_BYTES * 2))}${extraNonce}${extraNonce2}`, 'hex');
//recompute the root since we updated the coinbase script with the nonces
testBlock.merkleRoot = this.calculateMerkleRootHash(testBlock.transactions[0].getHash(false), jobTemplate.merkle_branch);
testBlock.merkleRoot = this.calculateMerkleRootHash(testBlock.transactions[0].getHash(false), this.merkleBranchBuffers);
testBlock.timestamp = timestamp;
@@ -101,14 +146,14 @@ export class MiningJob {
}
private calculateMerkleRootHash(newRoot: Buffer, merkleBranches: string[]): Buffer {
private calculateMerkleRootHash(newRoot: Buffer, merkleBranches: Buffer[]): Buffer {
const bothMerkles = Buffer.alloc(64);
bothMerkles.set(newRoot);
for (let i = 0; i < merkleBranches.length; i++) {
bothMerkles.set(Buffer.from(merkleBranches[i], 'hex'), 32);
bothMerkles.set(merkleBranches[i], 32);
newRoot = bitcoinjs.crypto.hash256(bothMerkles);
bothMerkles.set(newRoot);
}
-235
View File
@@ -1,235 +0,0 @@
import Big from 'big.js';
import { Block } from 'bitcoinjs-lib';
import * as bitcoinjs from 'bitcoinjs-lib';
import { plainToInstance } from 'class-transformer';
import { validate, ValidatorOptions } from 'class-validator';
import { Socket } from 'net';
import { eRequestMethod } from './enums/eRequestMethod';
import { eResponseMethod } from './enums/eResponseMethod';
import { IMiningNotify } from './stratum-messages/IMiningNotify';
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
export class ProxyClient {
public braiinsSocket: Socket;
public serverDifficulty: number;
public jobs: { [jobId: string]: IMiningNotify } = {};
private extraNonce: string;
constructor(public socket: Socket) {
this.braiinsSocket = new Socket();
this.braiinsSocket.connect(3333, 'us-east.stratum.braiins.com', () => {
console.log('Connected to braiins');
});
this.braiinsSocket.on('data', (data) => {
data.toString()
.split('\n')
.filter(m => m.length > 0)
.forEach(async (m) => {
try {
await this.toClient(m);
} catch (e) {
await socket.end();
console.error(e);
}
})
});
this.braiinsSocket.on('close', () => {
console.log('Braiins closed connection');
socket.end();
});
socket.on('error', async (error: Error) => { });
socket.on('data', (data: Buffer) => {
data.toString()
.split('\n')
.filter(m => m.length > 0)
.forEach(async (m) => {
try {
await this.toServer(m);
} catch (e) {
await socket.end();
console.error(e);
}
})
});
socket.on('close', () => {
console.log('Client closed connection');
this.braiinsSocket.end();
})
}
private async toClient(message: string) {
let parsedMessage: IMiningNotify | any;
try {
parsedMessage = JSON.parse(message);
} catch (e) {
//console.log("Invalid JSON");
await this.socket.end();
return;
}
if (parsedMessage.method == null && Array.isArray(parsedMessage.result)) {
this.extraNonce = parsedMessage.result[1];
}
switch (parsedMessage.method) {
case eResponseMethod.SET_DIFFICULTY: {
this.serverDifficulty = parsedMessage.params[0];
parsedMessage.params[0] = 512;
message = JSON.stringify(parsedMessage);
break;
}
case eResponseMethod.MINING_NOTIFY: {
// clear jobs
if ((parsedMessage as IMiningNotify).params[8]) {
this.jobs = {};
}
this.jobs[parsedMessage.params[0]] = parsedMessage;
break;
}
}
console.log('Server:');
console.log(message);
this.socket.write(message + `\n`);
}
private async toServer(message: string) {
let parsedMessage = null;
try {
parsedMessage = JSON.parse(message);
} catch (e) {
//console.log("Invalid JSON");
await this.socket.end();
return;
}
switch (parsedMessage.method) {
case eRequestMethod.AUTHORIZE: {
parsedMessage.params[0] = 'battlechicken';
message = JSON.stringify(parsedMessage);
break;
}
case eRequestMethod.SUBMIT: {
parsedMessage.params[0] = 'battlechicken';
message = JSON.stringify(parsedMessage);
const miningSubmitMessage = plainToInstance(
MiningSubmitMessage,
parsedMessage,
);
const validatorOptions: ValidatorOptions = {
whitelist: true,
forbidNonWhitelisted: true,
};
const errors = await validate(miningSubmitMessage, validatorOptions);
if (errors.length === 0) {
this.checkSubmission(miningSubmitMessage);
}
else {
console.log('parsing error')
}
break;
}
}
console.log('Client:');
console.log(message);
this.braiinsSocket.write(message + '\n');
}
private checkSubmission(submission: MiningSubmitMessage) {
console.log('Submission:');
console.log(submission);
const job = this.jobs[submission.jobId];
if (job == null) {
console.log('Job not found');
return;
}
const versionMask = parseInt(submission.versionMask, 16);
const block = new Block();
block.version = parseInt(job.params[5], 16);
if (submission.versionMask !== undefined && versionMask != 0) {
block.version = (block.version ^ versionMask);
}
const prevHash = this.swapEndianWords(job.params[1]);
block.prevHash = Buffer.from(prevHash, 'hex');
const coinbase = Buffer.from(`${job.params[2]}${this.extraNonce}${submission.extraNonce2}${job.params[3]}`, 'hex');
const coinbaseHash = bitcoinjs.crypto.hash256(coinbase);
block.merkleRoot = this.calculateMerkleRootHash(coinbaseHash, job.params[4]);
block.timestamp = parseInt(submission.ntime, 16);
block.bits = parseInt(job.params[6], 16);
block.nonce = parseInt(submission.nonce, 16);
const header = block.toBuffer(true);
const diff = this.calculateDifficulty(header);
console.log(`DIFFICULTY: ${diff.submissionDifficulty}`)
}
private calculateMerkleRootHash(newRoot: Buffer, merkleBranches: string[]): Buffer {
const bothMerkles = Buffer.alloc(64);
bothMerkles.set(newRoot);
for (let i = 0; i < merkleBranches.length; i++) {
bothMerkles.set(Buffer.from(merkleBranches[i], 'hex'), 32);
newRoot = bitcoinjs.crypto.hash256(bothMerkles);
bothMerkles.set(newRoot);
}
return bothMerkles.subarray(0, 32)
}
private calculateDifficulty(header: Buffer): { submissionDifficulty: number, submissionHash: string } {
const hashResult = bitcoinjs.crypto.hash256(header);
let s64 = this.le256todouble(hashResult);
const truediffone = Big('26959535291011309493156476344723991336010898738574164086137773096960');
const difficulty = truediffone.div(s64.toString());
return { submissionDifficulty: difficulty.toNumber(), submissionHash: hashResult.toString('hex') };
}
private le256todouble(target: Buffer): bigint {
const number = target.reduceRight((acc, byte) => {
// Shift the number 8 bits to the left and OR with the current byte
return (acc << BigInt(8)) | BigInt(byte);
}, BigInt(0));
return number;
}
private swapEndianWords(str: string) {
const hexGroups = str.match(/.{1,8}/g);
// Reverse each group and concatenate them
const reversedHexString = hexGroups.reduce((pre, cur, indx, arr) => {
const reversed = cur.match(/.{2}/g).reverse();
return `${pre}${reversed.join('')}`;
}, '');
return reversedHexString;
}
}
+471 -87
View File
@@ -1,25 +1,18 @@
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Socket } from 'net';
import { BehaviorSubject } from 'rxjs';
import { DataSource } from 'typeorm';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { AddressSettingsModule } from '../ORM/address-settings/address-settings.module';
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
import { BlocksService } from '../ORM/blocks/blocks.service';
import { ClientStatisticsEntity } from '../ORM/client-statistics/client-statistics.entity';
import { ClientStatisticsModule } from '../ORM/client-statistics/client-statistics.module';
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
import { ClientEntity } from '../ORM/client/client.entity';
import { ClientModule } from '../ORM/client/client.module';
import { ClientService } from '../ORM/client/client.service';
import { BitcoinRpcService as MockBitcoinRpcService } from '../services/bitcoin-rpc.service';
import { NotificationService } from '../services/notification.service';
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { IMiningInfo } from './bitcoin-rpc/IMiningInfo';
import { IBlockTemplate } from './bitcoin-rpc/IBlockTemplate';
import { MiningJob } from './MiningJob';
import { StratumV1Client } from './StratumV1Client';
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
@@ -42,75 +35,71 @@ describe('StratumV1Client', () => {
let bitcoinRpcService: MockBitcoinRpcService;
let clientService: ClientService;
let clientStatisticsService: ClientStatisticsService;
let notificationService: NotificationService;
let blocksService: BlocksService;
let configService: ConfigService;
let addressSettings: AddressSettingsService;
let shareAccountingService: { recordAcceptedShare: jest.Mock };
let redisMessagingService: { setClientPresence: jest.Mock; removeClientPresence: jest.Mock };
let client: StratumV1Client;
let socketEmitter: (...args: any[]) => void;
const emitMessage = (message: string) => socketEmitter(Buffer.from(`${message}\n`));
let consoleLogSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
let consoleWarnSpy: jest.SpyInstance;
let newBlockEmitter: BehaviorSubject<IMiningInfo> = new BehaviorSubject(null);
let moduleRef: TestingModule;
beforeAll(async () => {
moduleRef = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: './DB/public-pool.test.sqlite',
synchronize: true,
autoLoadEntities: true,
cache: true,
logging: false
}),
ClientModule,
ClientStatisticsModule,
AddressSettingsModule
],
providers: [
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string) => {
switch (key) {
case 'DEV_FEE_ADDRESS':
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
case 'NETWORK':
return 'testnet';
}
return null;
})
}
}
],
}).compile();
})
let newBlockEmitter: BehaviorSubject<IBlockTemplate> = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
beforeEach(async () => {
console.log('NEW TEST')
jest.useFakeTimers({ advanceTimers: true })
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
clientService = moduleRef.get<ClientService>(ClientService);
const clients = new Map<string, any>();
let nextClientId = 1;
clientService = {
insert: jest.fn(async (partialClient) => {
const clientEntity = {
id: `00000000-0000-4000-8000-${String(nextClientId++).padStart(12, '0')}`,
hashRate: 0,
updatedAt: new Date(),
deletedAt: null,
...partialClient,
};
clients.set(clientEntity.id, clientEntity);
return clientEntity;
}),
delete: jest.fn(async (id: string) => {
clients.delete(id);
}),
connectedClientCount: jest.fn(async () => clients.size),
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue({ affected: 1 }),
} as any;
const dataSource = moduleRef.get<DataSource>(DataSource);
configService = {
get: jest.fn((key: string) => {
switch (key) {
case 'DEV_FEE_ADDRESS':
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
case 'NETWORK':
return 'testnet';
}
return null;
})
} as any;
(StratumV1Client as any).blockedUserAgentLogState.clear();
(StratumV1Client as any).validationErrorLogState.clear();
dataSource.getRepository(ClientEntity).delete({});
dataSource.getRepository(ClientStatisticsEntity).delete({});
clientStatisticsService = moduleRef.get<ClientStatisticsService>(ClientStatisticsService);
configService = moduleRef.get<ConfigService>(ConfigService);
bitcoinRpcService = new MockBitcoinRpcService(null);
jest.spyOn(bitcoinRpcService, 'getBlockTemplate').mockReturnValue(Promise.resolve(MockRecording1.BLOCK_TEMPLATE));
bitcoinRpcService.newBlock$ = newBlockEmitter.asObservable();
bitcoinRpcService = {
newBlockTemplate$: newBlockEmitter.asObservable(),
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height },
SUBMIT_BLOCK: jest.fn().mockResolvedValue(null)
} as any;
stratumV1JobsService = new StratumV1JobsService(bitcoinRpcService);
@@ -126,8 +115,26 @@ describe('StratumV1Client', () => {
});
socket.end = jest.fn();
jest.spyOn(socket, 'destroy').mockImplementation(() => socket);
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
addressSettings = {
getSettings: jest.fn().mockResolvedValue(null),
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue({ affected: 1 }),
resetBestDifficultyAndShares: jest.fn().mockResolvedValue(undefined),
} as any;
notificationService = {
notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined)
} as any;
blocksService = {
save: jest.fn().mockResolvedValue(undefined)
} as any;
shareAccountingService = {
recordAcceptedShare: jest.fn().mockResolvedValue(undefined),
};
redisMessagingService = {
setClientPresence: jest.fn().mockResolvedValue(undefined),
removeClientPresence: jest.fn().mockResolvedValue(undefined),
};
client = new StratumV1Client(
@@ -135,20 +142,24 @@ describe('StratumV1Client', () => {
stratumV1JobsService,
bitcoinRpcService,
clientService,
clientStatisticsService,
notificationService,
blocksService,
configService,
addressSettings
addressSettings,
shareAccountingService as any,
redisMessagingService as any
);
client.extraNonceAndSessionId = MockRecording1.EXTRA_NONCE;
jest.spyOn(client as any, 'getRandomHexString').mockReturnValue(MockRecording1.EXTRA_NONCE);
jest.useFakeTimers({ advanceTimers: true })
});
afterEach(async () => {
client.destroy();
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
consoleWarnSpy.mockRestore();
jest.useRealTimers();
})
@@ -157,8 +168,38 @@ describe('StratumV1Client', () => {
expect(socket.on).toHaveBeenCalled();
});
it('should clean up socket state only once when destroyed repeatedly', async () => {
const unsubscribe = jest.fn();
const timer = setInterval(() => undefined, 1000);
const removeListenerSpy = jest.spyOn(socket, 'removeListener');
(client as any).clientEntity = {
id: '00000000-0000-4000-8000-000000000001',
address: 'tb1qcleanup',
};
(client as any).stratumSubscription = { unsubscribe };
(client as any).backgroundWork = [timer];
(client as any).miningSubmissionHashes.add('submitted-share');
(client as any).buffer = 'partial-message';
await Promise.all([client.destroy(), client.destroy()]);
expect(redisMessagingService.removeClientPresence).toHaveBeenCalledTimes(1);
expect(redisMessagingService.removeClientPresence).toHaveBeenCalledWith(
'00000000-0000-4000-8000-000000000001',
'tb1qcleanup',
);
expect(clientService.delete).toHaveBeenCalledTimes(1);
expect(clientService.delete).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000001');
expect(unsubscribe).toHaveBeenCalledTimes(1);
expect(removeListenerSpy).toHaveBeenCalledWith('data', expect.any(Function));
expect((client as any).backgroundWork).toEqual([]);
expect((client as any).miningSubmissionHashes.size).toBe(0);
expect((client as any).buffer).toBe('');
});
it('should close socket on invalid JSON', () => {
socketEmitter(Buffer.from('INVALID'));
emitMessage('INVALID');
jest.spyOn(socket, 'destroy');
expect(socket.on).toHaveBeenCalled();
});
@@ -167,21 +208,98 @@ describe('StratumV1Client', () => {
jest.spyOn(socket, 'write').mockImplementation((data) => true);
expect(socket.on).toHaveBeenCalled();
socketEmitter(Buffer.from(MockRecording1.MINING_SUBSCRIBE));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
await new Promise((r) => setTimeout(r, 1));
expect(socket.write).toHaveBeenCalledWith(`{"id":1,"error":null,"result":[[["mining.notify","${client.extraNonceAndSessionId}"]],"${client.extraNonceAndSessionId}",4]}\n`, expect.any(Function));
expect(socket.write).toHaveBeenCalledWith(`{"id":1,"error":null,"result":[[["mining.notify","${client.extraNonceAndSessionId}"]],"${client.extraNonceAndSessionId}",8]}\n`, expect.any(Function));
});
it('should disable application idle timeout after Stratum initialization', async () => {
const setTimeoutSpy = jest.spyOn(socket, 'setTimeout').mockImplementation(() => socket);
jest.spyOn(client as any, 'write').mockImplementation(() => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
expect(setTimeoutSpy).toHaveBeenCalledWith(0);
});
it('should block non-compliant user agents on subscribe without allocating a session', async () => {
(configService.get as jest.Mock).mockImplementation((key: string) => {
switch (key) {
case 'NON_COMPLIANT_USER_AGENTS':
return 'NMMiner';
case 'DEV_FEE_ADDRESS':
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
case 'NETWORK':
return 'testnet';
}
return null;
});
jest.spyOn(socket, 'write').mockImplementation((data) => true);
emitMessage(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}`);
await new Promise((r) => setTimeout(r, 1));
expect(socket.destroy).toHaveBeenCalled();
expect(socket.write).not.toHaveBeenCalled();
expect((client as any).statistics).toBeUndefined();
expect(consoleLogSpy).toHaveBeenCalledWith('Blocked non-compliant connection from userAgent: NMMiner');
});
it('should throttle repeated non-compliant user agent logs', async () => {
(configService.get as jest.Mock).mockImplementation((key: string) => {
switch (key) {
case 'NON_COMPLIANT_USER_AGENTS':
return 'NMMiner';
case 'DEV_FEE_ADDRESS':
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
case 'NETWORK':
return 'testnet';
}
return null;
});
jest.spyOn(socket, 'write').mockImplementation((data) => true);
emitMessage(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}`);
await new Promise((r) => setTimeout(r, 1));
const secondSocket = new Socket();
jest.spyOn(secondSocket, 'on').mockImplementation((event: string, listener: (...args: any[]) => void) => {
socketEmitter = listener;
return secondSocket;
});
secondSocket.end = jest.fn();
jest.spyOn(secondSocket, 'destroy').mockImplementation(() => secondSocket);
const secondClient = new StratumV1Client(
secondSocket,
stratumV1JobsService,
bitcoinRpcService,
clientService,
notificationService,
blocksService,
configService,
addressSettings
);
socketEmitter(Buffer.from(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}\n`));
await new Promise((r) => setTimeout(r, 1));
expect(secondSocket.destroy).toHaveBeenCalled();
expect(consoleLogSpy.mock.calls.filter(call => call[0]?.startsWith('Blocked non-compliant connection'))).toHaveLength(1);
await secondClient.destroy();
});
it('should respond to mining.configure', async () => {
jest.spyOn(socket, 'write').mockImplementation((data) => true);
expect(socket.on).toHaveBeenCalled();
socketEmitter(Buffer.from(MockRecording1.MINING_CONFIGURE));
emitMessage(MockRecording1.MINING_CONFIGURE);
await new Promise((r) => setTimeout(r, 1));
expect(socket.write).toHaveBeenCalledWith(`{"id":2,"error":null,"result":{"version-rolling":true,"version-rolling.mask":"1fffe000"}}\n`, expect.any(Function));
});
@@ -191,7 +309,7 @@ describe('StratumV1Client', () => {
jest.spyOn(socket, 'write').mockImplementation((data) => true);
expect(socket.on).toHaveBeenCalled();
socketEmitter(Buffer.from(MockRecording1.MINING_AUTHORIZE));
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 1));
expect(socket.write).toHaveBeenCalledWith('{"id":3,"error":null,"result":true}\n', expect.any(Function));
});
@@ -200,28 +318,50 @@ describe('StratumV1Client', () => {
jest.spyOn(socket, 'write').mockImplementation((data) => true);
expect(socket.on).toHaveBeenCalled();
socketEmitter(Buffer.from(MockRecording1.MINING_SUGGEST_DIFFICULTY));
emitMessage(MockRecording1.MINING_SUGGEST_DIFFICULTY);
await new Promise((r) => setTimeout(r, 1));
expect(socket.write).toHaveBeenCalledWith(`{"id":4,"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 () => {
(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);
emitMessage(`{"id":4,"method":"mining.suggest_difficulty","params":[0]}`);
await new Promise((r) => setTimeout(r, 1));
expect(socket.write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[1]}\n`, expect.any(Function));
});
it('should set difficulty', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
console.log('should set difficulty')
socketEmitter(Buffer.from(MockRecording1.MINING_SUBSCRIBE));
socketEmitter(Buffer.from(MockRecording1.MINING_AUTHORIZE));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[16384]}\n`);
expect((client as any).write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[100000]}\n`);
});
it('should save client', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
socketEmitter(Buffer.from(MockRecording1.MINING_SUBSCRIBE));
socketEmitter(Buffer.from(MockRecording1.MINING_AUTHORIZE));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
const clientCount = await clientService.connectedClientCount();
@@ -244,9 +384,9 @@ describe('StratumV1Client', () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
socketEmitter(Buffer.from(MockRecording1.MINING_SUBSCRIBE));
socketEmitter(Buffer.from(MockRecording1.MINING_SUGGEST_DIFFICULTY));
socketEmitter(Buffer.from(MockRecording1.MINING_AUTHORIZE));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
@@ -255,19 +395,263 @@ describe('StratumV1Client', () => {
expect((client as any).write).lastCalledWith(`{"id":null,"method":"mining.notify","params":["1","171592f223740e92d223f6e68bff25279af7ac4f2246451e0000000200000000","02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff1903c943255c7075626c69632d706f6f6c5c","ffffffff037a90000000000000160014e6f22ca44dc800e9d049621a3b9a42c509f1c4bc3b0f250000000000160014e6f22ca44dc800e9d049621a3b9a42c509f1c4bc0000000000000000266a24aa21a9edbd3d1d916aa0b57326a2d88ebe1b68a1d7c48585f26d8335fe6a94b62755f64c00000000",["175335649d5e8746982969ec88f52e85ac9917106fba5468e699c8879ab974a1","d5644ab3e708c54cd68dc5aedc92b8d3037449687f92ec41ed6e37673d969d4a","5c9ec187517edc0698556cca5ce27e54c96acb014770599ed9df4d4937fbf2b0"],"20000000","192495f8","${MockRecording1.TIME}",false]}\n`);
expect((client as any).write).lastCalledWith(`{"id":null,"method":"mining.notify","params":["1","171592f223740e92d223f6e68bff25279af7ac4f2246451e0000000200000000","02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff1b03c943255075626c69632d506f6f6c","ffffffff02b59f250000000000160014e6f22ca44dc800e9d049621a3b9a42c509f1c4bc0000000000000000266a24aa21a9edbd3d1d916aa0b57326a2d88ebe1b68a1d7c48585f26d8335fe6a94b62755f64c00000000",["175335649d5e8746982969ec88f52e85ac9917106fba5468e699c8879ab974a1","d5644ab3e708c54cd68dc5aedc92b8d3037449687f92ec41ed6e37673d969d4a","5c9ec187517edc0698556cca5ce27e54c96acb014770599ed9df4d4937fbf2b0"],"20000000","192495f8","${MockRecording1.TIME}",true]}\n`);
socketEmitter(Buffer.from(MockRecording1.MINING_SUBMIT));
emitMessage(MockRecording1.MINING_SUBMIT);
jest.useRealTimers();
await new Promise((r) => setTimeout(r, 1000));
expect((client as any).write).lastCalledWith(`{\"id\":5,\"error\":null,\"result\":true}\n`);
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
protocol: 'sv1',
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'bitaxe3',
sessionId: MockRecording1.EXTRA_NONCE,
jobId: '1',
jobTemplateId: '1',
creditedDifficulty: 0,
isBlockCandidate: false,
}));
expect(redisMessagingService.setClientPresence).toHaveBeenCalledWith(expect.objectContaining({
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'bitaxe3',
sessionId: MockRecording1.EXTRA_NONCE,
}));
});
it('should use the header-only fast path for non-block submissions', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
const buildHeaderSpy = jest.spyOn(MiningJob.prototype, 'buildHeaderBuffer');
const fullBlockSpy = jest.spyOn(MiningJob.prototype, 'copyAndUpdateBlock');
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
jest.useRealTimers();
await new Promise((r) => setTimeout(r, 1000));
expect(buildHeaderSpy).toHaveBeenCalled();
expect(fullBlockSpy).not.toHaveBeenCalled();
});
it('should write accepted response before share accounting finishes', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
let finishAccounting: () => void;
const accountingPromise = new Promise<void>((resolve) => {
finishAccounting = resolve;
});
jest.spyOn((client as any).statistics, 'addShares').mockReturnValue(accountingPromise);
emitMessage(MockRecording1.MINING_SUBMIT);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect((client as any).write).toHaveBeenCalledWith(`{"id":5,"error":null,"result":true}\n`);
finishAccounting();
jest.useRealTimers();
await new Promise((r) => setTimeout(r, 100));
});
it('should update address best difficulty through the atomic path', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
submissionDifficulty: 1024,
submissionHash: 'share'
});
const getSettingsSpy = jest.spyOn(addressSettings, 'getSettings');
const updateIfHigherSpy = jest.spyOn(addressSettings as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 });
const clientUpdateIfHigherSpy = jest.spyOn(clientService as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 });
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
jest.useRealTimers();
await new Promise((r) => setTimeout(r, 1000));
expect(updateIfHigherSpy).toHaveBeenCalledWith(
'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
1024,
expect.any(String)
);
expect(clientUpdateIfHigherSpy).toHaveBeenCalledWith(expect.any(String), 1024);
expect(getSettingsSpy).not.toHaveBeenCalled();
});
it('should reject duplicate submissions', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[22,"Duplicate share",""]}\n`);
});
it('should reject submissions for unknown jobs', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
const hashSpy = jest.spyOn(MiningSubmitMessage.prototype, 'hash');
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "ff", "c708000000000000", "64b3f3ec", "ed460d91", "00002000"]}`);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job not found",""]}\n`);
expect(await clientService.connectedClientCount()).toBe(0);
expect(hashSpy).not.toHaveBeenCalled();
});
it('should reject submissions when the job template has expired', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
stratumV1JobsService.blocks = {};
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job Template not found",""]}\n`);
});
it('should reject low difficulty shares', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_SUGGEST_DIFFICULTY);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[23,"Difficulty too low",""]}\n`);
expect(await clientService.connectedClientCount()).toBe(0);
});
it('should reject submissions with short extranonce2 values', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c7080000", "64b3f3ec", "ed460d91", "00002000"]}`);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(expect.stringContaining(`"error":[20,"Mining Submit validation error"`));
expect(socket.destroy).toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Mining Submit validation error: extraNonce2:isLength'));
});
it('should throttle repeated mining submit validation logs', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c7080000", "64b3f3ec", "ed460d91", "00002000"]}`);
await new Promise((r) => setTimeout(r, 100));
const secondSocket = new Socket();
jest.spyOn(secondSocket, 'on').mockImplementation((event: string, listener: (...args: any[]) => void) => {
socketEmitter = listener;
return secondSocket;
});
secondSocket.end = jest.fn();
jest.spyOn(secondSocket, 'destroy').mockImplementation(() => secondSocket);
const secondClient = new StratumV1Client(
secondSocket,
stratumV1JobsService,
bitcoinRpcService,
clientService,
notificationService,
blocksService,
configService,
addressSettings
);
jest.spyOn(secondClient as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(secondClient as any, 'getRandomHexString').mockReturnValue(MockRecording1.EXTRA_NONCE);
socketEmitter(Buffer.from(`${MockRecording1.MINING_SUBSCRIBE}\n`));
socketEmitter(Buffer.from(`${MockRecording1.MINING_AUTHORIZE}\n`));
await new Promise((r) => setTimeout(r, 100));
socketEmitter(Buffer.from(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c7080000", "64b3f3ec", "ed460d91", "00002000"]}\n`));
await new Promise((r) => setTimeout(r, 100));
expect(consoleWarnSpy.mock.calls.filter(call => call[0]?.startsWith('Mining Submit validation error'))).toHaveLength(1);
await secondClient.destroy();
});
it('should close socket when a submit arrives before stratum is initialized', async () => {
const endSpy = jest.spyOn(socket, 'end');
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect(endSpy).toHaveBeenCalled();
});
it('should submit and persist found blocks', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
submissionDifficulty: Number.MAX_SAFE_INTEGER,
submissionHash: 'block-share'
});
jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined);
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
jest.useRealTimers();
await new Promise((r) => setTimeout(r, 1000));
expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledWith(expect.any(String));
expect(blocksService.save).toHaveBeenCalledWith(expect.objectContaining({
height: MockRecording1.BLOCK_TEMPLATE.height,
minerAddress: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
worker: 'bitaxe3',
sessionId: MockRecording1.EXTRA_NONCE,
blockData: expect.any(String)
}));
expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalled();
expect(addressSettings.resetBestDifficultyAndShares).toHaveBeenCalled();
expect((client as any).write).lastCalledWith(`{"id":5,"error":null,"result":true}\n`);
});
});
+405 -146
View File
@@ -1,8 +1,7 @@
import { ConfigService } from '@nestjs/config';
import Big from 'big.js';
import * as bitcoinjs from 'bitcoinjs-lib';
import { plainToInstance } from 'class-transformer';
import { validate, ValidatorOptions } from 'class-validator';
import { validate, ValidationError, ValidatorOptions } from 'class-validator';
import * as crypto from 'crypto';
import { Socket } from 'net';
import { firstValueFrom, Subscription } from 'rxjs';
@@ -10,11 +9,12 @@ import { clearInterval } from 'timers';
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
import { BlocksService } from '../ORM/blocks/blocks.service';
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
import { ClientEntity } from '../ORM/client/client.entity';
import { ClientService } from '../ORM/client/client.service';
import { ShareAccountingService } from '../ORM/share-accounting/share-accounting.service';
import { BitcoinRpcService } from '../services/bitcoin-rpc.service';
import { NotificationService } from '../services/notification.service';
import { RedisMessagingService } from '../services/redis-messaging.service';
import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { eRequestMethod } from './enums/eRequestMethod';
import { eResponseMethod } from './enums/eResponseMethod';
@@ -25,80 +25,126 @@ import { ConfigurationMessage } from './stratum-messages/ConfigurationMessage';
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
import { StratumErrorMessage } from './stratum-messages/StratumErrorMessage';
import { SubscriptionMessage } from './stratum-messages/SubscriptionMessage';
import { EXTRANONCE1_SIZE_BYTES } from './stratum.constants';
import { SuggestDifficulty } from './stratum-messages/SuggestDifficultyMessage';
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
const TRUE_DIFF_ONE = 2.695953529101131e67;
const BLOCKED_USER_AGENT_LOG_INTERVAL_MS = 60 * 1000;
const VALIDATION_ERROR_LOG_INTERVAL_MS = 60 * 1000;
const DEFAULT_MIN_DIFFICULTY = 0.001;
export class StratumV1Client {
private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>();
private static validationErrorLogState = new Map<string, { nextLogAt: number, suppressed: number, sample: string }>();
private clientSubscription: SubscriptionMessage;
public clientSubscription: SubscriptionMessage;
private clientConfiguration: ConfigurationMessage;
private clientAuthorization: AuthorizationMessage;
private clientSuggestedDifficulty: SuggestDifficulty;
private stratumSubscription: Subscription;
private backgroundWork: NodeJS.Timer[] = [];
private backgroundWork: NodeJS.Timeout[] = [];
private readonly socketDataHandler: (data: Buffer) => void;
private destroyPromise: Promise<void> | null = null;
private statistics: StratumV1ClientStatistics;
private stratumInitialized = false;
private usedSuggestedDifficulty = false;
private sessionDifficulty: number = 16384;
private sessionDifficulty: number = 100000;
private entity: ClientEntity;
private clientEntity: ClientEntity;
private creatingEntity: Promise<void>;
public extraNonceAndSessionId: string;
public sessionStart: Date;
public noFee: boolean;
public hashRate: number;
//public noFee: boolean;
//public hashRate: number = 0;
private buffer: string = '';
private connectionClosed = false;
private lastSentMiningJobTimestamp: number = null;
private miningSubmissionHashes = new Set<string>()
constructor(
public readonly socket: Socket,
private readonly stratumV1JobsService: StratumV1JobsService,
private readonly bitcoinRpcService: BitcoinRpcService,
private readonly clientService: ClientService,
private readonly clientStatisticsService: ClientStatisticsService,
private readonly notificationService: NotificationService,
private readonly blocksService: BlocksService,
private readonly configService: ConfigService,
private readonly addressSettingsService: AddressSettingsService
private readonly addressSettingsService: AddressSettingsService,
private readonly shareAccountingService?: ShareAccountingService,
private readonly redisMessagingService?: RedisMessagingService
) {
this.socket.on('data', (data: Buffer) => {
data.toString()
.split('\n')
.filter(m => m.length > 0)
.forEach(async (m) => {
try {
await this.handleMessage(m);
} catch (e) {
await this.socket.end();
console.error(e);
}
})
});
this.socketDataHandler = (data: Buffer) => {
void this.handleSocketData(data);
};
this.socket.on('data', this.socketDataHandler);
}
public async destroy() {
public async destroy(): Promise<void> {
if (this.destroyPromise != null) {
return this.destroyPromise;
}
await this.clientService.delete(this.extraNonceAndSessionId);
this.destroyPromise = this.destroyInternal();
return this.destroyPromise;
}
private async destroyInternal(): Promise<void> {
this.connectionClosed = true;
this.socket.removeListener('data', this.socketDataHandler);
this.buffer = '';
if (this.stratumSubscription != null) {
this.stratumSubscription.unsubscribe();
this.stratumSubscription = null;
}
this.backgroundWork.forEach(work => {
for (const work of this.backgroundWork) {
clearInterval(work);
});
}
this.backgroundWork = [];
this.miningSubmissionHashes.clear();
if (this.clientEntity?.id) {
const clientId = this.clientEntity.id;
const address = this.clientEntity.address;
await this.redisMessagingService?.removeClientPresence(clientId, address);
await this.clientService.delete(clientId);
}
}
private async handleSocketData(data: Buffer): Promise<void> {
if (this.connectionClosed || this.socket.destroyed || this.socket.writableEnded) {
return;
}
this.buffer += data.toString();
const lines = this.buffer.split('\n');
this.buffer = lines.pop() || ''; // Save the last part of the data (incomplete line) to the buffer
for (const m of lines.filter(l => l.length > 0)) {
if (this.connectionClosed || this.socket.destroyed || this.socket.writableEnded) {
break;
}
try {
await this.handleMessage(m);
} catch (e) {
await this.socket.end();
console.error(e);
}
}
}
private getRandomHexString() {
const randomBytes = crypto.randomBytes(4); // 4 bytes = 32 bits
const randomNumber = randomBytes.readUInt32BE(0); // Convert bytes to a 32-bit unsigned integer
const hexString = randomNumber.toString(16).padStart(8, '0'); // Convert to hex and pad with zeros
return hexString;
const randomBytes = crypto.randomBytes(EXTRANONCE1_SIZE_BYTES);
return randomBytes.toString('hex');
}
@@ -126,18 +172,23 @@ export class StratumV1Client {
const validatorOptions: ValidatorOptions = {
whitelist: true,
forbidNonWhitelisted: true,
//forbidNonWhitelisted: true,
};
const errors = await validate(subscriptionMessage, validatorOptions);
if (errors.length === 0) {
if (this.isBlockedUserAgent(subscriptionMessage.userAgent)) {
this.logBlockedUserAgent(subscriptionMessage.userAgent);
this.closeSocket();
return;
}
if (this.sessionStart == null) {
this.sessionStart = new Date();
this.statistics = new StratumV1ClientStatistics(this.clientStatisticsService);
this.statistics = new StratumV1ClientStatistics(this.getMinimumDifficulty());
this.extraNonceAndSessionId = this.getRandomHexString();
console.log(`New client ID: : ${this.extraNonceAndSessionId}, ${this.socket.remoteAddress}:${this.socket.remotePort}`);
//console.log(`New client ID: : ${this.extraNonceAndSessionId}, ${this.socket.remoteAddress}:${this.socket.remotePort}`);
}
this.clientSubscription = subscriptionMessage;
@@ -170,7 +221,7 @@ export class StratumV1Client {
const validatorOptions: ValidatorOptions = {
whitelist: true,
forbidNonWhitelisted: true,
//forbidNonWhitelisted: true,
};
const errors = await validate(configurationMessage, validatorOptions);
@@ -184,7 +235,7 @@ export class StratumV1Client {
}
} else {
console.error('Configuration validation error');
console.log('Configuration validation error');
const err = new StratumErrorMessage(
configurationMessage.id,
eStratumErrorCode.OtherUnknown,
@@ -208,25 +259,27 @@ export class StratumV1Client {
const validatorOptions: ValidatorOptions = {
whitelist: true,
forbidNonWhitelisted: true,
//forbidNonWhitelisted: true,
};
const errors = await validate(authorizationMessage, validatorOptions);
if (errors.length === 0) {
this.clientAuthorization = authorizationMessage;
if (this.clientSuggestedDifficulty == null && this.clientAuthorization.startingDiff != null && this.clientAuthorization.startingDiff > this.sessionDifficulty) {
this.sessionDifficulty = this.clientAuthorization.startingDiff;
}
const success = await this.write(JSON.stringify(this.clientAuthorization.response()) + '\n');
if (!success) {
return;
}
} else {
console.error('Authorization validation error');
const err = new StratumErrorMessage(
authorizationMessage.id,
eStratumErrorCode.OtherUnknown,
'Authorization validation error',
errors).response();
console.error(err);
//console.log(err);
const success = await this.write(err);
if (!success) {
return;
@@ -247,7 +300,7 @@ export class StratumV1Client {
const validatorOptions: ValidatorOptions = {
whitelist: true,
forbidNonWhitelisted: true,
//forbidNonWhitelisted: true,
};
const errors = await validate(suggestDifficultyMessage, validatorOptions);
@@ -255,7 +308,7 @@ export class StratumV1Client {
if (errors.length === 0) {
this.clientSuggestedDifficulty = suggestDifficultyMessage;
this.sessionDifficulty = suggestDifficultyMessage.suggestedDifficulty;
this.sessionDifficulty = this.clampDifficulty(suggestDifficultyMessage.suggestedDifficulty);
const success = await this.write(JSON.stringify(this.clientSuggestedDifficulty.response(this.sessionDifficulty)) + '\n');
if (!success) {
return;
@@ -279,7 +332,7 @@ export class StratumV1Client {
case eRequestMethod.SUBMIT: {
if (this.stratumInitialized == false) {
console.log('Submit before initalized');
//console.log('Submit before initalized');
await this.socket.end();
return;
}
@@ -292,7 +345,7 @@ export class StratumV1Client {
const validatorOptions: ValidatorOptions = {
whitelist: true,
forbidNonWhitelisted: true,
//forbidNonWhitelisted: true,
};
const errors = await validate(miningSubmitMessage, validatorOptions);
@@ -308,17 +361,18 @@ export class StratumV1Client {
} else {
console.error('Mining Submit validation error');
this.logValidationError('Mining Submit validation error', errors);
const err = new StratumErrorMessage(
miningSubmitMessage.id,
eStratumErrorCode.OtherUnknown,
'Mining Submit validation error',
errors).response();
console.error(err);
const success = await this.write(err);
if (!success) {
return;
}
this.closeSocket();
return;
}
break;
}
@@ -335,18 +389,19 @@ export class StratumV1Client {
&& this.clientAuthorization != null
&& this.stratumInitialized == false) {
await this.initStratum();
this.initStratum();
}
}
private async initStratum() {
this.stratumInitialized = true;
this.socket.setTimeout(0);
switch (this.clientSubscription.userAgent) {
case 'cpuminer': {
this.sessionDifficulty = 0.1;
}
if (this.isBlockedUserAgent(this.clientSubscription.userAgent)) {
this.logBlockedUserAgent(this.clientSubscription.userAgent);
this.closeSocket();
return;
}
if (this.clientSuggestedDifficulty == null) {
@@ -360,6 +415,9 @@ export class StratumV1Client {
this.stratumSubscription = this.stratumV1JobsService.newMiningJob$.subscribe(async (jobTemplate) => {
try {
if(jobTemplate.blockData.clearJobs){
this.miningSubmissionHashes.clear();
}
await this.sendNewMiningJob(jobTemplate);
} catch (e) {
await this.socket.end();
@@ -373,34 +431,39 @@ export class StratumV1Client {
}, 60 * 1000)
);
this.backgroundWork.push(
setInterval(async () => {
await this.statistics.saveShares(this.entity);
}, 60 * 1000)
);
// this.backgroundWork.push(
// setInterval(async () => {
// await this.statistics.saveShares(this.clientEntity);
// }, 60 * 1000)
// );
}
private async sendNewMiningJob(jobTemplate: IJobTemplate) {
let payoutInformation;
const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS');
//50Th/s
this.noFee = false;
if (this.entity) {
this.hashRate = await this.clientStatisticsService.getHashRateForSession(this.clientAuthorization.address, this.clientAuthorization.worker, this.extraNonceAndSessionId);
this.noFee = this.hashRate != 0 && this.hashRate < 50000000000000;
}
if (this.noFee || devFeeAddress == null || devFeeAddress.length < 1) {
payoutInformation = [
{ address: this.clientAuthorization.address, percent: 100 }
];
let payoutInformation= [
{ address: this.clientAuthorization.address, percent: 100 }
];
// const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS');
// //50Th/s
// this.noFee = false;
// if (this.clientEntity) {
// // 250Gh/s
// if(this.hashRate < 250000000000){
// this.statistics.targetSubmitShareEveryNSeconds = 10;
// }
// this.noFee = this.hashRate != 0 && this.hashRate < 50000000000000;
// }
// if (this.noFee || devFeeAddress == null || devFeeAddress.length < 1) {
// payoutInformation = [
// { address: this.clientAuthorization.address, percent: 100 }
// ];
} else {
payoutInformation = [
{ address: devFeeAddress, percent: 1.5 },
{ address: this.clientAuthorization.address, percent: 98.5 }
];
}
// } else {
// payoutInformation = [
// { address: devFeeAddress, percent: 1.5 },
// { address: this.clientAuthorization.address, percent: 98.5 }
// ];
// }
const networkConfig = this.configService.get('NETWORK');
let network;
@@ -429,6 +492,7 @@ export class StratumV1Client {
if (!success) {
return;
}
this.lastSentMiningJobTimestamp = jobTemplate.block.timestamp;
//console.log(`Sent new job to ${this.clientAuthorization.worker}.${this.extraNonceAndSessionId}. (clearJobs: ${jobTemplate.blockData.clearJobs}, fee?: ${!this.noFee})`)
@@ -436,36 +500,55 @@ export class StratumV1Client {
}
private async handleMiningSubmission(submission: MiningSubmitMessage) {
if (this.entity == null) {
if (this.creatingEntity == null) {
this.creatingEntity = new Promise(async (resolve, reject) => {
try {
this.entity = await this.clientService.insert({
sessionId: this.extraNonceAndSessionId,
address: this.clientAuthorization.address,
clientName: this.clientAuthorization.worker,
userAgent: this.clientSubscription.userAgent,
startTime: new Date(),
bestDifficulty: 0
});
} catch (e) {
reject(e);
}
resolve();
});
await this.creatingEntity;
} else {
await this.creatingEntity;
}
private async ensureClientEntity() {
if (this.clientEntity != null) {
return;
}
if (this.creatingEntity == null) {
this.creatingEntity = (async () => {
this.clientEntity = await this.clientService.insert({
sessionId: this.extraNonceAndSessionId,
address: this.clientAuthorization.address,
clientName: this.clientAuthorization.worker,
userAgent: this.clientSubscription.userAgent,
startTime: new Date(),
bestDifficulty: 0
});
await this.updateClientPresence(new Date());
})();
}
await this.creatingEntity;
}
private async updateClientPresence(lastSeen: Date): Promise<void> {
if (this.clientEntity == null) {
return;
}
try {
await this.redisMessagingService?.setClientPresence({
clientId: this.clientEntity.id,
address: this.clientEntity.address,
clientName: this.clientEntity.clientName,
sessionId: this.clientEntity.sessionId,
userAgent: this.clientEntity.userAgent,
startTime: new Date(this.clientEntity.startTime).toISOString(),
lastSeen: lastSeen.toISOString(),
hashRate: this.statistics?.hashRate ?? 0,
bestDifficulty: Number(this.clientEntity.bestDifficulty ?? 0),
});
} catch (error) {
console.error(`Failed to update SV1 client presence: ${error.message}`);
}
}
private async handleMiningSubmission(submission: MiningSubmitMessage) {
const job = this.stratumV1JobsService.getJobById(submission.jobId);
// a miner may submit a job that doesn't exist anymore if it was removed by a new block notification
// a miner may submit a job that doesn't exist anymore if it was removed by a new block notification (or expired, 5 min)
if (job == null) {
const err = new StratumErrorMessage(
submission.id,
@@ -478,28 +561,79 @@ export class StratumV1Client {
}
return false;
}
const jobTemplate = this.stratumV1JobsService.getJobTemplateById(job.jobTemplateId);
const updatedJobBlock = job.copyAndUpdateBlock(
if (jobTemplate == null) {
const err = new StratumErrorMessage(
submission.id,
eStratumErrorCode.JobNotFound,
'Job Template not found').response();
//console.log(err);
const success = await this.write(err);
if (!success) {
return false;
}
return false;
}
const submissionHash = [
submission.jobId,
submission.extraNonce2,
submission.ntime,
submission.nonce,
submission.versionMask ?? ''
].join(':');
if (this.miningSubmissionHashes.has(submissionHash)) {
const err = new StratumErrorMessage(
submission.id,
eStratumErrorCode.DuplicateShare,
'Duplicate share').response();
const success = await this.write(err);
if (!success) {
return false;
}
return false;
} else {
this.miningSubmissionHashes.add(submissionHash);
}
const versionMask = parseInt(submission.versionMask, 16);
const nonce = parseInt(submission.nonce, 16);
const timestamp = parseInt(submission.ntime, 16);
const header = job.buildHeaderBuffer(
jobTemplate,
parseInt(submission.versionMask, 16),
parseInt(submission.nonce, 16),
versionMask,
nonce,
this.extraNonceAndSessionId,
submission.extraNonce2,
parseInt(submission.ntime, 16)
timestamp
);
const header = updatedJobBlock.toBuffer(true);
const { submissionDifficulty } = this.calculateDifficulty(header);
//console.log(`DIFF: ${submissionDifficulty} of ${this.sessionDifficulty} from ${this.clientAuthorization.worker + '.' + this.extraNonceAndSessionId}`);
if (submissionDifficulty >= this.sessionDifficulty) {
const success = await this.write(JSON.stringify(submission.response()) + '\n');
if (!success) {
return false;
}
let blockSubmissionResult: string = null;
if (submissionDifficulty >= jobTemplate.blockData.networkDifficulty) {
console.log('!!! BLOCK FOUND !!!');
const updatedJobBlock = job.copyAndUpdateBlock(
jobTemplate,
versionMask,
nonce,
this.extraNonceAndSessionId,
submission.extraNonce2,
timestamp
);
const blockHex = updatedJobBlock.toHex(false);
const result = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
blockSubmissionResult = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
await this.blocksService.save({
height: jobTemplate.blockData.height,
minerAddress: this.clientAuthorization.address,
@@ -508,41 +642,50 @@ export class StratumV1Client {
blockData: blockHex
});
await this.notificationService.notifySubscribersBlockFound(this.clientAuthorization.address, jobTemplate.blockData.height, updatedJobBlock, result);
await this.notificationService.notifySubscribersBlockFound(this.clientAuthorization.address, jobTemplate.blockData.height, updatedJobBlock, blockSubmissionResult);
//success
if (result == null) {
if (blockSubmissionResult == null) {
await this.addressSettingsService.resetBestDifficultyAndShares();
}
}
await this.ensureClientEntity();
try {
await this.statistics.addShares(this.sessionDifficulty);
await this.shareAccountingService?.recordAcceptedShare({
protocol: 'sv1',
address: this.clientAuthorization.address,
clientName: this.clientAuthorization.worker,
sessionId: this.extraNonceAndSessionId,
clientId: this.clientEntity.id,
jobId: job.jobId,
jobTemplateId: job.jobTemplateId,
blockHeight: jobTemplate.blockData.height,
creditedDifficulty: this.sessionDifficulty,
submissionDifficulty,
networkDifficulty: jobTemplate.blockData.networkDifficulty,
nonce: submission.nonce,
ntime: submission.ntime,
version: Number.isFinite(versionMask)
? (jobTemplate.block.version ^ versionMask).toString(16)
: jobTemplate.block.version.toString(16),
extraNonce2: submission.extraNonce2,
isBlockCandidate: submissionDifficulty >= jobTemplate.blockData.networkDifficulty,
blockSubmissionResult,
});
await this.statistics.addShares(this.clientEntity, this.sessionDifficulty);
const now = new Date();
// only update every minute
if (this.entity.updatedAt == null || now.getTime() - this.entity.updatedAt.getTime() > 1000 * 60) {
await this.clientService.heartbeat(this.entity.address, this.entity.clientName, this.entity.sessionId, this.hashRate, now);
this.entity.updatedAt = now;
}
this.clientEntity.updatedAt = now;
this.clientEntity.hashRate = this.statistics.hashRate;
await this.updateClientPresence(now);
} catch (e) {
console.log(e);
const err = new StratumErrorMessage(
submission.id,
eStratumErrorCode.DuplicateShare,
'Duplicate share').response();
console.error(err);
const success = await this.write(err);
if (!success) {
return false;
}
return false;
}
if (submissionDifficulty > this.entity.bestDifficulty) {
await this.clientService.updateBestDifficulty(this.extraNonceAndSessionId, submissionDifficulty);
this.entity.bestDifficulty = submissionDifficulty;
if (submissionDifficulty > (await this.addressSettingsService.getSettings(this.clientAuthorization.address, true)).bestDifficulty) {
await this.addressSettingsService.updateBestDifficulty(this.clientAuthorization.address, submissionDifficulty);
}
if (submissionDifficulty > this.clientEntity.bestDifficulty) {
await this.clientService.updateBestDifficultyIfHigher(this.clientEntity.id, submissionDifficulty);
this.clientEntity.bestDifficulty = submissionDifficulty;
await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.clientEntity.userAgent);
await this.updateClientPresence(new Date());
}
@@ -562,12 +705,12 @@ export class StratumV1Client {
}
//await this.checkDifficulty();
return true;
return false;
}
private async checkDifficulty() {
const targetDiff = this.statistics.getSuggestedDifficulty(this.sessionDifficulty);
const targetDiff = this.clampDifficulty(this.statistics.getSuggestedDifficulty(this.sessionDifficulty));
if (targetDiff == null) {
return;
}
@@ -585,10 +728,22 @@ export class StratumV1Client {
await this.socket.write(data);
// we need to clear the jobs so that the difficulty set takes effect. Otherwise the different miner implementations can cause issues
const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$);
await this.sendNewMiningJob(jobTemplate);
const nextTimestamp = Math.max(
jobTemplate.block.timestamp,
Math.floor(Date.now() / 1000),
(this.lastSentMiningJobTimestamp ?? 0) + 1
);
// We need to clear jobs so the difficulty takes effect, but avoid mutating or
// re-sending the shared cached template with byte-identical work.
const refreshedJobTemplate: IJobTemplate = {
...jobTemplate,
block: Object.assign(new bitcoinjs.Block(), jobTemplate.block, {
timestamp: nextTimestamp
}),
blockData: { ...jobTemplate.blockData, clearJobs: true }
};
await this.sendNewMiningJob(refreshedJobTemplate);
}
}
@@ -597,24 +752,128 @@ export class StratumV1Client {
const hashResult = bitcoinjs.crypto.hash256(header);
let s64 = this.le256todouble(hashResult);
const truediffone = Big('26959535291011309493156476344723991336010898738574164086137773096960');
const difficulty = truediffone.div(s64.toString());
return { submissionDifficulty: difficulty.toNumber(), submissionHash: hashResult.toString('hex') };
const target = this.le256todouble(hashResult);
const submissionDifficulty = target === 0 ? Number.POSITIVE_INFINITY : TRUE_DIFF_ONE / target;
return { submissionDifficulty, submissionHash: hashResult.toString('hex') };
}
private le256todouble(target: Buffer): bigint {
private le256todouble(target: Buffer): number {
const number = target.reduceRight((acc, byte) => {
// Shift the number 8 bits to the left and OR with the current byte
return (acc << BigInt(8)) | BigInt(byte);
}, BigInt(0));
let number = 0;
for (let i = target.length - 1; i >= 0; i--) {
number = number * 256 + target[i];
}
return number;
}
private isBlockedUserAgent(userAgent: string): boolean {
const blockedUserAgents = this.configService.get<string>('NON_COMPLIANT_USER_AGENTS')
|| this.configService.get<string>('BLOCKED_USER_AGENTS')
|| this.configService.get<string>('COMPLIANT_HEADERS');
if (!blockedUserAgents || blockedUserAgents.trim() === '') {
return false;
}
const blockedList = blockedUserAgents.split(',').map(ua => ua.trim().toLowerCase());
const userAgentLower = userAgent.toLowerCase();
return blockedList.some(blocked => blocked.length > 0 && userAgentLower.includes(blocked));
}
private logBlockedUserAgent(userAgent: string) {
const now = Date.now();
const logState = StratumV1Client.blockedUserAgentLogState.get(userAgent);
if (logState != null && now < logState.nextLogAt) {
logState.suppressed += 1;
return;
}
const suppressed = logState?.suppressed ?? 0;
const suffix = suppressed > 0 ? ` (${suppressed} similar connections suppressed)` : '';
console.log(`Blocked non-compliant connection from userAgent: ${userAgent}${suffix}`);
StratumV1Client.blockedUserAgentLogState.set(userAgent, {
nextLogAt: now + BLOCKED_USER_AGENT_LOG_INTERVAL_MS,
suppressed: 0
});
}
private logValidationError(label: string, errors: ValidationError[]) {
const now = Date.now();
const signature = this.getValidationErrorSignature(errors);
const sample = this.getValidationErrorSample(errors);
const key = `${label}:${signature}`;
const logState = StratumV1Client.validationErrorLogState.get(key);
if (logState != null && now < logState.nextLogAt) {
logState.suppressed += 1;
return;
}
const suppressed = logState?.suppressed ?? 0;
const suffix = suppressed > 0 ? ` (${suppressed} similar validation errors suppressed)` : '';
console.warn(`${label}: ${signature}${sample}${suffix}`);
StratumV1Client.validationErrorLogState.set(key, {
nextLogAt: now + VALIDATION_ERROR_LOG_INTERVAL_MS,
suppressed: 0,
sample
});
}
private getValidationErrorSignature(errors: ValidationError[]): string {
if (errors.length === 0) {
return 'unknown';
}
return errors.map(error => {
const constraints = Object.keys(error.constraints ?? {}).sort().join('|') || 'invalid';
return `${error.property}:${constraints}`;
}).join(';');
}
private getValidationErrorSample(errors: ValidationError[]): string {
const values = errors
.map(error => error.value)
.filter(value => value != null)
.map(value => String(value).replace(/[\r\n]/g, '').slice(0, 64));
if (values.length === 0) {
return '';
}
return ` sample=${values.join(',')}`;
}
private clampDifficulty(difficulty: number | null): number | null {
if (difficulty == null || !Number.isFinite(difficulty)) {
return null;
}
const configuredMinimum = this.getConfiguredMinimumDifficulty();
return configuredMinimum == null ? difficulty : Math.max(difficulty, configuredMinimum);
}
private getMinimumDifficulty(): number {
return this.getConfiguredMinimumDifficulty() ?? DEFAULT_MIN_DIFFICULTY;
}
private getConfiguredMinimumDifficulty(): number | null {
const configured = parseFloat(
this.configService.get<string>('STRATUM_MIN_DIFFICULTY')
?? process.env.STRATUM_MIN_DIFFICULTY
?? '',
);
return Number.isFinite(configured) && configured > 0 ? configured : null;
}
private closeSocket() {
this.connectionClosed = true;
if (!this.socket.destroyed) {
this.socket.destroy();
}
}
private async write(message: string): Promise<boolean> {
try {
if (!this.socket.destroyed && !this.socket.writableEnded) {
@@ -631,7 +890,7 @@ export class StratumV1Client {
return true;
} else {
console.error(`Error: Cannot write to closed or ended socket. ${this.extraNonceAndSessionId} ${message}`);
//console.error(`Error: Cannot write to closed or ended socket. ${this.extraNonceAndSessionId} ${message}`);
this.destroy();
if (!this.socket.destroyed) {
this.socket.destroy();
@@ -645,7 +904,7 @@ export class StratumV1Client {
} else if (!this.socket.destroyed) {
this.socket.destroy();
}
console.error(`Error occurred while writing to socket: ${this.extraNonceAndSessionId}`, error);
//console.error(`Error occurred while writing to socket: ${this.extraNonceAndSessionId}`, error);
return false;
}
}
@@ -0,0 +1,72 @@
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
describe('StratumV1ClientStatistics', () => {
const client = {
id: 'client-id',
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'bitaxe3',
sessionId: '57a6f098'
} as any;
let statistics: StratumV1ClientStatistics;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-06T12:00:00Z'));
statistics = new StratumV1ClientStatistics();
});
afterEach(() => {
jest.useRealTimers();
});
it('should keep runtime hashrate at zero before enough time passes', async () => {
await statistics.addShares(client, 64);
expect(statistics.hashRate).toBe(0);
});
it('should update runtime hashrate from accepted shares', async () => {
for (let i = 0; i < 3; i++) {
jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 31000)));
await statistics.addShares(client, 64);
}
expect(statistics.hashRate).toBeGreaterThan(0);
});
it('should not suggest a difficulty change before enough time or shares have passed', () => {
expect(statistics.getSuggestedDifficulty(64)).toBeNull();
});
it('should lower difficulty when a miner has not submitted shares for several minutes', () => {
jest.setSystemTime(new Date('2026-05-06T12:06:00Z'));
expect(statistics.getSuggestedDifficulty(64)).toBe(8);
});
it('should increase difficulty for rapid submissions', async () => {
for (let i = 0; i < 5; i++) {
jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 1000)));
await statistics.addShares(client, 64);
}
expect(statistics.getSuggestedDifficulty(64)).toBe(2048);
});
it('should decrease difficulty for slow submissions', async () => {
for (let i = 0; i < 5; i++) {
jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 150000)));
await statistics.addShares(client, 64);
}
expect(statistics.getSuggestedDifficulty(128)).toBe(16);
});
it('should not suggest a difficulty below the configured minimum', () => {
statistics = new StratumV1ClientStatistics(1);
jest.setSystemTime(new Date('2026-05-06T12:06:00Z'));
expect(statistics.getSuggestedDifficulty(1)).toBe(1);
});
});
+21 -43
View File
@@ -1,67 +1,45 @@
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
import { ClientEntity } from '../ORM/client/client.entity';
const CACHE_SIZE = 30;
const TARGET_SUBMISSION_PER_SECOND = 10;
const MIN_DIFF = 0.00001;
const DEFAULT_MIN_DIFF = 0.001;
export class StratumV1ClientStatistics {
private shareBacklog: number = 0;
public targetSubmitShareEveryNSeconds: number = 30;
public hashRate = 0;
private submissionCacheStart: Date;
private submissionCache = [];
private submissionCache: { time: Date, difficulty: number }[] = [];
private submissionCacheDifficultySum = 0;
constructor(
private readonly clientStatisticsService: ClientStatisticsService
) {
constructor(private readonly minDifficulty = DEFAULT_MIN_DIFF) {
this.submissionCacheStart = new Date();
}
public async saveShares(client: ClientEntity) {
if (client == null || client.address == null || client.clientName == null || client.sessionId == null) {
return;
}
// 10 min
var coeff = 1000 * 60 * 10;
public async addShares(_client: ClientEntity, targetDifficulty: number) {
var date = new Date();
var rounded = new Date(Math.floor(date.getTime() / coeff) * coeff);
await this.clientStatisticsService.save({
time: rounded.getTime(),
shares: this.shareBacklog,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
});
this.shareBacklog = 0;
}
// We don't want to save them here because it can be DB intensive, stead do it every once in
// awhile with saveShares()
public async addShares(targetDifficulty: number) {
if (this.submissionCache.length > CACHE_SIZE) {
this.submissionCacheDifficultySum -= this.submissionCache[0].difficulty;
this.submissionCache.shift();
}
this.submissionCache.push({
time: new Date(),
time: date,
difficulty: targetDifficulty,
});
this.submissionCacheDifficultySum += targetDifficulty;
this.shareBacklog += targetDifficulty;
const time = new Date().getTime() - this.submissionCache[0].time.getTime();
if(time > 60000 && this.submissionCache.length > 2) {
this.hashRate = (this.submissionCacheDifficultySum * 4294967296) / (time / 1000);
}
}
public getSuggestedDifficulty(clientDifficulty: number) {
// miner hasn't submitted shares in one minute
if (this.submissionCache.length < 5) {
if ((new Date().getTime() - this.submissionCacheStart.getTime()) / 1000 > 60) {
if ((new Date().getTime() - this.submissionCacheStart.getTime()) / 5000 > 60) {
return this.nearestPowerOfTwo(clientDifficulty / 6);
} else {
return null;
@@ -76,7 +54,7 @@ export class StratumV1ClientStatistics {
const difficultyPerSecond = sum / diffSeconds;
const targetDifficulty = difficultyPerSecond * TARGET_SUBMISSION_PER_SECOND;
const targetDifficulty = difficultyPerSecond * this.targetSubmitShareEveryNSeconds;
if ((clientDifficulty * 2) < targetDifficulty || (clientDifficulty / 2) > targetDifficulty) {
return this.nearestPowerOfTwo(targetDifficulty)
@@ -89,8 +67,8 @@ export class StratumV1ClientStatistics {
if (val === 0) {
return null;
}
if (val < MIN_DIFF) {
return MIN_DIFF;
if (val < this.minDifficulty) {
return this.minDifficulty;
}
let x = val | (val >> 1);
x = x | (x >> 2);
@@ -99,8 +77,8 @@ export class StratumV1ClientStatistics {
x = x | (x >> 16);
x = x | (x >> 32);
const res = x - (x >> 1);
if (res == 0 && val * 100 < MIN_DIFF) {
return MIN_DIFF;
if (res == 0 && val * 100 < this.minDifficulty) {
return this.minDifficulty;
}
if (res == 0) {
return this.nearestPowerOfTwo(val * 100) / 100;
+256
View File
@@ -0,0 +1,256 @@
import { ConfigService } from '@nestjs/config';
import { Socket } from 'net';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { ClientEntity } from '../ORM/client/client.entity';
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { BufferReader } from './sv2/sv2-binary-codec';
import { SV2_CHANNEL_MSG_FLAG, Sv2MsgType } from './sv2/sv2-constants';
import {
deserializeOpenExtendedMiningChannelSuccess,
deserializeNewExtendedMiningJob,
serializeOpenExtendedMiningChannel,
serializeSubmitSharesExtended,
} from './sv2/sv2-extended-messages';
import { deserializeSetNewPrevHash, deserializeSubmitSharesError } from './sv2/sv2-messages';
import { StratumV2Client } from './StratumV2Client';
describe('StratumV2Client extended channels', () => {
let consoleLogSpy: jest.SpyInstance;
let consoleWarnSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
consoleLogSpy.mockRestore();
consoleWarnSpy.mockRestore();
consoleErrorSpy.mockRestore();
jest.useRealTimers();
});
it('opens an extended channel and sends initial extended work', async () => {
const { client, sentFrames } = await createClient();
const maxTarget = Buffer.alloc(32, 0xff);
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
requestId: 7,
userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker',
nominalHashRate: 0,
maxTarget,
minExtranonceSize: 8,
}));
const successFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.OPEN_EXTENDED_MINING_CHANNEL_SUCCESS);
const jobFrames = sentFrames.filter(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB);
const jobFrame = jobFrames[jobFrames.length - 1];
const prevHashFrames = sentFrames.filter(frame => frame.msgType === Sv2MsgType.SET_NEW_PREV_HASH);
const prevHashFrame = prevHashFrames[prevHashFrames.length - 1];
expect(successFrame?.extensionType).toBe(0);
const success = deserializeOpenExtendedMiningChannelSuccess(new BufferReader(successFrame.payload));
expect(success.requestId).toBe(7);
expect(success.channelId).toBe(1);
expect(success.extranonceSize).toBe(8);
expect(success.extranoncePrefix).toEqual(Buffer.from('00000001', 'hex'));
expect(jobFrame?.extensionType).toBe(SV2_CHANNEL_MSG_FLAG);
const job = deserializeNewExtendedMiningJob(new BufferReader(jobFrame.payload));
expect(job.channelId).toBe(1);
expect(job.minNtime).toBeNull();
expect(job.coinbasePrefix.length).toBeGreaterThan(0);
expect(job.coinbaseSuffix.length).toBeGreaterThan(0);
expect(job.merklePath.length).toBeGreaterThan(0);
expect(prevHashFrame?.extensionType).toBe(SV2_CHANNEL_MSG_FLAG);
const prevHash = deserializeSetNewPrevHash(new BufferReader(prevHashFrame.payload));
expect(prevHash.channelId).toBe(1);
expect(prevHash.jobId).toBe(job.jobId);
});
it('rejects extended shares with the wrong negotiated extranonce size', async () => {
const { client, sentFrames } = await createClient();
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
requestId: 1,
userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker',
nominalHashRate: 0,
maxTarget: Buffer.alloc(32, 0xff),
minExtranonceSize: 8,
}));
const jobFrames = sentFrames.filter(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB);
const jobFrame = jobFrames[jobFrames.length - 1];
const job = deserializeNewExtendedMiningJob(new BufferReader(jobFrame.payload));
sentFrames.length = 0;
await (client as any).handleSubmitSharesExtended(serializeSubmitSharesExtended({
channelId: 1,
sequenceNumber: 42,
jobId: job.jobId,
nonce: 0,
ntime: parseInt(MockRecording1.TIME, 16),
version: job.version,
extranonce: Buffer.alloc(7),
}));
const errorFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.SUBMIT_SHARES_ERROR);
expect(errorFrame?.extensionType).toBe(SV2_CHANNEL_MSG_FLAG);
const error = deserializeSubmitSharesError(new BufferReader(errorFrame.payload));
expect(error.channelId).toBe(1);
expect(error.sequenceNumber).toBe(42);
expect(error.errorCode).toBe('invalid-extranonce-size');
});
it('records accepted SV2 shares before presence updates', async () => {
const { client, shareAccountingService, redisMessagingService, jobTemplate } = await createClient();
(client as any).address = 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
(client as any).workerName = 'worker';
(client as any).sessionId = 'sv2-session';
(client as any).userAgent = 'test/sv2';
await (client as any).recordAcceptedShare(
2048,
1024,
jobTemplate,
null,
{
jobId: '1',
nonce: 123,
ntime: parseInt(MockRecording1.TIME, 16),
version: jobTemplate.block.version,
extraNonce2: 'c708000000000000',
},
);
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
protocol: 'sv2',
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'worker',
sessionId: 'sv2-session',
jobId: '1',
creditedDifficulty: 1024,
submissionDifficulty: 2048,
nonce: 123,
extraNonce2: 'c708000000000000',
}));
const accountingCallOrder = shareAccountingService.recordAcceptedShare.mock.invocationCallOrder[0];
const postAccountingPresenceUpdates = redisMessagingService.setClientPresence.mock.invocationCallOrder
.filter(callOrder => callOrder > accountingCallOrder);
expect(postAccountingPresenceUpdates.length).toBeGreaterThan(0);
});
async function createClient(): Promise<{
client: StratumV2Client;
sentFrames: any[];
shareAccountingService: { recordAcceptedShare: jest.Mock };
redisMessagingService: { setClientPresence: jest.Mock; removeClientPresence: jest.Mock };
jobTemplate: any;
}> {
const blockTemplate$ = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
const bitcoinRpcService = {
newBlockTemplate$: blockTemplate$.asObservable(),
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height },
SUBMIT_BLOCK: jest.fn().mockResolvedValue(null),
};
const stratumV1JobsService = new StratumV1JobsService(bitcoinRpcService as any);
const jobTemplate = await firstValueFrom(stratumV1JobsService.newMiningJob$);
const mockSocket: any = {
destroyed: false,
writableEnded: false,
on: jest.fn().mockReturnThis(),
write: jest.fn((data, callback) => {
callback?.();
return true;
}),
destroy: jest.fn(function destroy() {
mockSocket.destroyed = true;
return mockSocket;
}),
};
const socket = mockSocket as Socket;
const clientEntity = {
id: 1,
sessionId: 'sv2-session',
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'worker',
userAgent: 'test/sv2',
startTime: new Date(),
bestDifficulty: 0,
} as unknown as ClientEntity;
let nextChannelId = 1;
const sentFrames: any[] = [];
const clientService = {
insert: jest.fn().mockResolvedValue(clientEntity),
delete: jest.fn().mockResolvedValue(undefined),
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
};
const shareAccountingService = {
recordAcceptedShare: jest.fn().mockResolvedValue(undefined),
};
const redisMessagingService = {
setClientPresence: jest.fn().mockResolvedValue(undefined),
removeClientPresence: jest.fn().mockResolvedValue(undefined),
};
const client = new StratumV2Client(
socket,
Buffer.alloc(0),
{
getNoiseConfig: () => ({
staticKeypair: {
privateKey: Buffer.alloc(32, 1),
publicKey: Buffer.alloc(64, 2),
},
certificateMessage: {
version: 0,
validFrom: 0,
notValidAfter: 1,
signature: Buffer.alloc(64),
},
}),
getNextChannelId: () => nextChannelId++,
generateExtranoncePrefix: () => Buffer.from('00000002', 'hex'),
allocateExtendedExtranoncePrefix: jest.fn(() => Buffer.from('00000001', 'hex')),
releaseExtendedExtranoncePrefix: jest.fn(),
getExtendedMinerExtranonceSize: () => 8,
} as any,
stratumV1JobsService,
bitcoinRpcService as any,
clientService as any,
{ notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined) } as any,
{ save: jest.fn().mockResolvedValue(undefined) } as any,
{
get: jest.fn((key: string) => {
switch (key) {
case 'NETWORK':
return 'testnet';
case 'SV2_START_DIFFICULTY':
return '1';
default:
return null;
}
}),
} as unknown as ConfigService,
{
resetBestDifficultyAndShares: jest.fn().mockResolvedValue(undefined),
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
} as any,
shareAccountingService as any,
redisMessagingService as any,
);
(client as any).sendFrame = jest.fn((msgType: number, payload: Buffer, extensionType = 0) => {
sentFrames.push({ msgType, payload, extensionType });
return Promise.resolve();
});
return { client, sentFrames, shareAccountingService, redisMessagingService, jobTemplate };
}
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
import { plainToInstance } from 'class-transformer';
import { AuthorizationMessage } from './AuthorizationMessage';
describe('AuthorizationMessage', () => {
it('should parse address, worker, and starting difficulty', () => {
const message = plainToInstance(
AuthorizationMessage,
JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker1","x,d=2048"]}')
);
expect(message.address).toBe('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4');
expect(message.worker).toBe('worker1');
expect(message.password).toBe('x,d=2048');
expect(message.startingDiff).toBe(2048);
});
it('should default worker name when one is not provided', () => {
const message = plainToInstance(
AuthorizationMessage,
JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4","x"]}')
);
expect(message.worker).toBe('worker');
expect(message.startingDiff).toBeNull();
});
it('should build successful authorization responses', () => {
const message = plainToInstance(
AuthorizationMessage,
JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker1","x"]}')
);
expect(message.response()).toEqual({
id: 3,
error: null,
result: true
});
});
});
@@ -1,5 +1,5 @@
import { Expose, Transform } from 'class-transformer';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, MaxLength } from 'class-validator';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsNumber, IsOptional, IsString, MaxLength } from 'class-validator';
import { eRequestMethod } from '../enums/eRequestMethod';
import { IsBitcoinAddress } from '../validators/bitcoin-address.validator';
@@ -8,7 +8,7 @@ import { StratumBaseMessage } from './StratumBaseMessage';
export class AuthorizationMessage extends StratumBaseMessage {
@IsArray()
@ArrayMinSize(2)
@ArrayMinSize(1)
@ArrayMaxSize(2)
params: string[];
@@ -24,18 +24,36 @@ export class AuthorizationMessage extends StratumBaseMessage {
@IsString()
@MaxLength(64)
@Transform(({ value, key, obj, type }) => {
return obj.params[0].split('.')[1] == null ? 'worker' : obj.params[0].split('.')[1];
const workerName = obj.params[0].split('.')[1];
if (workerName == null) {
return 'worker';
}
return workerName;
})
public worker: string;
@Expose()
@IsNumber()
@Transform(({ value, key, obj, type }) => {
const password: string | null = obj.params[1];
if (password?.includes('d=')) {
return parseInt(password.split('d=')[1]);
}
return null;
})
@IsOptional()
public startingDiff: number;
@Expose()
@IsString()
@Transform(({ value, key, obj, type }) => {
return obj.params[1];
})
@MaxLength(64)
public password: string;
@IsOptional()
public password?: string;
constructor() {
super();
@@ -1,4 +1,5 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { MiningSubmitMessage } from './MiningSubmitMessage';
@@ -13,7 +14,7 @@ describe('MiningSubmitMessage', () => {
describe('test message parsing', () => {
const MINING_SUBMIT_MESSAGE = ' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "99020000", "64b1f10f", "2402812d", "00006000"]}'
const MINING_SUBMIT_MESSAGE = ' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "9902000000000000", "64b1f10f", "2402812d", "00006000"]}'
const message = plainToInstance(
MiningSubmitMessage,
@@ -24,11 +25,39 @@ describe('MiningSubmitMessage', () => {
expect(message.id).toEqual(5);
expect(message.userId).toEqual('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3');
expect(message.jobId).toEqual('1');
expect(message.extraNonce2).toEqual('99020000');
expect(message.extraNonce2).toEqual('9902000000000000');
expect(message.ntime).toEqual('64b1f10f');
expect(message.nonce).toEqual('2402812d');
expect(message.versionMask).toEqual('00006000');
});
it('should validate 8-byte extranonce2 submissions', async () => {
const errors = await validate(message);
expect(errors).toEqual([]);
});
it('should reject short extranonce2 submissions', async () => {
const shortMessage = plainToInstance(
MiningSubmitMessage,
JSON.parse(' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "99020000", "64b1f10f", "2402812d", "00006000"]}'),
);
const errors = await validate(shortMessage);
expect(errors.some(error => error.property === 'extraNonce2')).toBe(true);
});
it('should reject long extranonce2 submissions', async () => {
const longMessage = plainToInstance(
MiningSubmitMessage,
JSON.parse(' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "990200000000000000", "64b1f10f", "2402812d", "00006000"]}'),
);
const errors = await validate(longMessage);
expect(errors.some(error => error.property === 'extraNonce2')).toBe(true);
});
});
@@ -1,8 +1,11 @@
import { Expose, Transform } from 'class-transformer';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, Length } from 'class-validator';
import { eRequestMethod } from '../enums/eRequestMethod';
import { EXTRANONCE2_SIZE_BYTES } from '../stratum.constants';
import { StratumBaseMessage } from './StratumBaseMessage';
import * as bitcoinjs from 'bitcoinjs-lib';
export class MiningSubmitMessage extends StratumBaseMessage {
@@ -25,6 +28,7 @@ export class MiningSubmitMessage extends StratumBaseMessage {
public jobId: string;
@Expose()
@IsString()
@Length(EXTRANONCE2_SIZE_BYTES * 2, EXTRANONCE2_SIZE_BYTES * 2)
@Transform(({ value, key, obj, type }) => {
return obj.params[2];
})
@@ -63,6 +67,11 @@ export class MiningSubmitMessage extends StratumBaseMessage {
};
}
public hash(): string{
const buffer = Buffer.from(this.versionMask + this.nonce + this.extraNonce2 + this.ntime + this.jobId);
return bitcoinjs.crypto.hash256(buffer).toString('base64');
}
@@ -1,10 +1,10 @@
import { IsEnum, IsNumber } from 'class-validator';
import { IsDefined, IsEnum, IsNumber, IsNumberString, isNumberString, IsOptional } from 'class-validator';
import { eRequestMethod } from '../enums/eRequestMethod';
export class StratumBaseMessage {
@IsNumber()
id?: number = null;
@IsDefined()
id?: number | string = null;
@IsEnum(eRequestMethod)
method: eRequestMethod;
}
@@ -5,7 +5,7 @@ import { eStratumErrorCode } from '../enums/eStratumErrorCode';
export class StratumErrorMessage {
constructor(
private id: number = null,
private id: number | string = null,
private errorCode: eStratumErrorCode,
private errorMessage: string,
private validationErrors: ValidationError[] = []
@@ -0,0 +1,45 @@
import { plainToInstance } from 'class-transformer';
import { SubscriptionMessage } from './SubscriptionMessage';
describe('SubscriptionMessage', () => {
it('should parse and refine known user agents', () => {
const bosminer = plainToInstance(
SubscriptionMessage,
JSON.parse('{"id":1,"method":"mining.subscribe","params":["bosminer/23.08"]}')
);
const cpuminer = plainToInstance(
SubscriptionMessage,
JSON.parse('{"id":1,"method":"mining.subscribe","params":["cpuminer-opt/1.0"]}')
);
expect(bosminer.userAgent).toBe('Braiins OS');
expect(cpuminer.userAgent).toBe('cpuminer');
});
it('should default missing user agents to unknown', () => {
const message = plainToInstance(
SubscriptionMessage,
JSON.parse('{"id":1,"method":"mining.subscribe","params":[]}')
);
expect(message.userAgent).toBe('unknown');
});
it('should respond with extranonce2 size of 8 bytes', () => {
const message = plainToInstance(
SubscriptionMessage,
JSON.parse('{"id":1,"method":"mining.subscribe","params":["bitaxe v2.2"]}')
);
expect(message.response('57a6f098')).toEqual({
id: 1,
error: null,
result: [
[['mining.notify', '57a6f098']],
'57a6f098',
8
]
});
});
});
@@ -2,6 +2,7 @@ import { Expose, Transform } from 'class-transformer';
import { IsArray, IsString, MaxLength } from 'class-validator';
import { eRequestMethod } from '../enums/eRequestMethod';
import { EXTRANONCE2_SIZE_BYTES } from '../stratum.constants';
import { StratumBaseMessage } from './StratumBaseMessage';
export class SubscriptionMessage extends StratumBaseMessage {
@@ -14,7 +15,7 @@ export class SubscriptionMessage extends StratumBaseMessage {
@IsString()
@MaxLength(128)
@Transform(({ value, key, obj, type }) => {
return obj.params[0] == null ? 'unknown' : SubscriptionMessage.refineUserAgent(obj.params[0]);
return obj?.params?.[0] == null ? 'unknown' : SubscriptionMessage.refineUserAgent(obj.params[0]);
})
public userAgent: string;
@@ -32,7 +33,7 @@ export class SubscriptionMessage extends StratumBaseMessage {
['mining.notify', clientId]
],
clientId, //Extranonce1 - Hex-encoded, per-connection unique string which will be used for coinbase serialization later. Keep it safe!
4 //Extranonce2_size - Represents expected length of extranonce2 which will be generated by the miner.
EXTRANONCE2_SIZE_BYTES //Extranonce2_size - Represents expected length of extranonce2 which will be generated by the miner.
]
}
@@ -41,7 +42,7 @@ export class SubscriptionMessage extends StratumBaseMessage {
public static refineUserAgent(userAgent: string): string {
// return userAgent;
userAgent = userAgent.split(' ')[0].split('/')[0].split('V')[0];
userAgent = userAgent.split(' ')[0].split('/')[0].split('V')[0].split('-')[0];
if (userAgent.includes('bosminer') || userAgent.includes('bOS')) {
userAgent = 'Braiins OS';
@@ -31,7 +31,7 @@ export class SuggestDifficulty extends StratumBaseMessage {
public response(difficulty: number) {
return {
id: this.id,
id: null,
method: eResponseMethod.SET_DIFFICULTY,
params: [difficulty]
}
+3
View File
@@ -0,0 +1,3 @@
export const EXTRANONCE1_SIZE_BYTES = 4;
export const EXTRANONCE2_SIZE_BYTES = 8;
export const TOTAL_EXTRANONCE_SIZE_BYTES = EXTRANONCE1_SIZE_BYTES + EXTRANONCE2_SIZE_BYTES;
+19
View File
@@ -0,0 +1,19 @@
import { encodeSv2AuthorityPublicKey } from './sv2-authority-key';
describe('SV2 authority key encoding', () => {
it('matches the protocol security test vector', () => {
const rawPublicKey = Buffer.from([
118, 99, 112, 0, 151, 156, 28, 17,
175, 12, 48, 11, 205, 140, 127, 228,
134, 16, 252, 233, 185, 193, 30, 61,
174, 227, 90, 224, 176, 138, 116, 85,
]);
expect(encodeSv2AuthorityPublicKey(rawPublicKey))
.toBe('9bXiEd8boQVhq7WddEcERUL5tyyJVFYdU8th3HfbNXK3Yw6GRXh');
});
it('rejects non-x-only public keys', () => {
expect(() => encodeSv2AuthorityPublicKey(Buffer.alloc(33))).toThrow(RangeError);
});
});
+19
View File
@@ -0,0 +1,19 @@
import * as bs58 from 'bs58';
import * as crypto from 'crypto';
const AUTHORITY_KEY_VERSION_PREFIX = Buffer.from([1, 0]);
export function encodeSv2AuthorityPublicKey(publicKey: Buffer): string {
if (publicKey.length !== 32) {
throw new RangeError(`SV2 authority public key must be 32 bytes, got ${publicKey.length}`);
}
const versionedKey = Buffer.concat([AUTHORITY_KEY_VERSION_PREFIX, publicKey]);
const checksum = crypto
.createHash('sha256')
.update(crypto.createHash('sha256').update(versionedKey).digest())
.digest()
.subarray(0, 4);
return bs58.encode(Buffer.concat([versionedKey, checksum]));
}
+249
View File
@@ -0,0 +1,249 @@
import { BufferReader, BufferWriter } from './sv2-binary-codec';
describe('SV2 Binary Codec', () => {
// ── Round-trip Tests ────────────────────────────────────────────────
describe('BufferWriter + BufferReader round-trips', () => {
it('should round-trip Bool', () => {
const w = new BufferWriter();
w.writeBool(true);
w.writeBool(false);
const r = new BufferReader(w.toBuffer());
expect(r.readBool()).toBe(true);
expect(r.readBool()).toBe(false);
});
it('should round-trip U8', () => {
const w = new BufferWriter();
w.writeU8(0);
w.writeU8(255);
w.writeU8(42);
const r = new BufferReader(w.toBuffer());
expect(r.readU8()).toBe(0);
expect(r.readU8()).toBe(255);
expect(r.readU8()).toBe(42);
});
it('should round-trip U16', () => {
const w = new BufferWriter();
w.writeU16(0);
w.writeU16(65535);
w.writeU16(1234);
const r = new BufferReader(w.toBuffer());
expect(r.readU16()).toBe(0);
expect(r.readU16()).toBe(65535);
expect(r.readU16()).toBe(1234);
});
it('should round-trip U24', () => {
const w = new BufferWriter();
w.writeU24(0);
w.writeU24(0xffffff);
w.writeU24(123456);
const r = new BufferReader(w.toBuffer());
expect(r.readU24()).toBe(0);
expect(r.readU24()).toBe(0xffffff);
expect(r.readU24()).toBe(123456);
});
it('should round-trip U32', () => {
const w = new BufferWriter();
w.writeU32(0);
w.writeU32(0xffffffff);
w.writeU32(305419896);
const r = new BufferReader(w.toBuffer());
expect(r.readU32()).toBe(0);
expect(r.readU32()).toBe(0xffffffff);
expect(r.readU32()).toBe(305419896);
});
it('should round-trip U64', () => {
const w = new BufferWriter();
w.writeU64(0n);
w.writeU64(0xffffffffffffffffn);
w.writeU64(123456789012345678n);
const r = new BufferReader(w.toBuffer());
expect(r.readU64()).toBe(0n);
expect(r.readU64()).toBe(0xffffffffffffffffn);
expect(r.readU64()).toBe(123456789012345678n);
});
it('should round-trip U256', () => {
const val = Buffer.alloc(32);
for (let i = 0; i < 32; i++) val[i] = i;
const w = new BufferWriter();
w.writeU256(val);
const r = new BufferReader(w.toBuffer());
expect(r.readU256()).toEqual(val);
});
it('should round-trip F32', () => {
const w = new BufferWriter();
w.writeF32(3.14);
w.writeF32(0);
w.writeF32(-1.5);
const r = new BufferReader(w.toBuffer());
expect(r.readF32()).toBeCloseTo(3.14, 2);
expect(r.readF32()).toBe(0);
expect(r.readF32()).toBe(-1.5);
});
it('should round-trip Str0_255', () => {
const w = new BufferWriter();
w.writeStr0_255('');
w.writeStr0_255('hello');
w.writeStr0_255('mining/pool/1.0');
const r = new BufferReader(w.toBuffer());
expect(r.readStr0_255()).toBe('');
expect(r.readStr0_255()).toBe('hello');
expect(r.readStr0_255()).toBe('mining/pool/1.0');
});
it('should round-trip B0_32', () => {
const val = Buffer.from([1, 2, 3, 4]);
const w = new BufferWriter();
w.writeB0_32(Buffer.alloc(0));
w.writeB0_32(val);
const r = new BufferReader(w.toBuffer());
expect(r.readB0_32()).toEqual(Buffer.alloc(0));
expect(r.readB0_32()).toEqual(val);
});
it('should round-trip B0_255', () => {
const val = Buffer.alloc(100, 0xab);
const w = new BufferWriter();
w.writeB0_255(val);
const r = new BufferReader(w.toBuffer());
expect(r.readB0_255()).toEqual(val);
});
it('should round-trip B0_64K', () => {
const val = Buffer.alloc(1000, 0xcd);
const w = new BufferWriter();
w.writeB0_64K(val);
const r = new BufferReader(w.toBuffer());
expect(r.readB0_64K()).toEqual(val);
});
it('should round-trip Seq0_255', () => {
const items = [10, 20, 30];
const w = new BufferWriter();
w.writeSeq0_255(items, (wr, v) => wr.writeU32(v));
const r = new BufferReader(w.toBuffer());
const result = r.readSeq0_255((rd) => rd.readU32());
expect(result).toEqual(items);
});
it('should round-trip Seq0_64K', () => {
const items = ['alpha', 'beta'];
const w = new BufferWriter();
w.writeSeq0_64K(items, (wr, v) => wr.writeStr0_255(v));
const r = new BufferReader(w.toBuffer());
const result = r.readSeq0_64K((rd) => rd.readStr0_255());
expect(result).toEqual(items);
});
});
// ── Little-endian Verification ──────────────────────────────────────
describe('little-endian encoding', () => {
it('U16 should be little-endian', () => {
const w = new BufferWriter();
w.writeU16(0x0102);
const buf = w.toBuffer();
expect(buf[0]).toBe(0x02); // low byte first
expect(buf[1]).toBe(0x01);
});
it('U32 should be little-endian', () => {
const w = new BufferWriter();
w.writeU32(0x01020304);
const buf = w.toBuffer();
expect(buf[0]).toBe(0x04);
expect(buf[1]).toBe(0x03);
expect(buf[2]).toBe(0x02);
expect(buf[3]).toBe(0x01);
});
});
// ── Bounds Checking ─────────────────────────────────────────────────
describe('bounds checking', () => {
it('BufferReader should throw on underflow', () => {
const r = new BufferReader(Buffer.alloc(1));
r.readU8(); // consumes the one byte
expect(() => r.readU8()).toThrow(RangeError);
});
it('BufferReader should throw when reading U32 from 2-byte buffer', () => {
const r = new BufferReader(Buffer.alloc(2));
expect(() => r.readU32()).toThrow(RangeError);
});
it('B0_32 should reject payloads > 32 bytes', () => {
const w = new BufferWriter();
expect(() => w.writeB0_32(Buffer.alloc(33))).toThrow(RangeError);
});
it('B0_32 reader should reject length > 32', () => {
// Craft a buffer with length prefix = 33
const buf = Buffer.alloc(34);
buf[0] = 33;
const r = new BufferReader(buf);
expect(() => r.readB0_32()).toThrow(RangeError);
});
it('U256 writer should reject non-32-byte buffers', () => {
const w = new BufferWriter();
expect(() => w.writeU256(Buffer.alloc(31))).toThrow(RangeError);
});
it('Str0_255 should reject strings > 255 bytes', () => {
const w = new BufferWriter();
expect(() => w.writeStr0_255('a'.repeat(256))).toThrow(RangeError);
});
it('remaining should track consumed bytes', () => {
const r = new BufferReader(Buffer.alloc(10));
expect(r.remaining).toBe(10);
r.readU32();
expect(r.remaining).toBe(6);
expect(r.position).toBe(4);
});
});
// ── Edge Cases ──────────────────────────────────────────────────────
describe('edge cases', () => {
it('empty buffer reader has 0 remaining', () => {
const r = new BufferReader(Buffer.alloc(0));
expect(r.remaining).toBe(0);
});
it('empty Seq0_255 round-trips', () => {
const w = new BufferWriter();
w.writeSeq0_255([], (wr, v) => wr.writeU8(v as number));
const r = new BufferReader(w.toBuffer());
expect(r.readSeq0_255((rd) => rd.readU8())).toEqual([]);
});
it('maximum B0_32 (32 bytes) round-trips', () => {
const val = Buffer.alloc(32, 0xff);
const w = new BufferWriter();
w.writeB0_32(val);
const r = new BufferReader(w.toBuffer());
expect(r.readB0_32()).toEqual(val);
});
it('writeU32 handles signed int32 from bitcoinjs block.version', () => {
// bitcoinjs-lib stores block.version as signed Int32 (readInt32).
// Version rolling or BIP9 signaling can set bit 31, producing
// a negative JS number. writeU32 must handle this via >>> 0.
const signedVersion = -1554660091; // 0xA355C505 unsigned
const w = new BufferWriter();
w.writeU32(signedVersion);
const r = new BufferReader(w.toBuffer());
expect(r.readU32()).toBe(0xA355C505);
});
});
});
+301
View File
@@ -0,0 +1,301 @@
// ── SV2 Binary Codec ────────────────────────────────────────────────
// Sequential Buffer reader / accumulator-pattern writer for all SV2
// data types. All multi-byte integers are little-endian per spec.
// ── BufferReader ────────────────────────────────────────────────────
export class BufferReader {
private offset = 0;
constructor(private readonly buf: Buffer) {}
get remaining(): number {
return this.buf.length - this.offset;
}
get position(): number {
return this.offset;
}
private ensure(n: number): void {
if (this.offset + n > this.buf.length) {
throw new RangeError(
`BufferReader: need ${n} bytes at offset ${this.offset}, only ${this.remaining} available`,
);
}
}
readBool(): boolean {
this.ensure(1);
return this.buf[this.offset++] !== 0;
}
readU8(): number {
this.ensure(1);
return this.buf[this.offset++];
}
readU16(): number {
this.ensure(2);
const v = this.buf.readUInt16LE(this.offset);
this.offset += 2;
return v;
}
readU24(): number {
this.ensure(3);
const v =
this.buf[this.offset] |
(this.buf[this.offset + 1] << 8) |
(this.buf[this.offset + 2] << 16);
this.offset += 3;
return v;
}
readU32(): number {
this.ensure(4);
const v = this.buf.readUInt32LE(this.offset);
this.offset += 4;
return v;
}
/** Read Sv2Option<u32>: 1-byte flag (0=None, 1=Some) + optional 4-byte u32 LE */
readOptionU32(): number | null {
this.ensure(1);
const flag = this.buf[this.offset++];
if (flag === 0) return null;
this.ensure(4);
const v = this.buf.readUInt32LE(this.offset);
this.offset += 4;
return v;
}
readU64(): bigint {
this.ensure(8);
const v = this.buf.readBigUInt64LE(this.offset);
this.offset += 8;
return v;
}
readU256(): Buffer {
this.ensure(32);
const v = Buffer.from(this.buf.subarray(this.offset, this.offset + 32));
this.offset += 32;
return v;
}
readF32(): number {
this.ensure(4);
const v = this.buf.readFloatLE(this.offset);
this.offset += 4;
return v;
}
readBytes(n: number): Buffer {
this.ensure(n);
const v = Buffer.from(this.buf.subarray(this.offset, this.offset + n));
this.offset += n;
return v;
}
/** STR0_255: 1-byte length prefix + UTF-8 payload */
readStr0_255(): string {
const len = this.readU8();
return this.readBytes(len).toString('utf8');
}
/** B0_32: 1-byte length prefix, max 32 bytes */
readB0_32(): Buffer {
const len = this.readU8();
if (len > 32) throw new RangeError(`B0_32: length ${len} > 32`);
return this.readBytes(len);
}
/** B0_255: 1-byte length prefix, max 255 bytes */
readB0_255(): Buffer {
const len = this.readU8();
return this.readBytes(len);
}
/** B0_64K: 2-byte length prefix */
readB0_64K(): Buffer {
const len = this.readU16();
return this.readBytes(len);
}
/** B0_16M: 3-byte length prefix, max 16,777,215 bytes */
readB0_16M(): Buffer {
const len = this.readU24();
return this.readBytes(len);
}
/** SEQ0_255: 1-byte count + repeated elements */
readSeq0_255<T>(readFn: (reader: BufferReader) => T): T[] {
const count = this.readU8();
const result: T[] = [];
for (let i = 0; i < count; i++) {
result.push(readFn(this));
}
return result;
}
/** SEQ0_64K: 2-byte count + repeated elements */
readSeq0_64K<T>(readFn: (reader: BufferReader) => T): T[] {
const count = this.readU16();
const result: T[] = [];
for (let i = 0; i < count; i++) {
result.push(readFn(this));
}
return result;
}
}
// ── BufferWriter ────────────────────────────────────────────────────
export class BufferWriter {
private buf: Buffer;
private offset = 0;
constructor(initialSize = 256) {
this.buf = Buffer.allocUnsafe(initialSize);
}
private ensureCapacity(n: number): void {
if (this.offset + n > this.buf.length) {
let newSize = this.buf.length * 2;
while (newSize < this.offset + n) newSize *= 2;
const newBuf = Buffer.allocUnsafe(newSize);
this.buf.copy(newBuf, 0, 0, this.offset);
this.buf = newBuf;
}
}
writeBool(v: boolean): void {
this.ensureCapacity(1);
this.buf[this.offset++] = v ? 1 : 0;
}
writeU8(v: number): void {
this.ensureCapacity(1);
this.buf[this.offset++] = v & 0xff;
}
/** Write Sv2Option<u32>: 1-byte flag + optional 4-byte u32 LE */
writeOptionU32(v: number | null): void {
if (v === null) {
this.writeU8(0);
} else {
this.writeU8(1);
this.writeU32(v);
}
}
writeU16(v: number): void {
this.ensureCapacity(2);
this.buf.writeUInt16LE(v, this.offset);
this.offset += 2;
}
writeU24(v: number): void {
this.ensureCapacity(3);
this.buf[this.offset] = v & 0xff;
this.buf[this.offset + 1] = (v >> 8) & 0xff;
this.buf[this.offset + 2] = (v >> 16) & 0xff;
this.offset += 3;
}
writeU32(v: number): void {
this.ensureCapacity(4);
this.buf.writeUInt32LE(v >>> 0, this.offset);
this.offset += 4;
}
writeU64(v: bigint): void {
this.ensureCapacity(8);
this.buf.writeBigUInt64LE(v, this.offset);
this.offset += 8;
}
writeU256(v: Buffer): void {
if (v.length !== 32) throw new RangeError('U256 must be 32 bytes');
this.ensureCapacity(32);
v.copy(this.buf, this.offset);
this.offset += 32;
}
writeF32(v: number): void {
this.ensureCapacity(4);
this.buf.writeFloatLE(v, this.offset);
this.offset += 4;
}
writeBytes(v: Buffer): void {
this.ensureCapacity(v.length);
v.copy(this.buf, this.offset);
this.offset += v.length;
}
/** STR0_255: 1-byte length prefix + UTF-8 payload */
writeStr0_255(v: string): void {
const payload = Buffer.from(v, 'utf8');
if (payload.length > 255)
throw new RangeError(`STR0_255: length ${payload.length} > 255`);
this.writeU8(payload.length);
this.writeBytes(payload);
}
/** B0_32: 1-byte length prefix, max 32 bytes */
writeB0_32(v: Buffer): void {
if (v.length > 32) throw new RangeError(`B0_32: length ${v.length} > 32`);
this.writeU8(v.length);
this.writeBytes(v);
}
/** B0_255: 1-byte length prefix, max 255 bytes */
writeB0_255(v: Buffer): void {
if (v.length > 255)
throw new RangeError(`B0_255: length ${v.length} > 255`);
this.writeU8(v.length);
this.writeBytes(v);
}
/** B0_64K: 2-byte length prefix */
writeB0_64K(v: Buffer): void {
if (v.length > 65535)
throw new RangeError(`B0_64K: length ${v.length} > 65535`);
this.writeU16(v.length);
this.writeBytes(v);
}
/** B0_16M: 3-byte length prefix, max 16,777,215 bytes */
writeB0_16M(v: Buffer): void {
if (v.length > 16777215)
throw new RangeError(`B0_16M: length ${v.length} > 16777215`);
this.writeU24(v.length);
this.writeBytes(v);
}
/** SEQ0_255: 1-byte count + repeated elements */
writeSeq0_255<T>(items: T[], writeFn: (writer: BufferWriter, item: T) => void): void {
if (items.length > 255)
throw new RangeError(`SEQ0_255: count ${items.length} > 255`);
this.writeU8(items.length);
for (const item of items) {
writeFn(this, item);
}
}
/** SEQ0_64K: 2-byte count + repeated elements */
writeSeq0_64K<T>(items: T[], writeFn: (writer: BufferWriter, item: T) => void): void {
if (items.length > 65535)
throw new RangeError(`SEQ0_64K: count ${items.length} > 65535`);
this.writeU16(items.length);
for (const item of items) {
writeFn(this, item);
}
}
toBuffer(): Buffer {
return Buffer.from(this.buf.subarray(0, this.offset));
}
}
+138
View File
@@ -0,0 +1,138 @@
// ── SV2 Protocol Constants ──────────────────────────────────────────
/** Standard (unencrypted) frame header: 2-byte ext+type + 3-byte length + 1-byte channel bit = 6 */
export const SV2_HEADER_SIZE = 6;
/** Poly1305 MAC appended to every AEAD ciphertext block */
export const SV2_MAC_SIZE = 16;
/** Encrypted frame header size: SV2_HEADER_SIZE + SV2_MAC_SIZE = 22 */
export const SV2_ENCRYPTED_HEADER_SIZE = SV2_HEADER_SIZE + SV2_MAC_SIZE;
/** Max plaintext bytes in a single AEAD chunk (2^16 - 1 - MAC) */
export const SV2_MAX_PLAINTEXT_CHUNK = 65519;
/** Max ciphertext bytes in a single AEAD chunk (2^16 - 1) */
export const SV2_MAX_CIPHERTEXT_CHUNK = 65535;
/** EllSwift-encoded public key size (64 bytes) */
export const SV2_ELLSWIFT_KEY_SIZE = 64;
/** Bit flag in extension_type indicating a channel message */
export const SV2_CHANNEL_MSG_FLAG = 0x8000;
/** Protocol identifier string used in SetupConnection */
export const SV2_PROTOCOL_NAME = 'MiningProtocol';
// ── Noise NX Handshake Constants ────────────────────────────────────
/** Noise NX protocol name used to initialize h (ChaCha20-Poly1305) */
export const SV2_NOISE_PROTOCOL_NAME = 'Noise_NX_Secp256k1+EllSwift_ChaChaPoly_SHA256';
/** Noise NX protocol name for AES-256-GCM cipher */
export const SV2_NOISE_PROTOCOL_NAME_AESGCM = 'Noise_NX_Secp256k1+EllSwift_AESGCM_SHA256';
/** Noise NX Act 1 size: 64-byte initiator ephemeral EllSwift key */
export const SV2_NOISE_ACT1_SIZE = 64;
/** Noise NX Act 2 size: 64 (server ephemeral) + 80 (encrypted static) + 90 (encrypted cert) = 234 */
export const SV2_NOISE_ACT2_SIZE = 234;
/** SIGNATURE_NOISE_MESSAGE payload size (without MAC): version(2) + validFrom(4) + notValidAfter(4) + signature(64) = 74 */
export const SV2_SIGNATURE_NOISE_MSG_SIZE = 74;
// ── Message Type Enum ───────────────────────────────────────────────
export enum Sv2MsgType {
// Common messages
SETUP_CONNECTION = 0x00,
SETUP_CONNECTION_SUCCESS = 0x01,
SETUP_CONNECTION_ERROR = 0x02,
CHANNEL_ENDPOINT_CHANGED = 0x03,
// Mining channel messages
OPEN_STANDARD_MINING_CHANNEL = 0x10,
OPEN_STANDARD_MINING_CHANNEL_SUCCESS = 0x11,
OPEN_STANDARD_MINING_CHANNEL_ERROR = 0x12,
CLOSE_CHANNEL = 0x18,
// Share submission
SUBMIT_SHARES_STANDARD = 0x1a,
SUBMIT_SHARES_SUCCESS = 0x1c,
SUBMIT_SHARES_ERROR = 0x1d,
// Extended Mining Channels
OPEN_EXTENDED_MINING_CHANNEL = 0x13,
OPEN_EXTENDED_MINING_CHANNEL_SUCCESS = 0x14,
SET_EXTRANONCE_PREFIX = 0x19,
SET_GROUP_CHANNEL = 0x25,
// Share submission (extended)
SUBMIT_SHARES_EXTENDED = 0x1b,
// Job & target
NEW_MINING_JOB = 0x15,
NEW_EXTENDED_MINING_JOB = 0x1f,
SET_NEW_PREV_HASH = 0x20,
SET_TARGET = 0x21,
// Channel management
UPDATE_CHANNEL = 0x16,
UPDATE_CHANNEL_ERROR = 0x17,
// Mining Protocol - Custom Job Bridge
SET_CUSTOM_MINING_JOB = 0x22,
SET_CUSTOM_MINING_JOB_SUCCESS = 0x23,
SET_CUSTOM_MINING_JOB_ERROR = 0x24,
// Reconnect
RECONNECT = 0x04,
// Job Declaration Protocol
JDP_ALLOCATE_MINING_JOB_TOKEN = 0x50,
JDP_ALLOCATE_MINING_JOB_TOKEN_SUCCESS = 0x51,
JDP_PROVIDE_MISSING_TRANSACTIONS = 0x55,
JDP_PROVIDE_MISSING_TRANSACTIONS_SUCCESS = 0x56,
JDP_DECLARE_MINING_JOB = 0x57,
JDP_DECLARE_MINING_JOB_SUCCESS = 0x58,
JDP_DECLARE_MINING_JOB_ERROR = 0x59,
JDP_PUSH_SOLUTION = 0x60,
// Template Distribution Protocol
TDP_COINBASE_OUTPUT_CONSTRAINTS = 0x70,
TDP_NEW_TEMPLATE = 0x71,
TDP_SET_NEW_PREV_HASH = 0x72,
TDP_REQUEST_TRANSACTION_DATA = 0x73,
TDP_REQUEST_TRANSACTION_DATA_SUCCESS = 0x74,
TDP_REQUEST_TRANSACTION_DATA_ERROR = 0x75,
TDP_SUBMIT_SOLUTION = 0x76,
}
// ── Protocol Enum ───────────────────────────────────────────────────
export enum Sv2Protocol {
MINING = 0,
JOB_DECLARATION = 1,
TEMPLATE_DISTRIBUTION = 2,
}
// ── Mining Setup Flags ──────────────────────────────────────────────
export enum Sv2MiningSetupFlags {
REQUIRES_STANDARD_JOBS = 1 << 0,
REQUIRES_WORK_SELECTION = 1 << 1,
REQUIRES_VERSION_ROLLING = 1 << 2,
}
// ── SetupConnection.Success Flags (Mining Protocol) ─────────────────
export enum Sv2MiningSetupSuccessFlags {
REQUIRES_FIXED_VERSION = 1 << 0,
REQUIRES_EXTENDED_CHANNELS = 1 << 1,
}
// ── SetupConnection Flags (Job Declaration Protocol) ────────────────
export enum Sv2JdpSetupFlags {
DECLARE_TX_DATA = 1 << 0,
}
@@ -0,0 +1,188 @@
import { BufferReader } from './sv2-binary-codec';
import {
Sv2OpenExtendedMiningChannel,
serializeOpenExtendedMiningChannel,
deserializeOpenExtendedMiningChannel,
Sv2OpenExtendedMiningChannelSuccess,
serializeOpenExtendedMiningChannelSuccess,
deserializeOpenExtendedMiningChannelSuccess,
Sv2NewExtendedMiningJob,
serializeNewExtendedMiningJob,
deserializeNewExtendedMiningJob,
Sv2SubmitSharesExtended,
serializeSubmitSharesExtended,
deserializeSubmitSharesExtended,
Sv2SetExtranoncePrefix,
serializeSetExtranoncePrefix,
deserializeSetExtranoncePrefix,
Sv2SetGroupChannel,
serializeSetGroupChannel,
deserializeSetGroupChannel,
} from './sv2-extended-messages';
function roundTrip<T>(
msg: T,
serialize: (m: T) => Buffer,
deserialize: (r: BufferReader) => T,
): T {
const buf = serialize(msg);
return deserialize(new BufferReader(buf));
}
describe('SV2 Extended Mining Channel Messages', () => {
it('OpenExtendedMiningChannel round-trips', () => {
const maxTarget = Buffer.alloc(32, 0x00);
maxTarget[0] = 0xff;
const msg: Sv2OpenExtendedMiningChannel = {
requestId: 1,
userIdentity: 'bc1qtest.worker1',
nominalHashRate: 500000.0,
maxTarget,
minExtranonceSize: 8,
};
const result = roundTrip(msg, serializeOpenExtendedMiningChannel, deserializeOpenExtendedMiningChannel);
expect(result.requestId).toBe(msg.requestId);
expect(result.userIdentity).toBe(msg.userIdentity);
expect(result.nominalHashRate).toBeCloseTo(msg.nominalHashRate, 0);
expect(result.maxTarget).toEqual(msg.maxTarget);
expect(result.minExtranonceSize).toBe(msg.minExtranonceSize);
});
it('OpenExtendedMiningChannelSuccess round-trips', () => {
const target = Buffer.alloc(32);
target[0] = 0xff;
const msg: Sv2OpenExtendedMiningChannelSuccess = {
requestId: 1,
channelId: 42,
target,
extranonceSize: 8,
extranoncePrefix: Buffer.from([0x00, 0x00, 0x00, 0x01]),
groupChannelId: 0,
};
const result = roundTrip(
msg,
serializeOpenExtendedMiningChannelSuccess,
deserializeOpenExtendedMiningChannelSuccess,
);
expect(result.requestId).toBe(msg.requestId);
expect(result.channelId).toBe(msg.channelId);
expect(result.target).toEqual(msg.target);
expect(result.extranonceSize).toBe(msg.extranonceSize);
expect(result.extranoncePrefix).toEqual(msg.extranoncePrefix);
expect(result.groupChannelId).toBe(msg.groupChannelId);
});
it('NewExtendedMiningJob round-trips with minNtime (mine immediately)', () => {
const hash1 = Buffer.alloc(32, 0xaa);
const hash2 = Buffer.alloc(32, 0xbb);
const msg: Sv2NewExtendedMiningJob = {
channelId: 1,
jobId: 100,
minNtime: 1700000000,
version: 0x20000000,
versionRollingAllowed: true,
merklePath: [hash1, hash2],
coinbasePrefix: Buffer.from('deadbeef', 'hex'),
coinbaseSuffix: Buffer.from('cafebabe', 'hex'),
};
const result = roundTrip(msg, serializeNewExtendedMiningJob, deserializeNewExtendedMiningJob);
expect(result.channelId).toBe(msg.channelId);
expect(result.jobId).toBe(msg.jobId);
expect(result.minNtime).toBe(msg.minNtime);
expect(result.version).toBe(msg.version);
expect(result.versionRollingAllowed).toBe(msg.versionRollingAllowed);
expect(result.merklePath).toHaveLength(2);
expect(result.merklePath[0]).toEqual(hash1);
expect(result.merklePath[1]).toEqual(hash2);
expect(result.coinbasePrefix).toEqual(msg.coinbasePrefix);
expect(result.coinbaseSuffix).toEqual(msg.coinbaseSuffix);
});
it('NewExtendedMiningJob with null minNtime (future job)', () => {
const msg: Sv2NewExtendedMiningJob = {
channelId: 1,
jobId: 1,
minNtime: null,
version: 0x20000000,
versionRollingAllowed: false,
merklePath: [],
coinbasePrefix: Buffer.alloc(0),
coinbaseSuffix: Buffer.alloc(0),
};
const result = roundTrip(msg, serializeNewExtendedMiningJob, deserializeNewExtendedMiningJob);
expect(result.merklePath).toHaveLength(0);
expect(result.minNtime).toBeNull();
expect(result.versionRollingAllowed).toBe(false);
});
it('SubmitSharesExtended round-trips', () => {
const msg: Sv2SubmitSharesExtended = {
channelId: 1,
sequenceNumber: 42,
jobId: 100,
nonce: 0xdeadbeef,
ntime: 1700000000,
version: 0x20000000,
extranonce: Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]),
};
const result = roundTrip(msg, serializeSubmitSharesExtended, deserializeSubmitSharesExtended);
expect(result.channelId).toBe(msg.channelId);
expect(result.sequenceNumber).toBe(msg.sequenceNumber);
expect(result.jobId).toBe(msg.jobId);
expect(result.nonce).toBe(msg.nonce);
expect(result.ntime).toBe(msg.ntime);
expect(result.version).toBe(msg.version);
expect(result.extranonce).toEqual(msg.extranonce);
});
it('SubmitSharesExtended with short extranonce', () => {
const msg: Sv2SubmitSharesExtended = {
channelId: 1,
sequenceNumber: 1,
jobId: 1,
nonce: 0,
ntime: 1700000000,
version: 0x20000000,
extranonce: Buffer.from([0xab]),
};
const result = roundTrip(msg, serializeSubmitSharesExtended, deserializeSubmitSharesExtended);
expect(result.extranonce).toEqual(Buffer.from([0xab]));
});
it('SetExtranoncePrefix round-trips', () => {
const msg: Sv2SetExtranoncePrefix = {
channelId: 5,
extranoncePrefix: Buffer.from([0x00, 0x00, 0x00, 0x02]),
};
const result = roundTrip(msg, serializeSetExtranoncePrefix, deserializeSetExtranoncePrefix);
expect(result.channelId).toBe(msg.channelId);
expect(result.extranoncePrefix).toEqual(msg.extranoncePrefix);
});
it('SetGroupChannel round-trips', () => {
const msg: Sv2SetGroupChannel = {
groupChannelId: 10,
channelIds: [42, 43, 44],
};
const result = roundTrip(msg, serializeSetGroupChannel, deserializeSetGroupChannel);
expect(result).toEqual(msg);
});
it('serialized SubmitSharesExtended has correct byte layout', () => {
const msg: Sv2SubmitSharesExtended = {
channelId: 1,
sequenceNumber: 2,
jobId: 3,
nonce: 4,
ntime: 5,
version: 6,
extranonce: Buffer.from([0xab, 0xcd]),
};
const buf = serializeSubmitSharesExtended(msg);
// 6 * U32(4) = 24 + B0_32(1+2) = 27
expect(buf.length).toBe(27);
expect(buf.readUInt32LE(0)).toBe(1);
expect(buf.readUInt32LE(4)).toBe(2);
expect(buf.readUInt32LE(8)).toBe(3);
});
});
+184
View File
@@ -0,0 +1,184 @@
// ── SV2 Extended Mining Channel Messages ────────────────────────────
// Interfaces + serialize/deserialize for extended channel message types.
import { BufferReader, BufferWriter } from './sv2-binary-codec';
// ── OpenExtendedMiningChannel (0x13) ───────────────────────────────
export interface Sv2OpenExtendedMiningChannel {
requestId: number; // U32
userIdentity: string; // STR0_255
nominalHashRate: number; // F32
maxTarget: Buffer; // U256 (32 bytes LE)
minExtranonceSize: number; // U16
}
export function serializeOpenExtendedMiningChannel(msg: Sv2OpenExtendedMiningChannel): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeStr0_255(msg.userIdentity);
w.writeF32(msg.nominalHashRate);
w.writeU256(msg.maxTarget);
w.writeU16(msg.minExtranonceSize);
return w.toBuffer();
}
export function deserializeOpenExtendedMiningChannel(reader: BufferReader): Sv2OpenExtendedMiningChannel {
return {
requestId: reader.readU32(),
userIdentity: reader.readStr0_255(),
nominalHashRate: reader.readF32(),
maxTarget: reader.readU256(),
minExtranonceSize: reader.readU16(),
};
}
// ── OpenExtendedMiningChannel.Success (0x14) ───────────────────────
export interface Sv2OpenExtendedMiningChannelSuccess {
requestId: number; // U32
channelId: number; // U32
target: Buffer; // U256
extranonceSize: number; // U16
extranoncePrefix: Buffer; // B0_32
groupChannelId: number; // U32
}
export function serializeOpenExtendedMiningChannelSuccess(msg: Sv2OpenExtendedMiningChannelSuccess): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeU32(msg.channelId);
w.writeU256(msg.target);
w.writeU16(msg.extranonceSize);
w.writeB0_32(msg.extranoncePrefix);
w.writeU32(msg.groupChannelId);
return w.toBuffer();
}
export function deserializeOpenExtendedMiningChannelSuccess(reader: BufferReader): Sv2OpenExtendedMiningChannelSuccess {
return {
requestId: reader.readU32(),
channelId: reader.readU32(),
target: reader.readU256(),
extranonceSize: reader.readU16(),
extranoncePrefix: reader.readB0_32(),
groupChannelId: reader.readU32(),
};
}
// ── NewExtendedMiningJob (0x1f) ────────────────────────────────────
export interface Sv2NewExtendedMiningJob {
channelId: number; // U32
jobId: number; // U32
minNtime: number | null; // Sv2Option<U32> - null = future job (activated by SetNewPrevHash), Some(ts) = mine immediately
version: number; // U32
versionRollingAllowed: boolean; // BOOL
merklePath: Buffer[]; // SEQ0_255<U256>
coinbasePrefix: Buffer; // B0_64K
coinbaseSuffix: Buffer; // B0_64K
}
export function serializeNewExtendedMiningJob(msg: Sv2NewExtendedMiningJob): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.jobId);
w.writeOptionU32(msg.minNtime);
w.writeU32(msg.version);
w.writeBool(msg.versionRollingAllowed);
w.writeSeq0_255(msg.merklePath, (writer, hash) => writer.writeU256(hash));
w.writeB0_64K(msg.coinbasePrefix);
w.writeB0_64K(msg.coinbaseSuffix);
return w.toBuffer();
}
export function deserializeNewExtendedMiningJob(reader: BufferReader): Sv2NewExtendedMiningJob {
return {
channelId: reader.readU32(),
jobId: reader.readU32(),
minNtime: reader.readOptionU32(),
version: reader.readU32(),
versionRollingAllowed: reader.readBool(),
merklePath: reader.readSeq0_255((r) => r.readU256()),
coinbasePrefix: reader.readB0_64K(),
coinbaseSuffix: reader.readB0_64K(),
};
}
// ── SubmitSharesExtended (0x1b) ────────────────────────────────────
export interface Sv2SubmitSharesExtended {
channelId: number; // U32
sequenceNumber: number; // U32
jobId: number; // U32
nonce: number; // U32
ntime: number; // U32
version: number; // U32
extranonce: Buffer; // B0_32
}
export function serializeSubmitSharesExtended(msg: Sv2SubmitSharesExtended): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.sequenceNumber);
w.writeU32(msg.jobId);
w.writeU32(msg.nonce);
w.writeU32(msg.ntime);
w.writeU32(msg.version);
w.writeB0_32(msg.extranonce);
return w.toBuffer();
}
export function deserializeSubmitSharesExtended(reader: BufferReader): Sv2SubmitSharesExtended {
return {
channelId: reader.readU32(),
sequenceNumber: reader.readU32(),
jobId: reader.readU32(),
nonce: reader.readU32(),
ntime: reader.readU32(),
version: reader.readU32(),
extranonce: reader.readB0_32(),
};
}
// ── SetExtranoncePrefix (0x19) ─────────────────────────────────────
export interface Sv2SetExtranoncePrefix {
channelId: number; // U32
extranoncePrefix: Buffer; // B0_32
}
export function serializeSetExtranoncePrefix(msg: Sv2SetExtranoncePrefix): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeB0_32(msg.extranoncePrefix);
return w.toBuffer();
}
export function deserializeSetExtranoncePrefix(reader: BufferReader): Sv2SetExtranoncePrefix {
return {
channelId: reader.readU32(),
extranoncePrefix: reader.readB0_32(),
};
}
// ── SetGroupChannel (0x25) ─────────────────────────────────────────
export interface Sv2SetGroupChannel {
groupChannelId: number; // U32
channelIds: number[]; // SEQ0_64K[U32]
}
export function serializeSetGroupChannel(msg: Sv2SetGroupChannel): Buffer {
const w = new BufferWriter();
w.writeU32(msg.groupChannelId);
w.writeSeq0_64K(msg.channelIds, (writer, id) => writer.writeU32(id));
return w.toBuffer();
}
export function deserializeSetGroupChannel(reader: BufferReader): Sv2SetGroupChannel {
return {
groupChannelId: reader.readU32(),
channelIds: reader.readSeq0_64K((r) => r.readU32()),
};
}
@@ -0,0 +1,114 @@
import { Sv2ExtranonceManager } from './sv2-extranonce-manager';
describe('Sv2ExtranonceManager', () => {
it('allocates unique prefixes for different channels', () => {
const mgr = new Sv2ExtranonceManager();
const p1 = mgr.allocate(1);
const p2 = mgr.allocate(2);
const p3 = mgr.allocate(3);
expect(p1).not.toEqual(p2);
expect(p2).not.toEqual(p3);
expect(p1).not.toEqual(p3);
});
it('returns same prefix for same channel on re-allocation', () => {
const mgr = new Sv2ExtranonceManager();
const p1a = mgr.allocate(1);
const p1b = mgr.allocate(1);
expect(p1a).toEqual(p1b);
});
it('prefix is 4 bytes by default', () => {
const mgr = new Sv2ExtranonceManager();
const p = mgr.allocate(1);
expect(p.length).toBe(4);
});
it('minerExtranonceSize is total minus prefix', () => {
const mgr = new Sv2ExtranonceManager(4, 8);
expect(mgr.minerExtranonceSize).toBe(4);
const mgr2 = new Sv2ExtranonceManager(2, 6);
expect(mgr2.minerExtranonceSize).toBe(4);
});
it('releases prefix and allows reuse', () => {
const mgr = new Sv2ExtranonceManager();
const p1 = mgr.allocate(1);
expect(mgr.allocatedCount).toBe(1);
mgr.release(1);
expect(mgr.allocatedCount).toBe(0);
expect(mgr.getPrefix(1)).toBeUndefined();
// Should be able to allocate again (may get same or different prefix)
const p2 = mgr.allocate(10);
expect(p2.length).toBe(4);
expect(mgr.allocatedCount).toBe(1);
});
it('release is idempotent for unknown channels', () => {
const mgr = new Sv2ExtranonceManager();
expect(() => mgr.release(999)).not.toThrow();
});
it('getPrefix returns undefined for unallocated channel', () => {
const mgr = new Sv2ExtranonceManager();
expect(mgr.getPrefix(42)).toBeUndefined();
});
it('getPrefix returns the allocated prefix', () => {
const mgr = new Sv2ExtranonceManager();
const p = mgr.allocate(1);
expect(mgr.getPrefix(1)).toEqual(p);
});
it('handles many allocations without collision', () => {
const mgr = new Sv2ExtranonceManager();
const prefixes = new Set<string>();
for (let i = 1; i <= 1000; i++) {
const p = mgr.allocate(i);
const hex = p.toString('hex');
expect(prefixes.has(hex)).toBe(false);
prefixes.add(hex);
}
expect(mgr.allocatedCount).toBe(1000);
});
it('works with 2-byte prefix size', () => {
const mgr = new Sv2ExtranonceManager(2, 4);
const p = mgr.allocate(1);
expect(p.length).toBe(2);
expect(mgr.minerExtranonceSize).toBe(2);
});
it('reuses released prefix slot', () => {
const mgr = new Sv2ExtranonceManager();
const p1 = mgr.allocate(1);
mgr.allocate(2);
mgr.release(1);
// Allocate a new channel - p1's prefix should be available
const p3 = mgr.allocate(3);
expect(p3.length).toBe(4);
expect(mgr.allocatedCount).toBe(2);
});
it('tracks allocatedCount correctly', () => {
const mgr = new Sv2ExtranonceManager();
expect(mgr.allocatedCount).toBe(0);
mgr.allocate(1);
expect(mgr.allocatedCount).toBe(1);
mgr.allocate(2);
expect(mgr.allocatedCount).toBe(2);
mgr.release(1);
expect(mgr.allocatedCount).toBe(1);
mgr.release(2);
expect(mgr.allocatedCount).toBe(0);
});
});
+113
View File
@@ -0,0 +1,113 @@
// ── SV2 Extranonce Manager ──────────────────────────────────────────
// Manages extranonce prefix allocation for extended mining channels.
// Ensures no two channels share the same prefix to prevent hash collisions.
export class Sv2ExtranonceManager {
private nextPrefix: number;
private readonly workerOffset: number;
private readonly maxPrefix: number;
private allocatedPrefixes = new Map<number, number>(); // channelId → prefix
private usedPrefixes = new Set<number>();
private readonly prefixSize: number;
private readonly totalExtranonceSize: number;
/**
* @param prefixSize Bytes used for pool-assigned prefix (default 4)
* @param totalExtranonceSize Total extranonce bytes (default 12: 4 prefix + 8 miner-controlled).
* Must match MiningJob.ts's coinbase slot size (12 bytes) — both
* protocols share the same coinbase template. Bumped from the
* previous 8-byte total because the Braiins Hashpower marketplace
* requires extranonce2_size >= 7 (compatibility spec).
*/
constructor(prefixSize = 4, totalExtranonceSize = 12) {
this.prefixSize = prefixSize;
this.totalExtranonceSize = totalExtranonceSize;
const workerId = 0;
const bitsPerWorker = (prefixSize - 1) * 8;
this.workerOffset = workerId * Math.pow(2, bitsPerWorker);
this.maxPrefix = Math.pow(2, bitsPerWorker) - 1; // max within this worker's slice
this.nextPrefix = 1;
}
get minerExtranonceSize(): number {
return this.totalExtranonceSize - this.prefixSize;
}
/**
* Allocate a unique extranonce prefix for a channel.
* Returns a Buffer of `prefixSize` bytes.
*/
allocate(channelId: number): Buffer {
// Check if already allocated
if (this.allocatedPrefixes.has(channelId)) {
const existing = this.allocatedPrefixes.get(channelId)!;
return this.prefixToBuffer(existing);
}
// Find next available prefix within this worker's partition
let localPrefix = this.nextPrefix;
let attempts = 0;
const globalPrefix = () => this.workerOffset + localPrefix;
while (this.usedPrefixes.has(globalPrefix()) && attempts <= this.maxPrefix) {
localPrefix = localPrefix + 1;
if (localPrefix > this.maxPrefix) localPrefix = 1; // Wrap within partition, skip 0
attempts++;
}
if (this.usedPrefixes.has(globalPrefix())) {
throw new Error('Extranonce prefix space exhausted');
}
const prefix = globalPrefix();
this.allocatedPrefixes.set(channelId, prefix);
this.usedPrefixes.add(prefix);
this.nextPrefix = localPrefix + 1;
if (this.nextPrefix > this.maxPrefix) this.nextPrefix = 1;
return this.prefixToBuffer(prefix);
}
/**
* Release the prefix allocated to a channel.
*/
release(channelId: number): void {
const prefix = this.allocatedPrefixes.get(channelId);
if (prefix !== undefined) {
this.allocatedPrefixes.delete(channelId);
this.usedPrefixes.delete(prefix);
}
}
/**
* Get the prefix for a channel, or undefined if not allocated.
*/
getPrefix(channelId: number): Buffer | undefined {
const prefix = this.allocatedPrefixes.get(channelId);
if (prefix === undefined) return undefined;
return this.prefixToBuffer(prefix);
}
get allocatedCount(): number {
return this.allocatedPrefixes.size;
}
private prefixToBuffer(prefix: number): Buffer {
const buf = Buffer.alloc(this.prefixSize);
// Write as big-endian so prefix 1 = 0x00000001
if (this.prefixSize === 4) {
buf.writeUInt32BE(prefix, 0);
} else if (this.prefixSize === 2) {
buf.writeUInt16BE(prefix, 0);
} else {
// Generic: write big-endian
let val = prefix;
for (let i = this.prefixSize - 1; i >= 0; i--) {
buf[i] = val & 0xff;
val = val >>> 8;
}
}
return buf;
}
}
+243
View File
@@ -0,0 +1,243 @@
import {
encodeFrameHeader,
decodeFrameHeader,
isChannelMessage,
plaintextToCiphertextLength,
ciphertextToPlaintextLength,
Sv2FrameReader,
Sv2FrameWriter,
Sv2FrameHeader,
} from './sv2-frame';
import { SV2_CHANNEL_MSG_FLAG, SV2_MAC_SIZE, SV2_MAX_PLAINTEXT_CHUNK } from './sv2-constants';
describe('SV2 Frame', () => {
// ── Header Encode / Decode ──────────────────────────────────────────
describe('frame header', () => {
it('should encode and decode a basic header', () => {
const header: Sv2FrameHeader = {
extensionType: 0x0000,
msgType: 0x10,
msgLength: 42,
};
const buf = encodeFrameHeader(header);
expect(buf.length).toBe(6);
const decoded = decodeFrameHeader(buf);
expect(decoded).toEqual(header);
});
it('should handle max msgLength (24-bit)', () => {
const header: Sv2FrameHeader = {
extensionType: 0,
msgType: 0xff,
msgLength: 0xffffff,
};
const buf = encodeFrameHeader(header);
const decoded = decodeFrameHeader(buf);
expect(decoded.msgLength).toBe(0xffffff);
});
it('should preserve channel message flag', () => {
const header: Sv2FrameHeader = {
extensionType: SV2_CHANNEL_MSG_FLAG | 0x01,
msgType: 0x1a,
msgLength: 24,
};
const buf = encodeFrameHeader(header);
const decoded = decodeFrameHeader(buf);
expect(decoded.extensionType).toBe(SV2_CHANNEL_MSG_FLAG | 0x01);
expect(isChannelMessage(decoded)).toBe(true);
});
it('should detect non-channel messages', () => {
const header: Sv2FrameHeader = {
extensionType: 0x0000,
msgType: 0x00,
msgLength: 10,
};
expect(isChannelMessage(header)).toBe(false);
});
it('should throw on buffer too short', () => {
expect(() => decodeFrameHeader(Buffer.alloc(5))).toThrow(RangeError);
});
});
// ── Ciphertext Length Calculations ──────────────────────────────────
describe('ciphertext length calculations', () => {
it('should return 0 for 0-length plaintext', () => {
expect(plaintextToCiphertextLength(0)).toBe(0);
expect(ciphertextToPlaintextLength(0)).toBe(0);
});
it('should add one MAC for small payload', () => {
expect(plaintextToCiphertextLength(100)).toBe(100 + SV2_MAC_SIZE);
});
it('should handle exactly one full chunk', () => {
const ct = plaintextToCiphertextLength(SV2_MAX_PLAINTEXT_CHUNK);
expect(ct).toBe(SV2_MAX_PLAINTEXT_CHUNK + SV2_MAC_SIZE);
});
it('should handle one full chunk + 1 byte remainder', () => {
const ct = plaintextToCiphertextLength(SV2_MAX_PLAINTEXT_CHUNK + 1);
expect(ct).toBe(SV2_MAX_PLAINTEXT_CHUNK + SV2_MAC_SIZE + 1 + SV2_MAC_SIZE);
});
it('plaintext -> ciphertext -> plaintext round-trips', () => {
const sizes = [0, 1, 100, SV2_MAX_PLAINTEXT_CHUNK, SV2_MAX_PLAINTEXT_CHUNK + 1, SV2_MAX_PLAINTEXT_CHUNK * 2 + 500];
for (const pt of sizes) {
const ct = plaintextToCiphertextLength(pt);
expect(ciphertextToPlaintextLength(ct)).toBe(pt);
}
});
it('should throw for invalid ciphertext length (remainder too small)', () => {
// A remainder of exactly SV2_MAC_SIZE would mean 0 plaintext bytes but still a chunk
// Actually that's valid (0 plaintext + 16 MAC). Let's test remainder < MAC
expect(() => ciphertextToPlaintextLength(5)).toThrow(RangeError);
});
});
// ── Plaintext Frame Reader / Writer ─────────────────────────────────
describe('plaintext framing', () => {
it('should write and read a complete plaintext frame', () => {
const writer = new Sv2FrameWriter(null);
const header: Sv2FrameHeader = {
extensionType: 0,
msgType: 0x00,
msgLength: 5,
};
const payload = Buffer.from('hello');
const wire = writer.writeFrame(header, payload);
const reader = new Sv2FrameReader(null);
const frames = reader.feed(wire);
expect(frames.length).toBe(1);
expect(frames[0].header.msgType).toBe(0x00);
expect(frames[0].payload.toString()).toBe('hello');
});
it('should handle fragmented TCP delivery', () => {
const writer = new Sv2FrameWriter(null);
const header: Sv2FrameHeader = {
extensionType: 0,
msgType: 0x10,
msgLength: 10,
};
const payload = Buffer.alloc(10, 0xab);
const wire = writer.writeFrame(header, payload);
const reader = new Sv2FrameReader(null);
// Feed byte by byte
let frames: any[] = [];
for (let i = 0; i < wire.length; i++) {
const chunk = wire.subarray(i, i + 1);
frames = frames.concat(reader.feed(Buffer.from(chunk)));
}
expect(frames.length).toBe(1);
expect(frames[0].header.msgType).toBe(0x10);
expect(frames[0].payload).toEqual(payload);
});
it('should parse multiple frames in one feed', () => {
const writer = new Sv2FrameWriter(null);
const frames: Buffer[] = [];
for (let i = 0; i < 3; i++) {
const header: Sv2FrameHeader = {
extensionType: 0,
msgType: i,
msgLength: 4,
};
frames.push(writer.writeFrame(header, Buffer.alloc(4, i)));
}
const reader = new Sv2FrameReader(null);
const result = reader.feed(Buffer.concat(frames));
expect(result.length).toBe(3);
for (let i = 0; i < 3; i++) {
expect(result[i].header.msgType).toBe(i);
expect(result[i].payload).toEqual(Buffer.alloc(4, i));
}
});
it('should handle zero-length payload', () => {
const writer = new Sv2FrameWriter(null);
const header: Sv2FrameHeader = {
extensionType: 0,
msgType: 0x01,
msgLength: 0,
};
const wire = writer.writeFrame(header, Buffer.alloc(0));
const reader = new Sv2FrameReader(null);
const frames = reader.feed(wire);
expect(frames.length).toBe(1);
expect(frames[0].payload.length).toBe(0);
});
});
// ── Encrypted Frame Reader / Writer ─────────────────────────────────
describe('encrypted framing', () => {
it('should write and read encrypted frames with mock cipher', () => {
// Simple XOR "encryption" for testing frame logic
const xorKey = 0x42;
const encrypt = (pt: Buffer) => {
const ct = Buffer.alloc(pt.length + SV2_MAC_SIZE);
for (let i = 0; i < pt.length; i++) ct[i] = pt[i] ^ xorKey;
// Fake MAC: zeros
return ct;
};
const decrypt = (ct: Buffer) => {
const pt = Buffer.alloc(ct.length - SV2_MAC_SIZE);
for (let i = 0; i < pt.length; i++) pt[i] = ct[i] ^ xorKey;
return pt;
};
const writer = new Sv2FrameWriter(encrypt);
const header: Sv2FrameHeader = {
extensionType: 0,
msgType: 0x10,
msgLength: 8,
};
const payload = Buffer.from('testdata');
const wire = writer.writeFrame(header, payload);
const reader = new Sv2FrameReader(decrypt);
const frames = reader.feed(wire);
expect(frames.length).toBe(1);
expect(frames[0].header.msgType).toBe(0x10);
expect(frames[0].payload.toString()).toBe('testdata');
});
it('should switch from plaintext to encrypted mode', () => {
const encrypt = (pt: Buffer) => Buffer.concat([pt, Buffer.alloc(SV2_MAC_SIZE)]);
const decrypt = (ct: Buffer) => ct.subarray(0, ct.length - SV2_MAC_SIZE);
// Start in plaintext mode
const writer = new Sv2FrameWriter(null);
const reader = new Sv2FrameReader(null);
const plainHeader: Sv2FrameHeader = { extensionType: 0, msgType: 0x00, msgLength: 3 };
const plainWire = writer.writeFrame(plainHeader, Buffer.from('abc'));
let frames = reader.feed(plainWire);
expect(frames.length).toBe(1);
expect(frames[0].payload.toString()).toBe('abc');
// Switch to encrypted
writer.setEncryptFn(encrypt);
reader.setDecryptFn(decrypt);
const encHeader: Sv2FrameHeader = { extensionType: 0, msgType: 0x01, msgLength: 3 };
const encWire = writer.writeFrame(encHeader, Buffer.from('xyz'));
frames = reader.feed(encWire);
expect(frames.length).toBe(1);
expect(frames[0].payload.toString()).toBe('xyz');
});
});
});
+232
View File
@@ -0,0 +1,232 @@
// ── SV2 Frame Encoding / Decoding ───────────────────────────────────
// Handles 6-byte frame headers, encrypted framing with ChaCha20-Poly1305
// AEAD chunking, and incremental TCP reassembly.
import {
SV2_HEADER_SIZE,
SV2_MAC_SIZE,
SV2_ENCRYPTED_HEADER_SIZE,
SV2_MAX_PLAINTEXT_CHUNK,
SV2_CHANNEL_MSG_FLAG,
} from './sv2-constants';
// ── Frame Header ────────────────────────────────────────────────────
export interface Sv2FrameHeader {
extensionType: number; // U16 (includes channel msg bit)
msgType: number; // U8
msgLength: number; // U24 (payload byte count)
}
/**
* Encode a 6-byte SV2 frame header.
* extension_type[0..1] | msg_type[2] | msg_length[3..5]
*/
export function encodeFrameHeader(header: Sv2FrameHeader): Buffer {
const buf = Buffer.alloc(SV2_HEADER_SIZE);
buf.writeUInt16LE(header.extensionType, 0);
buf[2] = header.msgType & 0xff;
buf[3] = header.msgLength & 0xff;
buf[4] = (header.msgLength >> 8) & 0xff;
buf[5] = (header.msgLength >> 16) & 0xff;
return buf;
}
/**
* Decode a 6-byte SV2 frame header.
*/
export function decodeFrameHeader(buf: Buffer): Sv2FrameHeader {
if (buf.length < SV2_HEADER_SIZE) {
throw new RangeError(`Frame header requires ${SV2_HEADER_SIZE} bytes, got ${buf.length}`);
}
const extensionType = buf.readUInt16LE(0);
const msgType = buf[2];
const msgLength = buf[3] | (buf[4] << 8) | (buf[5] << 16);
return { extensionType, msgType, msgLength };
}
/**
* Check if a frame header indicates a channel message.
*/
export function isChannelMessage(header: Sv2FrameHeader): boolean {
return (header.extensionType & SV2_CHANNEL_MSG_FLAG) !== 0;
}
// ── Ciphertext / Plaintext Size Calculations ────────────────────────
/**
* Calculate ciphertext length from plaintext length.
* Each chunk of up to 65519 plaintext bytes gets a 16-byte MAC.
*/
export function plaintextToCiphertextLength(plaintextLen: number): number {
if (plaintextLen === 0) return 0;
const fullChunks = Math.floor(plaintextLen / SV2_MAX_PLAINTEXT_CHUNK);
const remainder = plaintextLen % SV2_MAX_PLAINTEXT_CHUNK;
let len = fullChunks * (SV2_MAX_PLAINTEXT_CHUNK + SV2_MAC_SIZE);
if (remainder > 0) {
len += remainder + SV2_MAC_SIZE;
}
return len;
}
/**
* Calculate plaintext length from ciphertext length.
*/
export function ciphertextToPlaintextLength(ciphertextLen: number): number {
if (ciphertextLen === 0) return 0;
const chunkSize = SV2_MAX_PLAINTEXT_CHUNK + SV2_MAC_SIZE;
const fullChunks = Math.floor(ciphertextLen / chunkSize);
const remainder = ciphertextLen % chunkSize;
let len = fullChunks * SV2_MAX_PLAINTEXT_CHUNK;
if (remainder > 0) {
if (remainder <= SV2_MAC_SIZE) {
throw new RangeError('Invalid ciphertext length: remainder chunk too small for MAC');
}
len += remainder - SV2_MAC_SIZE;
}
return len;
}
// ── Decryption / Encryption Function Types ──────────────────────────
export type DecryptFn = (ciphertext: Buffer) => Buffer;
export type EncryptFn = (plaintext: Buffer) => Buffer;
// ── Frame Reader (TCP reassembly + decryption) ──────────────────────
interface ParsedFrame {
header: Sv2FrameHeader;
payload: Buffer;
}
const enum ReaderState {
READING_HEADER,
READING_PAYLOAD,
}
export class Sv2FrameReader {
private decryptFn: DecryptFn | null;
private buf: Buffer = Buffer.alloc(0);
private state = ReaderState.READING_HEADER;
private currentHeader: Sv2FrameHeader | null = null;
constructor(decryptFn: DecryptFn | null) {
this.decryptFn = decryptFn;
}
/** Switch to encrypted mode (after handshake completes). */
setDecryptFn(fn: DecryptFn): void {
this.decryptFn = fn;
}
/** Expected header size: 22 bytes encrypted, 6 bytes plaintext. */
private get headerSize(): number {
return this.decryptFn ? SV2_ENCRYPTED_HEADER_SIZE : SV2_HEADER_SIZE;
}
/**
* Feed incoming TCP data and return any complete frames.
*/
feed(data: Buffer): ParsedFrame[] {
this.buf = this.buf.length === 0 ? data : Buffer.concat([this.buf, data]);
const frames: ParsedFrame[] = [];
while (true) {
if (this.state === ReaderState.READING_HEADER) {
if (this.buf.length < this.headerSize) break;
let headerBytes: Buffer;
if (this.decryptFn) {
headerBytes = this.decryptFn(this.buf.subarray(0, SV2_ENCRYPTED_HEADER_SIZE));
} else {
headerBytes = this.buf.subarray(0, SV2_HEADER_SIZE);
}
this.currentHeader = decodeFrameHeader(headerBytes);
this.buf = Buffer.from(this.buf.subarray(this.headerSize));
this.state = ReaderState.READING_PAYLOAD;
}
if (this.state === ReaderState.READING_PAYLOAD) {
const payloadWireLen = this.decryptFn
? plaintextToCiphertextLength(this.currentHeader!.msgLength)
: this.currentHeader!.msgLength;
if (this.buf.length < payloadWireLen) break;
let payload: Buffer;
if (this.decryptFn && payloadWireLen > 0) {
payload = this.decryptPayloadChunks(this.buf.subarray(0, payloadWireLen));
} else {
payload = Buffer.from(this.buf.subarray(0, payloadWireLen));
}
frames.push({ header: this.currentHeader!, payload });
this.buf = Buffer.from(this.buf.subarray(payloadWireLen));
this.currentHeader = null;
this.state = ReaderState.READING_HEADER;
}
}
return frames;
}
private decryptPayloadChunks(ciphertext: Buffer): Buffer {
const chunkSize = SV2_MAX_PLAINTEXT_CHUNK + SV2_MAC_SIZE;
const parts: Buffer[] = [];
let offset = 0;
while (offset < ciphertext.length) {
const remaining = ciphertext.length - offset;
const thisChunkLen = Math.min(remaining, chunkSize);
const chunk = ciphertext.subarray(offset, offset + thisChunkLen);
parts.push(this.decryptFn!(Buffer.from(chunk)));
offset += thisChunkLen;
}
return Buffer.concat(parts);
}
}
// ── Frame Writer (serialization + encryption) ───────────────────────
export class Sv2FrameWriter {
private encryptFn: EncryptFn | null;
constructor(encryptFn: EncryptFn | null) {
this.encryptFn = encryptFn;
}
/** Switch to encrypted mode (after handshake completes). */
setEncryptFn(fn: EncryptFn): void {
this.encryptFn = fn;
}
/**
* Encode a complete frame (header + payload) ready for the wire.
*/
writeFrame(header: Sv2FrameHeader, payload: Buffer): Buffer {
const headerBuf = encodeFrameHeader(header);
if (!this.encryptFn) {
return Buffer.concat([headerBuf, payload]);
}
// Encrypt header
const encHeader = this.encryptFn(headerBuf);
// Encrypt payload in chunks
const parts: Buffer[] = [encHeader];
let offset = 0;
while (offset < payload.length) {
const remaining = payload.length - offset;
const chunkLen = Math.min(remaining, SV2_MAX_PLAINTEXT_CHUNK);
const chunk = payload.subarray(offset, offset + chunkLen);
parts.push(this.encryptFn(Buffer.from(chunk)));
offset += chunkLen;
}
return Buffer.concat(parts);
}
}
+312
View File
@@ -0,0 +1,312 @@
import { BufferReader } from './sv2-binary-codec';
import {
Sv2AllocateMiningJobToken,
serializeAllocateMiningJobToken,
deserializeAllocateMiningJobToken,
Sv2AllocateMiningJobTokenSuccess,
serializeAllocateMiningJobTokenSuccess,
deserializeAllocateMiningJobTokenSuccess,
Sv2DeclareMiningJob,
serializeDeclareMiningJob,
deserializeDeclareMiningJob,
Sv2DeclareMiningJobSuccess,
serializeDeclareMiningJobSuccess,
deserializeDeclareMiningJobSuccess,
Sv2DeclareMiningJobError,
serializeDeclareMiningJobError,
deserializeDeclareMiningJobError,
Sv2ProvideMissingTransactions,
serializeProvideMissingTransactions,
deserializeProvideMissingTransactions,
Sv2ProvideMissingTransactionsSuccess,
serializeProvideMissingTransactionsSuccess,
deserializeProvideMissingTransactionsSuccess,
Sv2SetCustomMiningJob,
serializeSetCustomMiningJob,
deserializeSetCustomMiningJob,
Sv2SetCustomMiningJobSuccess,
serializeSetCustomMiningJobSuccess,
deserializeSetCustomMiningJobSuccess,
Sv2SetCustomMiningJobError,
serializeSetCustomMiningJobError,
deserializeSetCustomMiningJobError,
Sv2PushSolution,
serializePushSolution,
deserializePushSolution,
} from './sv2-jdp-messages';
function roundTrip<T>(
msg: T,
serialize: (m: T) => Buffer,
deserialize: (r: BufferReader) => T,
): T {
const buf = serialize(msg);
return deserialize(new BufferReader(buf));
}
describe('SV2 JDP Messages', () => {
it('AllocateMiningJobToken round-trips', () => {
const msg: Sv2AllocateMiningJobToken = {
userIdentifier: 'miner-001',
requestId: 1,
};
const result = roundTrip(msg, serializeAllocateMiningJobToken, deserializeAllocateMiningJobToken);
expect(result.userIdentifier).toBe(msg.userIdentifier);
expect(result.requestId).toBe(msg.requestId);
});
it('AllocateMiningJobTokenSuccess round-trips', () => {
const msg: Sv2AllocateMiningJobTokenSuccess = {
requestId: 1,
miningJobToken: Buffer.from('token123', 'utf8'),
coinbaseOutputs: Buffer.from('cafebabe', 'hex'),
};
const result = roundTrip(msg, serializeAllocateMiningJobTokenSuccess, deserializeAllocateMiningJobTokenSuccess);
expect(result.requestId).toBe(msg.requestId);
expect(result.miningJobToken).toEqual(msg.miningJobToken);
expect(result.coinbaseOutputs).toEqual(msg.coinbaseOutputs);
});
it('AllocateMiningJobTokenSuccess with empty coinbaseOutputs', () => {
const msg: Sv2AllocateMiningJobTokenSuccess = {
requestId: 42,
miningJobToken: Buffer.from('tok'),
coinbaseOutputs: Buffer.alloc(0),
};
const result = roundTrip(msg, serializeAllocateMiningJobTokenSuccess, deserializeAllocateMiningJobTokenSuccess);
expect(result.coinbaseOutputs.length).toBe(0);
});
it('DeclareMiningJob round-trips', () => {
const wtxid1 = Buffer.alloc(32, 0x11);
const wtxid2 = Buffer.alloc(32, 0x22);
const msg: Sv2DeclareMiningJob = {
requestId: 42,
miningJobToken: Buffer.from('mytoken'),
version: 0x20000000,
coinbaseTxPrefix: Buffer.from('deadbeef', 'hex'),
coinbaseTxSuffix: Buffer.from('cafebabe', 'hex'),
wtxidList: [wtxid1, wtxid2],
excessData: Buffer.from('extra'),
};
const result = roundTrip(msg, serializeDeclareMiningJob, deserializeDeclareMiningJob);
expect(result.requestId).toBe(msg.requestId);
expect(result.miningJobToken).toEqual(msg.miningJobToken);
expect(result.version).toBe(msg.version);
expect(result.coinbaseTxPrefix).toEqual(msg.coinbaseTxPrefix);
expect(result.coinbaseTxSuffix).toEqual(msg.coinbaseTxSuffix);
expect(result.wtxidList).toHaveLength(2);
expect(result.wtxidList[0]).toEqual(wtxid1);
expect(result.wtxidList[1]).toEqual(wtxid2);
expect(result.excessData).toEqual(msg.excessData);
});
it('DeclareMiningJob with empty tx list', () => {
const msg: Sv2DeclareMiningJob = {
requestId: 1,
miningJobToken: Buffer.alloc(0),
version: 0x20000000,
coinbaseTxPrefix: Buffer.alloc(0),
coinbaseTxSuffix: Buffer.alloc(0),
wtxidList: [],
excessData: Buffer.alloc(0),
};
const result = roundTrip(msg, serializeDeclareMiningJob, deserializeDeclareMiningJob);
expect(result.wtxidList).toHaveLength(0);
});
it('DeclareMiningJobSuccess round-trips', () => {
const msg: Sv2DeclareMiningJobSuccess = {
requestId: 42,
newMiningJobToken: Buffer.from('newtoken'),
};
const result = roundTrip(msg, serializeDeclareMiningJobSuccess, deserializeDeclareMiningJobSuccess);
expect(result).toEqual(msg);
});
it('DeclareMiningJobError round-trips', () => {
const msg: Sv2DeclareMiningJobError = {
requestId: 42,
errorCode: 'invalid-mining-job-token',
errorDetails: Buffer.from('Token expired'),
};
const result = roundTrip(msg, serializeDeclareMiningJobError, deserializeDeclareMiningJobError);
expect(result.requestId).toBe(msg.requestId);
expect(result.errorCode).toBe(msg.errorCode);
expect(result.errorDetails).toEqual(msg.errorDetails);
});
it('ProvideMissingTransactions round-trips', () => {
const msg: Sv2ProvideMissingTransactions = {
requestId: 7,
unknownTxPositionList: [0, 3, 7, 15],
};
const result = roundTrip(msg, serializeProvideMissingTransactions, deserializeProvideMissingTransactions);
expect(result.requestId).toBe(msg.requestId);
expect(result.unknownTxPositionList).toEqual([0, 3, 7, 15]);
});
it('ProvideMissingTransactionsSuccess round-trips (B0_16M inner type)', () => {
const tx1 = Buffer.from('0100000001', 'hex');
const tx2 = Buffer.from('0200000002abcdef', 'hex');
const msg: Sv2ProvideMissingTransactionsSuccess = {
requestId: 7,
transactionList: [tx1, tx2],
};
const result = roundTrip(msg, serializeProvideMissingTransactionsSuccess, deserializeProvideMissingTransactionsSuccess);
expect(result.requestId).toBe(msg.requestId);
expect(result.transactionList).toHaveLength(2);
expect(result.transactionList[0]).toEqual(tx1);
expect(result.transactionList[1]).toEqual(tx2);
});
it('ProvideMissingTransactionsSuccess with large transaction (>64KB)', () => {
const largeTx = Buffer.alloc(100000, 0xab); // 100KB > 64KB limit of B0_64K
const msg: Sv2ProvideMissingTransactionsSuccess = {
requestId: 1,
transactionList: [largeTx],
};
const result = roundTrip(msg, serializeProvideMissingTransactionsSuccess, deserializeProvideMissingTransactionsSuccess);
expect(result.transactionList[0].length).toBe(100000);
expect(result.transactionList[0]).toEqual(largeTx);
});
it('SetCustomMiningJob round-trips', () => {
const hash1 = Buffer.alloc(32, 0xaa);
const msg: Sv2SetCustomMiningJob = {
channelId: 1,
requestId: 100,
token: Buffer.from('mytoken'),
version: 0x20000000,
prevHash: Buffer.alloc(32, 0xbb),
minNtime: 1700000000,
nBits: 0x1d00ffff,
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.from('deadbeef', 'hex'),
coinbaseTxInputNSequence: 0xffffffff,
coinbaseTxOutputs: Buffer.from('outputs'),
coinbaseTxLocktime: 0,
merklePath: [hash1],
};
const result = roundTrip(msg, serializeSetCustomMiningJob, deserializeSetCustomMiningJob);
expect(result.channelId).toBe(msg.channelId);
expect(result.requestId).toBe(msg.requestId);
expect(result.token).toEqual(msg.token);
expect(result.version).toBe(msg.version);
expect(result.prevHash).toEqual(msg.prevHash);
expect(result.minNtime).toBe(msg.minNtime);
expect(result.nBits).toBe(msg.nBits);
expect(result.coinbaseTxVersion).toBe(msg.coinbaseTxVersion);
expect(result.coinbasePrefix).toEqual(msg.coinbasePrefix);
expect(result.coinbaseTxInputNSequence).toBe(msg.coinbaseTxInputNSequence);
expect(result.coinbaseTxOutputs).toEqual(msg.coinbaseTxOutputs);
expect(result.coinbaseTxLocktime).toBe(msg.coinbaseTxLocktime);
expect(result.merklePath).toHaveLength(1);
expect(result.merklePath[0]).toEqual(hash1);
});
it('SetCustomMiningJobSuccess round-trips', () => {
const msg: Sv2SetCustomMiningJobSuccess = {
channelId: 1,
requestId: 100,
jobId: 42,
};
const result = roundTrip(msg, serializeSetCustomMiningJobSuccess, deserializeSetCustomMiningJobSuccess);
expect(result).toEqual(msg);
});
it('SetCustomMiningJobError round-trips', () => {
const msg: Sv2SetCustomMiningJobError = {
channelId: 1,
requestId: 100,
errorCode: 'invalid-mining-job-token',
};
const result = roundTrip(msg, serializeSetCustomMiningJobError, deserializeSetCustomMiningJobError);
expect(result).toEqual(msg);
});
it('serialized DeclareMiningJob has correct structure', () => {
const msg: Sv2DeclareMiningJob = {
requestId: 1,
miningJobToken: Buffer.from([0xab]),
version: 2,
coinbaseTxPrefix: Buffer.alloc(0),
coinbaseTxSuffix: Buffer.alloc(0),
wtxidList: [],
excessData: Buffer.alloc(0),
};
const buf = serializeDeclareMiningJob(msg);
// U32(4) + B0_255(1+1) + U32(4) + B0_64K(2+0) + B0_64K(2+0) + SEQ0_64K(2+0) + B0_64K(2+0)
// = 4 + 2 + 4 + 2 + 2 + 2 + 2 = 18
expect(buf.length).toBe(18);
});
it('PushSolution round-trips', () => {
const msg: Sv2PushSolution = {
extranonce: Buffer.from('aabbccdd', 'hex'),
prevHash: Buffer.alloc(32, 0x11),
nonce: 0xdeadbeef,
ntime: 1700000000,
nBits: 0x1d00ffff,
version: 0x20000000,
};
const result = roundTrip(msg, serializePushSolution, deserializePushSolution);
expect(result.extranonce).toEqual(msg.extranonce);
expect(result.prevHash).toEqual(msg.prevHash);
expect(result.nonce).toBe(msg.nonce);
expect(result.ntime).toBe(msg.ntime);
expect(result.nBits).toBe(msg.nBits);
expect(result.version).toBe(msg.version);
});
it('PushSolution with empty extranonce', () => {
const msg: Sv2PushSolution = {
extranonce: Buffer.alloc(0),
prevHash: Buffer.alloc(32, 0xff),
nonce: 0,
ntime: 0,
nBits: 0,
version: 0,
};
const result = roundTrip(msg, serializePushSolution, deserializePushSolution);
expect(result.extranonce.length).toBe(0);
expect(result.prevHash).toEqual(msg.prevHash);
});
it('serialized PushSolution has correct size', () => {
const msg: Sv2PushSolution = {
extranonce: Buffer.alloc(8),
prevHash: Buffer.alloc(32),
nonce: 0,
ntime: 0,
nBits: 0,
version: 0,
};
const buf = serializePushSolution(msg);
// B0_32(1+8) + U256(32) + U32(4) + U32(4) + U32(4) + U32(4) = 57
expect(buf.length).toBe(57);
});
it('serialized SetCustomMiningJob has correct fixed overhead', () => {
const msg: Sv2SetCustomMiningJob = {
channelId: 1,
requestId: 2,
token: Buffer.alloc(0),
version: 0x20000000,
prevHash: Buffer.alloc(32),
minNtime: 0,
nBits: 0,
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.alloc(0),
coinbaseTxInputNSequence: 0xffffffff,
coinbaseTxOutputs: Buffer.alloc(0),
coinbaseTxLocktime: 0,
merklePath: [],
};
const buf = serializeSetCustomMiningJob(msg);
// U32(4) + U32(4) + B0_255(1+0) + U32(4) + U256(32) + U32(4) + U32(4) + U32(4) +
// B0_255(1+0) + U32(4) + B0_64K(2+0) + U32(4) + SEQ0_255(1+0)
// = 4+4+1+4+32+4+4+4+1+4+2+4+1 = 69
expect(buf.length).toBe(69);
});
});
+309
View File
@@ -0,0 +1,309 @@
// ── SV2 Job Declaration Protocol (JDP) Messages ────────────────────
// Interfaces + serialize/deserialize for all JDP message types and
// the Mining Protocol custom job bridge messages.
// Aligned with Rust SV2 reference (stratum/sv2/subprotocols/job-declaration).
import { BufferReader, BufferWriter } from './sv2-binary-codec';
// ── AllocateMiningJobToken (0x50) ──────────────────────────────────
export interface Sv2AllocateMiningJobToken {
userIdentifier: string; // STR0_255
requestId: number; // U32
}
export function serializeAllocateMiningJobToken(msg: Sv2AllocateMiningJobToken): Buffer {
const w = new BufferWriter();
w.writeStr0_255(msg.userIdentifier);
w.writeU32(msg.requestId);
return w.toBuffer();
}
export function deserializeAllocateMiningJobToken(reader: BufferReader): Sv2AllocateMiningJobToken {
return {
userIdentifier: reader.readStr0_255(),
requestId: reader.readU32(),
};
}
// ── AllocateMiningJobToken.Success (0x51) ──────────────────────────
export interface Sv2AllocateMiningJobTokenSuccess {
requestId: number; // U32
miningJobToken: Buffer; // B0_255
coinbaseOutputs: Buffer; // B0_64K
}
export function serializeAllocateMiningJobTokenSuccess(msg: Sv2AllocateMiningJobTokenSuccess): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeB0_255(msg.miningJobToken);
w.writeB0_64K(msg.coinbaseOutputs);
return w.toBuffer();
}
export function deserializeAllocateMiningJobTokenSuccess(reader: BufferReader): Sv2AllocateMiningJobTokenSuccess {
return {
requestId: reader.readU32(),
miningJobToken: reader.readB0_255(),
coinbaseOutputs: reader.readB0_64K(),
};
}
// ── DeclareMiningJob (0x57) ────────────────────────────────────────
export interface Sv2DeclareMiningJob {
requestId: number; // U32
miningJobToken: Buffer; // B0_255
version: number; // U32
coinbaseTxPrefix: Buffer; // B0_64K
coinbaseTxSuffix: Buffer; // B0_64K
wtxidList: Buffer[]; // SEQ0_64K<U256>
excessData: Buffer; // B0_64K
}
export function serializeDeclareMiningJob(msg: Sv2DeclareMiningJob): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeB0_255(msg.miningJobToken);
w.writeU32(msg.version);
w.writeB0_64K(msg.coinbaseTxPrefix);
w.writeB0_64K(msg.coinbaseTxSuffix);
w.writeSeq0_64K(msg.wtxidList, (writer, hash) => writer.writeU256(hash));
w.writeB0_64K(msg.excessData);
return w.toBuffer();
}
export function deserializeDeclareMiningJob(reader: BufferReader): Sv2DeclareMiningJob {
return {
requestId: reader.readU32(),
miningJobToken: reader.readB0_255(),
version: reader.readU32(),
coinbaseTxPrefix: reader.readB0_64K(),
coinbaseTxSuffix: reader.readB0_64K(),
wtxidList: reader.readSeq0_64K((r) => r.readU256()),
excessData: reader.readB0_64K(),
};
}
// ── DeclareMiningJob.Success (0x58) ────────────────────────────────
export interface Sv2DeclareMiningJobSuccess {
requestId: number; // U32
newMiningJobToken: Buffer; // B0_255
}
export function serializeDeclareMiningJobSuccess(msg: Sv2DeclareMiningJobSuccess): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeB0_255(msg.newMiningJobToken);
return w.toBuffer();
}
export function deserializeDeclareMiningJobSuccess(reader: BufferReader): Sv2DeclareMiningJobSuccess {
return {
requestId: reader.readU32(),
newMiningJobToken: reader.readB0_255(),
};
}
// ── DeclareMiningJob.Error (0x59) ──────────────────────────────────
export interface Sv2DeclareMiningJobError {
requestId: number; // U32
errorCode: string; // STR0_255
errorDetails: Buffer; // B0_64K
}
export function serializeDeclareMiningJobError(msg: Sv2DeclareMiningJobError): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeStr0_255(msg.errorCode);
w.writeB0_64K(msg.errorDetails);
return w.toBuffer();
}
export function deserializeDeclareMiningJobError(reader: BufferReader): Sv2DeclareMiningJobError {
return {
requestId: reader.readU32(),
errorCode: reader.readStr0_255(),
errorDetails: reader.readB0_64K(),
};
}
// ── ProvideMissingTransactions (0x55) ──────────────────────────────
export interface Sv2ProvideMissingTransactions {
requestId: number; // U32
unknownTxPositionList: number[]; // SEQ0_64K<U16>
}
export function serializeProvideMissingTransactions(msg: Sv2ProvideMissingTransactions): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeSeq0_64K(msg.unknownTxPositionList, (writer, pos) => writer.writeU16(pos));
return w.toBuffer();
}
export function deserializeProvideMissingTransactions(reader: BufferReader): Sv2ProvideMissingTransactions {
return {
requestId: reader.readU32(),
unknownTxPositionList: reader.readSeq0_64K((r) => r.readU16()),
};
}
// ── ProvideMissingTransactions.Success (0x56) ──────────────────────
export interface Sv2ProvideMissingTransactionsSuccess {
requestId: number; // U32
transactionList: Buffer[]; // SEQ0_64K<B0_16M>
}
export function serializeProvideMissingTransactionsSuccess(msg: Sv2ProvideMissingTransactionsSuccess): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeSeq0_64K(msg.transactionList, (writer, tx) => writer.writeB0_16M(tx));
return w.toBuffer();
}
export function deserializeProvideMissingTransactionsSuccess(reader: BufferReader): Sv2ProvideMissingTransactionsSuccess {
return {
requestId: reader.readU32(),
transactionList: reader.readSeq0_64K((r) => r.readB0_16M()),
};
}
// ── PushSolution (0x60) ────────────────────────────────────────
export interface Sv2PushSolution {
extranonce: Buffer; // B0_32
prevHash: Buffer; // U256
nonce: number; // U32
ntime: number; // U32
nBits: number; // U32
version: number; // U32
}
export function serializePushSolution(msg: Sv2PushSolution): Buffer {
const w = new BufferWriter();
w.writeB0_32(msg.extranonce);
w.writeU256(msg.prevHash);
w.writeU32(msg.nonce);
w.writeU32(msg.ntime);
w.writeU32(msg.nBits);
w.writeU32(msg.version);
return w.toBuffer();
}
export function deserializePushSolution(reader: BufferReader): Sv2PushSolution {
return {
extranonce: reader.readB0_32(),
prevHash: reader.readU256(),
nonce: reader.readU32(),
ntime: reader.readU32(),
nBits: reader.readU32(),
version: reader.readU32(),
};
}
// ── SetCustomMiningJob (0x22) ──────────────────────────────────────
export interface Sv2SetCustomMiningJob {
channelId: number; // U32
requestId: number; // U32
token: Buffer; // B0_255
version: number; // U32
prevHash: Buffer; // U256
minNtime: number; // U32
nBits: number; // U32
coinbaseTxVersion: number; // U32
coinbasePrefix: Buffer; // B0_255
coinbaseTxInputNSequence: number; // U32
coinbaseTxOutputs: Buffer; // B0_64K
coinbaseTxLocktime: number; // U32
merklePath: Buffer[]; // SEQ0_255<U256>
}
export function serializeSetCustomMiningJob(msg: Sv2SetCustomMiningJob): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.requestId);
w.writeB0_255(msg.token);
w.writeU32(msg.version);
w.writeU256(msg.prevHash);
w.writeU32(msg.minNtime);
w.writeU32(msg.nBits);
w.writeU32(msg.coinbaseTxVersion);
w.writeB0_255(msg.coinbasePrefix);
w.writeU32(msg.coinbaseTxInputNSequence);
w.writeB0_64K(msg.coinbaseTxOutputs);
w.writeU32(msg.coinbaseTxLocktime);
w.writeSeq0_255(msg.merklePath, (writer, hash) => writer.writeU256(hash));
return w.toBuffer();
}
export function deserializeSetCustomMiningJob(reader: BufferReader): Sv2SetCustomMiningJob {
return {
channelId: reader.readU32(),
requestId: reader.readU32(),
token: reader.readB0_255(),
version: reader.readU32(),
prevHash: reader.readU256(),
minNtime: reader.readU32(),
nBits: reader.readU32(),
coinbaseTxVersion: reader.readU32(),
coinbasePrefix: reader.readB0_255(),
coinbaseTxInputNSequence: reader.readU32(),
coinbaseTxOutputs: reader.readB0_64K(),
coinbaseTxLocktime: reader.readU32(),
merklePath: reader.readSeq0_255((r) => r.readU256()),
};
}
// ── SetCustomMiningJob.Success (0x23) ──────────────────────────────
export interface Sv2SetCustomMiningJobSuccess {
channelId: number; // U32
requestId: number; // U32
jobId: number; // U32
}
export function serializeSetCustomMiningJobSuccess(msg: Sv2SetCustomMiningJobSuccess): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.requestId);
w.writeU32(msg.jobId);
return w.toBuffer();
}
export function deserializeSetCustomMiningJobSuccess(reader: BufferReader): Sv2SetCustomMiningJobSuccess {
return {
channelId: reader.readU32(),
requestId: reader.readU32(),
jobId: reader.readU32(),
};
}
// ── SetCustomMiningJob.Error (0x24) ────────────────────────────────
export interface Sv2SetCustomMiningJobError {
channelId: number; // U32
requestId: number; // U32
errorCode: string; // STR0_255
}
export function serializeSetCustomMiningJobError(msg: Sv2SetCustomMiningJobError): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.requestId);
w.writeStr0_255(msg.errorCode);
return w.toBuffer();
}
export function deserializeSetCustomMiningJobError(reader: BufferReader): Sv2SetCustomMiningJobError {
return {
channelId: reader.readU32(),
requestId: reader.readU32(),
errorCode: reader.readStr0_255(),
};
}
+250
View File
@@ -0,0 +1,250 @@
import { BufferReader } from './sv2-binary-codec';
import {
Sv2SetupConnection, serializeSetupConnection, deserializeSetupConnection,
Sv2SetupConnectionSuccess, serializeSetupConnectionSuccess, deserializeSetupConnectionSuccess,
Sv2SetupConnectionError, serializeSetupConnectionError, deserializeSetupConnectionError,
Sv2ChannelEndpointChanged, serializeChannelEndpointChanged, deserializeChannelEndpointChanged,
Sv2OpenStandardMiningChannel, serializeOpenStandardMiningChannel, deserializeOpenStandardMiningChannel,
Sv2OpenStandardMiningChannelSuccess, serializeOpenStandardMiningChannelSuccess, deserializeOpenStandardMiningChannelSuccess,
Sv2NewMiningJob, serializeNewMiningJob, deserializeNewMiningJob,
Sv2SetNewPrevHash, serializeSetNewPrevHash, deserializeSetNewPrevHash,
Sv2SetTarget, serializeSetTarget, deserializeSetTarget,
Sv2SubmitSharesStandard, serializeSubmitSharesStandard, deserializeSubmitSharesStandard,
Sv2SubmitSharesSuccess, serializeSubmitSharesSuccess, deserializeSubmitSharesSuccess,
Sv2SubmitSharesError, serializeSubmitSharesError, deserializeSubmitSharesError,
Sv2CloseChannel, serializeCloseChannel, deserializeCloseChannel,
Sv2UpdateChannel, serializeUpdateChannel, deserializeUpdateChannel,
Sv2UpdateChannelError, serializeUpdateChannelError, deserializeUpdateChannelError,
Sv2Reconnect, serializeReconnect, deserializeReconnect,
} from './sv2-messages';
function roundTrip<T>(
msg: T,
serialize: (m: T) => Buffer,
deserialize: (r: BufferReader) => T,
): T {
const buf = serialize(msg);
return deserialize(new BufferReader(buf));
}
describe('SV2 Messages', () => {
it('SetupConnection round-trips', () => {
const msg: Sv2SetupConnection = {
protocol: 0,
minVersion: 2,
maxVersion: 2,
flags: 0x07,
endpoint_host: 'pool.example.com',
endpoint_port: 3333,
vendor: 'TestMiner',
hardwareVersion: '1.0',
firmwareVersion: '2.1',
deviceId: 'device-001',
};
const result = roundTrip(msg, serializeSetupConnection, deserializeSetupConnection);
expect(result).toEqual(msg);
});
it('SetupConnectionSuccess round-trips', () => {
const msg: Sv2SetupConnectionSuccess = {
usedVersion: 2,
flags: 0x05,
};
const result = roundTrip(msg, serializeSetupConnectionSuccess, deserializeSetupConnectionSuccess);
expect(result).toEqual(msg);
});
it('SetupConnectionError round-trips', () => {
const msg: Sv2SetupConnectionError = {
flags: 0x00,
errorCode: 'unsupported-feature-flags',
};
const result = roundTrip(msg, serializeSetupConnectionError, deserializeSetupConnectionError);
expect(result).toEqual(msg);
});
it('ChannelEndpointChanged round-trips', () => {
const msg: Sv2ChannelEndpointChanged = {
channelId: 7,
};
const result = roundTrip(msg, serializeChannelEndpointChanged, deserializeChannelEndpointChanged);
expect(result).toEqual(msg);
});
it('OpenStandardMiningChannel round-trips', () => {
const maxTarget = Buffer.alloc(32, 0x00);
maxTarget[0] = 0xff;
maxTarget[28] = 0x1d;
const msg: Sv2OpenStandardMiningChannel = {
requestId: 1,
user_identity: 'worker1',
nominalHashRate: 1000000.0,
maxTarget,
};
const result = roundTrip(msg, serializeOpenStandardMiningChannel, deserializeOpenStandardMiningChannel);
expect(result.requestId).toBe(msg.requestId);
expect(result.user_identity).toBe(msg.user_identity);
expect(result.nominalHashRate).toBeCloseTo(msg.nominalHashRate, 0);
expect(result.maxTarget).toEqual(msg.maxTarget);
});
it('OpenStandardMiningChannelSuccess round-trips', () => {
const target = Buffer.alloc(32);
target[0] = 0xff;
target[31] = 0x01;
const msg: Sv2OpenStandardMiningChannelSuccess = {
requestId: 1,
channelId: 42,
target,
extranonce_prefix: Buffer.from([0x01, 0x02, 0x03]),
groupChannelId: 0,
};
const result = roundTrip(
msg,
serializeOpenStandardMiningChannelSuccess,
deserializeOpenStandardMiningChannelSuccess,
);
expect(result.requestId).toBe(msg.requestId);
expect(result.channelId).toBe(msg.channelId);
expect(result.target).toEqual(msg.target);
expect(result.extranonce_prefix).toEqual(msg.extranonce_prefix);
expect(result.groupChannelId).toBe(msg.groupChannelId);
});
it('NewMiningJob round-trips', () => {
const merkleRoot = Buffer.alloc(32, 0xab);
const msg: Sv2NewMiningJob = {
channelId: 1,
jobId: 100,
minNtime: null, // Sv2Option<U32>: null = future job
version: 0x20000000,
merkleRoot,
};
const result = roundTrip(msg, serializeNewMiningJob, deserializeNewMiningJob);
expect(result).toEqual(msg);
});
it('NewMiningJob round-trips with minNtime value', () => {
const merkleRoot = Buffer.alloc(32, 0xab);
const msg: Sv2NewMiningJob = {
channelId: 1,
jobId: 101,
minNtime: 1700000000, // Sv2Option<U32>: Some(timestamp) = ready to mine
version: 0x20000000,
merkleRoot,
};
const result = roundTrip(msg, serializeNewMiningJob, deserializeNewMiningJob);
expect(result).toEqual(msg);
});
it('SetNewPrevHash round-trips', () => {
const prevHash = Buffer.alloc(32, 0xcd);
const msg: Sv2SetNewPrevHash = {
channelId: 1,
jobId: 100,
prevHash,
minNtime: 1700000000,
nBits: 0x1d00ffff,
};
const result = roundTrip(msg, serializeSetNewPrevHash, deserializeSetNewPrevHash);
expect(result).toEqual(msg);
});
it('SetTarget round-trips', () => {
const maxTarget = Buffer.alloc(32, 0x00);
maxTarget[0] = 0xff;
const msg: Sv2SetTarget = {
channelId: 5,
maxTarget,
};
const result = roundTrip(msg, serializeSetTarget, deserializeSetTarget);
expect(result).toEqual(msg);
});
it('SubmitSharesStandard round-trips', () => {
const msg: Sv2SubmitSharesStandard = {
channelId: 1,
sequenceNumber: 42,
jobId: 100,
nonce: 0xdeadbeef,
ntime: 1700000000,
version: 0x20000000,
};
const result = roundTrip(msg, serializeSubmitSharesStandard, deserializeSubmitSharesStandard);
expect(result).toEqual(msg);
});
it('SubmitSharesSuccess round-trips', () => {
const msg: Sv2SubmitSharesSuccess = {
channelId: 1,
lastSequenceNumber: 42,
newSubmitsAcceptedCount: 10,
newSharesSum: 123456789012345n,
};
const result = roundTrip(msg, serializeSubmitSharesSuccess, deserializeSubmitSharesSuccess);
expect(result).toEqual(msg);
});
it('SubmitSharesError round-trips', () => {
const msg: Sv2SubmitSharesError = {
channelId: 1,
sequenceNumber: 42,
errorCode: 'stale-share',
};
const result = roundTrip(msg, serializeSubmitSharesError, deserializeSubmitSharesError);
expect(result).toEqual(msg);
});
it('CloseChannel round-trips', () => {
const msg: Sv2CloseChannel = {
channelId: 7,
reasonCode: 'client-disconnect',
};
const result = roundTrip(msg, serializeCloseChannel, deserializeCloseChannel);
expect(result).toEqual(msg);
});
it('UpdateChannel round-trips', () => {
const maximumTarget = Buffer.alloc(32, 0x00);
maximumTarget[0] = 0xff;
const msg: Sv2UpdateChannel = {
channelId: 3,
nominalHashRate: 500000.0,
maximumTarget,
};
const result = roundTrip(msg, serializeUpdateChannel, deserializeUpdateChannel);
expect(result.channelId).toBe(msg.channelId);
expect(result.nominalHashRate).toBeCloseTo(msg.nominalHashRate, 0);
expect(result.maximumTarget).toEqual(msg.maximumTarget);
});
it('UpdateChannelError round-trips', () => {
const msg: Sv2UpdateChannelError = {
channelId: 3,
errorCode: 'invalid-channel',
};
const result = roundTrip(msg, serializeUpdateChannelError, deserializeUpdateChannelError);
expect(result).toEqual(msg);
});
it('Reconnect round-trips', () => {
const msg: Sv2Reconnect = {
newHost: 'pool2.example.com',
newPort: 3334,
};
const result = roundTrip(msg, serializeReconnect, deserializeReconnect);
expect(result).toEqual(msg);
});
it('serialized bytes match expected layout (SetupConnectionSuccess)', () => {
const msg: Sv2SetupConnectionSuccess = {
usedVersion: 2,
flags: 0x01,
};
const buf = serializeSetupConnectionSuccess(msg);
// U16 LE(2) + U32 LE(1) = 6 bytes
expect(buf.length).toBe(6);
expect(buf.readUInt16LE(0)).toBe(2);
expect(buf.readUInt32LE(2)).toBe(1);
});
});
+478
View File
@@ -0,0 +1,478 @@
// ── SV2 Mining Protocol Messages ────────────────────────────────────
// Interfaces + serialize/deserialize for each message needed for
// standard mining channels.
import { BufferReader, BufferWriter } from './sv2-binary-codec';
// ── SetupConnection (0x00) ──────────────────────────────────────────
export interface Sv2SetupConnection {
protocol: number; // U8 (0 = Mining, 1 = Job Declaration, 2 = Template Distribution)
minVersion: number; // U16
maxVersion: number; // U16
flags: number; // U32
endpoint_host: string; // STR0_255
endpoint_port: number; // U16
vendor: string; // STR0_255
hardwareVersion: string; // STR0_255
firmwareVersion: string; // STR0_255
deviceId: string; // STR0_255
}
export function serializeSetupConnection(msg: Sv2SetupConnection): Buffer {
const w = new BufferWriter();
w.writeU8(msg.protocol);
w.writeU16(msg.minVersion);
w.writeU16(msg.maxVersion);
w.writeU32(msg.flags);
w.writeStr0_255(msg.endpoint_host);
w.writeU16(msg.endpoint_port);
w.writeStr0_255(msg.vendor);
w.writeStr0_255(msg.hardwareVersion);
w.writeStr0_255(msg.firmwareVersion);
w.writeStr0_255(msg.deviceId);
return w.toBuffer();
}
export function deserializeSetupConnection(reader: BufferReader): Sv2SetupConnection {
return {
protocol: reader.readU8(),
minVersion: reader.readU16(),
maxVersion: reader.readU16(),
flags: reader.readU32(),
endpoint_host: reader.readStr0_255(),
endpoint_port: reader.readU16(),
vendor: reader.readStr0_255(),
hardwareVersion: reader.readStr0_255(),
firmwareVersion: reader.readStr0_255(),
deviceId: reader.readStr0_255(),
};
}
// ── SetupConnectionSuccess (0x01) ───────────────────────────────────
export interface Sv2SetupConnectionSuccess {
usedVersion: number; // U16
flags: number; // U32
}
export function serializeSetupConnectionSuccess(msg: Sv2SetupConnectionSuccess): Buffer {
const w = new BufferWriter();
w.writeU16(msg.usedVersion);
w.writeU32(msg.flags);
return w.toBuffer();
}
export function deserializeSetupConnectionSuccess(reader: BufferReader): Sv2SetupConnectionSuccess {
return {
usedVersion: reader.readU16(),
flags: reader.readU32(),
};
}
// ── SetupConnectionError (0x02) ─────────────────────────────────────
export interface Sv2SetupConnectionError {
flags: number; // U32
errorCode: string; // STR0_255
}
export function serializeSetupConnectionError(msg: Sv2SetupConnectionError): Buffer {
const w = new BufferWriter();
w.writeU32(msg.flags);
w.writeStr0_255(msg.errorCode);
return w.toBuffer();
}
export function deserializeSetupConnectionError(reader: BufferReader): Sv2SetupConnectionError {
return {
flags: reader.readU32(),
errorCode: reader.readStr0_255(),
};
}
// ── ChannelEndpointChanged (0x03) ───────────────────────────────────
export interface Sv2ChannelEndpointChanged {
channelId: number; // U32
}
export function serializeChannelEndpointChanged(msg: Sv2ChannelEndpointChanged): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
return w.toBuffer();
}
export function deserializeChannelEndpointChanged(reader: BufferReader): Sv2ChannelEndpointChanged {
return {
channelId: reader.readU32(),
};
}
// ── OpenStandardMiningChannel (0x10) ────────────────────────────────
export interface Sv2OpenStandardMiningChannel {
requestId: number; // U32
user_identity: string; // STR0_255
nominalHashRate: number; // F32
maxTarget: Buffer; // U256 (32 bytes LE)
}
export function serializeOpenStandardMiningChannel(msg: Sv2OpenStandardMiningChannel): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeStr0_255(msg.user_identity);
w.writeF32(msg.nominalHashRate);
w.writeU256(msg.maxTarget);
return w.toBuffer();
}
export function deserializeOpenStandardMiningChannel(
reader: BufferReader,
): Sv2OpenStandardMiningChannel {
return {
requestId: reader.readU32(),
user_identity: reader.readStr0_255(),
nominalHashRate: reader.readF32(),
maxTarget: reader.readU256(),
};
}
// ── OpenStandardMiningChannelSuccess (0x11) ─────────────────────────
export interface Sv2OpenStandardMiningChannelSuccess {
requestId: number; // U32
channelId: number; // U32
target: Buffer; // U256 (32 bytes LE)
extranonce_prefix: Buffer; // B0_32
groupChannelId: number; // U32
}
export function serializeOpenStandardMiningChannelSuccess(
msg: Sv2OpenStandardMiningChannelSuccess,
): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeU32(msg.channelId);
w.writeU256(msg.target);
w.writeB0_32(msg.extranonce_prefix);
w.writeU32(msg.groupChannelId);
return w.toBuffer();
}
export function deserializeOpenStandardMiningChannelSuccess(
reader: BufferReader,
): Sv2OpenStandardMiningChannelSuccess {
return {
requestId: reader.readU32(),
channelId: reader.readU32(),
target: reader.readU256(),
extranonce_prefix: reader.readB0_32(),
groupChannelId: reader.readU32(),
};
}
// ── OpenMiningChannel.Error (0x12) ──────────────────────────────────
export interface Sv2OpenMiningChannelError {
requestId: number; // U32
errorCode: string; // STR0_255
}
export function serializeOpenMiningChannelError(msg: Sv2OpenMiningChannelError): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeStr0_255(msg.errorCode);
return w.toBuffer();
}
export function deserializeOpenMiningChannelError(reader: BufferReader): Sv2OpenMiningChannelError {
return {
requestId: reader.readU32(),
errorCode: reader.readStr0_255(),
};
}
// ── NewMiningJob (0x15) ─────────────────────────────────────────────
export interface Sv2NewMiningJob {
channelId: number; // U32
jobId: number; // U32
minNtime: number | null; // Sv2Option<U32> - null = future job, number = min ntime value
version: number; // U32
merkleRoot: Buffer; // U256 (32 bytes)
}
export function serializeNewMiningJob(msg: Sv2NewMiningJob): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.jobId);
w.writeOptionU32(msg.minNtime);
w.writeU32(msg.version);
w.writeU256(msg.merkleRoot);
return w.toBuffer();
}
export function deserializeNewMiningJob(reader: BufferReader): Sv2NewMiningJob {
return {
channelId: reader.readU32(),
jobId: reader.readU32(),
minNtime: reader.readOptionU32(),
version: reader.readU32(),
merkleRoot: reader.readU256(),
};
}
// ── SetNewPrevHash (0x20) ───────────────────────────────────────────
export interface Sv2SetNewPrevHash {
channelId: number; // U32
jobId: number; // U32
prevHash: Buffer; // U256 (32 bytes)
minNtime: number; // U32
nBits: number; // U32
}
export function serializeSetNewPrevHash(msg: Sv2SetNewPrevHash): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.jobId);
w.writeU256(msg.prevHash);
w.writeU32(msg.minNtime);
w.writeU32(msg.nBits);
return w.toBuffer();
}
export function deserializeSetNewPrevHash(reader: BufferReader): Sv2SetNewPrevHash {
return {
channelId: reader.readU32(),
jobId: reader.readU32(),
prevHash: reader.readU256(),
minNtime: reader.readU32(),
nBits: reader.readU32(),
};
}
// ── SetTarget (0x21) ────────────────────────────────────────────────
export interface Sv2SetTarget {
channelId: number; // U32
maxTarget: Buffer; // U256 (32 bytes)
}
export function serializeSetTarget(msg: Sv2SetTarget): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU256(msg.maxTarget);
return w.toBuffer();
}
export function deserializeSetTarget(reader: BufferReader): Sv2SetTarget {
return {
channelId: reader.readU32(),
maxTarget: reader.readU256(),
};
}
// ── SubmitSharesStandard (0x1a) ─────────────────────────────────────
export interface Sv2SubmitSharesStandard {
channelId: number; // U32
sequenceNumber: number; // U32
jobId: number; // U32
nonce: number; // U32
ntime: number; // U32
version: number; // U32
}
export function serializeSubmitSharesStandard(msg: Sv2SubmitSharesStandard): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.sequenceNumber);
w.writeU32(msg.jobId);
w.writeU32(msg.nonce);
w.writeU32(msg.ntime);
w.writeU32(msg.version);
return w.toBuffer();
}
export function deserializeSubmitSharesStandard(reader: BufferReader): Sv2SubmitSharesStandard {
return {
channelId: reader.readU32(),
sequenceNumber: reader.readU32(),
jobId: reader.readU32(),
nonce: reader.readU32(),
ntime: reader.readU32(),
version: reader.readU32(),
};
}
// ── SubmitSharesSuccess (0x1c) ──────────────────────────────────────
export interface Sv2SubmitSharesSuccess {
channelId: number; // U32
lastSequenceNumber: number; // U32
newSubmitsAcceptedCount: number; // U32
newSharesSum: bigint; // U64
}
export function serializeSubmitSharesSuccess(msg: Sv2SubmitSharesSuccess): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.lastSequenceNumber);
w.writeU32(msg.newSubmitsAcceptedCount);
w.writeU64(msg.newSharesSum);
return w.toBuffer();
}
export function deserializeSubmitSharesSuccess(reader: BufferReader): Sv2SubmitSharesSuccess {
return {
channelId: reader.readU32(),
lastSequenceNumber: reader.readU32(),
newSubmitsAcceptedCount: reader.readU32(),
newSharesSum: reader.readU64(),
};
}
// ── SubmitSharesError (0x1d) ────────────────────────────────────────
export interface Sv2SubmitSharesError {
channelId: number; // U32
sequenceNumber: number; // U32
errorCode: string; // STR0_255
}
export function serializeSubmitSharesError(msg: Sv2SubmitSharesError): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.sequenceNumber);
w.writeStr0_255(msg.errorCode);
return w.toBuffer();
}
export function deserializeSubmitSharesError(reader: BufferReader): Sv2SubmitSharesError {
return {
channelId: reader.readU32(),
sequenceNumber: reader.readU32(),
errorCode: reader.readStr0_255(),
};
}
// ── CloseChannel (0x18) ─────────────────────────────────────────────
export interface Sv2CloseChannel {
channelId: number; // U32
reasonCode: string; // STR0_255
}
export function serializeCloseChannel(msg: Sv2CloseChannel): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeStr0_255(msg.reasonCode);
return w.toBuffer();
}
export function deserializeCloseChannel(reader: BufferReader): Sv2CloseChannel {
return {
channelId: reader.readU32(),
reasonCode: reader.readStr0_255(),
};
}
// ── UpdateChannel (0x16) ────────────────────────────────────────────
export interface Sv2UpdateChannel {
channelId: number; // U32
nominalHashRate: number; // F32
maximumTarget: Buffer; // U256 (32 bytes LE)
}
export function serializeUpdateChannel(msg: Sv2UpdateChannel): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeF32(msg.nominalHashRate);
w.writeU256(msg.maximumTarget);
return w.toBuffer();
}
export function deserializeUpdateChannel(reader: BufferReader): Sv2UpdateChannel {
return {
channelId: reader.readU32(),
nominalHashRate: reader.readF32(),
maximumTarget: reader.readU256(),
};
}
// ── UpdateChannel.Error (0x17) ──────────────────────────────────────
export interface Sv2UpdateChannelError {
channelId: number; // U32
errorCode: string; // STR0_255
}
export function serializeUpdateChannelError(msg: Sv2UpdateChannelError): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeStr0_255(msg.errorCode);
return w.toBuffer();
}
export function deserializeUpdateChannelError(reader: BufferReader): Sv2UpdateChannelError {
return {
channelId: reader.readU32(),
errorCode: reader.readStr0_255(),
};
}
// ── Extension 1: Extensions Negotiation (extension_type = 0x0001) ───
// Spec: extensions/0x0001-extensions-negotiation.md
// RequestExtensions msgType 0x00 (client → pool)
// RequestExtensions.Success msgType 0x01 (pool → client)
// RequestExtensions.Error msgType 0x02 (pool → client)
export const SV2_EXTENSION_NEGOTIATION_ID = 0x0001;
export interface Sv2RequestExtensions {
requestId: number; // U16
requestedExtensions: number[]; // SEQ0_64K[U16]
}
export function deserializeRequestExtensions(reader: BufferReader): Sv2RequestExtensions {
return {
requestId: reader.readU16(),
requestedExtensions: reader.readSeq0_64K((r) => r.readU16()),
};
}
export interface Sv2RequestExtensionsSuccess {
requestId: number; // U16
supportedExtensions: number[]; // SEQ0_64K[U16]
}
export function serializeRequestExtensionsSuccess(msg: Sv2RequestExtensionsSuccess): Buffer {
const w = new BufferWriter();
w.writeU16(msg.requestId);
w.writeSeq0_64K(msg.supportedExtensions, (writer, ext) => writer.writeU16(ext));
return w.toBuffer();
}
// ── Reconnect (0x04) ────────────────────────────────────────────────
export interface Sv2Reconnect {
newHost: string; // STR0_255
newPort: number; // U16
}
export function serializeReconnect(msg: Sv2Reconnect): Buffer {
const w = new BufferWriter();
w.writeStr0_255(msg.newHost);
w.writeU16(msg.newPort);
return w.toBuffer();
}
export function deserializeReconnect(reader: BufferReader): Sv2Reconnect {
return {
newHost: reader.readStr0_255(),
newPort: reader.readU16(),
};
}
+500
View File
@@ -0,0 +1,500 @@
import * as crypto from 'crypto';
import {
hmacHash,
sha256,
hkdf2,
Sv2CipherState,
Sv2CipherAlgorithm,
Sv2NoiseSession,
Sv2NoiseInitiator,
generateServerKeypair,
xOnlyPubKeyFromPriv,
createSignatureNoiseMessage,
validateSignatureNoiseMessage,
serializeSignatureNoiseMessage,
deserializeSignatureNoiseMessage,
loadEllSwift,
} from './sv2-noise';
import { SV2_MAC_SIZE } from './sv2-constants';
describe('SV2 Noise', () => {
// ── HMAC-SHA256 Test Vectors ──────────────────────────────────────
describe('HMAC-SHA256', () => {
it('should match known test vector (RFC 4231 test 1)', () => {
const key = Buffer.from('0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b', 'hex');
const data = Buffer.from('Hi There');
const expected = 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7';
expect(hmacHash(key, data).toString('hex')).toBe(expected);
});
it('should match known test vector (RFC 4231 test 2)', () => {
const key = Buffer.from('Jefe');
const data = Buffer.from('what do ya want for nothing?');
const expected = '5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843';
expect(hmacHash(key, data).toString('hex')).toBe(expected);
});
});
// ── HKDF2 ─────────────────────────────────────────────────────────
describe('HKDF2', () => {
it('should produce two 32-byte outputs', () => {
const ck = Buffer.alloc(32, 0x01);
const ikm = Buffer.alloc(32, 0x02);
const [out1, out2] = hkdf2(ck, ikm);
expect(out1.length).toBe(32);
expect(out2.length).toBe(32);
expect(out1.equals(out2)).toBe(false);
});
it('should be deterministic', () => {
const ck = crypto.randomBytes(32);
const ikm = crypto.randomBytes(32);
const [a1, a2] = hkdf2(ck, ikm);
const [b1, b2] = hkdf2(ck, ikm);
expect(a1.equals(b1)).toBe(true);
expect(a2.equals(b2)).toBe(true);
});
it('should work with empty IKM', () => {
const ck = Buffer.alloc(32, 0xaa);
const [out1, out2] = hkdf2(ck, Buffer.alloc(0));
expect(out1.length).toBe(32);
expect(out2.length).toBe(32);
});
});
// ── CipherState ───────────────────────────────────────────────────
describe('CipherState', () => {
it('should encrypt and decrypt with matching keys', () => {
const key = crypto.randomBytes(32);
const enc = new Sv2CipherState();
const dec = new Sv2CipherState();
enc.initializeKey(key);
dec.initializeKey(key);
const ad = Buffer.alloc(0);
const plaintext = Buffer.from('test message for cipher state');
const ciphertext = enc.encryptWithAd(ad, plaintext);
expect(ciphertext.length).toBe(plaintext.length + SV2_MAC_SIZE);
expect(ciphertext.subarray(0, plaintext.length).equals(plaintext)).toBe(false);
const decrypted = dec.decryptWithAd(ad, ciphertext);
expect(decrypted.equals(plaintext)).toBe(true);
});
it('should fail decryption with wrong key', () => {
const enc = new Sv2CipherState();
const dec = new Sv2CipherState();
enc.initializeKey(crypto.randomBytes(32));
dec.initializeKey(crypto.randomBytes(32));
const ct = enc.encryptWithAd(Buffer.alloc(0), Buffer.from('secret'));
expect(() => dec.decryptWithAd(Buffer.alloc(0), ct)).toThrow();
});
it('should fail decryption with wrong AD', () => {
const key = crypto.randomBytes(32);
const enc = new Sv2CipherState();
const dec = new Sv2CipherState();
enc.initializeKey(key);
dec.initializeKey(key);
const ct = enc.encryptWithAd(Buffer.from('ad1'), Buffer.from('data'));
expect(() => dec.decryptWithAd(Buffer.from('ad2'), ct)).toThrow();
});
it('should increment nonce (different ciphertext for same plaintext)', () => {
const key = crypto.randomBytes(32);
const enc = new Sv2CipherState();
enc.initializeKey(key);
const ad = Buffer.alloc(0);
const pt = Buffer.from('same');
const ct1 = enc.encryptWithAd(ad, pt);
const ct2 = enc.encryptWithAd(ad, pt);
expect(ct1.equals(ct2)).toBe(false);
});
it('should pass through plaintext when key is not set', () => {
const cs = new Sv2CipherState();
const ad = Buffer.alloc(0);
const pt = Buffer.from('hello');
expect(cs.encryptWithAd(ad, pt).equals(pt)).toBe(true);
expect(cs.decryptWithAd(ad, pt).equals(pt)).toBe(true);
});
it('should track hasKey', () => {
const cs = new Sv2CipherState();
expect(cs.hasKey).toBe(false);
cs.initializeKey(crypto.randomBytes(32));
expect(cs.hasKey).toBe(true);
});
it('should reject non-32-byte keys', () => {
const cs = new Sv2CipherState();
expect(() => cs.initializeKey(Buffer.alloc(16))).toThrow();
});
it('should reject ciphertext shorter than MAC', () => {
const cs = new Sv2CipherState();
cs.initializeKey(crypto.randomBytes(32));
expect(() => cs.decryptWithAd(Buffer.alloc(0), Buffer.alloc(10))).toThrow();
});
it('should handle multiple sequential encryptions and decryptions', () => {
const key = crypto.randomBytes(32);
const enc = new Sv2CipherState();
const dec = new Sv2CipherState();
enc.initializeKey(key);
dec.initializeKey(key);
const ad = Buffer.alloc(0);
for (let i = 0; i < 10; i++) {
const pt = Buffer.from(`message ${i}`);
const ct = enc.encryptWithAd(ad, pt);
const result = dec.decryptWithAd(ad, ct);
expect(result.toString()).toBe(`message ${i}`);
}
});
});
// ── Certificate / Signature ───────────────────────────────────────
describe('Certificate', () => {
it('should create and validate a signature noise message', () => {
const authorityPrivKey = crypto.randomBytes(32);
const authorityPubKey = xOnlyPubKeyFromPriv(authorityPrivKey);
const staticPubKeyXOnly = crypto.randomBytes(32);
const now = Math.floor(Date.now() / 1000);
const cert = createSignatureNoiseMessage(
authorityPrivKey,
staticPubKeyXOnly,
now - 3600,
now + 86400,
);
expect(cert.version).toBe(0);
expect(cert.signature.length).toBe(64);
expect(validateSignatureNoiseMessage(cert, authorityPubKey, staticPubKeyXOnly)).toBe(true);
});
it('should fail validation with wrong authority key', () => {
const authorityPrivKey = crypto.randomBytes(32);
const wrongPubKey = xOnlyPubKeyFromPriv(crypto.randomBytes(32));
const staticPubKeyXOnly = crypto.randomBytes(32);
const now = Math.floor(Date.now() / 1000);
const cert = createSignatureNoiseMessage(authorityPrivKey, staticPubKeyXOnly, now, now + 86400);
expect(validateSignatureNoiseMessage(cert, wrongPubKey, staticPubKeyXOnly)).toBe(false);
});
it('should serialize and deserialize signature noise message', () => {
const msg = {
version: 0,
validFrom: 1700000000,
notValidAfter: 1700086400,
signature: crypto.randomBytes(64),
};
const buf = serializeSignatureNoiseMessage(msg);
expect(buf.length).toBe(74);
const result = deserializeSignatureNoiseMessage(buf);
expect(result.version).toBe(msg.version);
expect(result.validFrom).toBe(msg.validFrom);
expect(result.notValidAfter).toBe(msg.notValidAfter);
expect(result.signature.equals(msg.signature)).toBe(true);
});
});
// ── Key Generation ────────────────────────────────────────────────
describe('Key Generation', () => {
it('should generate server keypair with correct sizes', async () => {
const kp = await generateServerKeypair();
expect(kp.privateKey.length).toBe(32);
expect(kp.publicKey.length).toBe(64);
});
it('should produce unique keypairs', async () => {
const kp1 = await generateServerKeypair();
const kp2 = await generateServerKeypair();
expect(kp1.privateKey.equals(kp2.privateKey)).toBe(false);
});
it('should derive x-only pubkey from privkey', () => {
const privKey = crypto.randomBytes(32);
const xOnly = xOnlyPubKeyFromPriv(privKey);
expect(xOnly.length).toBe(32);
});
});
// ── EllSwift Loader ───────────────────────────────────────────────
describe('EllSwift Loader', () => {
it('should load EllSwift API', async () => {
const es = await loadEllSwift();
expect(typeof es.keygen).toBe('function');
expect(typeof es.getSharedSecret).toBe('function');
});
it('should cache the loaded module', async () => {
const es1 = await loadEllSwift();
const es2 = await loadEllSwift();
expect(es1).toBe(es2);
});
});
// ── Full Noise NX Handshake Self-Test ─────────────────────────────
describe('Full Handshake', () => {
it('should complete NX handshake and exchange encrypted messages', async () => {
// 1. Generate server config
const serverKeypair = await generateServerKeypair();
const authorityPrivKey = crypto.randomBytes(32);
const authorityPubKey = xOnlyPubKeyFromPriv(authorityPrivKey);
const staticXOnly = xOnlyPubKeyFromPriv(serverKeypair.privateKey);
const now = Math.floor(Date.now() / 1000);
const cert = createSignatureNoiseMessage(
authorityPrivKey,
staticXOnly,
now - 3600,
now + 86400,
);
// 2. Create sessions
const responder = new Sv2NoiseSession({
staticKeypair: serverKeypair,
certificateMessage: cert,
});
const initiator = new Sv2NoiseInitiator();
// 3. Act 1: Initiator -> Responder (64 bytes)
const act1 = await initiator.generateAct1();
expect(act1.length).toBe(64);
// 4. Act 2: Responder -> Initiator (234 bytes)
const act2 = await responder.processAct1(act1);
expect(act2.length).toBe(234);
// 5. Initiator processes Act 2
const { serverStaticKey, certificate } = initiator.processAct2(act2);
expect(serverStaticKey.length).toBe(64);
expect(certificate.version).toBe(0);
// 6. Validate certificate
expect(
validateSignatureNoiseMessage(certificate, authorityPubKey, staticXOnly),
).toBe(true);
// 7. Both sides should be ready
expect(responder.isHandshakeComplete).toBe(true);
expect(initiator.isHandshakeComplete).toBe(true);
// 8. Exchange encrypted messages: initiator -> responder
const msg1 = Buffer.from('hello from initiator');
const enc1 = initiator.encrypt(msg1);
expect(enc1.length).toBe(msg1.length + SV2_MAC_SIZE);
const dec1 = responder.decrypt(enc1);
expect(dec1.toString()).toBe('hello from initiator');
// 9. Exchange encrypted messages: responder -> initiator
const msg2 = Buffer.from('hello from responder');
const enc2 = responder.encrypt(msg2);
const dec2 = initiator.decrypt(enc2);
expect(dec2.toString()).toBe('hello from responder');
// 10. Multiple messages with nonce increment
for (let i = 0; i < 5; i++) {
const pt = Buffer.from(`bidirectional msg ${i}`);
const ct = responder.encrypt(pt);
expect(initiator.decrypt(ct).toString()).toBe(`bidirectional msg ${i}`);
const pt2 = Buffer.from(`reply ${i}`);
const ct2 = initiator.encrypt(pt2);
expect(responder.decrypt(ct2).toString()).toBe(`reply ${i}`);
}
});
it('should fail handshake with wrong Act 1 size', async () => {
const serverKeypair = await generateServerKeypair();
const authorityPrivKey = crypto.randomBytes(32);
const staticXOnly = xOnlyPubKeyFromPriv(serverKeypair.privateKey);
const now = Math.floor(Date.now() / 1000);
const cert = createSignatureNoiseMessage(authorityPrivKey, staticXOnly, now, now + 86400);
const responder = new Sv2NoiseSession({
staticKeypair: serverKeypair,
certificateMessage: cert,
});
await expect(responder.processAct1(Buffer.alloc(32))).rejects.toThrow();
});
it('should not encrypt before handshake is complete', () => {
const session = new Sv2NoiseInitiator();
expect(() => session.encrypt(Buffer.from('test'))).toThrow('Handshake not complete');
expect(() => session.decrypt(Buffer.alloc(32))).toThrow('Handshake not complete');
});
it('should complete NX handshake with AES-256-GCM and exchange encrypted messages', async () => {
// 1. Generate server config
const serverKeypair = await generateServerKeypair();
const authorityPrivKey = crypto.randomBytes(32);
const authorityPubKey = xOnlyPubKeyFromPriv(authorityPrivKey);
const staticXOnly = xOnlyPubKeyFromPriv(serverKeypair.privateKey);
const now = Math.floor(Date.now() / 1000);
const cert = createSignatureNoiseMessage(
authorityPrivKey,
staticXOnly,
now - 3600,
now + 86400,
);
const cipher: Sv2CipherAlgorithm = 'aes-256-gcm';
// 2. Create sessions with AES-GCM
const responder = new Sv2NoiseSession(
{ staticKeypair: serverKeypair, certificateMessage: cert },
cipher,
);
const initiator = new Sv2NoiseInitiator(cipher);
// 3. Act 1: Initiator -> Responder (64 bytes)
const act1 = await initiator.generateAct1();
expect(act1.length).toBe(64);
// 4. Act 2: Responder -> Initiator (234 bytes)
const act2 = await responder.processAct1(act1);
expect(act2.length).toBe(234);
// 5. Initiator processes Act 2
const { serverStaticKey, certificate } = initiator.processAct2(act2);
expect(serverStaticKey.length).toBe(64);
expect(certificate.version).toBe(0);
// 6. Validate certificate
expect(
validateSignatureNoiseMessage(certificate, authorityPubKey, staticXOnly),
).toBe(true);
// 7. Both sides should be ready
expect(responder.isHandshakeComplete).toBe(true);
expect(initiator.isHandshakeComplete).toBe(true);
// 8. Exchange encrypted messages: initiator -> responder
const msg1 = Buffer.from('hello from AES-GCM initiator');
const enc1 = initiator.encrypt(msg1);
expect(enc1.length).toBe(msg1.length + SV2_MAC_SIZE);
const dec1 = responder.decrypt(enc1);
expect(dec1.toString()).toBe('hello from AES-GCM initiator');
// 9. Exchange encrypted messages: responder -> initiator
const msg2 = Buffer.from('hello from AES-GCM responder');
const enc2 = responder.encrypt(msg2);
const dec2 = initiator.decrypt(enc2);
expect(dec2.toString()).toBe('hello from AES-GCM responder');
// 10. Multiple messages with nonce increment
for (let i = 0; i < 5; i++) {
const pt = Buffer.from(`AES-GCM bidirectional msg ${i}`);
const ct = responder.encrypt(pt);
expect(initiator.decrypt(ct).toString()).toBe(`AES-GCM bidirectional msg ${i}`);
const pt2 = Buffer.from(`AES-GCM reply ${i}`);
const ct2 = initiator.encrypt(pt2);
expect(responder.decrypt(ct2).toString()).toBe(`AES-GCM reply ${i}`);
}
});
it('should not cross-decrypt between ChaCha20 and AES-GCM sessions', async () => {
const serverKeypair = await generateServerKeypair();
const authorityPrivKey = crypto.randomBytes(32);
const staticXOnly = xOnlyPubKeyFromPriv(serverKeypair.privateKey);
const now = Math.floor(Date.now() / 1000);
const cert = createSignatureNoiseMessage(authorityPrivKey, staticXOnly, now, now + 86400);
// ChaCha session
const chachaResp = new Sv2NoiseSession(
{ staticKeypair: serverKeypair, certificateMessage: cert },
);
const chachaInit = new Sv2NoiseInitiator();
const chachaAct1 = await chachaInit.generateAct1();
const chachaAct2 = await chachaResp.processAct1(chachaAct1);
chachaInit.processAct2(chachaAct2);
// AES-GCM session (different keys, so ciphertexts are incompatible)
const serverKeypair2 = await generateServerKeypair();
const staticXOnly2 = xOnlyPubKeyFromPriv(serverKeypair2.privateKey);
const cert2 = createSignatureNoiseMessage(authorityPrivKey, staticXOnly2, now, now + 86400);
const aesResp = new Sv2NoiseSession(
{ staticKeypair: serverKeypair2, certificateMessage: cert2 },
'aes-256-gcm',
);
const aesInit = new Sv2NoiseInitiator('aes-256-gcm');
const aesAct1 = await aesInit.generateAct1();
const aesAct2 = await aesResp.processAct1(aesAct1);
aesInit.processAct2(aesAct2);
// Encrypt with ChaCha, try to decrypt with AES-GCM -> should fail
const ct = chachaInit.encrypt(Buffer.from('test'));
expect(() => aesResp.decrypt(ct)).toThrow();
});
});
// ── AES-GCM CipherState ─────────────────────────────────────────────
describe('CipherState AES-256-GCM', () => {
it('should encrypt and decrypt with AES-256-GCM', () => {
const key = crypto.randomBytes(32);
const enc = new Sv2CipherState('aes-256-gcm');
const dec = new Sv2CipherState('aes-256-gcm');
enc.initializeKey(key);
dec.initializeKey(key);
const ad = Buffer.alloc(0);
const plaintext = Buffer.from('test message for AES-GCM cipher state');
const ciphertext = enc.encryptWithAd(ad, plaintext);
expect(ciphertext.length).toBe(plaintext.length + SV2_MAC_SIZE);
const decrypted = dec.decryptWithAd(ad, ciphertext);
expect(decrypted.equals(plaintext)).toBe(true);
});
it('should handle multiple sequential encryptions with AES-256-GCM', () => {
const key = crypto.randomBytes(32);
const enc = new Sv2CipherState('aes-256-gcm');
const dec = new Sv2CipherState('aes-256-gcm');
enc.initializeKey(key);
dec.initializeKey(key);
const ad = Buffer.alloc(0);
for (let i = 0; i < 10; i++) {
const pt = Buffer.from(`AES-GCM message ${i}`);
const ct = enc.encryptWithAd(ad, pt);
const result = dec.decryptWithAd(ad, ct);
expect(result.toString()).toBe(`AES-GCM message ${i}`);
}
});
it('should encrypt and decrypt with non-empty AD using AES-256-GCM', () => {
const key = crypto.randomBytes(32);
const enc = new Sv2CipherState('aes-256-gcm');
const dec = new Sv2CipherState('aes-256-gcm');
enc.initializeKey(key);
dec.initializeKey(key);
const ad = Buffer.from('additional data');
const plaintext = Buffer.from('secret payload');
const ciphertext = enc.encryptWithAd(ad, plaintext);
const decrypted = dec.decryptWithAd(ad, ciphertext);
expect(decrypted.equals(plaintext)).toBe(true);
});
});
});
+601
View File
@@ -0,0 +1,601 @@
// ── SV2 Noise NX Handshake & Encrypted Transport ────────────────────
// Implements Noise_NX_Secp256k1+EllSwift_ChaChaPoly_SHA256 for SV2.
// Server (responder) side: receives Act 1, sends Act 2, then
// encrypted transport in both directions.
import * as crypto from 'crypto';
import * as ecc from 'tiny-secp256k1';
import {
SV2_NOISE_PROTOCOL_NAME,
SV2_NOISE_PROTOCOL_NAME_AESGCM,
SV2_ELLSWIFT_KEY_SIZE,
SV2_MAC_SIZE,
SV2_SIGNATURE_NOISE_MSG_SIZE,
} from './sv2-constants';
// ── Cipher Algorithm Type ───────────────────────────────────────────
export type Sv2CipherAlgorithm = 'chacha20-poly1305' | 'aes-256-gcm';
function getNoiseProtocolName(cipher: Sv2CipherAlgorithm): string {
return cipher === 'aes-256-gcm'
? SV2_NOISE_PROTOCOL_NAME_AESGCM
: SV2_NOISE_PROTOCOL_NAME;
}
// ── EllSwift Loader (ESM dynamic import) ────────────────────────────
interface EllSwiftApi {
keygen(): { privateKey: Uint8Array; publicKey: Uint8Array };
getSharedSecret(privKey: Uint8Array, pubKey: Uint8Array): Uint8Array;
getSharedSecretBip324(
privateKeyOurs: Uint8Array,
publicKeyTheirs: Uint8Array,
publicKeyOurs: Uint8Array,
initiating: boolean,
): Uint8Array;
}
let ellSwiftCache: EllSwiftApi | null = null;
export async function loadEllSwift(): Promise<EllSwiftApi> {
if (ellSwiftCache) return ellSwiftCache;
let mod: any;
try {
// Works in Jest (ts-jest transforms ESM to CJS) and bundled environments
mod = require('@scure/btc-signer/p2p.js');
} catch {
// Runtime fallback: dynamic import for ESM-only packages in Node CJS mode
const importFn = new Function('specifier', 'return import(specifier)');
mod = await importFn('@scure/btc-signer/p2p.js');
}
ellSwiftCache = mod.elligatorSwift as EllSwiftApi;
return ellSwiftCache;
}
// ── Crypto Primitives ───────────────────────────────────────────────
export function hmacHash(key: Buffer, data: Buffer): Buffer {
return crypto.createHmac('sha256', key).update(data).digest();
}
export function sha256(data: Buffer): Buffer {
return crypto.createHash('sha256').update(data).digest();
}
/**
* SV2 HKDF with 2 outputs (not RFC 5869).
* temp_key = HMAC(chaining_key, input_key_material)
* output1 = HMAC(temp_key, 0x01)
* output2 = HMAC(temp_key, output1 || 0x02)
*/
export function hkdf2(chainingKey: Buffer, ikm: Buffer): [Buffer, Buffer] {
const tempKey = hmacHash(chainingKey, ikm);
const out1 = hmacHash(tempKey, Buffer.from([0x01]));
const out2 = hmacHash(tempKey, Buffer.concat([out1, Buffer.from([0x02])]));
return [out1, out2];
}
// ── CipherState ─────────────────────────────────────────────────────
/**
* Nonce encoding per Noise spec:
* - ChaCha20-Poly1305 (Section 12.3): 4 zero bytes + LE64(n)
* - AES-256-GCM (Section 12.4): 4 zero bytes + BE64(n)
*
* Set SV2_NONCE_LE=true to force LE for all ciphers (matches SRI Rust behavior).
*/
function encodeNonce(n: bigint, algorithm: Sv2CipherAlgorithm): Buffer {
const buf = Buffer.alloc(12);
if (algorithm === 'aes-256-gcm' && process.env.SV2_NONCE_LE !== 'true') {
buf.writeBigUInt64BE(n, 4);
} else {
buf.writeBigUInt64LE(n, 4);
}
return buf;
}
export class Sv2CipherState {
private k: Buffer | null = null;
private n = 0n;
private readonly algorithm: Sv2CipherAlgorithm;
constructor(algorithm: Sv2CipherAlgorithm = 'chacha20-poly1305') {
this.algorithm = algorithm;
}
get hasKey(): boolean {
return this.k !== null;
}
initializeKey(key: Buffer): void {
if (key.length !== 32) throw new Error('CipherState key must be 32 bytes');
this.k = key;
this.n = 0n;
}
encryptWithAd(ad: Buffer, plaintext: Buffer): Buffer {
if (!this.k) return plaintext;
const nonce = encodeNonce(this.n, this.algorithm);
const cipher = crypto.createCipheriv(this.algorithm, this.k, nonce, {
authTagLength: SV2_MAC_SIZE,
} as any) as crypto.CipherGCM;
if (this.algorithm === 'aes-256-gcm') {
cipher.setAAD(ad, { plaintextLength: plaintext.length } as any);
} else {
cipher.setAAD(ad, {} as any);
}
const encrypted = cipher.update(plaintext);
cipher.final();
const tag = cipher.getAuthTag();
this.n++;
return Buffer.concat([encrypted, tag]);
}
decryptWithAd(ad: Buffer, ciphertext: Buffer): Buffer {
if (!this.k) return ciphertext;
if (ciphertext.length < SV2_MAC_SIZE) {
throw new Error('Ciphertext too short for MAC');
}
const nonce = encodeNonce(this.n, this.algorithm);
const encData = ciphertext.subarray(0, ciphertext.length - SV2_MAC_SIZE);
const tag = ciphertext.subarray(ciphertext.length - SV2_MAC_SIZE);
const decipher = crypto.createDecipheriv(this.algorithm, this.k, nonce, {
authTagLength: SV2_MAC_SIZE,
} as any) as crypto.DecipherGCM;
if (this.algorithm === 'aes-256-gcm') {
decipher.setAAD(ad, { plaintextLength: encData.length } as any);
} else {
decipher.setAAD(ad, {} as any);
}
decipher.setAuthTag(tag);
const decrypted = decipher.update(encData);
decipher.final();
this.n++;
return Buffer.from(decrypted);
}
}
// ── Certificate / Signature Noise Message ───────────────────────────
export interface Sv2SignatureNoiseMessage {
version: number; // U16
validFrom: number; // U32 (unix timestamp)
notValidAfter: number; // U32 (unix timestamp)
signature: Buffer; // 64-byte Schnorr signature
}
/**
* Serialize a SignatureNoiseMessage to 74 bytes.
*/
export function serializeSignatureNoiseMessage(msg: Sv2SignatureNoiseMessage): Buffer {
const buf = Buffer.alloc(SV2_SIGNATURE_NOISE_MSG_SIZE);
buf.writeUInt16LE(msg.version, 0);
buf.writeUInt32LE(msg.validFrom, 2);
buf.writeUInt32LE(msg.notValidAfter, 6);
msg.signature.copy(buf, 10);
return buf;
}
/**
* Deserialize a 74-byte SignatureNoiseMessage.
*/
export function deserializeSignatureNoiseMessage(buf: Buffer): Sv2SignatureNoiseMessage {
if (buf.length < SV2_SIGNATURE_NOISE_MSG_SIZE) {
throw new Error(`SignatureNoiseMessage requires ${SV2_SIGNATURE_NOISE_MSG_SIZE} bytes`);
}
return {
version: buf.readUInt16LE(0),
validFrom: buf.readUInt32LE(2),
notValidAfter: buf.readUInt32LE(6),
signature: Buffer.from(buf.subarray(10, 74)),
};
}
/**
* Create a SignatureNoiseMessage that certifies a static key.
* Signs: version || validFrom || notValidAfter || staticPubKey (x-only, 32 bytes)
* using the authority's private key (BIP-340 Schnorr).
*/
export function createSignatureNoiseMessage(
authorityPrivKey: Buffer,
staticPubKeyXOnly: Buffer,
validFrom: number,
notValidAfter: number,
): Sv2SignatureNoiseMessage {
const version = 0;
const msgBuf = Buffer.alloc(2 + 4 + 4 + 32);
msgBuf.writeUInt16LE(version, 0);
msgBuf.writeUInt32LE(validFrom, 2);
msgBuf.writeUInt32LE(notValidAfter, 6);
staticPubKeyXOnly.copy(msgBuf, 10);
const msgHash = sha256(msgBuf);
const sig = ecc.signSchnorr(msgHash, authorityPrivKey);
if (!sig) throw new Error('Schnorr signing failed');
return {
version,
validFrom,
notValidAfter,
signature: Buffer.from(sig),
};
}
/**
* Validate a SignatureNoiseMessage against an authority public key.
*/
export function validateSignatureNoiseMessage(
msg: Sv2SignatureNoiseMessage,
authorityPubKeyXOnly: Buffer,
staticPubKeyXOnly: Buffer,
): boolean {
const msgBuf = Buffer.alloc(2 + 4 + 4 + 32);
msgBuf.writeUInt16LE(msg.version, 0);
msgBuf.writeUInt32LE(msg.validFrom, 2);
msgBuf.writeUInt32LE(msg.notValidAfter, 6);
staticPubKeyXOnly.copy(msgBuf, 10);
const msgHash = sha256(msgBuf);
return ecc.verifySchnorr(msgHash, authorityPubKeyXOnly, msg.signature);
}
// ── Server Keypair Generation ───────────────────────────────────────
export interface Sv2ServerKeypair {
privateKey: Buffer; // 32-byte secret scalar
publicKey: Buffer; // 64-byte EllSwift-encoded public key
}
export async function generateServerKeypair(): Promise<Sv2ServerKeypair> {
const es = await loadEllSwift();
const kp = es.keygen();
return {
privateKey: Buffer.from(kp.privateKey),
publicKey: Buffer.from(kp.publicKey),
};
}
/**
* Get the x-only (32-byte) public key from a 32-byte private key.
*/
export function xOnlyPubKeyFromPriv(privKey: Buffer): Buffer {
const xOnly = ecc.xOnlyPointFromScalar(privKey);
if (!xOnly) throw new Error('Failed to derive x-only public key');
return Buffer.from(xOnly);
}
// ── Noise NX Session (Responder / Server side) ──────────────────────
export interface Sv2NoiseConfig {
/** Server's static keypair (EllSwift). */
staticKeypair: Sv2ServerKeypair;
/** Pre-computed certificate message (signed by authority). */
certificateMessage: Sv2SignatureNoiseMessage;
}
export class Sv2NoiseSession {
private h: Buffer; // handshake hash
private ck: Buffer; // chaining key
private tempCipher: Sv2CipherState;
private sendCipher: Sv2CipherState;
private recvCipher: Sv2CipherState;
private handshakeComplete = false;
private ellSwift!: EllSwiftApi;
private readonly cipherAlgorithm: Sv2CipherAlgorithm;
private serverEphemeralPriv!: Buffer;
private serverEphemeralPub!: Buffer;
constructor(
private readonly config: Sv2NoiseConfig,
cipherAlgorithm: Sv2CipherAlgorithm = 'chacha20-poly1305',
prologue: Buffer = Buffer.alloc(0),
) {
this.cipherAlgorithm = cipherAlgorithm;
const protocolNameStr = getNoiseProtocolName(cipherAlgorithm);
this.tempCipher = new Sv2CipherState(cipherAlgorithm);
this.sendCipher = new Sv2CipherState(cipherAlgorithm);
this.recvCipher = new Sv2CipherState(cipherAlgorithm);
// Initialize h = HASH(protocol_name) if len > 32, else pad
const protocolName = Buffer.from(protocolNameStr, 'ascii');
if (protocolName.length <= 32) {
this.h = Buffer.alloc(32);
protocolName.copy(this.h);
} else {
this.h = sha256(protocolName);
}
// ck = h
this.ck = Buffer.from(this.h);
// MixHash(prologue) per Noise spec: h = HASH(h || prologue)
this.h = sha256(Buffer.concat([this.h, prologue]));
}
// ── Noise Symmetric State Helpers ─────────────────────────────────
private mixHash(data: Buffer): void {
this.h = sha256(Buffer.concat([this.h, data]));
}
private mixKey(ikm: Buffer): void {
const [newCk, tempK] = hkdf2(this.ck, ikm);
this.ck = newCk;
this.tempCipher = new Sv2CipherState(this.cipherAlgorithm);
this.tempCipher.initializeKey(tempK);
}
private encryptAndHash(plaintext: Buffer): Buffer {
const ciphertext = this.tempCipher.encryptWithAd(this.h, plaintext);
this.mixHash(ciphertext);
return ciphertext;
}
private decryptAndHash(ciphertext: Buffer): Buffer {
const plaintext = this.tempCipher.decryptWithAd(this.h, ciphertext);
this.mixHash(ciphertext);
return plaintext;
}
// ── ECDH via EllSwift ─────────────────────────────────────────────
/**
* Compute BIP-324 EllSwift ECDH.
* @param localPriv - Our private key (32 bytes)
* @param localPub - Our EllSwift public key (64 bytes)
* @param remotePub - Their EllSwift public key (64 bytes)
* @param weAreInitiator - Party bit: true for initiator, false for responder
*/
private ecdh(localPriv: Buffer, localPub: Buffer, remotePub: Buffer, weAreInitiator: boolean): Buffer {
const secret = this.ellSwift.getSharedSecretBip324(
new Uint8Array(localPriv),
new Uint8Array(remotePub),
new Uint8Array(localPub),
weAreInitiator,
);
return Buffer.from(secret);
}
// ── Handshake: Process Act 1, Generate Act 2 ─────────────────────
/**
* Process the initiator's Act 1 ephemeral key and produce Act 2.
* EllSwift mode: 64-byte Act 1 -> 234-byte Act 2
*/
async processAct1(act1: Buffer): Promise<Buffer> {
if (act1.length !== SV2_ELLSWIFT_KEY_SIZE) {
throw new Error(`Act 1 must be ${SV2_ELLSWIFT_KEY_SIZE} bytes, got ${act1.length}`);
}
const debug = process.env.SV2_NOISE_DEBUG === 'true';
if (debug) {
const protocolNameStr = getNoiseProtocolName(this.cipherAlgorithm);
console.log(`[NOISE] Protocol name: "${protocolNameStr}" (${protocolNameStr.length} bytes)`);
console.log(`[NOISE] Cipher: ${this.cipherAlgorithm}`);
console.log(`[NOISE] Init ck=${this.ck.toString('hex')}`);
console.log(`[NOISE] Init h=${this.h.toString('hex')}`);
}
this.ellSwift = await loadEllSwift();
const remoteEphemeral = act1;
this.mixHash(remoteEphemeral);
if (debug) console.log(`[NOISE] After MixHash(re): h=${this.h.toString('hex')}`);
this.decryptAndHash(Buffer.alloc(0));
if (debug) console.log(`[NOISE] After DecryptAndHash(empty): h=${this.h.toString('hex')}`);
const serverEph = this.ellSwift.keygen();
this.serverEphemeralPriv = Buffer.from(serverEph.privateKey);
this.serverEphemeralPub = Buffer.from(serverEph.publicKey);
if (debug) console.log(`[NOISE] Server eph pub: ${this.serverEphemeralPub.toString('hex')}`);
this.mixHash(this.serverEphemeralPub);
if (debug) console.log(`[NOISE] After MixHash(se): h=${this.h.toString('hex')}`);
const eeDH = this.ecdh(
this.serverEphemeralPriv,
this.serverEphemeralPub,
remoteEphemeral,
false,
);
if (debug) console.log(`[NOISE] ee DH shared secret: ${eeDH.toString('hex')}`);
this.mixKey(eeDH);
if (debug) console.log(`[NOISE] After MixKey(ee): ck=${this.ck.toString('hex')}`);
const staticPubKey = this.config.staticKeypair.publicKey;
if (debug) {
console.log(`[NOISE] h (AD for encrypt static): ${this.h.toString('hex')}`);
console.log(`[NOISE] Static pub (plaintext): ${staticPubKey.toString('hex')}`);
}
const encryptedStatic = this.encryptAndHash(staticPubKey);
if (debug) console.log(`[NOISE] Encrypted static (${encryptedStatic.length} bytes): ${encryptedStatic.toString('hex')}`);
const esDH = this.ecdh(
this.config.staticKeypair.privateKey,
this.config.staticKeypair.publicKey,
remoteEphemeral,
false,
);
if (debug) console.log(`[NOISE] es DH shared secret: ${esDH.toString('hex')}`);
this.mixKey(esDH);
if (debug) console.log(`[NOISE] After MixKey(es): ck=${this.ck.toString('hex')}`);
const certPayload = serializeSignatureNoiseMessage(this.config.certificateMessage);
if (debug) console.log(`[NOISE] h (AD for encrypt cert): ${this.h.toString('hex')}`);
if (debug) console.log(`[NOISE] Cert payload (${certPayload.length} bytes): ${certPayload.toString('hex')}`);
const encryptedCert = this.encryptAndHash(certPayload);
if (debug) console.log(`[NOISE] Encrypted cert (${encryptedCert.length} bytes)`);
const [k1, k2] = hkdf2(this.ck, Buffer.alloc(0));
this.recvCipher.initializeKey(k1);
this.sendCipher.initializeKey(k2);
this.handshakeComplete = true;
const act2 = Buffer.concat([this.serverEphemeralPub, encryptedStatic, encryptedCert]);
if (debug) console.log(`[NOISE] Act 2 total: ${act2.length} bytes`);
return act2;
}
// ── Post-Handshake Transport ──────────────────────────────────────
encrypt(plaintext: Buffer): Buffer {
if (!this.handshakeComplete) throw new Error('Handshake not complete');
return this.sendCipher.encryptWithAd(Buffer.alloc(0), plaintext);
}
decrypt(ciphertext: Buffer): Buffer {
if (!this.handshakeComplete) throw new Error('Handshake not complete');
return this.recvCipher.decryptWithAd(Buffer.alloc(0), ciphertext);
}
get isHandshakeComplete(): boolean {
return this.handshakeComplete;
}
}
// ── Initiator Session (for testing) ─────────────────────────────────
export class Sv2NoiseInitiator {
private h: Buffer;
private ck: Buffer;
private tempCipher: Sv2CipherState;
private sendCipher: Sv2CipherState;
private recvCipher: Sv2CipherState;
private handshakeComplete = false;
private ellSwift!: EllSwiftApi;
private readonly cipherAlgorithm: Sv2CipherAlgorithm;
private ephemeralPriv!: Buffer;
private ephemeralPub!: Buffer;
constructor(
cipherAlgorithm: Sv2CipherAlgorithm = 'chacha20-poly1305',
prologue: Buffer = Buffer.alloc(0),
) {
this.cipherAlgorithm = cipherAlgorithm;
const protocolNameStr = getNoiseProtocolName(cipherAlgorithm);
this.tempCipher = new Sv2CipherState(cipherAlgorithm);
this.sendCipher = new Sv2CipherState(cipherAlgorithm);
this.recvCipher = new Sv2CipherState(cipherAlgorithm);
const protocolName = Buffer.from(protocolNameStr, 'ascii');
if (protocolName.length <= 32) {
this.h = Buffer.alloc(32);
protocolName.copy(this.h);
} else {
this.h = sha256(protocolName);
}
this.ck = Buffer.from(this.h);
// MixHash(prologue) per Noise spec: h = HASH(h || prologue)
this.h = sha256(Buffer.concat([this.h, prologue]));
}
private mixHash(data: Buffer): void {
this.h = sha256(Buffer.concat([this.h, data]));
}
private mixKey(ikm: Buffer): void {
const [newCk, tempK] = hkdf2(this.ck, ikm);
this.ck = newCk;
this.tempCipher = new Sv2CipherState(this.cipherAlgorithm);
this.tempCipher.initializeKey(tempK);
}
private decryptAndHash(ciphertext: Buffer): Buffer {
const plaintext = this.tempCipher.decryptWithAd(this.h, ciphertext);
this.mixHash(ciphertext);
return plaintext;
}
private ecdh(localPriv: Buffer, localPub: Buffer, remotePub: Buffer, weAreInitiator: boolean): Buffer {
const secret = this.ellSwift.getSharedSecretBip324(
new Uint8Array(localPriv),
new Uint8Array(remotePub),
new Uint8Array(localPub),
weAreInitiator,
);
return Buffer.from(secret);
}
/** Generate Act 1: 64-byte EllSwift ephemeral key. */
async generateAct1(): Promise<Buffer> {
this.ellSwift = await loadEllSwift();
const kp = this.ellSwift.keygen();
this.ephemeralPriv = Buffer.from(kp.privateKey);
this.ephemeralPub = Buffer.from(kp.publicKey);
this.mixHash(this.ephemeralPub);
this.mixHash(Buffer.alloc(0));
return this.ephemeralPub;
}
/** Process Act 2 from responder, returns the decrypted server static key and cert. */
processAct2(act2: Buffer): {
serverStaticKey: Buffer;
certificate: Sv2SignatureNoiseMessage;
} {
if (act2.length !== 234) {
throw new Error(`Act 2 must be 234 bytes, got ${act2.length}`);
}
const serverEphemeral = act2.subarray(0, 64);
const encryptedStatic = act2.subarray(64, 144);
const encryptedCert = act2.subarray(144, 234);
this.mixHash(Buffer.from(serverEphemeral));
const eeDH = this.ecdh(
this.ephemeralPriv,
this.ephemeralPub,
Buffer.from(serverEphemeral),
true,
);
this.mixKey(eeDH);
const serverStaticKey = this.decryptAndHash(Buffer.from(encryptedStatic));
const esDH = this.ecdh(
this.ephemeralPriv,
this.ephemeralPub,
serverStaticKey,
true,
);
this.mixKey(esDH);
const certPayload = this.decryptAndHash(Buffer.from(encryptedCert));
const certificate = deserializeSignatureNoiseMessage(certPayload);
const [k1, k2] = hkdf2(this.ck, Buffer.alloc(0));
this.sendCipher.initializeKey(k1);
this.recvCipher.initializeKey(k2);
this.handshakeComplete = true;
return { serverStaticKey, certificate };
}
encrypt(plaintext: Buffer): Buffer {
if (!this.handshakeComplete) throw new Error('Handshake not complete');
return this.sendCipher.encryptWithAd(Buffer.alloc(0), plaintext);
}
decrypt(ciphertext: Buffer): Buffer {
if (!this.handshakeComplete) throw new Error('Handshake not complete');
return this.recvCipher.decryptWithAd(Buffer.alloc(0), ciphertext);
}
get isHandshakeComplete(): boolean {
return this.handshakeComplete;
}
}
+107
View File
@@ -0,0 +1,107 @@
import { sipHash24, shortTxId, sipHashKeyFromNonce } from './sv2-siphash';
describe('SipHash-2-4', () => {
// Official SipHash-2-4 test vectors from the reference paper
// Key: 00 01 02 ... 0f
// Input: empty, 00, 00 01, 00 01 02, ...
// Expected outputs (first few from Appendix A of the paper)
const testKey = Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);
// Reference vectors from vectors.h: uint8_t vectors_sip64[64][8]
// These are the raw output bytes, so we compare directly with our output buffer.
const expectedOutputs = [
'310e0edd47db6f72', // empty input
'fd67dc93c539f874', // [0x00]
'5a4fa9d909806c0d', // [0x00, 0x01]
'2d7efbd796666785', // [0x00, 0x01, 0x02]
'b7877127e09427cf', // [0x00, 0x01, 0x02, 0x03]
'8da699cd64557618', // [0x00, ..., 0x04]
'cee3fe586e46c9cb', // [0x00, ..., 0x05]
'37d1018bf50002ab', // [0x00, ..., 0x06]
'6224939a79f5f593', // [0x00, ..., 0x07]
'b0e4a90bdf82009e', // [0x00, ..., 0x08]
'f3b9dd94c5bb5d7a', // [0x00, ..., 0x09]
'a7ad6b22462fb3f4', // [0x00, ..., 0x0a]
'fbe50e86bc8f1e75', // [0x00, ..., 0x0b]
'903d84c02756ea14', // [0x00, ..., 0x0c]
'eef27a8e90ca23f7', // [0x00, ..., 0x0d]
'e545be4961ca29a1', // [0x00, ..., 0x0e]
];
expectedOutputs.forEach((expected, i) => {
it(`matches test vector for ${i}-byte input`, () => {
const input = Buffer.from(Array.from({ length: i }, (_, j) => j));
const result = sipHash24(testKey, input);
// Compare raw output bytes directly
expect(result.toString('hex')).toBe(expected);
});
});
it('throws for invalid key length', () => {
expect(() => sipHash24(Buffer.alloc(8), Buffer.alloc(0))).toThrow('SipHash key must be 16 bytes');
expect(() => sipHash24(Buffer.alloc(32), Buffer.alloc(0))).toThrow('SipHash key must be 16 bytes');
});
it('produces 8-byte output', () => {
const result = sipHash24(testKey, Buffer.from('hello'));
expect(result.length).toBe(8);
});
it('produces consistent results', () => {
const data = Buffer.from('test data');
const r1 = sipHash24(testKey, data);
const r2 = sipHash24(testKey, data);
expect(r1).toEqual(r2);
});
it('different keys produce different results', () => {
const key2 = Buffer.alloc(16, 0xff);
const data = Buffer.from('hello');
const r1 = sipHash24(testKey, data);
const r2 = sipHash24(key2, data);
expect(r1).not.toEqual(r2);
});
describe('shortTxId', () => {
it('produces 6-byte output', () => {
const txHash = Buffer.alloc(32, 0xab);
const result = shortTxId(testKey, txHash);
expect(result.length).toBe(6);
});
it('is the first 6 bytes of sipHash24', () => {
const txHash = Buffer.alloc(32, 0xcd);
const full = sipHash24(testKey, txHash);
const short = shortTxId(testKey, txHash);
expect(short).toEqual(full.subarray(0, 6));
});
it('different tx hashes produce different short IDs', () => {
const tx1 = Buffer.alloc(32, 0x01);
const tx2 = Buffer.alloc(32, 0x02);
const s1 = shortTxId(testKey, tx1);
const s2 = shortTxId(testKey, tx2);
expect(s1).not.toEqual(s2);
});
});
describe('sipHashKeyFromNonce', () => {
it('produces 16-byte key', () => {
const key = sipHashKeyFromNonce(42n);
expect(key.length).toBe(16);
});
it('nonce is stored in first 8 bytes LE', () => {
const key = sipHashKeyFromNonce(0x0102030405060708n);
expect(key.readBigUInt64LE(0)).toBe(0x0102030405060708n);
// Second 8 bytes should be zero
expect(key.readBigUInt64LE(8)).toBe(0n);
});
it('produces valid SipHash key', () => {
const key = sipHashKeyFromNonce(12345n);
const result = sipHash24(key, Buffer.from('test'));
expect(result.length).toBe(8);
});
});
});
+154
View File
@@ -0,0 +1,154 @@
// ── SipHash-2-4 ────────────────────────────────────────────────────
// Pure TypeScript implementation of SipHash-2-4 for SV2 JDP short
// transaction IDs. Produces 8-byte (64-bit) hashes, truncated to
// 6 bytes per the SV2 spec for SHORT_TX_ID.
//
// Reference: https://131002.net/siphash/siphash.pdf
function add64(a: [number, number], b: [number, number]): [number, number] {
const lo = (a[1] + b[1]) >>> 0;
const hi = ((a[0] + b[0]) >>> 0) + (lo < a[1] ? 1 : 0);
return [(hi >>> 0), lo];
}
function rotl64(v: [number, number], n: number): [number, number] {
if (n === 0) return v;
if (n === 32) return [v[1], v[0]];
if (n < 32) {
return [
((v[0] << n) | (v[1] >>> (32 - n))) >>> 0,
((v[1] << n) | (v[0] >>> (32 - n))) >>> 0,
];
}
const s = n - 32;
return [
((v[1] << s) | (v[0] >>> (32 - s))) >>> 0,
((v[0] << s) | (v[1] >>> (32 - s))) >>> 0,
];
}
function xor64(a: [number, number], b: [number, number]): [number, number] {
return [(a[0] ^ b[0]) >>> 0, (a[1] ^ b[1]) >>> 0];
}
function sipRound(
v0: [number, number],
v1: [number, number],
v2: [number, number],
v3: [number, number],
): [[number, number], [number, number], [number, number], [number, number]] {
v0 = add64(v0, v1);
v1 = rotl64(v1, 13);
v1 = xor64(v1, v0);
v0 = rotl64(v0, 32);
v2 = add64(v2, v3);
v3 = rotl64(v3, 16);
v3 = xor64(v3, v2);
v2 = add64(v2, v1);
v1 = rotl64(v1, 17);
v1 = xor64(v1, v2);
v2 = rotl64(v2, 32);
v0 = add64(v0, v3);
v3 = rotl64(v3, 21);
v3 = xor64(v3, v0);
return [v0, v1, v2, v3];
}
function readU64LE(buf: Buffer, offset: number): [number, number] {
const lo = buf.readUInt32LE(offset);
const hi = buf.readUInt32LE(offset + 4);
return [hi, lo];
}
/**
* SipHash-2-4 producing an 8-byte hash.
* @param key 16-byte key
* @param data input data
* @returns 8-byte Buffer (little-endian hash)
*/
export function sipHash24(key: Buffer, data: Buffer): Buffer {
if (key.length !== 16) throw new Error('SipHash key must be 16 bytes');
const k0 = readU64LE(key, 0);
const k1 = readU64LE(key, 8);
let v0: [number, number] = xor64(k0, [0x736f6d65, 0x70736575]); // "somepseu"
let v1: [number, number] = xor64(k1, [0x646f7261, 0x6e646f6d]); // "dorandom"
let v2: [number, number] = xor64(k0, [0x6c796765, 0x6e657261]); // "lygenera"
let v3: [number, number] = xor64(k1, [0x74656462, 0x79746573]); // "tedbytes"
const len = data.length;
const blocks = Math.floor(len / 8);
// Process 8-byte blocks
for (let i = 0; i < blocks; i++) {
const m = readU64LE(data, i * 8);
v3 = xor64(v3, m);
// 2 rounds (SipHash-2-4)
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
v0 = xor64(v0, m);
}
// Last block: remaining bytes + length in the high byte
const remaining = len - blocks * 8;
let last: [number, number] = [0, 0];
// High byte of hi word = (len & 0xff) << 24
last[0] = ((len & 0xff) << 24) >>> 0;
// Read remaining bytes
const offset = blocks * 8;
if (remaining >= 7) last[0] |= (data[offset + 6] << 16) >>> 0;
if (remaining >= 6) last[0] |= (data[offset + 5] << 8) >>> 0;
if (remaining >= 5) last[0] |= data[offset + 4];
if (remaining >= 4) last[1] = (last[1] | ((data[offset + 3] << 24) >>> 0)) >>> 0;
if (remaining >= 3) last[1] = (last[1] | ((data[offset + 2] << 16) >>> 0)) >>> 0;
if (remaining >= 2) last[1] = (last[1] | ((data[offset + 1] << 8) >>> 0)) >>> 0;
if (remaining >= 1) last[1] = (last[1] | data[offset]) >>> 0;
v3 = xor64(v3, last);
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
v0 = xor64(v0, last);
// Finalization
v2 = xor64(v2, [0, 0xff]);
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
const result = xor64(xor64(v0, v1), xor64(v2, v3));
const out = Buffer.alloc(8);
out.writeUInt32LE(result[1], 0);
out.writeUInt32LE(result[0], 4);
return out;
}
/**
* Compute a 6-byte SHORT_TX_ID per SV2 spec.
* SipHash-2-4(key, txid) truncated to first 6 bytes.
* @param key 16-byte key (derived from nonce)
* @param txHash 32-byte transaction hash
* @returns 6-byte Buffer
*/
export function shortTxId(key: Buffer, txHash: Buffer): Buffer {
const hash = sipHash24(key, txHash);
return hash.subarray(0, 6);
}
/**
* Build a 16-byte SipHash key from a U64 nonce.
* First 8 bytes = nonce LE, second 8 bytes = zeros.
*/
export function sipHashKeyFromNonce(nonce: bigint): Buffer {
const key = Buffer.alloc(16);
key.writeBigUInt64LE(nonce, 0);
return key;
}
+226
View File
@@ -0,0 +1,226 @@
import { BufferReader } from './sv2-binary-codec';
import {
Sv2TdpCoinbaseOutputConstraints,
serializeTdpCoinbaseOutputConstraints,
deserializeTdpCoinbaseOutputConstraints,
Sv2TdpNewTemplate,
serializeTdpNewTemplate,
deserializeTdpNewTemplate,
Sv2TdpSetNewPrevHash,
serializeTdpSetNewPrevHash,
deserializeTdpSetNewPrevHash,
Sv2TdpRequestTransactionData,
serializeTdpRequestTransactionData,
deserializeTdpRequestTransactionData,
Sv2TdpRequestTransactionDataSuccess,
serializeTdpRequestTransactionDataSuccess,
deserializeTdpRequestTransactionDataSuccess,
Sv2TdpRequestTransactionDataError,
serializeTdpRequestTransactionDataError,
deserializeTdpRequestTransactionDataError,
Sv2TdpSubmitSolution,
serializeTdpSubmitSolution,
deserializeTdpSubmitSolution,
} from './sv2-tdp-messages';
function roundTrip<T>(
msg: T,
serialize: (m: T) => Buffer,
deserialize: (r: BufferReader) => T,
): T {
const buf = serialize(msg);
return deserialize(new BufferReader(buf));
}
describe('SV2 TDP Messages', () => {
it('CoinbaseOutputConstraints round-trips', () => {
const msg: Sv2TdpCoinbaseOutputConstraints = {
coinbaseOutputMaxAdditionalSize: 128,
coinbaseOutputMaxAdditionalSigops: 400,
};
const result = roundTrip(msg, serializeTdpCoinbaseOutputConstraints, deserializeTdpCoinbaseOutputConstraints);
expect(result).toEqual(msg);
});
it('NewTemplate round-trips with merkle path', () => {
const hash1 = Buffer.alloc(32, 0xaa);
const hash2 = Buffer.alloc(32, 0xbb);
const msg: Sv2TdpNewTemplate = {
templateId: 42n,
futureTemplate: false,
version: 0x20000000,
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.from('deadbeef', 'hex'),
coinbaseTxInputSequence: 0xffffffff,
coinbaseTxValueRemaining: 625000000n,
coinbaseTxOutputsCount: 1,
coinbaseTxOutputs: Buffer.from('cafebabe', 'hex'),
coinbaseTxLocktime: 0,
merklePath: [hash1, hash2],
};
const result = roundTrip(msg, serializeTdpNewTemplate, deserializeTdpNewTemplate);
expect(result.templateId).toBe(msg.templateId);
expect(result.futureTemplate).toBe(msg.futureTemplate);
expect(result.version).toBe(msg.version);
expect(result.coinbasePrefix).toEqual(msg.coinbasePrefix);
expect(result.coinbaseTxVersion).toBe(msg.coinbaseTxVersion);
expect(result.coinbaseTxInputSequence).toBe(msg.coinbaseTxInputSequence);
expect(result.coinbaseTxValueRemaining).toBe(msg.coinbaseTxValueRemaining);
expect(result.coinbaseTxOutputsCount).toBe(msg.coinbaseTxOutputsCount);
expect(result.coinbaseTxOutputs).toEqual(msg.coinbaseTxOutputs);
expect(result.coinbaseTxLocktime).toBe(msg.coinbaseTxLocktime);
expect(result.merklePath).toHaveLength(2);
expect(result.merklePath[0]).toEqual(hash1);
expect(result.merklePath[1]).toEqual(hash2);
});
it('NewTemplate round-trips with empty merkle path', () => {
const msg: Sv2TdpNewTemplate = {
templateId: 1n,
futureTemplate: true,
version: 0x20000000,
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.alloc(0),
coinbaseTxInputSequence: 0xffffffff,
coinbaseTxValueRemaining: 0n,
coinbaseTxOutputsCount: 0,
coinbaseTxOutputs: Buffer.alloc(0),
coinbaseTxLocktime: 0,
merklePath: [],
};
const result = roundTrip(msg, serializeTdpNewTemplate, deserializeTdpNewTemplate);
expect(result.merklePath).toHaveLength(0);
expect(result.futureTemplate).toBe(true);
});
it('NewTemplate round-trips with large coinbase data', () => {
const largeCoinbase = Buffer.alloc(100, 0xcc); // B0_255 max 255 bytes
const largeOutputs = Buffer.alloc(2000, 0xdd);
const msg: Sv2TdpNewTemplate = {
templateId: 999n,
futureTemplate: false,
version: 0x20400000,
coinbaseTxVersion: 2,
coinbasePrefix: largeCoinbase,
coinbaseTxInputSequence: 0xfffffffe,
coinbaseTxValueRemaining: 312500000n,
coinbaseTxOutputsCount: 3,
coinbaseTxOutputs: largeOutputs,
coinbaseTxLocktime: 500000,
merklePath: Array.from({ length: 12 }, (_, i) => Buffer.alloc(32, i)),
};
const result = roundTrip(msg, serializeTdpNewTemplate, deserializeTdpNewTemplate);
expect(result.coinbasePrefix).toEqual(largeCoinbase); // B0_255, 100 bytes
expect(result.coinbaseTxOutputs).toEqual(largeOutputs);
expect(result.merklePath).toHaveLength(12);
});
it('SetNewPrevHash round-trips', () => {
const prevHash = Buffer.alloc(32, 0xab);
const target = Buffer.alloc(32, 0x00);
target[0] = 0xff;
const msg: Sv2TdpSetNewPrevHash = {
templateId: 42n,
prevHash,
headerTimestamp: 1700000000,
nBits: 0x1d00ffff,
target,
};
const result = roundTrip(msg, serializeTdpSetNewPrevHash, deserializeTdpSetNewPrevHash);
expect(result).toEqual(msg);
});
it('RequestTransactionData round-trips', () => {
const msg: Sv2TdpRequestTransactionData = {
templateId: 100n,
};
const result = roundTrip(msg, serializeTdpRequestTransactionData, deserializeTdpRequestTransactionData);
expect(result).toEqual(msg);
});
it('RequestTransactionData.Success round-trips with transactions', () => {
const tx1 = Buffer.from('0100000001', 'hex');
const tx2 = Buffer.from('0200000002abcd', 'hex');
const msg: Sv2TdpRequestTransactionDataSuccess = {
templateId: 100n,
excessData: Buffer.from('extra', 'utf8'),
transactionList: [tx1, tx2],
};
const result = roundTrip(
msg,
serializeTdpRequestTransactionDataSuccess,
deserializeTdpRequestTransactionDataSuccess,
);
expect(result.templateId).toBe(msg.templateId);
expect(result.excessData).toEqual(msg.excessData);
expect(result.transactionList).toHaveLength(2);
expect(result.transactionList[0]).toEqual(tx1);
expect(result.transactionList[1]).toEqual(tx2);
});
it('RequestTransactionData.Success round-trips with empty transaction list', () => {
const msg: Sv2TdpRequestTransactionDataSuccess = {
templateId: 1n,
excessData: Buffer.alloc(0),
transactionList: [],
};
const result = roundTrip(
msg,
serializeTdpRequestTransactionDataSuccess,
deserializeTdpRequestTransactionDataSuccess,
);
expect(result.transactionList).toHaveLength(0);
});
it('RequestTransactionData.Error round-trips', () => {
const msg: Sv2TdpRequestTransactionDataError = {
templateId: 42n,
errorCode: 'template-not-found',
};
const result = roundTrip(
msg,
serializeTdpRequestTransactionDataError,
deserializeTdpRequestTransactionDataError,
);
expect(result).toEqual(msg);
});
it('SubmitSolution round-trips', () => {
const coinbaseTx = Buffer.alloc(250, 0xef);
const msg: Sv2TdpSubmitSolution = {
templateId: 42n,
version: 0x20000000,
headerTimestamp: 1700000000,
headerNonce: 0xdeadbeef,
coinbaseTx,
};
const result = roundTrip(msg, serializeTdpSubmitSolution, deserializeTdpSubmitSolution);
expect(result.templateId).toBe(msg.templateId);
expect(result.version).toBe(msg.version);
expect(result.headerTimestamp).toBe(msg.headerTimestamp);
expect(result.headerNonce).toBe(msg.headerNonce);
expect(result.coinbaseTx).toEqual(coinbaseTx);
});
it('serialized NewTemplate has correct byte layout', () => {
const msg: Sv2TdpNewTemplate = {
templateId: 1n,
futureTemplate: false,
version: 0x20000000,
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.from([0xab]),
coinbaseTxInputSequence: 0xffffffff,
coinbaseTxValueRemaining: 100n,
coinbaseTxOutputsCount: 0,
coinbaseTxOutputs: Buffer.alloc(0),
coinbaseTxLocktime: 0,
merklePath: [],
};
const buf = serializeTdpNewTemplate(msg);
// U64(8) + BOOL(1) + U32(4) + U32(4) + B0_255(1+1) + U32(4) + U64(8) + U32(4) + B0_64K(2+0) + U32(4) + SEQ0_255(1+0)
// = 8 + 1 + 4 + 4 + 2 + 4 + 8 + 4 + 2 + 4 + 1 = 42
expect(buf.length).toBe(42);
expect(buf.readBigUInt64LE(0)).toBe(1n);
expect(buf[8]).toBe(0); // futureTemplate = false
});
});

Some files were not shown because too many files have changed in this diff Show More