174 Commits
Author SHA1 Message Date
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
115 changed files with 21836 additions and 1749 deletions
+53 -9
View File
@@ -10,16 +10,40 @@ BITCOIN_RPC_PASSWORD=
BITCOIN_RPC_PORT=8332 BITCOIN_RPC_PORT=8332
BITCOIN_RPC_TIMEOUT=10000 BITCOIN_RPC_TIMEOUT=10000
# You can use this instead of BITCOIN_RPC_USER and BITCOIN_RPC_PASSWORD # Enable in bitcoin.conf with:
BITCOIN_RPC_COOKIEFILE= # zmqpubrawblock=tcp://0.0.0.0:3000
BITCOIN_ZMQ_HOST=tcp://192.168.1.100:3000
# Enable in bitcoin.conf with
# zmqpubrawblock=tcp://*:3000
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
API_PORT=3334 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_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_MAX_CONNECTIONS_PER_LISTENER=10000
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS=10000
#optional telegram bot #optional telegram bot
#TELEGRAM_BOT_TOKEN= #TELEGRAM_BOT_TOKEN=
@@ -35,5 +59,25 @@ DEV_FEE_ADDRESS=
NETWORK=mainnet NETWORK=mainnet
API_SECURE=false API_SECURE=false
# Default is "Public-Pool", you can change it to any string it will be removed if it will make the block or coinbase script too big
POOL_IDENTIFIER="Public-Pool" #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
#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
#redis
REDIS_URL=redis://redis:6379
PRODUCTION=false
+4 -1
View File
@@ -13,10 +13,13 @@ lerna-debug.log*
# OS # OS
.DS_Store .DS_Store
._*
# Tests # Tests
/coverage /coverage
/.nyc_output /.nyc_output
/.baseline-*.json
/.test-artifacts
# IDEs and editors # IDEs and editors
/.idea /.idea
@@ -41,4 +44,4 @@ settings.json
./full-setup/data/* ./full-setup/data/*
#https #https
**.pem **.pem
+2 -2
View File
@@ -20,7 +20,7 @@ WORKDIR /build
COPY . . COPY . .
# Build Public Pool using NPM # Build Public Pool using NPM
RUN npm i && npm run build RUN npm ci && npm run build && npm prune --omit=dev
############################ ############################
# Docker final environment # # Docker final environment #
@@ -37,4 +37,4 @@ WORKDIR /public-pool
COPY --from=build /build . COPY --from=build /build .
#COPY .env.example .env #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"]
+79 -17
View File
@@ -39,9 +39,13 @@ See [public-pool-ui](https://github.com/benjamin-wilson/public-pool-ui)
## Deployment ## 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 ```bash
$ npm run build
$ npm run migration:run:prod
$ pm2 start dist/main.js $ pm2 start dist/main.js
``` ```
@@ -61,43 +65,101 @@ with the default limit of `10000` allow up to `280000` connections on one port.
## Docker ## 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 ```bash
$ docker build -t public-pool . $ docker compose up --build -d
``` ```
Run container: Use an external TimescaleDB/Postgres server:
```bash ```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 ```bash
$ docker compose build $ docker compose run --rm public-pool npm run migration:run:prod
``` ```
Run container: Watch logs:
```bash ```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: Back up TimescaleDB:
```diff
ports: ```bash
- - "127.0.0.1:3333:3333/tcp" $ docker compose exec timescaledb pg_dump -U public_pool public_pool > public-pool.sql
- - "127.0.0.1:3334:3334/tcp"
+ - "3333"
+ - "3334"
``` ```
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 **note**: To successfully connect to the bitcoin RPC you will need to add
``` ```
rpcallowip=172.16.0.0/12 rpcallowip=172.16.0.0/12
zmqpubrawblock=tcp://0.0.0.0:3000
``` ```
to your bitcoin.conf. 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
```
+85
View File
@@ -0,0 +1,85 @@
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_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: 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: public-pool:
container_name: public-pool container_name: public-pool
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
logging: *log-limits
depends_on:
timescaledb:
condition: service_healthy
redis:
condition: service_healthy
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
ports: ports:
- "127.0.0.1:${STRATUM_PORT}:${STRATUM_PORT}/tcp" - "0.0.0.0:3333:3333/tcp"
- "127.0.0.1:${API_PORT}:${API_PORT}/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: volumes:
- "./${NETWORK}-DB:/public-pool/DB"
- "./.env:/public-pool/.env:ro" - "./.env:/public-pool/.env:ro"
- "./secrets:/public-pool/secrets:ro"
environment: 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:
+53
View File
@@ -0,0 +1,53 @@
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",
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 # 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: It exposes following ports:
@@ -8,6 +9,7 @@ It exposes following ports:
- `8333/18333` Bitcoin peering on `0.0.0.0` - `8333/18333` Bitcoin peering on `0.0.0.0`
- `3333/13333` Public-Pool Stratum port on `0.0.0.0` - `3333/13333` Public-Pool Stratum port on `0.0.0.0`
- `3334/13334` Public-Pool API port on `localhost` - `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. 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` - `bitcoin-testnet.conf`
**note: pruning (`prune=550`) is enabled by default in the config** **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 # Running the setup
To start the setup in foreground mode: To start the setup in foreground mode:
@@ -65,6 +70,25 @@ To stop the setup use:
docker compose -f docker-compose-mainnet.yml down 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 # Regtest
After running the `regtest` setup a couple of blocks need to be generated: After running the `regtest` setup a couple of blocks need to be generated:
+3
View File
@@ -18,6 +18,9 @@ rpcuser=bitcoin
# enable server # enable server
server=1 server=1
# notify pool master about new blocks
zmqpubrawblock=tcp://0.0.0.0:3000
[main] [main]
[test] [test]
rpcbind=0.0.0.0 rpcbind=0.0.0.0
+3
View File
@@ -20,3 +20,6 @@ rpcuser=bitcoin
# set pruning # set pruning
prune=550 prune=550
# notify pool master about new blocks
zmqpubrawblock=tcp://0.0.0.0:3000
+4 -1
View File
@@ -18,6 +18,9 @@ rpcuser=bitcoin
# enable server # enable server
server=1 server=1
# notify pool master about new blocks
zmqpubrawblock=tcp://0.0.0.0:3000
[main] [main]
[test] [test]
rpcbind=0.0.0.0 rpcbind=0.0.0.0
@@ -25,4 +28,4 @@ rpcport=18332
# no additional testnet settings # no additional testnet settings
[signet] [signet]
[regtest] [regtest]
+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: services:
bitcoin: bitcoin:
@@ -8,6 +12,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
stop_grace_period: 30s stop_grace_period: 30s
logging: *log-limits
networks: networks:
- bitcoin - bitcoin
ports: ports:
@@ -20,6 +25,52 @@ services:
- "./data/mainnet/bitcoin:/app/data" - "./data/mainnet/bitcoin:/app/data"
- "./bitcoin-mainnet.conf:/app/data/bitcoin.conf:ro" - "./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: public-pool:
container_name: public-pool container_name: public-pool
build: build:
@@ -27,16 +78,41 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
stop_grace_period: 30s stop_grace_period: 30s
logging: *log-limits
depends_on:
timescaledb:
condition: service_healthy
redis:
condition: service_healthy
networks: networks:
- bitcoin - bitcoin
ports: ports:
- "0.0.0.0:3333:3333/tcp" - "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: volumes:
- "./data/mainnet/public-pool:/public-pool/DB"
- "./public-pool-mainnet.env:/public-pool/.env:ro" - "./public-pool-mainnet.env:/public-pool/.env:ro"
- "../secrets:/public-pool/secrets:ro"
environment: 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: networks:
bitcoin: 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: services:
bitcoin-regtest: bitcoin-regtest:
@@ -8,6 +12,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
stop_grace_period: 30s stop_grace_period: 30s
logging: *log-limits
networks: networks:
- bitcoin-regtest - bitcoin-regtest
ports: ports:
@@ -22,6 +27,52 @@ services:
- "./data/regtest/bitcoin:/app/data" - "./data/regtest/bitcoin:/app/data"
- "./bitcoin-regtest.conf:/app/data/bitcoin.conf:ro" - "./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: public-pool-regtest:
container_name: public-pool-regtest container_name: public-pool-regtest
build: build:
@@ -29,16 +80,36 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
stop_grace_period: 30s stop_grace_period: 30s
logging: *log-limits
depends_on:
timescaledb-regtest:
condition: service_healthy
redis-regtest:
condition: service_healthy
networks: networks:
- bitcoin-regtest - bitcoin-regtest
ports: ports:
- "0.0.0.0:23333:23333/tcp" - "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" - "127.0.0.1:23334:23334/tcp"
volumes: volumes:
- "./data/regtest/public-pool:/public-pool/DB"
- "./public-pool-regtest.env:/public-pool/.env:ro" - "./public-pool-regtest.env:/public-pool/.env:ro"
- "../secrets:/public-pool/secrets:ro"
environment: 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: networks:
bitcoin-regtest: 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: services:
bitcoin-testnet: bitcoin-testnet:
@@ -8,6 +12,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
stop_grace_period: 30s stop_grace_period: 30s
logging: *log-limits
networks: networks:
- bitcoin-testnet - bitcoin-testnet
ports: ports:
@@ -21,6 +26,52 @@ services:
- "./data/testnet/bitcoin:/app/data" - "./data/testnet/bitcoin:/app/data"
- "./bitcoin-testnet.conf:/app/data/bitcoin.conf:ro" - "./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: public-pool-testnet:
container_name: public-pool-testnet container_name: public-pool-testnet
build: build:
@@ -28,16 +79,36 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
stop_grace_period: 30s stop_grace_period: 30s
logging: *log-limits
depends_on:
timescaledb-testnet:
condition: service_healthy
redis-testnet:
condition: service_healthy
networks: networks:
- bitcoin-testnet - bitcoin-testnet
ports: ports:
- "0.0.0.0:13333:13333/tcp" - "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" - "127.0.0.1:13334:13334/tcp"
volumes: volumes:
- "./data/testnet/public-pool:/public-pool/DB"
- "./public-pool-testnet.env:/public-pool/.env:ro" - "./public-pool-testnet.env:/public-pool/.env:ro"
- "../secrets:/public-pool/secrets:ro"
environment: 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: networks:
bitcoin-testnet: bitcoin-testnet:
+14 -4
View File
@@ -4,12 +4,14 @@ BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=8332 BITCOIN_RPC_PORT=8332
BITCOIN_RPC_TIMEOUT=10000 BITCOIN_RPC_TIMEOUT=10000
# Enable in bitcoin.conf with BITCOIN_ZMQ_HOST=tcp://bitcoin:3000
# zmqpubrawblock=tcp://*:3000
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
API_PORT=3334 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 #optional telegram bot
#TELEGRAM_BOT_TOKEN= #TELEGRAM_BOT_TOKEN=
@@ -25,3 +27,11 @@ DEV_FEE_ADDRESS=
NETWORK=mainnet NETWORK=mainnet
API_SECURE=false API_SECURE=false
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
+14 -4
View File
@@ -4,12 +4,14 @@ BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=28332 BITCOIN_RPC_PORT=28332
BITCOIN_RPC_TIMEOUT=10000 BITCOIN_RPC_TIMEOUT=10000
# Enable in bitcoin.conf with BITCOIN_ZMQ_HOST=tcp://bitcoin-regtest:3000
# zmqpubrawblock=tcp://*:3000
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
API_PORT=23334 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 #optional telegram bot
#TELEGRAM_BOT_TOKEN= #TELEGRAM_BOT_TOKEN=
@@ -25,3 +27,11 @@ DEV_FEE_ADDRESS=
NETWORK=regtest NETWORK=regtest
API_SECURE=false API_SECURE=false
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
+15 -5
View File
@@ -4,12 +4,14 @@ BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=18332 BITCOIN_RPC_PORT=18332
BITCOIN_RPC_TIMEOUT=10000 BITCOIN_RPC_TIMEOUT=10000
# Enable in bitcoin.conf with BITCOIN_ZMQ_HOST=tcp://bitcoin-testnet:3000
# zmqpubrawblock=tcp://*:3000
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
API_PORT=13334 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 #optional telegram bot
#TELEGRAM_BOT_TOKEN= #TELEGRAM_BOT_TOKEN=
@@ -24,4 +26,12 @@ DEV_FEE_ADDRESS=
# mainnet | testnet # mainnet | testnet
NETWORK=testnet NETWORK=testnet
API_SECURE=false API_SECURE=false
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
+9866 -74
View File
File diff suppressed because it is too large Load Diff
+21 -2
View File
@@ -16,8 +16,13 @@
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "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", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "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:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
@@ -32,6 +37,7 @@
"@nestjs/platform-fastify": "^11.1.19", "@nestjs/platform-fastify": "^11.1.19",
"@nestjs/schedule": "^6.1.3", "@nestjs/schedule": "^6.1.3",
"@nestjs/typeorm": "^11.0.1", "@nestjs/typeorm": "^11.0.1",
"@scure/btc-signer": "^2.2.0",
"axios": "^1.16.0", "axios": "^1.16.0",
"bitcoin-address-validation": "^2.2.3", "bitcoin-address-validation": "^2.2.3",
"bitcoinjs-lib": "^6.1.7", "bitcoinjs-lib": "^6.1.7",
@@ -40,8 +46,11 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.4", "class-validator": "^0.14.4",
"discord.js": "^14.26.4", "discord.js": "^14.26.4",
"dotenv": "^16.6.1",
"merkle-lib": "^2.0.10", "merkle-lib": "^2.0.10",
"sqlite3": "^5.1.6", "pg": "^8.20.0",
"pm2": "^7.0.1",
"redis": "^5.12.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"tiny-secp256k1": "^2.2.4", "tiny-secp256k1": "^2.2.4",
@@ -87,8 +96,18 @@
"rootDir": "src", "rootDir": "src",
"testRegex": ".*\\.spec\\.ts$", "testRegex": ".*\\.spec\\.ts$",
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": [
"ts-jest",
{
"tsconfig": {
"allowJs": true
}
}
]
}, },
"transformIgnorePatterns": [
"node_modules/(?!(@scure|@noble|micro-packed))"
],
"collectCoverageFrom": [ "collectCoverageFrom": [
"**/*.(t|j)s" "**/*.(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,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,14 +11,14 @@ export class AddressSettingsEntity extends TrackedEntity {
@Column({ default: 0 }) @Column({ default: 0 })
shares: number; shares: number;
@Column({ type: 'real', default: 0 }) @Column({ type: 'decimal', default: 0 })
bestDifficulty: number; bestDifficulty: number;
@Column({ nullable: true })
miscCoinbaseScriptData: string;
@Column({ nullable: true }) @Column({ nullable: true })
bestDifficultyUserAgent: string; bestDifficultyUserAgent: string;
@Column({ nullable: true })
miscCoinbaseScriptData: string;
} }
@@ -49,27 +49,19 @@ export class AddressSettingsService {
.execute(); .execute();
} }
public async getHighScores() {
return await this.addressSettingsRepository.createQueryBuilder()
.select('"updatedAt", "bestDifficulty", "bestDifficultyUserAgent"')
.orderBy('"bestDifficulty"', 'DESC')
.limit(10)
.execute();
}
public async createNew(address: string) { public async createNew(address: string) {
return await this.addressSettingsRepository.save({ address }); return await this.addressSettingsRepository.save({ address });
} }
public async addShares(address: string, shares: number) { // public async addShares(address: string, shares: number) {
return await this.addressSettingsRepository.createQueryBuilder() // return await this.addressSettingsRepository.createQueryBuilder()
.update(AddressSettingsEntity) // .update(AddressSettingsEntity)
.set({ // .set({
shares: () => `"shares" + ${shares}` // Use the actual value of shares here // shares: () => `"shares" + ${shares}` // Use the actual value of shares here
}) // })
.where('address = :address', { address }) // .where('address = :address', { address })
.execute(); // .execute();
} // }
public async resetBestDifficultyAndShares() { public async resetBestDifficultyAndShares() {
return await this.addressSettingsRepository return await this.addressSettingsRepository
@@ -81,4 +73,12 @@ export class AddressSettingsService {
}) })
.execute(); .execute();
} }
}
public async getHighScores() {
return await this.addressSettingsRepository.createQueryBuilder()
.select('"updatedAt", "bestDifficulty", "bestDifficultyUserAgent"')
.orderBy('"bestDifficulty"', 'DESC')
.limit(10)
.execute();
}
}
+4 -3
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'; import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Entity() @Entity()
export class BlocksEntity extends TrackedEntity { export class BlocksEntity extends TrackedEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedBigIntColumn()
id: number; id: number;
@Column() @Column()
@@ -23,4 +24,4 @@ export class BlocksEntity extends TrackedEntity {
@Column() @Column()
blockData: string; blockData: string;
} }
@@ -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 { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClientStatisticsEntity } from './client-statistics.entity';
import { ClientStatisticsService } from './client-statistics.service'; import { ClientStatisticsService } from './client-statistics.service';
@Global() @Global()
@Module({ @Module({
imports: [TypeOrmModule.forFeature([ClientStatisticsEntity])],
providers: [ClientStatisticsService], providers: [ClientStatisticsService],
exports: [TypeOrmModule, ClientStatisticsService], exports: [ClientStatisticsService],
}) })
export class ClientStatisticsModule { } export class ClientStatisticsModule { }
@@ -1,257 +1,104 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { DataSource } from 'typeorm';
import { ClientStatisticsEntity } from './client-statistics.entity';
const HASHES_PER_DIFFICULTY = 4294967296;
const CHART_BUCKET_SECONDS = 600;
const CHART_WINDOW = '24 hours';
const SITE_CHART_WINDOW = '7 days';
@Injectable() @Injectable()
export class ClientStatisticsService { export class ClientStatisticsService {
constructor( constructor(
@InjectDataSource()
private dataSource: DataSource,
@InjectRepository(ClientStatisticsEntity)
private clientStatisticsRepository: Repository<ClientStatisticsEntity>,
) { ) {
} }
public async update(clientStatistic: Partial<ClientStatisticsEntity>) { public async getChartDataForSite(limit: number = 144 * 7) {
return this.getAcceptedShareChartData('', [], limit, SITE_CHART_WINDOW);
await this.clientStatisticsRepository.update({
address: clientStatistic.address,
clientName: clientStatistic.clientName,
sessionId: clientStatistic.sessionId,
time: clientStatistic.time
},
{
shares: clientStatistic.shares,
acceptedCount: clientStatistic.acceptedCount,
updatedAt: new Date()
});
} }
public async insert(clientStatistic: Partial<ClientStatisticsEntity>) {
// If no rows were updated, insert a new record
await this.clientStatisticsRepository.insert(clientStatistic);
}
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) { public async getChartDataForAddress(address: string) {
return this.getAcceptedShareChartData(
var yesterday = new Date(new Date().getTime() - (24 * 60 * 60 * 1000)); 'AND "address" = $1',
[address],
const query = ` 144,
SELECT CHART_WINDOW,
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);
} }
public async getHashRateForGroup(address: string, clientName: string) { public async getHashRateForGroup(address: string, clientName: string) {
const result = await this.dataSource.query(`
var oneHour = new Date(new Date().getTime() - (60 * 60 * 1000));
const query = `
SELECT SELECT
SUM(entry.shares) AS difficultySum COALESCE((SUM("creditedDifficulty") * ${HASHES_PER_DIFFICULTY}) / ${CHART_BUCKET_SECONDS}, 0) AS "hashRate"
FROM FROM "accepted_share_entity"
client_statistics_entity AS entry WHERE "address" = $1
WHERE AND "clientName" = $2
entry.address = ? AND entry.clientName = ? AND entry.time > ${oneHour.getTime()} AND "acceptedAt" > NOW() - INTERVAL '1 hour'
`; `, [address, clientName]);
const result = await this.clientStatisticsRepository.query(query, [address, clientName]);
const difficultySum = result[0].difficultySum;
return (difficultySum * 4294967296) / (600);
return parseFloat(result[0]?.hashRate ?? '0');
} }
public async getChartDataForGroup(address: string, clientName: string) { 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 = ` const query = `
WITH bounds AS (
SELECT
NOW() - INTERVAL '${windowSql}' AS since,
time_bucket(INTERVAL '10 minutes', NOW()) AS current_bucket
)
SELECT SELECT
time label, "label",
(SUM(shares) * 4294967296) / 600 AS data "data",
FROM "shares",
client_statistics_entity AS entry "acceptedCount"
WHERE FROM (
entry.address = ? AND entry.clientName = ? AND entry.time > ${yesterday.getTime()} SELECT
GROUP BY "bucket" AS "label",
time ROUND((SUM("shares") * ${HASHES_PER_DIFFICULTY}) / ${CHART_BUCKET_SECONDS}) AS "data",
ORDER BY SUM("shares") AS "shares",
time SUM("acceptedCount") AS "acceptedCount"
LIMIT 144; 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 => { return result.map(res => {
res.label = new Date(res.label).toISOString(); return {
return res; label: new Date(res.label).toISOString(),
}).slice(0, result.length - 1); data: res.data,
shares: Number(res.shares ?? 0),
acceptedCount: Number(res.acceptedCount ?? 0),
} };
});
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();
// 1min
if (time < 1000 * 60) {
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();
// 1min
if (time < 1000 * 60) {
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.clear()
} }
} }
+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'; 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 @Entity()
// or composite (multi-column) PRIMARY KEYs and that do not store large strings or BLOBs. @Index("IDX_unique_nonce", { synchronize: false })
//WITHOUT ROWID tables work best when individual rows are not too large. @Index('idx_client_cleanup', ['id'], {
@Entity({ withoutRowid: true }) where: '"deletedAt" IS NULL',
@Index(['address', 'clientName', 'sessionId'], { unique: true }) // This is a partial (filtered) index — only indexes active clients
})
export class ClientEntity extends TrackedEntity { export class ClientEntity extends TrackedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@PrimaryColumn({ length: 62, type: 'varchar' }) @Index()
@Column({ length: 62, type: 'varchar' })
address: string; address: string;
@PrimaryColumn({ length: 64, type: 'varchar' }) @Column({ length: 64, type: 'varchar' })
clientName: string; clientName: string;
@PrimaryColumn({ length: 8, type: 'varchar' }) @Column({ length: 8, type: 'varchar', })
sessionId: string; sessionId: string;
@@ -27,15 +29,13 @@ export class ClientEntity extends TrackedEntity {
userAgent: string; userAgent: string;
@Column()
@Column({ type: 'datetime', transformer: new DateTimeTransformer() })
startTime: Date; startTime: Date;
@Column({ type: 'real', default: 0 }) @Column({ type: 'decimal', default: 0 })
bestDifficulty: number bestDifficulty: number
@Column({ default: 0 }) @Column({ default: 0, type: 'decimal' })
hashRate: number; hashRate: number;
} }
+23 -77
View File
@@ -1,8 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { firstValueFrom, Subject } from 'rxjs'; import { Repository } from 'typeorm';
import { ObjectLiteral, Repository } from 'typeorm';
import { ClientEntity } from './client.entity'; import { ClientEntity } from './client.entity';
@@ -11,10 +9,6 @@ import { ClientEntity } from './client.entity';
@Injectable() @Injectable()
export class ClientService { export class ClientService {
public insertQueue: { result: Subject<ObjectLiteral>, partialClient: Partial<ClientEntity> }[] = [];
constructor( constructor(
@InjectRepository(ClientEntity) @InjectRepository(ClientEntity)
private clientRepository: Repository<ClientEntity> private clientRepository: Repository<ClientEntity>
@@ -22,69 +16,19 @@ export class ClientService {
} }
@Interval(1000 * 5)
public async insertClients() {
const queueCopy = [...this.insertQueue];
this.insertQueue = [];
if (queueCopy.length === 0) {
return;
}
try {
const results = await this.clientRepository.insert(queueCopy.map(c => c.partialClient));
queueCopy.forEach((c, index) => {
c.result.next(results.generatedMaps[index]);
c.result.complete();
});
} catch (e) {
queueCopy.forEach(c => c.result.error(e));
throw e;
}
}
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> { public async insert(partialClient: Partial<ClientEntity>): Promise<ClientEntity> {
const insertResult = await this.clientRepository.insert(partialClient);
const result = new Subject<ObjectLiteral>();
this.insertQueue.push({ result, partialClient });
// const insertResult = await this.clientRepository.insert(partialClient);
const generatedMap = await firstValueFrom(result);
const client = { const client = {
...partialClient, ...partialClient,
...generatedMap ...insertResult.generatedMaps[0]
}; };
return client as ClientEntity; return client as ClientEntity;
} }
public async delete(sessionId: string) { public async delete(id: string) {
return await this.clientRepository.softDelete({ sessionId }); return await this.clientRepository.softDelete({ id });
} }
public async deleteOldClients() { public async deleteOldClients() {
@@ -100,16 +44,16 @@ export class ClientService {
} }
public async updateBestDifficulty(sessionId: string, bestDifficulty: number) { public async updateBestDifficulty(id: string, bestDifficulty: number) {
return await this.clientRepository.update({ sessionId }, { bestDifficulty }); return await this.clientRepository.update({ id }, { bestDifficulty });
} }
public async updateBestDifficultyIfHigher(sessionId: string, bestDifficulty: number) { public async updateBestDifficultyIfHigher(id: string, bestDifficulty: number) {
return await this.clientRepository return await this.clientRepository
.createQueryBuilder() .createQueryBuilder()
.update(ClientEntity) .update(ClientEntity)
.set({ bestDifficulty }) .set({ bestDifficulty })
.where('sessionId = :sessionId', { sessionId }) .where('id = :id', { id })
.andWhere('"bestDifficulty" < :bestDifficulty', { bestDifficulty }) .andWhere('"bestDifficulty" < :bestDifficulty', { bestDifficulty })
.execute(); .execute();
} }
@@ -149,20 +93,22 @@ export class ClientService {
public async deleteAll() { public async deleteAll() {
return await this.clientRepository return await this.clientRepository
.createQueryBuilder() .createQueryBuilder()
.softDelete() .delete()
.from(ClientEntity)
.where('"deletedAt" IS NULL')
.execute(); .execute();
} }
public async getUserAgents() { // public async getUserAgents() {
const result = await this.clientRepository.createQueryBuilder('client') // const result = await this.clientRepository.createQueryBuilder('client')
.select('client.userAgent as userAgent') // .select('client.userAgent as "userAgent"')
.addSelect('COUNT(client.userAgent)', 'count') // .addSelect('COUNT(client.userAgent)', 'count')
.addSelect('MAX(client.bestDifficulty)', 'bestDifficulty') // .addSelect('MAX(client.bestDifficulty)', 'bestDifficulty')
.addSelect('SUM(client.hashRate)', 'totalHashRate') // .addSelect('SUM(client.hashRate)', 'totalHashRate')
.groupBy('client.userAgent') // .groupBy('client.userAgent')
.orderBy('count', 'DESC') // .orderBy('"totalHashRate"', 'DESC')
.getRawMany(); // .getRawMany();
return result; // return result;
} // }
} }
@@ -1,30 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Entity()
@Index(['address', 'time'])
export class ExternalSharesEntity extends TrackedEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 62, type: 'varchar' })
address: string;
@Column()
clientName: string;
@Column({ type: 'integer' })
time: number;
@Column({ type: 'real' })
difficulty: number;
@Column({ length: 128, type: 'varchar', nullable: true })
userAgent: string;
@Column({ length: 128, type: 'varchar', nullable: true })
externalPoolName: string;
@Column()
header: string;
}
@@ -1,13 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExternalSharesEntity } from './external-shares.entity';
import { ExternalSharesService } from './external-shares.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([ExternalSharesEntity])],
providers: [ExternalSharesService],
exports: [TypeOrmModule, ExternalSharesService],
})
export class ExternalSharesModule { }
@@ -1,52 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ExternalSharesEntity } from './external-shares.entity';
@Injectable()
export class ExternalSharesService {
constructor(
@InjectRepository(ExternalSharesEntity)
private externalSharesRepository: Repository<ExternalSharesEntity>
) {}
public async insert(externalShare: Partial<ExternalSharesEntity>) {
return await this.externalSharesRepository.insert(externalShare);
}
public async getTopDifficulties(): Promise<Array<{userAgent: string, time: number, externalPoolName: string, difficulty: number}>> {
return await this.externalSharesRepository
.createQueryBuilder('share')
.select('share.userAgent', 'userAgent')
.addSelect('share.time', 'time')
.addSelect('share.externalPoolName', 'externalPoolName')
.addSelect('MAX(share.difficulty)', 'difficulty')
.groupBy('share.address')
.orderBy('MAX(share.difficulty)', 'DESC')
.limit(10)
.getRawMany();
}
public async getAddressBestDifficulty(address: string): Promise<number> {
const result = await this.externalSharesRepository
.createQueryBuilder()
.select('MAX(difficulty)', 'maxDifficulty')
.where('address = :address', { address })
.getRawOne();
return result?.maxDifficulty || 0;
}
public async deleteOldShares() {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
return await this.externalSharesRepository
.createQueryBuilder()
.delete()
.from(ExternalSharesEntity)
.where('time < :time', { time: oneDayAgo.getTime() })
.execute();
}
public async deleteAll() {
return await this.externalSharesRepository.delete({});
}
}
-3
View File
@@ -6,9 +6,6 @@ export class RpcBlockEntity {
@PrimaryColumn() @PrimaryColumn()
blockHeight: number; blockHeight: number;
@Column({ nullable: true })
lockedBy?: string;
@Column({ nullable: true }) @Column({ nullable: true })
data?: string; data?: string;
} }
+4 -8
View File
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { InsertResult, Repository } from 'typeorm';
import { RpcBlockEntity } from './rpc-block.entity'; import { RpcBlockEntity } from './rpc-block.entity';
@@ -12,18 +12,14 @@ export class RpcBlockService {
) { ) {
} }
public getBlock(blockHeight: number) { public getSavedBlockTemplate(blockHeight: number) {
return this.rpcBlockRepository.findOne({ return this.rpcBlockRepository.findOne({
where: { blockHeight } where: { blockHeight }
}); });
} }
public lockBlock(blockHeight: number, process: string) { public saveBlock(blockHeight: number, data: string): Promise<InsertResult> {
return this.rpcBlockRepository.save({ blockHeight, data: null, lockedBy: process }); return this.rpcBlockRepository.upsert({ blockHeight, data }, ['blockHeight']);
}
public saveBlock(blockHeight: number, data: string) {
return this.rpcBlockRepository.update(blockHeight, { data })
} }
public async deleteOldBlocks() { public async deleteOldBlocks() {
@@ -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,360 @@
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 overlay live pool data and best share from the current round', async () => {
const redis = {
getJsonCache: jest.fn().mockResolvedValue(null),
setJsonCache: jest.fn().mockResolvedValue(undefined),
};
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '3',
totalCreditedDifficulty: '96',
acceptedSharesLast10Minutes: '0',
creditedDifficultyLast10Minutes: '0',
acceptedSharesLastHour: '3',
creditedDifficultyLastHour: '96',
acceptedSharesLastDay: '3',
creditedDifficultyLastDay: '96',
hashRateLast10Minutes: '0',
hashRateLastHour: '114532461.2',
latestShareAt: new Date('2026-06-07T12:10:00Z'),
}])
.mockResolvedValueOnce([{
acceptedSharesLast10Minutes: '7',
creditedDifficultyLast10Minutes: '224',
hashRateLast10Minutes: '1603451170.77',
latestShareAt: new Date('2026-06-07T12:20:00Z'),
}])
.mockResolvedValueOnce([{
bestSubmissionDifficulty: '4096',
bestSubmissionDifficultyAt: new Date('2026-06-07T12:19:00Z'),
}])
.mockResolvedValueOnce([{
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: 7,
creditedDifficultyLast10Minutes: 224,
hashRateLast10Minutes: 1603451170.77,
bestSubmissionDifficulty: 4096,
bestSubmissionDifficultyAt: '2026-06-07T12:19:00.000Z',
workSinceLastBlock: 352,
currentRoundAcceptedShares: 11,
currentRoundNetworkDifficulty: 1000,
networkDifficultyPercent: 35.2,
latestShareAt: '2026-06-07T12:20:00.000Z',
}));
expect(repository.query).toHaveBeenNthCalledWith(
3,
expect.stringContaining('WHERE "blockHeight" > latest_found_block."height"'),
);
expect(repository.query).toHaveBeenNthCalledWith(
4,
expect.stringContaining('FROM "accepted_share_block_10m"'),
);
});
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,530 @@
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.withPoolLiveOverlay(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 withPoolLiveOverlay(summary: ShareAccountingSummary): Promise<ShareAccountingSummary> {
if (process.env.API_ONLY === 'true') {
return summary;
}
const [[liveWindow], [bestDifficultyRow], [currentRoundRow]] = await Promise.all([
this.acceptedShareRepository.query(`
SELECT
COUNT(*)::int AS "acceptedSharesLast10Minutes",
COALESCE(SUM("creditedDifficulty"), 0)::float AS "creditedDifficultyLast10Minutes",
COALESCE((SUM("creditedDifficulty") * ${HASHES_PER_DIFFICULTY}) / 600, 0)::float AS "hashRateLast10Minutes",
MAX("acceptedAt") AS "latestShareAt"
FROM "accepted_share_entity"
WHERE "acceptedAt" > NOW() - INTERVAL '10 minutes'
`),
this.acceptedShareRepository.query(`
WITH latest_found_block AS (
SELECT COALESCE(MAX("height"), 0) AS "height"
FROM "blocks_entity"
)
SELECT
COALESCE("submissionDifficulty", 0)::float AS "bestSubmissionDifficulty",
"acceptedAt" AS "bestSubmissionDifficultyAt"
FROM "accepted_share_entity", latest_found_block
WHERE "blockHeight" > latest_found_block."height"
ORDER BY "submissionDifficulty" DESC, "acceptedAt" DESC
LIMIT 1
`),
this.acceptedShareRepository.query(`
WITH latest_found_block AS (
SELECT COALESCE(MAX("height"), 0) AS "height"
FROM "blocks_entity"
)
SELECT
COALESCE(SUM("acceptedCount"), 0)::int AS "currentRoundAcceptedShares",
COALESCE(SUM("shares"), 0)::float AS "workSinceLastBlock",
COALESCE(MAX("networkDifficulty"), 0)::float AS "currentRoundNetworkDifficulty"
FROM "accepted_share_block_10m", latest_found_block
WHERE "blockHeight" > latest_found_block."height"
`),
]);
const currentRoundNetworkDifficulty = this.toNumber(currentRoundRow?.currentRoundNetworkDifficulty);
const workSinceLastBlock = this.toNumber(currentRoundRow?.workSinceLastBlock);
return {
...summary,
acceptedSharesLast10Minutes: this.toNumber(liveWindow?.acceptedSharesLast10Minutes),
creditedDifficultyLast10Minutes: this.toNumber(liveWindow?.creditedDifficultyLast10Minutes),
hashRateLast10Minutes: this.toNumber(liveWindow?.hashRateLast10Minutes),
bestSubmissionDifficulty: this.toNumber(bestDifficultyRow?.bestSubmissionDifficulty),
bestSubmissionDifficultyAt: bestDifficultyRow?.bestSubmissionDifficultyAt == null
? null
: new Date(bestDifficultyRow.bestSubmissionDifficultyAt).toISOString(),
workSinceLastBlock,
currentRoundAcceptedShares: this.toNumber(currentRoundRow?.currentRoundAcceptedShares),
currentRoundNetworkDifficulty,
networkDifficultyPercent: currentRoundNetworkDifficulty > 0
? this.roundPercent((workSinceLastBlock / currentRoundNetworkDifficulty) * 100)
: 0,
latestShareAt: liveWindow?.latestShareAt == null
? summary.latestShareAt
: new Date(liveWindow.latestShareAt).toISOString(),
};
}
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 bounds AS (
SELECT
time_bucket(INTERVAL '10 minutes', NOW()) AS "currentBucket",
time_bucket(INTERVAL '10 minutes', NOW()) - INTERVAL '10 minutes' AS "lastCompletedBucket"
),
filtered_rows AS (
SELECT "accepted_share_10m".*, bounds."lastCompletedBucket"
FROM "accepted_share_10m", bounds
WHERE "clientId" = ANY($1::uuid[])
)
SELECT
"clientId",
MAX("bucket") AS "latestShareAt",
COALESCE((SUM("shares") FILTER (WHERE "bucket" = "lastCompletedBucket") * ${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 bounds AS (
SELECT
time_bucket(INTERVAL '10 minutes', NOW()) AS "currentBucket",
time_bucket(INTERVAL '10 minutes', NOW()) - INTERVAL '10 minutes' AS "lastCompletedBucket"
),
filtered_rows AS (
SELECT "accepted_share_10m".*, bounds."currentBucket", bounds."lastCompletedBucket"
FROM "accepted_share_10m", bounds
${whereSql}
)
SELECT
COALESCE(SUM("acceptedCount"), 0)::int AS "totalAcceptedShares",
COALESCE(SUM("shares"), 0)::float AS "totalCreditedDifficulty",
COALESCE(SUM("acceptedCount") FILTER (WHERE "bucket" = "lastCompletedBucket"), 0)::int AS "acceptedSharesLast10Minutes",
COALESCE(SUM("shares") FILTER (WHERE "bucket" = "lastCompletedBucket"), 0)::float AS "creditedDifficultyLast10Minutes",
COALESCE(SUM("acceptedCount") FILTER (WHERE "bucket" >= "currentBucket" - INTERVAL '1 hour' AND "bucket" < "currentBucket"), 0)::int AS "acceptedSharesLastHour",
COALESCE(SUM("shares") FILTER (WHERE "bucket" >= "currentBucket" - INTERVAL '1 hour' AND "bucket" < "currentBucket"), 0)::float AS "creditedDifficultyLastHour",
COALESCE(SUM("acceptedCount") FILTER (WHERE "bucket" >= "currentBucket" - INTERVAL '1 day' AND "bucket" < "currentBucket"), 0)::int AS "acceptedSharesLastDay",
COALESCE(SUM("shares") FILTER (WHERE "bucket" >= "currentBucket" - INTERVAL '1 day' AND "bucket" < "currentBucket"), 0)::float AS "creditedDifficultyLastDay",
COALESCE((SUM("shares") FILTER (WHERE "bucket" = "lastCompletedBucket") * ${HASHES_PER_DIFFICULTY}) / ${ROLLUP_BUCKET_SECONDS}, 0)::float AS "hashRateLast10Minutes",
COALESCE((SUM("shares") FILTER (WHERE "bucket" >= "currentBucket" - INTERVAL '1 hour' AND "bucket" < "currentBucket") * ${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'; import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Entity() @Entity()
export class TelegramSubscriptionsEntity extends TrackedEntity { export class TelegramSubscriptionsEntity extends TrackedEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedBigIntColumn()
id: number; id: number;
@Index() @Index()
@@ -16,4 +17,4 @@ export class TelegramSubscriptionsEntity extends TrackedEntity {
telegramChatId: number; telegramChatId: number;
} }
@@ -0,0 +1,5 @@
import { PrimaryGeneratedColumn } from 'typeorm';
export const PrimaryGeneratedBigIntColumn = () => PrimaryGeneratedColumn({
type: process.env.NODE_ENV === 'test' ? 'integer' : 'bigint'
});
+4 -6
View File
@@ -1,14 +1,12 @@
import { CreateDateColumn, DeleteDateColumn, UpdateDateColumn } from 'typeorm'; import { CreateDateColumn, DeleteDateColumn, UpdateDateColumn } from 'typeorm';
import { DateTimeTransformer } from './DateTimeTransformer';
export abstract class TrackedEntity { export abstract class TrackedEntity {
@DeleteDateColumn({ nullable: true, type: 'datetime', transformer: new DateTimeTransformer() }) @DeleteDateColumn({ nullable: true })
public deletedAt?: Date; public deletedAt?: Date;
@CreateDateColumn({ type: 'datetime', transformer: new DateTimeTransformer() }) @CreateDateColumn()
public createdAt?: Date public createdAt?: Date
@UpdateDateColumn({ type: 'datetime', transformer: new DateTimeTransformer() }) @UpdateDateColumn()
public updatedAt?: Date public updatedAt?: Date
} }
+188 -22
View File
@@ -3,16 +3,22 @@ import { Controller, Get, Inject } from '@nestjs/common';
import { Cache } from 'cache-manager'; import { Cache } from 'cache-manager';
import { firstValueFrom } from 'rxjs'; 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 { AddressSettingsService } from './ORM/address-settings/address-settings.service';
import { BlocksService } from './ORM/blocks/blocks.service'; import { BlocksService } from './ORM/blocks/blocks.service';
import { ClientStatisticsService } from './ORM/client-statistics/client-statistics.service'; import { ClientStatisticsService } from './ORM/client-statistics/client-statistics.service';
import { ClientService } from './ORM/client/client.service'; import { ClientService } from './ORM/client/client.service';
import { BitcoinRpcService } from './services/bitcoin-rpc.service'; import { BitcoinRpcService } from './services/bitcoin-rpc.service';
import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
import { StratumV2Service } from './services/stratum-v2.service';
import { ShareAccountingService } from './ORM/share-accounting/share-accounting.service';
import { RedisMessagingService } from './services/redis-messaging.service';
@Controller() @Controller()
export class AppController { export class AppController {
private uptime = new Date(); private uptime = new Date();
private siteInfoRefreshPromise: Promise<SiteInfoResponse> | null = null;
constructor( constructor(
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache, @Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
@@ -21,53 +27,139 @@ export class AppController {
private readonly blocksService: BlocksService, private readonly blocksService: BlocksService,
private readonly bitcoinRpcService: BitcoinRpcService, private readonly bitcoinRpcService: BitcoinRpcService,
private readonly addressSettingsService: AddressSettingsService, private readonly addressSettingsService: AddressSettingsService,
private readonly userAgentReportService: UserAgentReportService,
private readonly stratumV2Service: StratumV2Service,
private readonly shareAccountingService: ShareAccountingService,
private readonly redisMessagingService: RedisMessagingService
) { } ) { }
@Get('info') @Get('info')
public async info() { public async info() {
const CACHE_KEY = 'SITE_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) { if (cachedResult != null) {
return cachedResult; 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(); return await this.refreshSiteInfo(null);
const userAgents = await this.clientService.getUserAgents();
const highScores = await this.addressSettingsService.getHighScores();
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, blockData,
userAgents, userAgents,
highScores, highScores,
sv2: {
poolAuthorityPublicKey: poolAuthority.publicKey,
authorityKeyConfigured: poolAuthority.configured
},
uptime: this.uptime uptime: this.uptime
}; };
//1 min // Cache a complete response even when one slow component falls back to stale data.
await this.cacheManager.set(CACHE_KEY, data, 1 * 60 * 1000); // 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; 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') @Get('pool')
public async pool() { public async pool() {
const CACHE_KEY = 'POOL_INFO'; 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) { if (cachedResult != null) {
return cachedResult; 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 totalHashRate = userAgents.reduce((acc, userAgent) => acc + parseFloat(userAgent.totalHashRate), 0);
const totalMiners = userAgents.reduce((acc, userAgent) => acc + parseInt(userAgent.count), 0); const totalMiners = userAgents.reduce((acc, userAgent) => acc + parseFloat(userAgent.count), 0);
const blockHeight = (await firstValueFrom(this.bitcoinRpcService.newBlock$)).blocks; const blockHeight = this.bitcoinRpcService.miningInfo.blocks;
const blocksFound = await this.blocksService.getFoundBlocks(); const blocksFound = await this.blocksService.getFoundBlocks();
const data = { const data = {
@@ -78,24 +170,21 @@ export class AppController {
fee: 0 fee: 0
} }
//5 min // Keep online miner counts responsive after reconnect cleanup.
await this.cacheManager.set(CACHE_KEY, data, 5 * 60 * 1000); await this.setCached(CACHE_KEY, data, 15 * 1000);
return data; return data;
} }
@Get('network') @Get('network')
public async network() { public async network() {
const miningInfo = await firstValueFrom(this.bitcoinRpcService.newBlock$); return this.bitcoinRpcService.miningInfo ?? {};
return miningInfo;
} }
@Get('info/chart') @Get('info/chart')
public async infoChart() { public async infoChart() {
const CACHE_KEY = 'SITE_HASHRATE_GRAPH'; 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) { if (cachedResult != null) {
return cachedResult; return cachedResult;
@@ -104,11 +193,88 @@ export class AppController {
const chartData = await this.clientStatisticsService.getChartDataForSite(); const chartData = await this.clientStatisticsService.getChartDataForSite();
//10 min //10 min
await this.cacheManager.set(CACHE_KEY, chartData, 10 * 60 * 1000); await this.setCached(CACHE_KEY, chartData, 10 * 60 * 1000);
return chartData; 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;
} }
+28 -18
View File
@@ -1,7 +1,7 @@
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { CacheModule } from '@nestjs/cache-manager'; import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
@@ -9,24 +9,27 @@ import { AppController } from './app.controller';
import { AddressController } from './controllers/address/address.controller'; import { AddressController } from './controllers/address/address.controller';
import { ClientController } from './controllers/client/client.controller'; import { ClientController } from './controllers/client/client.controller';
import { BitcoinAddressValidator } from './models/validators/bitcoin-address.validator'; 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 { AddressSettingsModule } from './ORM/address-settings/address-settings.module';
import { BlocksModule } from './ORM/blocks/blocks.module'; import { BlocksModule } from './ORM/blocks/blocks.module';
import { ClientStatisticsModule } from './ORM/client-statistics/client-statistics.module'; import { ClientStatisticsModule } from './ORM/client-statistics/client-statistics.module';
import { ClientModule } from './ORM/client/client.module'; import { ClientModule } from './ORM/client/client.module';
import { RpcBlocksModule } from './ORM/rpc-block/rpc-block.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 { TelegramSubscriptionsModule } from './ORM/telegram-subscriptions/telegram-subscriptions.module';
import { createDatabaseOptions } from './database.config';
import { AppService } from './services/app.service'; import { AppService } from './services/app.service';
import { BitcoinRpcService } from './services/bitcoin-rpc.service'; import { BitcoinRpcService } from './services/bitcoin-rpc.service';
import { BraiinsService } from './services/braiins.service'; import { BraiinsService } from './services/braiins.service';
import { BTCPayService } from './services/btc-pay.service'; import { BTCPayService } from './services/btc-pay.service';
import { DiscordService } from './services/discord.service'; import { DiscordService } from './services/discord.service';
import { NotificationService } from './services/notification.service'; import { NotificationService } from './services/notification.service';
import { RedisMessagingModule } from './services/redis-messaging.module';
import { StratumV1JobsService } from './services/stratum-v1-jobs.service'; import { StratumV1JobsService } from './services/stratum-v1-jobs.service';
import { StratumV1Service } from './services/stratum-v1.service'; import { StratumV1Service } from './services/stratum-v1.service';
import { StratumV2Service } from './services/stratum-v2.service';
import { TelegramService } from './services/telegram.service'; import { TelegramService } from './services/telegram.service';
import { ExternalSharesService } from './services/external-shares.service';
import { ExternalShareController } from './controllers/external-share/external-share.controller';
import { ExternalSharesModule } from './ORM/external-shares/external-shares.module';
const ORMModules = [ const ORMModules = [
ClientStatisticsModule, ClientStatisticsModule,
@@ -35,32 +38,39 @@ const ORMModules = [
TelegramSubscriptionsModule, TelegramSubscriptionsModule,
BlocksModule, BlocksModule,
RpcBlocksModule, RpcBlocksModule,
ExternalSharesModule UserAgentReportModule,
ShareAccountingModule
] ]
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot(), ConfigModule.forRoot(),
TypeOrmModule.forRoot({ TypeOrmModule.forRootAsync({
type: 'sqlite', useFactory: (configService: ConfigService) => {
database: './DB/public-pool.sqlite', return createDatabaseOptions({
synchronize: true, ...process.env,
autoLoadEntities: true, DB_HOST: configService.get('DB_HOST'),
logging: false, DB_PORT: configService.get('DB_PORT'),
enableWAL: true, DB_USERNAME: configService.get('DB_USERNAME'),
busyTimeout: 30 * 1000, 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(), CacheModule.register(),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
HttpModule, HttpModule,
RedisMessagingModule,
...ORMModules ...ORMModules
], ],
controllers: [ controllers: [
AppController, AppController,
ClientController, ClientController,
AddressController, AddressController
ExternalShareController
], ],
providers: [ providers: [
DiscordService, DiscordService,
@@ -71,9 +81,9 @@ const ORMModules = [
NotificationService, NotificationService,
BitcoinAddressValidator, BitcoinAddressValidator,
StratumV1JobsService, StratumV1JobsService,
StratumV2Service,
BTCPayService, BTCPayService,
BraiinsService, BraiinsService
ExternalSharesService,
], ],
}) })
export class AppModule { export class AppModule {
@@ -1,37 +1,103 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AddressSettingsModule } from '../../ORM/address-settings/address-settings.module'; import { AddressSettingsService } from '../../ORM/address-settings/address-settings.service';
import { ClientStatisticsModule } from '../../ORM/client-statistics/client-statistics.module'; import { ClientStatisticsService } from '../../ORM/client-statistics/client-statistics.service';
import { ClientModule } from '../../ORM/client/client.module'; 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'; import { ClientController } from './client.controller';
describe('ClientController', () => { describe('ClientController', () => {
let controller: 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 () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [ controllers: [ClientController],
TypeOrmModule.forRoot({ providers: [
type: 'sqlite', {
database: ':memory:', provide: ClientService,
synchronize: true, useValue: {
autoLoadEntities: true, getByAddress: jest.fn(),
cache: true, getByName: jest.fn(),
logging: false getBySessionId: jest.fn(),
}), },
AddressSettingsModule, },
ClientModule, {
ClientStatisticsModule provide: ClientStatisticsService,
], useValue: {
controllers: [ClientController], 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); controller = module.get<ClientController>(ClientController);
addressSettingsService = module.get(AddressSettingsService);
shareAccountingService = module.get(ShareAccountingService);
redisMessagingService = module.get(RedisMessagingService);
}); });
it('should be defined', () => { it('should be defined', () => {
expect(controller).toBeDefined(); 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 { AddressSettingsService } from '../../ORM/address-settings/address-settings.service';
import { ClientStatisticsService } from '../../ORM/client-statistics/client-statistics.service'; import { ClientStatisticsService } from '../../ORM/client-statistics/client-statistics.service';
import { ClientService } from '../../ORM/client/client.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') @Controller('client')
@@ -11,45 +13,63 @@ export class ClientController {
constructor( constructor(
private readonly clientService: ClientService, private readonly clientService: ClientService,
private readonly clientStatisticsService: ClientStatisticsService, private readonly clientStatisticsService: ClientStatisticsService,
private readonly addressSettingsService: AddressSettingsService private readonly addressSettingsService: AddressSettingsService,
private readonly shareAccountingService: ShareAccountingService,
private readonly redisMessagingService: RedisMessagingService
) { } ) { }
@Get(':address') @Get(':address')
async getClientInfo(@Param('address') address: string) { 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); const response = {
bestDifficulty,
return {
bestDifficulty: addressSettings?.bestDifficulty,
workersCount: workers.length, workersCount: workers.length,
accounting,
workers: await Promise.all( workers: await Promise.all(
workers.map(async (worker) => { workers.map(async (worker) => {
const sessionSummary = sessionSummaries.get(worker.clientId);
const bestDifficulty = Math.max(
Number(worker.bestDifficulty ?? 0),
Number(sessionSummary?.bestSubmissionDifficulty ?? 0),
);
return { return {
sessionId: worker.sessionId, sessionId: worker.sessionId,
name: worker.clientName, name: worker.clientName,
bestDifficulty: worker.bestDifficulty.toFixed(2), bestDifficulty: bestDifficulty.toFixed(2),
hashRate: worker.hashRate, hashRate: sessionSummary?.hashRateLast10Minutes ?? worker.hashRate,
startTime: worker.startTime, startTime: worker.startTime,
lastSeen: worker.updatedAt lastSeen: sessionSummary?.latestShareAt ?? worker.lastSeen
}; };
}) })
) )
} }
return response;
} }
@Get(':address/chart') @Get(':address/chart')
async getClientInfoChart(@Param('address') address: string) { async getClientInfoChart(@Param('address') address: string) {
const chartData = await this.clientStatisticsService.getChartDataForAddress(address); return await this.clientStatisticsService.getChartDataForAddress(address);
return chartData;
} }
@Get(':address/:workerName') @Get(':address/:workerName')
async getWorkerGroupInfo(@Param('address') address: string, @Param('workerName') workerName: string) { async getWorkerGroupInfo(@Param('address') address: string, @Param('workerName') workerName: string) {
const addressWorkers = await this.redisMessagingService.getClientPresenceByAddress(address);
const workers = await this.clientService.getByName(address, workerName); const workers = addressWorkers
.filter(worker => worker.clientName === workerName);
const bestDifficulty = workers.reduce((pre, cur, idx, arr) => { const bestDifficulty = workers.reduce((pre, cur, idx, arr) => {
if (cur.bestDifficulty > pre) { if (cur.bestDifficulty > pre) {
@@ -59,30 +79,69 @@ export class ClientController {
}, 0); }, 0);
const chartData = await this.clientStatisticsService.getChartDataForGroup(address, workerName); const chartData = await this.clientStatisticsService.getChartDataForGroup(address, workerName);
return { const accounting = this.withBestSubmissionDifficulty(
await this.shareAccountingService.getWorkerGroupSummary(address, workerName),
bestDifficulty,
);
const response = {
name: workerName, name: workerName,
bestDifficulty: Math.floor(bestDifficulty), bestDifficulty: Math.floor(bestDifficulty),
accounting,
chartData: chartData, chartData: chartData,
} }
return response;
} }
@Get(':address/:workerName/:sessionId') @Get(':address/:workerName/:sessionId')
async getWorkerInfo(@Param('address') address: string, @Param('workerName') workerName: string, @Param('sessionId') sessionId: string) { async getWorkerInfo(@Param('address') address: string, @Param('workerName') workerName: string, @Param('sessionId') sessionId: string) {
const addressWorkers = await this.redisMessagingService.getClientPresenceByAddress(address);
const worker = await this.clientService.getBySessionId(address, workerName, sessionId); 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) { if (worker == null) {
return new NotFoundException(); 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, sessionId: worker.sessionId,
name: worker.clientName, name: worker.clientName,
bestDifficulty: Math.floor(worker.bestDifficulty), bestDifficulty: Math.floor(worker.bestDifficulty),
accounting,
chartData: chartData, chartData: chartData,
startTime: worker.startTime 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,
};
} }
} }
@@ -1,72 +0,0 @@
import { Body, Controller, Post, Get, UnauthorizedException, Headers } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ExternalPoolShare } from '../../models/ExternalPoolShare';
import { ExternalSharesService } from '../../ORM/external-shares/external-shares.service';
import { DifficultyUtils } from '../../utils/difficulty.utils';
import * as bitcoinjs from 'bitcoinjs-lib';
@Controller('share')
export class ExternalShareController {
private readonly apiKey: string;
private readonly minimumDifficulty: number;
constructor(
private readonly configService: ConfigService,
private readonly externalSharesService: ExternalSharesService,
) {
this.apiKey = this.configService.get('SHARE_SUBMISSION_API_KEY');
this.minimumDifficulty = this.configService.get('MINIMUM_DIFFICULTY') || 1000000000000; // 1T
}
@Get('top-difficulties')
async getTopDifficulties() {
const topDifficulties = await this.externalSharesService.getTopDifficulties();
return topDifficulties;
}
@Post()
async submitExternalShare(
@Body() externalShare: ExternalPoolShare,
@Headers('x-api-key') apiKey: string,
) {
// Only validate API key if one is configured
if (this.apiKey && apiKey !== this.apiKey) {
throw new UnauthorizedException('Invalid API key');
}
// Validate the header hash matches claimed difficulty
const headerBuffer = Buffer.from(externalShare.header, 'hex');
const { submissionDifficulty: difficulty } = DifficultyUtils.calculateDifficulty(headerBuffer);
// Verify the calculated difficulty matches or exceeds minimum difficulty
if (difficulty < this.minimumDifficulty) {
throw new UnauthorizedException('Share difficulty too low');
}
const block = bitcoinjs.Block.fromBuffer(headerBuffer);
const tenMinutesAgo = Math.floor(Date.now() / 1000) - (10 * 60);
if (block.timestamp < tenMinutesAgo) {
throw new UnauthorizedException('Share timestamp too old - must be within last 10 minutes');
}
// Store share submission
await this.externalSharesService.insert({
address: externalShare.address,
clientName: externalShare.worker,
time: new Date().getTime(),
difficulty: difficulty,
userAgent: externalShare.userAgent,
externalPoolName: externalShare.externalPoolName,
header: externalShare.header
});
console.log(`Accepted external share. ${difficulty}`);
return {
success: true,
calculatedDifficulty: difficulty,
};
}
}
+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',
};
}
+60 -9
View File
@@ -3,32 +3,37 @@ import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'; import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import * as bitcoinjs from 'bitcoinjs-lib'; import * as bitcoinjs from 'bitcoinjs-lib';
import { useContainer } from 'class-validator'; 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 * as ecc from 'tiny-secp256k1';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
if (process.env.API_PORT == null) { if (process.env.API_PORT == null) {
console.error('It appears your environment is not configured, create and populate an .env file.'); console.error('It appears your environment is not configured, create and populate an .env file.');
return; return;
} }
let options = {}; const apiEnabled = process.env.API_ENABLED?.toLowerCase();
const secure = process.env.API_SECURE?.toLowerCase() == 'true'; 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) { if (secure) {
const currentDirectory = process.cwd();
options = { options = {
https: { https: {
key: readFileSync(`${currentDirectory}/secrets/key.pem`), key: readFileSync(keyPath),
cert: readFileSync(`${currentDirectory}/secrets/cert.pem`), cert: readFileSync(certPath),
} }
}; };
} }
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(options)); const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(options));
app.setGlobalPrefix('api') app.setGlobalPrefix('api');
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
transform: true, transform: true,
@@ -51,13 +56,59 @@ async function bootstrap() {
app.enableCors(); app.enableCors();
useContainer(app.select(AppModule), { fallbackOnErrors: true }); useContainer(app.select(AppModule), { fallbackOnErrors: true });
//Taproot // Taproot
bitcoinjs.initEccLib(ecc); bitcoinjs.initEccLib(ecc);
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) => { 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}`); 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(); bootstrap();
-27
View File
@@ -1,27 +0,0 @@
import { IsString, Matches, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsBitcoinAddress } from './validators/bitcoin-address.validator';
export class ExternalPoolShare {
@IsString()
@MaxLength(64)
worker: string;
@IsString()
@IsBitcoinAddress()
address: string;
@IsString()
@MaxLength(128)
userAgent: string;
@IsString()
@MaxLength(128)
externalPoolName: string;
@IsString()
@Matches(/^[0-9a-fA-F]+$/, {
message: 'Header must be a valid hex string'
})
header: string;
}
+102 -269
View File
@@ -1,295 +1,128 @@
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import * as bitcoinjs from 'bitcoinjs-lib'; import * as bitcoinjs from 'bitcoinjs-lib';
import { BehaviorSubject, firstValueFrom } from 'rxjs'; import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1'; import { MockRecording1 } from '../../test/models/MockRecording1';
import { IMiningInfo } from './bitcoin-rpc/IMiningInfo'; import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { MiningJob } from './MiningJob'; import { MiningJob } from './MiningJob';
function hexToAscii(hex: string): string {
let ascii = '';
for (let i = 0; i < hex.length; i += 2) {
const char = String.fromCharCode(parseInt(hex.substr(i, 2), 16));
if (char !== '\0') { // Ignore null characters to make sure string matching works
ascii += char;
}
}
return ascii;
}
function extractPoolIdentifierFromScript(coinbaseHex: string) {
let offset = 8 + 2 + 64 + 8; // Skip Version, Input Count, Previous Transaction Hash, Previous Output Index
// Coinbase Data Length (1 byte / 2 hex characters)
const coinbaseDataLengthHex = coinbaseHex.substr(offset, 2);
const coinbaseDataLength = parseInt(coinbaseDataLengthHex, 16);
offset += 2;
// Coinbase Data (coinbaseDataLength * 2 hex characters)
const coinbaseDataHex = coinbaseHex.substr(offset, coinbaseDataLength * 2);
const coinbaseDataAscii = hexToAscii(coinbaseDataHex);
return coinbaseDataAscii;
}
describe('MiningJob', () => { describe('MiningJob', () => {
let moduleRef: TestingModule; let jobTemplate;
let configService: ConfigService; let job: MiningJob;
let block: bitcoinjs.Block;
let jobTemplate: IJobTemplate;
let payoutInformation: any[];
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
beforeAll(async () => { const blockTemplate$ = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
moduleRef = await Test.createTestingModule({ const bitcoinRpcService = {
providers: [ newBlockTemplate$: blockTemplate$.asObservable(),
{ miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height }
provide: ConfigService, };
useValue: { jest.spyOn(console, 'log').mockImplementation(() => undefined);
get: jest.fn(() => null)
} const jobsService = new StratumV1JobsService(bitcoinRpcService as any);
} jobTemplate = await firstValueFrom(jobsService.newMiningJob$);
], job = new MiningJob(
}).compile(); bitcoinjs.networks.testnet,
configService = moduleRef.get<ConfigService>(ConfigService); '1',
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
jobTemplate
);
}); });
describe('constructor', () => { afterEach(() => {
beforeEach(() => { jest.restoreAllMocks();
jest.useRealTimers();
console.warn = jest.fn((message: string) => console.log('WARN:', message));
configService.get = jest.fn(() => null);
payoutInformation = [
{
address: 'tb1qr2ylpdgp9ejpt6v2uxlqrn9penp82rzz2grnns',
percent: 100,
}
];
block = new bitcoinjs.Block();
block.witnessCommit = Buffer.from('000');
block.prevHash = Buffer.from('000');
block.merkleRoot = Buffer.from('000');
block.transactions = [];
const maxTransactions = 7545; // this is only for block weight calculation, other metrics like operations are not considered because they are not affected by the size of the pool identifier
for (let i = 0; i < maxTransactions; i++) {
block.transactions.push(bitcoinjs.Transaction.fromHex("010000000001017405e391018c5e9dc79f324f9607c9c46d21b02f66dabaa870b4add871d6379f01000000171600148d7a0a3461e3891723e5fdf8129caa0075060cffffffffff01fcf60200000000001600148d7a0a3461e3891723e5fdf8129caa0075060cff0248304502210088025cffdaf69d310c6fed11832edd9c19b6a912c132262701ad0e6133227d9202207d73bbf777abd2aeae995d684e6bb1a048c5ac722e16de48bdd35643df7decf001210283409659355b6d1cc3c32decd5d561abaac86c37a353b52895a5e6c196d6f44800000000"));
}
jobTemplate = {
block: block,
merkle_branch: [],
blockData: {
id: '1',
creation: Date.now(),
coinbasevalue: 0,
networkDifficulty: 0,
height: 0,
clearJobs: false,
}
} as IJobTemplate;
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should create a new MiningJob if POOL_IDENTIFIER is not set and use the default', () => {
const expectedMiningIdentifier = 'Public-Pool';
expect(jobTemplate.block).toBeDefined();
const miningJob = new MiningJob(configService, bitcoinjs.networks.testnet, '1', payoutInformation, jobTemplate);
const response = JSON.parse(miningJob.response(jobTemplate));
const miningIdentifier = extractPoolIdentifierFromScript(response.params[2]);
expect(miningIdentifier).toBe(expectedMiningIdentifier);
expect(console.warn).not.toBeCalled();
});
it('should use the POOL_IDENTIFIER if it doesn\'t make the script size too big', () => {
const expectedMiningIdentifier = 'My Mining Pool';
configService.get = jest.fn((key: string) => {
switch (key) {
case 'POOL_IDENTIFIER': return expectedMiningIdentifier;
}
return null;
});
const miningJob = new MiningJob(configService, bitcoinjs.networks.testnet, '1', payoutInformation, jobTemplate);
const response = JSON.parse(miningJob.response(jobTemplate));
const miningIdentifier = extractPoolIdentifierFromScript(response.params[2]);
expect(miningIdentifier).toBe(expectedMiningIdentifier);
expect(console.warn).not.toBeCalled();
});
it('should remove pool identifier if block is too big with identifier', () => {
const expectedMiningIdentifier = '';
configService.get = jest.fn((key: string) => {
switch (key) {
case 'POOL_IDENTIFIER': return 'A'.repeat(84);
}
return null;
});
const miningJob = new MiningJob(configService, bitcoinjs.networks.testnet, '1', payoutInformation, jobTemplate);
const response = JSON.parse(miningJob.response(jobTemplate));
const miningIdentifier = extractPoolIdentifierFromScript(response.params[2]);
expect(console.warn).toBeCalledWith('Block weight exceeds the maximum allowed weight, removing the pool identifier');
expect(miningIdentifier).toBe(expectedMiningIdentifier);
});
it('should use the POOL_IDENTIFIER if it doesn\'t make the script size too big with identifier abcabc', () => {
jobTemplate.block.transactions = []; // remove transactions because we only want to test the script size
const expectedMiningIdentifier = 'A'.repeat(84); // 84 chars fits after reserving 12 bytes for extranonce space
configService.get = jest.fn((key: string) => {
switch (key) {
case 'POOL_IDENTIFIER': return expectedMiningIdentifier;
}
return null;
});
const miningJob = new MiningJob(configService, bitcoinjs.networks.testnet, '1', payoutInformation, jobTemplate);
const response = JSON.parse(miningJob.response(jobTemplate));
const miningIdentifier = extractPoolIdentifierFromScript(response.params[2]);
expect(miningIdentifier).toBe(expectedMiningIdentifier);
expect(console.warn).not.toBeCalled();
});
it('should remove pool identifier if script is too big with identifier', () => {
const expectedMiningIdentifier = '';
jobTemplate.block.transactions = []; // remove transactions because we only want to test the script size
configService.get = jest.fn((key: string) => {
switch (key) {
case 'POOL_IDENTIFIER': return 'A'.repeat(85);
}
return null;
});
const miningJob = new MiningJob(configService, bitcoinjs.networks.testnet, '1', payoutInformation, jobTemplate);
const response = JSON.parse(miningJob.response(jobTemplate));
const miningIdentifier = extractPoolIdentifierFromScript(response.params[2]);
expect(console.warn).toBeCalledWith('Pool identifier is too long, removing the pool identifier');
expect(miningIdentifier).toBe(expectedMiningIdentifier);
});
}); });
describe('block updates', () => { it('should split coinbase around 12 bytes of extranonce space', () => {
let job: MiningJob; 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}`);
beforeEach(async () => { expect(Buffer.byteLength(extraNonce1 + extraNonce2, 'hex')).toBe(12);
jest.useFakeTimers(); expect(coinbase.ins[0].script.toString('hex')).toContain(`${extraNonce1}${extraNonce2}`);
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000)); expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
configService.get = jest.fn(() => null); });
const miningInfo$ = new BehaviorSubject<IMiningInfo>({ it('should expose coinbase prefix and suffix buffers for SV2 extended jobs', () => {
blocks: MockRecording1.BLOCK_TEMPLATE.height const notify = JSON.parse(job.response(jobTemplate));
} as IMiningInfo); const extraNonce1 = '57a6f098';
const bitcoinRpcService = { const extraNonce2 = 'c708000000000000';
newBlock$: miningInfo$.asObservable(), const fromNotify = Buffer.from(`${notify.params[2]}${extraNonce1}${extraNonce2}${notify.params[3]}`, 'hex');
getBlockTemplate: jest.fn().mockResolvedValue(MockRecording1.BLOCK_TEMPLATE) const fromBuffers = Buffer.concat([
}; job.getCoinbasePrefixBuffer(),
jest.spyOn(console, 'log').mockImplementation(() => undefined); Buffer.from(`${extraNonce1}${extraNonce2}`, 'hex'),
job.getCoinbaseSuffixBuffer(),
]);
const jobsService = new StratumV1JobsService(bitcoinRpcService as any); expect(fromBuffers).toEqual(fromNotify);
jobTemplate = await firstValueFrom(jobsService.newMiningJob$); expect(bitcoinjs.Transaction.fromBuffer(fromBuffers).ins[0].script.toString('hex'))
job = new MiningJob( .toContain(`${extraNonce1}${extraNonce2}`);
configService, });
bitcoinjs.networks.testnet,
'1',
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
jobTemplate
);
});
afterEach(() => { it('should update block nonce, timestamp, version mask, and coinbase script', () => {
jest.restoreAllMocks(); const extraNonce1 = '57a6f098';
jest.useRealTimers(); const extraNonce2 = 'c708000000000000';
}); const timestamp = parseInt(MockRecording1.TIME, 16);
const originalMerkleRoot = Buffer.from(jobTemplate.block.merkleRoot);
it('should split coinbase around 12 bytes of extranonce space', () => { const updatedBlock = job.copyAndUpdateBlock(
const notify = JSON.parse(job.response(jobTemplate)); jobTemplate,
const coinbasePart1 = notify.params[2]; parseInt('00002000', 16),
const coinbasePart2 = notify.params[3]; parseInt('ed460d91', 16),
const extraNonce1 = '57a6f098'; extraNonce1,
const extraNonce2 = 'c708000000000000'; extraNonce2,
const coinbase = bitcoinjs.Transaction.fromHex(`${coinbasePart1}${extraNonce1}${extraNonce2}${coinbasePart2}`); timestamp
);
expect(Buffer.byteLength(extraNonce1 + extraNonce2, 'hex')).toBe(12); expect(updatedBlock.nonce).toBe(parseInt('ed460d91', 16));
expect(coinbase.ins[0].script.toString('hex')).toContain(`${extraNonce1}${extraNonce2}`); expect(updatedBlock.timestamp).toBe(timestamp);
expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true); 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 update block nonce, timestamp, version mask, and coinbase script', () => { it('should leave block version unchanged without a version mask', () => {
const extraNonce1 = '57a6f098'; const updatedBlock = job.copyAndUpdateBlock(
const extraNonce2 = 'c708000000000000'; jobTemplate,
const timestamp = parseInt(MockRecording1.TIME, 16); 0,
const originalMerkleRoot = Buffer.from(jobTemplate.block.merkleRoot); parseInt('ed460d91', 16),
'57a6f098',
'c708000000000000',
parseInt(MockRecording1.TIME, 16)
);
const updatedBlock = job.copyAndUpdateBlock( expect(updatedBlock.version).toBe(jobTemplate.block.version);
jobTemplate, });
parseInt('00002000', 16),
parseInt('ed460d91', 16),
extraNonce1,
extraNonce2,
timestamp
);
expect(updatedBlock.nonce).toBe(parseInt('ed460d91', 16)); it('should build the same header as the full block update path', () => {
expect(updatedBlock.timestamp).toBe(timestamp); const versionMask = parseInt('00002000', 16);
expect(updatedBlock.version).toBe(jobTemplate.block.version ^ parseInt('00002000', 16)); const nonce = parseInt('ed460d91', 16);
expect(updatedBlock.transactions[0].ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true); const extraNonce1 = '57a6f098';
expect(updatedBlock.merkleRoot.equals(originalMerkleRoot)).toBe(false); const extraNonce2 = 'c708000000000000';
}); const timestamp = parseInt(MockRecording1.TIME, 16);
it('should leave block version unchanged without a version mask', () => { const updatedBlock = job.copyAndUpdateBlock(
const updatedBlock = job.copyAndUpdateBlock( jobTemplate,
jobTemplate, versionMask,
0, nonce,
parseInt('ed460d91', 16), extraNonce1,
'57a6f098', extraNonce2,
'c708000000000000', timestamp
parseInt(MockRecording1.TIME, 16) );
); const fastHeader = job.buildHeaderBuffer(
jobTemplate,
versionMask,
nonce,
extraNonce1,
extraNonce2,
timestamp
);
expect(updatedBlock.version).toBe(jobTemplate.block.version); expect(fastHeader.equals(updatedBlock.toBuffer(true))).toBe(true);
});
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);
});
}); });
}); });
+13 -23
View File
@@ -4,11 +4,9 @@ import * as bitcoinjs from 'bitcoinjs-lib';
import { IJobTemplate } from '../services/stratum-v1-jobs.service'; import { IJobTemplate } from '../services/stratum-v1-jobs.service';
import { eResponseMethod } from './enums/eResponseMethod'; import { eResponseMethod } from './enums/eResponseMethod';
import { IMiningNotify } from './stratum-messages/IMiningNotify'; import { IMiningNotify } from './stratum-messages/IMiningNotify';
import { ConfigService } from '@nestjs/config';
import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants'; import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants';
const MAX_BLOCK_WEIGHT = 4000000;
const MAX_SCRIPT_SIZE = 100; // https://github.com/bitcoin/bitcoin/blob/ffdc3d6060f6e65e69cf115a13b83e6eb4a0a0a8/src/consensus/tx_check.cpp#L49
interface AddressObject { interface AddressObject {
address: string; address: string;
percent: number; percent: number;
@@ -27,7 +25,6 @@ export class MiningJob {
public creation: number; public creation: number;
constructor( constructor(
configService: ConfigService,
private network: bitcoinjs.networks.Network, private network: bitcoinjs.networks.Network,
public jobId: string, public jobId: string,
payoutInformation: AddressObject[], payoutInformation: AddressObject[],
@@ -48,9 +45,7 @@ export class MiningJob {
// 32-byte - Commitment hash: Double-SHA256(witness root hash|witness reserved value) // 32-byte - Commitment hash: Double-SHA256(witness root hash|witness reserved value)
// 39th byte onwards: Optional data with no consensus meaning // 39th byte onwards: Optional data with no consensus meaning
// Initial pool identifier const extra = Buffer.from('Public-Pool');
let poolIdentifier = configService.get('POOL_IDENTIFIER') || 'Public-Pool';
let extra = Buffer.from(poolIdentifier);
// Encode the block height // Encode the block height
// https://github.com/bitcoin/bips/blob/master/bip-0034.mediawiki // https://github.com/bitcoin/bips/blob/master/bip-0034.mediawiki
@@ -59,27 +54,14 @@ export class MiningJob {
// Get the length of the block height encoding // Get the length of the block height encoding
const blockHeightLengthByte = Buffer.from([blockHeightEncoded.length]); const blockHeightLengthByte = Buffer.from([blockHeightEncoded.length]);
// Generate padding and take length of encode blockHeight into account // generate padding and take length of encode blockHeight into account
const padding = Buffer.alloc(TOTAL_EXTRANONCE_SIZE_BYTES + (3 - blockHeightEncoded.length), 0) const padding = Buffer.alloc(TOTAL_EXTRANONCE_SIZE_BYTES + (3 - blockHeightEncoded.length), 0)
// Build the script // build the script
let script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, extra, padding]); this.coinbaseTransaction.ins[0].script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, extra, padding])
// Check if the pool identifier is too long
if (script.length > MAX_SCRIPT_SIZE) {
console.warn('Pool identifier is too long, removing the pool identifier');
script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, padding]);
}
this.coinbaseTransaction.ins[0].script = script;
this.coinbaseTransaction.addOutput(bitcoinjs.script.compile([bitcoinjs.opcodes.OP_RETURN, Buffer.concat([segwitMagicBits, jobTemplate.block.witnessCommit])]), 0); this.coinbaseTransaction.addOutput(bitcoinjs.script.compile([bitcoinjs.opcodes.OP_RETURN, Buffer.concat([segwitMagicBits, jobTemplate.block.witnessCommit])]), 0);
// Check if the pool identifier is too long
if ((this.coinbaseTransaction.weight() + jobTemplate.block.weight()) > MAX_BLOCK_WEIGHT) {
console.warn('Block weight exceeds the maximum allowed weight, removing the pool identifier');
let script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, padding]);
this.coinbaseTransaction.ins[0].script = script;
}
// get the non-witness coinbase tx // get the non-witness coinbase tx
//@ts-ignore //@ts-ignore
const serializedCoinbaseTx = this.coinbaseTransaction.__toBuffer().toString('hex'); const serializedCoinbaseTx = this.coinbaseTransaction.__toBuffer().toString('hex');
@@ -100,6 +82,14 @@ export class MiningJob {
return bitcoinjs.Transaction.fromBuffer(this.coinbaseTransaction.toBuffer()); 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 { public buildHeaderBuffer(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): Buffer {
const coinbaseBuffer = Buffer.concat([ const coinbaseBuffer = Buffer.concat([
this.coinbasePart1Buffer, this.coinbasePart1Buffer,
+104 -108
View File
@@ -1,26 +1,15 @@
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Socket } from 'net'; import { Socket } from 'net';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { DataSource } from 'typeorm';
import { MockRecording1 } from '../../test/models/MockRecording1'; 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 { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
import { BlocksEntity } from '../ORM/blocks/blocks.entity';
import { BlocksService } from '../ORM/blocks/blocks.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 { ClientService } from '../ORM/client/client.service';
import { BitcoinRpcService as MockBitcoinRpcService } from '../services/bitcoin-rpc.service'; import { BitcoinRpcService as MockBitcoinRpcService } from '../services/bitcoin-rpc.service';
import { NotificationService } from '../services/notification.service'; import { NotificationService } from '../services/notification.service';
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service'; import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { IBlockTemplate } from './bitcoin-rpc/IBlockTemplate'; import { IBlockTemplate } from './bitcoin-rpc/IBlockTemplate';
import { IMiningInfo } from './bitcoin-rpc/IMiningInfo';
import { MiningJob } from './MiningJob'; import { MiningJob } from './MiningJob';
import { StratumV1Client } from './StratumV1Client'; import { StratumV1Client } from './StratumV1Client';
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage'; import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
@@ -46,11 +35,12 @@ describe('StratumV1Client', () => {
let bitcoinRpcService: MockBitcoinRpcService; let bitcoinRpcService: MockBitcoinRpcService;
let clientService: ClientService; let clientService: ClientService;
let clientStatisticsService: ClientStatisticsService;
let notificationService: NotificationService; let notificationService: NotificationService;
let blocksService: BlocksService; let blocksService: BlocksService;
let externalSharesService;
let configService: ConfigService; let configService: ConfigService;
let addressSettings: AddressSettingsService;
let shareAccountingService: { recordAcceptedShare: jest.Mock };
let redisMessagingService: { setClientPresence: jest.Mock; removeClientPresence: jest.Mock };
let client: StratumV1Client; let client: StratumV1Client;
@@ -60,49 +50,7 @@ describe('StratumV1Client', () => {
let consoleErrorSpy: jest.SpyInstance; let consoleErrorSpy: jest.SpyInstance;
let consoleWarnSpy: jest.SpyInstance; let consoleWarnSpy: jest.SpyInstance;
let newBlockEmitter: BehaviorSubject<IMiningInfo> = new BehaviorSubject({ let newBlockEmitter: BehaviorSubject<IBlockTemplate> = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
blocks: MockRecording1.BLOCK_TEMPLATE.height
} as IMiningInfo);
let moduleRef: TestingModule;
beforeAll(async () => {
moduleRef = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: ':memory:',
synchronize: true,
autoLoadEntities: true,
cache: true,
logging: false,
entities: [ClientEntity, ClientStatisticsEntity, BlocksEntity]
}),
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();
})
beforeEach(async () => { beforeEach(async () => {
@@ -112,34 +60,46 @@ describe('StratumV1Client', () => {
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
clientService = moduleRef.get<ClientService>(ClientService); const clients = new Map<string, any>();
clientService.insertQueue = []; 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) => {
await dataSource.getRepository(ClientStatisticsEntity).clear(); switch (key) {
await dataSource.getRepository(ClientEntity).clear(); case 'DEV_FEE_ADDRESS':
await dataSource.getRepository(BlocksEntity).clear(); return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
case 'NETWORK':
return 'testnet';
clientStatisticsService = moduleRef.get<ClientStatisticsService>(ClientStatisticsService); }
return null;
configService = moduleRef.get<ConfigService>(ConfigService); })
(configService.get as jest.Mock).mockImplementation((key: string) => { } as any;
switch (key) {
case 'DEV_FEE_ADDRESS':
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
case 'NETWORK':
return 'testnet';
}
return null;
});
(StratumV1Client as any).blockedUserAgentLogState.clear(); (StratumV1Client as any).blockedUserAgentLogState.clear();
(StratumV1Client as any).validationErrorLogState.clear(); (StratumV1Client as any).validationErrorLogState.clear();
bitcoinRpcService = new MockBitcoinRpcService(configService,null); bitcoinRpcService = {
jest.spyOn(bitcoinRpcService, 'getBlockTemplate').mockReturnValue(Promise.resolve(MockRecording1.BLOCK_TEMPLATE)); newBlockTemplate$: newBlockEmitter.asObservable(),
bitcoinRpcService.newBlock$ = newBlockEmitter.asObservable(); miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height },
SUBMIT_BLOCK: jest.fn().mockResolvedValue(null)
} as any;
stratumV1JobsService = new StratumV1JobsService(bitcoinRpcService); stratumV1JobsService = new StratumV1JobsService(bitcoinRpcService);
@@ -157,15 +117,23 @@ describe('StratumV1Client', () => {
socket.end = jest.fn(); socket.end = jest.fn();
jest.spyOn(socket, 'destroy').mockImplementation(() => socket); 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 = { notificationService = {
notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined) notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined)
} as any; } as any;
blocksService = { blocksService = {
save: jest.fn().mockResolvedValue(undefined) save: jest.fn().mockResolvedValue(undefined)
} as any; } as any;
externalSharesService = { shareAccountingService = {
submitShare: jest.fn().mockResolvedValue(undefined) recordAcceptedShare: jest.fn().mockResolvedValue(undefined),
};
redisMessagingService = {
setClientPresence: jest.fn().mockResolvedValue(undefined),
removeClientPresence: jest.fn().mockResolvedValue(undefined),
}; };
@@ -174,12 +142,12 @@ describe('StratumV1Client', () => {
stratumV1JobsService, stratumV1JobsService,
bitcoinRpcService, bitcoinRpcService,
clientService, clientService,
clientStatisticsService,
notificationService, notificationService,
blocksService, blocksService,
configService, configService,
addressSettings, addressSettings,
externalSharesService shareAccountingService as any,
redisMessagingService as any
); );
client.extraNonceAndSessionId = MockRecording1.EXTRA_NONCE; client.extraNonceAndSessionId = MockRecording1.EXTRA_NONCE;
@@ -218,6 +186,17 @@ describe('StratumV1Client', () => {
}); });
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 () => { it('should block non-compliant user agents on subscribe without allocating a session', async () => {
(configService.get as jest.Mock).mockImplementation((key: string) => { (configService.get as jest.Mock).mockImplementation((key: string) => {
switch (key) { switch (key) {
@@ -270,12 +249,10 @@ describe('StratumV1Client', () => {
stratumV1JobsService, stratumV1JobsService,
bitcoinRpcService, bitcoinRpcService,
clientService, clientService,
clientStatisticsService,
notificationService, notificationService,
blocksService, blocksService,
configService, configService,
moduleRef.get<AddressSettingsService>(AddressSettingsService), addressSettings
externalSharesService
); );
socketEmitter(Buffer.from(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}\n`)); socketEmitter(Buffer.from(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}\n`));
@@ -316,6 +293,26 @@ describe('StratumV1Client', () => {
expect(socket.write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[512]}\n`, expect.any(Function)); expect(socket.write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[512]}\n`, expect.any(Function));
}); });
it('should clamp suggested difficulty to the configured minimum', async () => {
(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 () => { it('should set difficulty', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
@@ -336,8 +333,6 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT); emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
await clientService.insertClients();
await new Promise((r) => setTimeout(r, 100));
const clientCount = await clientService.connectedClientCount(); const clientCount = await clientService.connectedClientCount();
expect(clientCount).toBe(1); expect(clientCount).toBe(1);
@@ -367,16 +362,10 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
const notifyCall = (client as any).write.mock.calls
.map(call => call[0])
.find((message: string) => message.includes('"method":"mining.notify"'));
const notify = JSON.parse(notifyCall);
expect(notify.params[0]).toBe('1');
expect(notify.params[5]).toBe('20000000');
expect(notify.params[6]).toBe('192495f8'); expect((client as any).write).lastCalledWith(`{"id":null,"method":"mining.notify","params":["1","171592f223740e92d223f6e68bff25279af7ac4f2246451e0000000200000000","02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff1b03c943255075626c69632d506f6f6c","ffffffff02b59f250000000000160014e6f22ca44dc800e9d049621a3b9a42c509f1c4bc0000000000000000266a24aa21a9edbd3d1d916aa0b57326a2d88ebe1b68a1d7c48585f26d8335fe6a94b62755f64c00000000",["175335649d5e8746982969ec88f52e85ac9917106fba5468e699c8879ab974a1","d5644ab3e708c54cd68dc5aedc92b8d3037449687f92ec41ed6e37673d969d4a","5c9ec187517edc0698556cca5ce27e54c96acb014770599ed9df4d4937fbf2b0"],"20000000","192495f8","${MockRecording1.TIME}",true]}\n`);
expect(notify.params[7]).toBe(MockRecording1.TIME);
expect(notify.params[8]).toBe(true);
emitMessage(MockRecording1.MINING_SUBMIT); emitMessage(MockRecording1.MINING_SUBMIT);
@@ -385,6 +374,21 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
expect((client as any).write).lastCalledWith(`{\"id\":5,\"error\":null,\"result\":true}\n`); 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,
}));
}); });
@@ -400,8 +404,6 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT); emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
await clientService.insertClients();
jest.useRealTimers(); jest.useRealTimers();
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
@@ -441,7 +443,6 @@ describe('StratumV1Client', () => {
submissionDifficulty: 1024, submissionDifficulty: 1024,
submissionHash: 'share' submissionHash: 'share'
}); });
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
const getSettingsSpy = jest.spyOn(addressSettings, 'getSettings'); const getSettingsSpy = jest.spyOn(addressSettings, 'getSettings');
const updateIfHigherSpy = jest.spyOn(addressSettings as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 }); const updateIfHigherSpy = jest.spyOn(addressSettings as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 });
const clientUpdateIfHigherSpy = jest.spyOn(clientService as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 }); const clientUpdateIfHigherSpy = jest.spyOn(clientService as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 });
@@ -452,8 +453,6 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT); emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
await clientService.insertClients();
jest.useRealTimers(); jest.useRealTimers();
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
@@ -566,12 +565,10 @@ describe('StratumV1Client', () => {
stratumV1JobsService, stratumV1JobsService,
bitcoinRpcService, bitcoinRpcService,
clientService, clientService,
clientStatisticsService,
notificationService, notificationService,
blocksService, blocksService,
configService, configService,
moduleRef.get<AddressSettingsService>(AddressSettingsService), addressSettings
externalSharesService
); );
jest.spyOn(secondClient as any, 'write').mockImplementation((data) => Promise.resolve(true)); jest.spyOn(secondClient as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(secondClient as any, 'getRandomHexString').mockReturnValue(MockRecording1.EXTRA_NONCE); jest.spyOn(secondClient as any, 'getRandomHexString').mockReturnValue(MockRecording1.EXTRA_NONCE);
@@ -601,7 +598,6 @@ describe('StratumV1Client', () => {
submissionDifficulty: Number.MAX_SAFE_INTEGER, submissionDifficulty: Number.MAX_SAFE_INTEGER,
submissionHash: 'block-share' submissionHash: 'block-share'
}); });
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined); jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined);
emitMessage(MockRecording1.MINING_SUBSCRIBE); emitMessage(MockRecording1.MINING_SUBSCRIBE);
+132 -73
View File
@@ -9,11 +9,12 @@ import { clearInterval } from 'timers';
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service'; import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
import { BlocksService } from '../ORM/blocks/blocks.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 { ClientEntity } from '../ORM/client/client.entity';
import { ClientService } from '../ORM/client/client.service'; import { ClientService } from '../ORM/client/client.service';
import { ShareAccountingService } from '../ORM/share-accounting/share-accounting.service';
import { BitcoinRpcService } from '../services/bitcoin-rpc.service'; import { BitcoinRpcService } from '../services/bitcoin-rpc.service';
import { NotificationService } from '../services/notification.service'; import { NotificationService } from '../services/notification.service';
import { RedisMessagingService } from '../services/redis-messaging.service';
import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service'; import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { eRequestMethod } from './enums/eRequestMethod'; import { eRequestMethod } from './enums/eRequestMethod';
import { eResponseMethod } from './enums/eResponseMethod'; import { eResponseMethod } from './enums/eResponseMethod';
@@ -27,17 +28,17 @@ import { SubscriptionMessage } from './stratum-messages/SubscriptionMessage';
import { EXTRANONCE1_SIZE_BYTES } from './stratum.constants'; import { EXTRANONCE1_SIZE_BYTES } from './stratum.constants';
import { SuggestDifficulty } from './stratum-messages/SuggestDifficultyMessage'; import { SuggestDifficulty } from './stratum-messages/SuggestDifficultyMessage';
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics'; import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
import { ExternalSharesService } from '../services/external-shares.service';
const TRUE_DIFF_ONE = 2.695953529101131e67; const TRUE_DIFF_ONE = 2.695953529101131e67;
const BLOCKED_USER_AGENT_LOG_INTERVAL_MS = 60 * 1000; const BLOCKED_USER_AGENT_LOG_INTERVAL_MS = 60 * 1000;
const VALIDATION_ERROR_LOG_INTERVAL_MS = 60 * 1000; const VALIDATION_ERROR_LOG_INTERVAL_MS = 60 * 1000;
const DEFAULT_MIN_DIFFICULTY = 0.001;
export class StratumV1Client { export class StratumV1Client {
private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>(); private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>();
private static validationErrorLogState = new Map<string, { nextLogAt: number, suppressed: number, sample: string }>(); private static validationErrorLogState = new Map<string, { nextLogAt: number, suppressed: number, sample: string }>();
private clientSubscription: SubscriptionMessage; public clientSubscription: SubscriptionMessage;
private clientConfiguration: ConfigurationMessage; private clientConfiguration: ConfigurationMessage;
private clientAuthorization: AuthorizationMessage; private clientAuthorization: AuthorizationMessage;
private clientSuggestedDifficulty: SuggestDifficulty; private clientSuggestedDifficulty: SuggestDifficulty;
@@ -49,13 +50,13 @@ export class StratumV1Client {
private usedSuggestedDifficulty = false; private usedSuggestedDifficulty = false;
private sessionDifficulty: number = 100000; private sessionDifficulty: number = 100000;
private entity: ClientEntity; private clientEntity: ClientEntity;
private creatingEntity: Promise<void>; private creatingEntity: Promise<void>;
public extraNonceAndSessionId: string; public extraNonceAndSessionId: string;
public sessionStart: Date; public sessionStart: Date;
public noFee: boolean; //public noFee: boolean;
public hashRate: number = 0; //public hashRate: number = 0;
private buffer: string = ''; private buffer: string = '';
private connectionClosed = false; private connectionClosed = false;
@@ -68,12 +69,12 @@ export class StratumV1Client {
private readonly stratumV1JobsService: StratumV1JobsService, private readonly stratumV1JobsService: StratumV1JobsService,
private readonly bitcoinRpcService: BitcoinRpcService, private readonly bitcoinRpcService: BitcoinRpcService,
private readonly clientService: ClientService, private readonly clientService: ClientService,
private readonly clientStatisticsService: ClientStatisticsService,
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService,
private readonly blocksService: BlocksService, private readonly blocksService: BlocksService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly addressSettingsService: AddressSettingsService, private readonly addressSettingsService: AddressSettingsService,
private readonly externalSharesService: ExternalSharesService private readonly shareAccountingService?: ShareAccountingService,
private readonly redisMessagingService?: RedisMessagingService
) { ) {
this.socket.on('data', (data: Buffer) => { this.socket.on('data', (data: Buffer) => {
@@ -101,8 +102,9 @@ export class StratumV1Client {
public async destroy() { public async destroy() {
if (this.extraNonceAndSessionId) { if (this.clientEntity?.id) {
await this.clientService.delete(this.extraNonceAndSessionId); await this.redisMessagingService?.removeClientPresence(this.clientEntity.id, this.clientEntity.address);
await this.clientService.delete(this.clientEntity.id);
} }
if (this.stratumSubscription != null) { if (this.stratumSubscription != null) {
@@ -158,9 +160,9 @@ export class StratumV1Client {
if (this.sessionStart == null) { if (this.sessionStart == null) {
this.sessionStart = new Date(); this.sessionStart = new Date();
this.statistics = new StratumV1ClientStatistics(this.clientStatisticsService); this.statistics = new StratumV1ClientStatistics(this.getMinimumDifficulty());
this.extraNonceAndSessionId = this.getRandomHexString(); 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; this.clientSubscription = subscriptionMessage;
@@ -207,7 +209,7 @@ export class StratumV1Client {
} }
} else { } else {
console.error('Configuration validation error'); console.log('Configuration validation error');
const err = new StratumErrorMessage( const err = new StratumErrorMessage(
configurationMessage.id, configurationMessage.id,
eStratumErrorCode.OtherUnknown, eStratumErrorCode.OtherUnknown,
@@ -246,13 +248,12 @@ export class StratumV1Client {
return; return;
} }
} else { } else {
console.error('Authorization validation error');
const err = new StratumErrorMessage( const err = new StratumErrorMessage(
authorizationMessage.id, authorizationMessage.id,
eStratumErrorCode.OtherUnknown, eStratumErrorCode.OtherUnknown,
'Authorization validation error', 'Authorization validation error',
errors).response(); errors).response();
console.error(err); //console.log(err);
const success = await this.write(err); const success = await this.write(err);
if (!success) { if (!success) {
return; return;
@@ -281,7 +282,7 @@ export class StratumV1Client {
if (errors.length === 0) { if (errors.length === 0) {
this.clientSuggestedDifficulty = suggestDifficultyMessage; 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'); const success = await this.write(JSON.stringify(this.clientSuggestedDifficulty.response(this.sessionDifficulty)) + '\n');
if (!success) { if (!success) {
return; return;
@@ -305,7 +306,7 @@ export class StratumV1Client {
case eRequestMethod.SUBMIT: { case eRequestMethod.SUBMIT: {
if (this.stratumInitialized == false) { if (this.stratumInitialized == false) {
console.log('Submit before initalized'); //console.log('Submit before initalized');
await this.socket.end(); await this.socket.end();
return; return;
} }
@@ -362,13 +363,14 @@ export class StratumV1Client {
&& this.clientAuthorization != null && this.clientAuthorization != null
&& this.stratumInitialized == false) { && this.stratumInitialized == false) {
await this.initStratum(); this.initStratum();
} }
} }
private async initStratum() { private async initStratum() {
this.stratumInitialized = true; this.stratumInitialized = true;
this.socket.setTimeout(0);
if (this.isBlockedUserAgent(this.clientSubscription.userAgent)) { if (this.isBlockedUserAgent(this.clientSubscription.userAgent)) {
this.logBlockedUserAgent(this.clientSubscription.userAgent); this.logBlockedUserAgent(this.clientSubscription.userAgent);
@@ -376,12 +378,6 @@ export class StratumV1Client {
return; return;
} }
switch (this.clientSubscription.userAgent) {
case 'cpuminer': {
this.sessionDifficulty = 0.1;
}
}
if (this.clientSuggestedDifficulty == null) { if (this.clientSuggestedDifficulty == null) {
//console.log(`Setting difficulty to ${this.sessionDifficulty}`) //console.log(`Setting difficulty to ${this.sessionDifficulty}`)
const setDifficulty = JSON.stringify(new SuggestDifficulty().response(this.sessionDifficulty)); const setDifficulty = JSON.stringify(new SuggestDifficulty().response(this.sessionDifficulty));
@@ -409,29 +405,39 @@ export class StratumV1Client {
}, 60 * 1000) }, 60 * 1000)
); );
// this.backgroundWork.push(
// setInterval(async () => {
// await this.statistics.saveShares(this.clientEntity);
// }, 60 * 1000)
// );
} }
private async sendNewMiningJob(jobTemplate: IJobTemplate) { private async sendNewMiningJob(jobTemplate: IJobTemplate) {
let payoutInformation; let payoutInformation= [
const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS'); { address: this.clientAuthorization.address, percent: 100 }
//50Th/s ];
this.noFee = false; // const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS');
if (this.entity) { // //50Th/s
this.hashRate = this.statistics.hashRate; // this.noFee = false;
this.noFee = this.hashRate != 0 && this.hashRate < 50000000000000; // if (this.clientEntity) {
} // // 250Gh/s
if (this.noFee || devFeeAddress == null || devFeeAddress.length < 1) { // if(this.hashRate < 250000000000){
payoutInformation = [ // this.statistics.targetSubmitShareEveryNSeconds = 10;
{ address: this.clientAuthorization.address, percent: 100 } // }
]; // this.noFee = this.hashRate != 0 && this.hashRate < 50000000000000;
// }
// if (this.noFee || devFeeAddress == null || devFeeAddress.length < 1) {
// payoutInformation = [
// { address: this.clientAuthorization.address, percent: 100 }
// ];
} else { // } else {
payoutInformation = [ // payoutInformation = [
{ address: devFeeAddress, percent: 1.5 }, // { address: devFeeAddress, percent: 1.5 },
{ address: this.clientAuthorization.address, percent: 98.5 } // { address: this.clientAuthorization.address, percent: 98.5 }
]; // ];
} // }
const networkConfig = this.configService.get('NETWORK'); const networkConfig = this.configService.get('NETWORK');
let network; let network;
@@ -447,7 +453,6 @@ export class StratumV1Client {
} }
const job = new MiningJob( const job = new MiningJob(
this.configService,
network, network,
this.stratumV1JobsService.getNextId(), this.stratumV1JobsService.getNextId(),
payoutInformation, payoutInformation,
@@ -470,13 +475,13 @@ export class StratumV1Client {
private async ensureClientEntity() { private async ensureClientEntity() {
if (this.entity != null) { if (this.clientEntity != null) {
return; return;
} }
if (this.creatingEntity == null) { if (this.creatingEntity == null) {
this.creatingEntity = (async () => { this.creatingEntity = (async () => {
this.entity = await this.clientService.insert({ this.clientEntity = await this.clientService.insert({
sessionId: this.extraNonceAndSessionId, sessionId: this.extraNonceAndSessionId,
address: this.clientAuthorization.address, address: this.clientAuthorization.address,
clientName: this.clientAuthorization.worker, clientName: this.clientAuthorization.worker,
@@ -484,12 +489,35 @@ export class StratumV1Client {
startTime: new Date(), startTime: new Date(),
bestDifficulty: 0 bestDifficulty: 0
}); });
await this.updateClientPresence(new Date());
})(); })();
} }
await this.creatingEntity; 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) { private async handleMiningSubmission(submission: MiningSubmitMessage) {
const job = this.stratumV1JobsService.getJobById(submission.jobId); const job = this.stratumV1JobsService.getJobById(submission.jobId);
@@ -507,6 +535,7 @@ export class StratumV1Client {
} }
return false; return false;
} }
const jobTemplate = this.stratumV1JobsService.getJobTemplateById(job.jobTemplateId); const jobTemplate = this.stratumV1JobsService.getJobTemplateById(job.jobTemplateId);
if (jobTemplate == null) { if (jobTemplate == null) {
@@ -566,6 +595,7 @@ export class StratumV1Client {
return false; return false;
} }
let blockSubmissionResult: string = null;
if (submissionDifficulty >= jobTemplate.blockData.networkDifficulty) { if (submissionDifficulty >= jobTemplate.blockData.networkDifficulty) {
console.log('!!! BLOCK FOUND !!!'); console.log('!!! BLOCK FOUND !!!');
const updatedJobBlock = job.copyAndUpdateBlock( const updatedJobBlock = job.copyAndUpdateBlock(
@@ -577,7 +607,7 @@ export class StratumV1Client {
timestamp timestamp
); );
const blockHex = updatedJobBlock.toHex(false); const blockHex = updatedJobBlock.toHex(false);
const result = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex); blockSubmissionResult = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
await this.blocksService.save({ await this.blocksService.save({
height: jobTemplate.blockData.height, height: jobTemplate.blockData.height,
minerAddress: this.clientAuthorization.address, minerAddress: this.clientAuthorization.address,
@@ -586,45 +616,53 @@ export class StratumV1Client {
blockData: blockHex 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 //success
if (result == null) { if (blockSubmissionResult == null) {
await this.addressSettingsService.resetBestDifficultyAndShares(); await this.addressSettingsService.resetBestDifficultyAndShares();
} }
} }
await this.ensureClientEntity(); await this.ensureClientEntity();
try { try {
await this.statistics.addShares(this.entity, 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(); const now = new Date();
// only update every minute this.clientEntity.updatedAt = now;
if (this.entity.updatedAt == null || now.getTime() - this.entity.updatedAt.getTime() > 1000 * 60) { this.clientEntity.hashRate = this.statistics.hashRate;
await this.clientService.heartbeat(this.entity.address, this.entity.clientName, this.entity.sessionId, this.hashRate, now); await this.updateClientPresence(now);
this.entity.updatedAt = now;
}
} catch (e) { } catch (e) {
console.log(e); console.log(e);
} }
if (submissionDifficulty > this.entity.bestDifficulty) { if (submissionDifficulty > this.clientEntity.bestDifficulty) {
await this.clientService.updateBestDifficultyIfHigher(this.extraNonceAndSessionId, submissionDifficulty); await this.clientService.updateBestDifficultyIfHigher(this.clientEntity.id, submissionDifficulty);
this.entity.bestDifficulty = submissionDifficulty; this.clientEntity.bestDifficulty = submissionDifficulty;
await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.entity.userAgent); await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.clientEntity.userAgent);
await this.updateClientPresence(new Date());
} }
const externalShareSubmissionEnabled: boolean = this.configService.get('EXTERNAL_SHARE_SUBMISSION_ENABLED')?.toLowerCase() == 'true';
const minimumDifficulty: number = parseFloat(this.configService.get('MINIMUM_DIFFICULTY')) || 1000000000000.0; // 1T
if (externalShareSubmissionEnabled && submissionDifficulty >= minimumDifficulty) {
// Submit share to API if enabled
this.externalSharesService.submitShare({
worker: this.clientAuthorization.worker,
address: this.clientAuthorization.address,
userAgent: this.clientSubscription.userAgent,
header: header.toString('hex'),
externalPoolName: this.configService.get('POOL_IDENTIFIER') || 'Public-Pool'
});
}
} else { } else {
const err = new StratumErrorMessage( const err = new StratumErrorMessage(
@@ -646,7 +684,7 @@ export class StratumV1Client {
} }
private async checkDifficulty() { private async checkDifficulty() {
const targetDiff = this.statistics.getSuggestedDifficulty(this.sessionDifficulty); const targetDiff = this.clampDifficulty(this.statistics.getSuggestedDifficulty(this.sessionDifficulty));
if (targetDiff == null) { if (targetDiff == null) {
return; return;
} }
@@ -782,6 +820,27 @@ export class StratumV1Client {
return ` sample=${values.join(',')}`; 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() { private closeSocket() {
this.connectionClosed = true; this.connectionClosed = true;
if (!this.socket.destroyed) { if (!this.socket.destroyed) {
@@ -805,7 +864,7 @@ export class StratumV1Client {
return true; return true;
} else { } 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(); this.destroy();
if (!this.socket.destroyed) { if (!this.socket.destroyed) {
this.socket.destroy(); this.socket.destroy();
@@ -819,7 +878,7 @@ export class StratumV1Client {
} else if (!this.socket.destroyed) { } else if (!this.socket.destroyed) {
this.socket.destroy(); 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; return false;
} }
} }
+18 -56
View File
@@ -8,76 +8,31 @@ describe('StratumV1ClientStatistics', () => {
sessionId: '57a6f098' sessionId: '57a6f098'
} as any; } as any;
let clientStatisticsService: {
insert: jest.Mock,
update: jest.Mock
};
let statistics: StratumV1ClientStatistics; let statistics: StratumV1ClientStatistics;
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-06T12:00:00Z')); jest.setSystemTime(new Date('2026-05-06T12:00:00Z'));
clientStatisticsService = { statistics = new StratumV1ClientStatistics();
insert: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined)
};
statistics = new StratumV1ClientStatistics(clientStatisticsService as any);
}); });
afterEach(() => { afterEach(() => {
jest.useRealTimers(); jest.useRealTimers();
}); });
it('should insert the first share bucket', async () => { it('should keep runtime hashrate at zero before enough time passes', async () => {
await statistics.addShares(client, 64); await statistics.addShares(client, 64);
expect(clientStatisticsService.insert).toHaveBeenCalledWith({ expect(statistics.hashRate).toBe(0);
time: new Date('2026-05-06T12:00:00Z').getTime(),
shares: 64,
acceptedCount: 1,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
});
}); });
it('should update the current share bucket for additional shares', async () => { it('should update runtime hashrate from accepted shares', async () => {
await statistics.addShares(client, 64); for (let i = 0; i < 3; i++) {
jest.setSystemTime(new Date('2026-05-06T12:01:01Z')); jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 31000)));
await statistics.addShares(client, 32); await statistics.addShares(client, 64);
}
expect(clientStatisticsService.update).toHaveBeenCalledWith({ expect(statistics.hashRate).toBeGreaterThan(0);
time: new Date('2026-05-06T12:00:00Z').getTime(),
shares: 96,
acceptedCount: 2,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
});
});
it('should create a new share bucket when the time slot changes', async () => {
await statistics.addShares(client, 64);
jest.setSystemTime(new Date('2026-05-06T12:10:00Z'));
await statistics.addShares(client, 32);
expect(clientStatisticsService.update).toHaveBeenCalledWith({
time: new Date('2026-05-06T12:00:00Z').getTime(),
shares: 64,
acceptedCount: 1,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
});
expect(clientStatisticsService.insert).toHaveBeenLastCalledWith({
time: new Date('2026-05-06T12:10:00Z').getTime(),
shares: 32,
acceptedCount: 1,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
});
}); });
it('should not suggest a difficulty change before enough time or shares have passed', () => { it('should not suggest a difficulty change before enough time or shares have passed', () => {
@@ -96,7 +51,7 @@ describe('StratumV1ClientStatistics', () => {
await statistics.addShares(client, 64); await statistics.addShares(client, 64);
} }
expect(statistics.getSuggestedDifficulty(64)).toBe(512); expect(statistics.getSuggestedDifficulty(64)).toBe(2048);
}); });
it('should decrease difficulty for slow submissions', async () => { it('should decrease difficulty for slow submissions', async () => {
@@ -105,6 +60,13 @@ describe('StratumV1ClientStatistics', () => {
await statistics.addShares(client, 64); await statistics.addShares(client, 64);
} }
expect(statistics.getSuggestedDifficulty(128)).toBe(4); 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);
}); });
}); });
+19 -100
View File
@@ -1,126 +1,45 @@
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
import { ClientEntity } from '../ORM/client/client.entity'; import { ClientEntity } from '../ORM/client/client.entity';
const CACHE_SIZE = 30; const CACHE_SIZE = 30;
const TARGET_SUBMISSION_PER_SECOND = 10; const DEFAULT_MIN_DIFF = 0.001;
const MIN_DIFF = 0.00001;
export class StratumV1ClientStatistics { export class StratumV1ClientStatistics {
private shares: number = 0; public targetSubmitShareEveryNSeconds: number = 30;
private acceptedCount: number = 0; public hashRate = 0;
private submissionCacheStart: Date; private submissionCacheStart: Date;
private submissionCache: { time: Date, difficulty: number }[] = []; private submissionCache: { time: Date, difficulty: number }[] = [];
private submissionCacheDifficultySum = 0;
private currentTimeSlot: number = null; constructor(private readonly minDifficulty = DEFAULT_MIN_DIFF) {
private lastSave: number = null;
public hashRate = 0;
private previousTimeSlotTime: Date;
private currentTimeSlotTime: Date;
private previousShares: number = 0;
constructor(
private readonly clientStatisticsService: ClientStatisticsService
) {
this.submissionCacheStart = new Date(); this.submissionCacheStart = new Date();
} }
public async addShares(_client: ClientEntity, targetDifficulty: number) {
// We don't want to save them here because it can be DB intensive, instead do it every once in
// awhile with saveShares()
public async addShares(client: ClientEntity, targetDifficulty: number) {
// 10 min
var coeff = 1000 * 60 * 10;
var date = new Date(); var date = new Date();
var timeSlot = new Date(Math.floor(date.getTime() / coeff) * coeff).getTime();
if (this.submissionCache.length > CACHE_SIZE) { if (this.submissionCache.length > CACHE_SIZE) {
this.submissionCacheDifficultySum -= this.submissionCache[0].difficulty;
this.submissionCache.shift(); this.submissionCache.shift();
} }
this.submissionCache.push({ this.submissionCache.push({
time: date, time: date,
difficulty: targetDifficulty, difficulty: targetDifficulty,
}); });
this.submissionCacheDifficultySum += targetDifficulty;
const time = new Date().getTime() - this.submissionCache[0].time.getTime();
if (this.currentTimeSlot == null) { if(time > 60000 && this.submissionCache.length > 2) {
// First record, insert it this.hashRate = (this.submissionCacheDifficultySum * 4294967296) / (time / 1000);
this.previousTimeSlotTime = new Date();
this.currentTimeSlotTime = new Date();
this.currentTimeSlot = timeSlot;
this.shares += targetDifficulty;
this.acceptedCount++;
await this.clientStatisticsService.insert({
time: this.currentTimeSlot,
shares: this.shares,
acceptedCount: this.acceptedCount,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
});
this.lastSave = new Date().getTime();
} else if (this.currentTimeSlot != timeSlot) {
// Transitioning to a new time slot,
// First update the old time slot with the latest data
await this.clientStatisticsService.update({
time: this.currentTimeSlot,
shares: this.shares,
acceptedCount: this.acceptedCount,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
});
this.previousShares = this.shares;
this.previousTimeSlotTime = this.currentTimeSlotTime;
this.currentTimeSlotTime = new Date();
// Set the new time slot and add incoming shares then insert it
this.currentTimeSlot = timeSlot;
this.shares = targetDifficulty;
this.acceptedCount = 1
await this.clientStatisticsService.insert({
time: this.currentTimeSlot,
shares: this.shares,
acceptedCount: this.acceptedCount,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
});
this.lastSave = new Date().getTime();
} else if ((date.getTime() - 60 * 1000) > this.lastSave) {
// If we haven't saved for a minute, update the table
this.shares += targetDifficulty;
this.acceptedCount++;
await this.clientStatisticsService.update({
time: this.currentTimeSlot,
shares: this.shares,
acceptedCount: this.acceptedCount,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
});
this.lastSave = new Date().getTime();
} else {
// Accept the shares if none of the prior conditions are met,
// saving to memory for storing later
this.shares += targetDifficulty;
this.acceptedCount++;
if(this.shares > 0) {
const time = new Date().getTime() - this.previousTimeSlotTime.getTime();
this.hashRate = ((this.previousShares + this.shares) * 4294967296) / (time / 1000);
} }
}
} }
public getSuggestedDifficulty(clientDifficulty: number) { public getSuggestedDifficulty(clientDifficulty: number) {
// miner hasn't submitted shares in one minute // miner hasn't submitted shares in one minute
if (this.submissionCache.length < 5) { 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); return this.nearestPowerOfTwo(clientDifficulty / 6);
} else { } else {
return null; return null;
@@ -135,7 +54,7 @@ export class StratumV1ClientStatistics {
const difficultyPerSecond = sum / diffSeconds; const difficultyPerSecond = sum / diffSeconds;
const targetDifficulty = difficultyPerSecond * TARGET_SUBMISSION_PER_SECOND; const targetDifficulty = difficultyPerSecond * this.targetSubmitShareEveryNSeconds;
if ((clientDifficulty * 2) < targetDifficulty || (clientDifficulty / 2) > targetDifficulty) { if ((clientDifficulty * 2) < targetDifficulty || (clientDifficulty / 2) > targetDifficulty) {
return this.nearestPowerOfTwo(targetDifficulty) return this.nearestPowerOfTwo(targetDifficulty)
@@ -148,8 +67,8 @@ export class StratumV1ClientStatistics {
if (val === 0) { if (val === 0) {
return null; return null;
} }
if (val < MIN_DIFF) { if (val < this.minDifficulty) {
return MIN_DIFF; return this.minDifficulty;
} }
let x = val | (val >> 1); let x = val | (val >> 1);
x = x | (x >> 2); x = x | (x >> 2);
@@ -158,8 +77,8 @@ export class StratumV1ClientStatistics {
x = x | (x >> 16); x = x | (x >> 16);
x = x | (x >> 32); x = x | (x >> 32);
const res = x - (x >> 1); const res = x - (x >> 1);
if (res == 0 && val * 100 < MIN_DIFF) { if (res == 0 && val * 100 < this.minDifficulty) {
return MIN_DIFF; return this.minDifficulty;
} }
if (res == 0) { if (res == 0) {
return this.nearestPowerOfTwo(val * 100) / 100; return this.nearestPowerOfTwo(val * 100) / 100;
@@ -167,4 +86,4 @@ export class StratumV1ClientStatistics {
return res; return res;
} }
} }
+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
@@ -25,15 +25,6 @@ describe('AuthorizationMessage', () => {
expect(message.startingDiff).toBeNull(); expect(message.startingDiff).toBeNull();
}); });
it('should ignore malformed starting difficulty hints', () => {
const message = plainToInstance(
AuthorizationMessage,
JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker1","x,d=abc"]}')
);
expect(message.startingDiff).toBeNull();
});
it('should build successful authorization responses', () => { it('should build successful authorization responses', () => {
const message = plainToInstance( const message = plainToInstance(
AuthorizationMessage, AuthorizationMessage,
@@ -24,23 +24,27 @@ export class AuthorizationMessage extends StratumBaseMessage {
@IsString() @IsString()
@MaxLength(64) @MaxLength(64)
@Transform(({ value, key, obj, type }) => { @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; public worker: string;
@Expose() @Expose()
@IsNumber() @IsNumber()
@Transform(({ value, key, obj, type }) => { @Transform(({ value, key, obj, type }) => {
const password: string | null = obj.params[1]; const password: string | null = obj.params[1];
const difficultyMatch = password?.match(/(?:^|,)d=(\d+)(?:,|$)/); if (password?.includes('d=')) {
if (difficultyMatch != null) { return parseInt(password.split('d=')[1]);
const startingDiff = Number.parseInt(difficultyMatch[1], 10);
return Number.isFinite(startingDiff) ? startingDiff : null;
} }
return null; return null;
}) })
@IsOptional() @IsOptional()
public startingDiff?: number; public startingDiff: number;
@Expose() @Expose()
@IsString() @IsString()
@@ -65,4 +69,4 @@ export class AuthorizationMessage extends StratumBaseMessage {
result: true result: true
}; };
} }
} }
+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
});
});
+196
View File
@@ -0,0 +1,196 @@
// ── SV2 Template Distribution Protocol (TDP) Messages ──────────────
// Interfaces + serialize/deserialize for all TDP message types.
import { BufferReader, BufferWriter } from './sv2-binary-codec';
// ── CoinbaseOutputConstraints (0x70) ────────────────────────────────
export interface Sv2TdpCoinbaseOutputConstraints {
coinbaseOutputMaxAdditionalSize: number; // U32
coinbaseOutputMaxAdditionalSigops: number; // U16
}
export function serializeTdpCoinbaseOutputConstraints(msg: Sv2TdpCoinbaseOutputConstraints): Buffer {
const w = new BufferWriter();
w.writeU32(msg.coinbaseOutputMaxAdditionalSize);
w.writeU16(msg.coinbaseOutputMaxAdditionalSigops);
return w.toBuffer();
}
export function deserializeTdpCoinbaseOutputConstraints(reader: BufferReader): Sv2TdpCoinbaseOutputConstraints {
return {
coinbaseOutputMaxAdditionalSize: reader.readU32(),
coinbaseOutputMaxAdditionalSigops: reader.readU16(),
};
}
// ── NewTemplate (0x71) ─────────────────────────────────────────────
export interface Sv2TdpNewTemplate {
templateId: bigint; // U64
futureTemplate: boolean; // BOOL
version: number; // U32
coinbaseTxVersion: number; // U32
coinbasePrefix: Buffer; // B0_255
coinbaseTxInputSequence: number; // U32
coinbaseTxValueRemaining: bigint; // U64
coinbaseTxOutputsCount: number; // U32
coinbaseTxOutputs: Buffer; // B0_64K (serialized outputs)
coinbaseTxLocktime: number; // U32
merklePath: Buffer[]; // SEQ0_255<U256>
}
export function serializeTdpNewTemplate(msg: Sv2TdpNewTemplate): Buffer {
const w = new BufferWriter();
w.writeU64(msg.templateId);
w.writeBool(msg.futureTemplate);
w.writeU32(msg.version);
w.writeU32(msg.coinbaseTxVersion);
w.writeB0_255(msg.coinbasePrefix);
w.writeU32(msg.coinbaseTxInputSequence);
w.writeU64(msg.coinbaseTxValueRemaining);
w.writeU32(msg.coinbaseTxOutputsCount);
w.writeB0_64K(msg.coinbaseTxOutputs);
w.writeU32(msg.coinbaseTxLocktime);
w.writeSeq0_255(msg.merklePath, (writer, hash) => writer.writeU256(hash));
return w.toBuffer();
}
export function deserializeTdpNewTemplate(reader: BufferReader): Sv2TdpNewTemplate {
return {
templateId: reader.readU64(),
futureTemplate: reader.readBool(),
version: reader.readU32(),
coinbaseTxVersion: reader.readU32(),
coinbasePrefix: reader.readB0_255(),
coinbaseTxInputSequence: reader.readU32(),
coinbaseTxValueRemaining: reader.readU64(),
coinbaseTxOutputsCount: reader.readU32(),
coinbaseTxOutputs: reader.readB0_64K(),
coinbaseTxLocktime: reader.readU32(),
merklePath: reader.readSeq0_255((r) => r.readU256()),
};
}
// ── SetNewPrevHash (0x72) ──────────────────────────────────────────
export interface Sv2TdpSetNewPrevHash {
templateId: bigint; // U64
prevHash: Buffer; // U256
headerTimestamp: number; // U32
nBits: number; // U32
target: Buffer; // U256
}
export function serializeTdpSetNewPrevHash(msg: Sv2TdpSetNewPrevHash): Buffer {
const w = new BufferWriter();
w.writeU64(msg.templateId);
w.writeU256(msg.prevHash);
w.writeU32(msg.headerTimestamp);
w.writeU32(msg.nBits);
w.writeU256(msg.target);
return w.toBuffer();
}
export function deserializeTdpSetNewPrevHash(reader: BufferReader): Sv2TdpSetNewPrevHash {
return {
templateId: reader.readU64(),
prevHash: reader.readU256(),
headerTimestamp: reader.readU32(),
nBits: reader.readU32(),
target: reader.readU256(),
};
}
// ── RequestTransactionData (0x73) ──────────────────────────────────
export interface Sv2TdpRequestTransactionData {
templateId: bigint; // U64
}
export function serializeTdpRequestTransactionData(msg: Sv2TdpRequestTransactionData): Buffer {
const w = new BufferWriter();
w.writeU64(msg.templateId);
return w.toBuffer();
}
export function deserializeTdpRequestTransactionData(reader: BufferReader): Sv2TdpRequestTransactionData {
return {
templateId: reader.readU64(),
};
}
// ── RequestTransactionData.Success (0x74) ──────────────────────────
export interface Sv2TdpRequestTransactionDataSuccess {
templateId: bigint; // U64
excessData: Buffer; // B0_64K
transactionList: Buffer[]; // SEQ0_64K<B0_16M>
}
export function serializeTdpRequestTransactionDataSuccess(msg: Sv2TdpRequestTransactionDataSuccess): Buffer {
const w = new BufferWriter();
w.writeU64(msg.templateId);
w.writeB0_64K(msg.excessData);
w.writeSeq0_64K(msg.transactionList, (writer, tx) => writer.writeB0_16M(tx));
return w.toBuffer();
}
export function deserializeTdpRequestTransactionDataSuccess(reader: BufferReader): Sv2TdpRequestTransactionDataSuccess {
return {
templateId: reader.readU64(),
excessData: reader.readB0_64K(),
transactionList: reader.readSeq0_64K((r) => r.readB0_16M()),
};
}
// ── RequestTransactionData.Error (0x75) ────────────────────────────
export interface Sv2TdpRequestTransactionDataError {
templateId: bigint; // U64
errorCode: string; // STR0_255
}
export function serializeTdpRequestTransactionDataError(msg: Sv2TdpRequestTransactionDataError): Buffer {
const w = new BufferWriter();
w.writeU64(msg.templateId);
w.writeStr0_255(msg.errorCode);
return w.toBuffer();
}
export function deserializeTdpRequestTransactionDataError(reader: BufferReader): Sv2TdpRequestTransactionDataError {
return {
templateId: reader.readU64(),
errorCode: reader.readStr0_255(),
};
}
// ── SubmitSolution (0x76) ──────────────────────────────────────────
export interface Sv2TdpSubmitSolution {
templateId: bigint; // U64
version: number; // U32
headerTimestamp: number; // U32
headerNonce: number; // U32
coinbaseTx: Buffer; // B0_64K
}
export function serializeTdpSubmitSolution(msg: Sv2TdpSubmitSolution): Buffer {
const w = new BufferWriter();
w.writeU64(msg.templateId);
w.writeU32(msg.version);
w.writeU32(msg.headerTimestamp);
w.writeU32(msg.headerNonce);
w.writeB0_64K(msg.coinbaseTx);
return w.toBuffer();
}
export function deserializeTdpSubmitSolution(reader: BufferReader): Sv2TdpSubmitSolution {
return {
templateId: reader.readU64(),
version: reader.readU32(),
headerTimestamp: reader.readU32(),
headerNonce: reader.readU32(),
coinbaseTx: reader.readB0_64K(),
};
}
+16
View File
@@ -0,0 +1,16 @@
import { AppDataSource } from '../data-source';
async function main() {
await AppDataSource.initialize();
try {
const migrations = await AppDataSource.runMigrations();
console.log(`Ran ${migrations.length} database migrations`);
} finally {
await AppDataSource.destroy();
}
}
main().catch(error => {
console.error('Database migration failed:', error);
process.exit(1);
});
+68
View File
@@ -0,0 +1,68 @@
import { spawn } from 'child_process';
import * as net from 'net';
async function main() {
await waitForTcp(process.env.DB_HOST, parseInt(process.env.DB_PORT ?? '5432', 10), 'TimescaleDB');
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://redis:6379');
await waitForTcp(redisUrl.hostname, parseInt(redisUrl.port || '6379', 10), 'Redis');
await run('node', ['dist/scripts/run-migrations.js']);
if (process.env.PM2_ENABLED === 'false') {
await run('node', ['dist/main.js'], true);
return;
}
await run('./node_modules/.bin/pm2-runtime', ['ecosystem.config.js'], true);
}
function waitForTcp(host: string, port: number, label: string): Promise<void> {
const deadline = Date.now() + parseInt(process.env.STARTUP_WAIT_TIMEOUT_MS ?? '60000', 10);
return new Promise((resolve, reject) => {
const attempt = () => {
const socket = net.createConnection({ host, port });
socket.once('connect', () => {
socket.destroy();
console.log(`${label} is reachable at ${host}:${port}`);
resolve();
});
socket.once('error', () => {
socket.destroy();
if (Date.now() > deadline) {
reject(new Error(`${label} was not reachable at ${host}:${port}`));
return;
}
setTimeout(attempt, 1000);
});
};
attempt();
});
}
function run(command: string, args: string[], inherit = false): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: inherit ? 'inherit' : 'pipe',
env: process.env,
});
if (!inherit) {
child.stdout?.on('data', data => process.stdout.write(data));
child.stderr?.on('data', data => process.stderr.write(data));
}
child.once('exit', code => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${command} ${args.join(' ')} exited with ${code}`));
});
});
}
main().catch(error => {
console.error(error);
process.exit(1);
});
+56 -29
View File
@@ -1,66 +1,93 @@
import { Injectable, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleInit } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service'; import { UserAgentReportService } from '../ORM/_views/user-agent-report/user-agent-report.service';
import { ClientService } from '../ORM/client/client.service'; import { ClientService } from '../ORM/client/client.service';
import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service'; import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service';
import { ShareAccountingService } from '../ORM/share-accounting/share-accounting.service';
@Injectable() @Injectable()
export class AppService implements OnModuleInit { export class AppService implements OnModuleInit {
private refreshingLiveUserAgentReport = false;
private refreshingPoolSummary = false;
constructor( constructor(
private readonly clientStatisticsService: ClientStatisticsService,
private readonly clientService: ClientService, private readonly clientService: ClientService,
private readonly dataSource: DataSource,
private readonly rpcBlockService: RpcBlockService, private readonly rpcBlockService: RpcBlockService,
private readonly userAgentReportService: UserAgentReportService,
private readonly shareAccountingService: ShareAccountingService
) { ) {
} }
async onModuleInit() { async onModuleInit() {
// if (process.env.NODE_APP_INSTANCE == '0') { if (process.env.MASTER == 'true') {
// await this.dataSource.query(`VACUUM;`);
// }
//https://phiresky.github.io/blog/2020/sqlite-performance-tuning/
// //500 MB DB cache
// await this.dataSource.query(`PRAGMA cache_size = -500000;`);
//Normal is still completely corruption safe in WAL mode, and means only WAL checkpoints have to wait for FSYNC.
await this.dataSource.query(`PRAGMA synchronous = off;`);
// //6Gb
// await this.dataSource.query(`PRAGMA mmap_size = 6000000000;`);
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
setInterval(async () => { setInterval(async () => {
await this.deleteOldStatistics(); await this.deleteOldClients();
}, 1000 * 60 * 60); }, 1000 * 60 * 60);
setInterval(async () => {
console.log('Killing dead clients');
await this.clientService.killDeadClients();
}, 1000 * 60 * 5);
setInterval(async () => { setInterval(async () => {
console.log('Deleting Old Blocks'); console.log('Deleting Old Blocks');
await this.rpcBlockService.deleteOldBlocks(); await this.rpcBlockService.deleteOldBlocks();
}, 1000 * 60 * 60 * 24); }, 1000 * 60 * 60 * 24);
setInterval(async () => {
await this.refreshLiveUserAgentReport();
}, 1000 * 30);
setTimeout(async () => {
await this.refreshLiveUserAgentReport();
await this.refreshPoolSummary();
}, 1000 * 15);
setInterval(async () => {
await this.refreshPoolSummary();
}, 1000 * 60 * 5);
setInterval(async () => {
console.log('Refreshing user agent report view')
await this.userAgentReportService.refreshReport();
console.log('Finished Refreshing user agent report view')
}, 1000 * 60 * 5);
} }
} }
private async deleteOldStatistics() { private async deleteOldClients() {
console.log('Deleting statistics'); console.log('Deleting old clients');
const deletedStatistics = await this.clientStatisticsService.deleteOldStatistics();
console.log(`Deleted ${deletedStatistics.affected} old statistics`);
const deletedClients = await this.clientService.deleteOldClients(); const deletedClients = await this.clientService.deleteOldClients();
console.log(`Deleted ${deletedClients.affected} old clients`); console.log(`Deleted ${deletedClients.affected} old clients`);
} }
private async refreshLiveUserAgentReport() {
if (this.refreshingLiveUserAgentReport) {
return;
}
} this.refreshingLiveUserAgentReport = true;
try {
await this.userAgentReportService.refreshLiveReport();
} catch (error) {
console.error(`Failed refreshing live user agent report: ${error.message}`);
} finally {
this.refreshingLiveUserAgentReport = false;
}
}
private async refreshPoolSummary() {
if (this.refreshingPoolSummary) {
return;
}
this.refreshingPoolSummary = true;
try {
await this.shareAccountingService.refreshPoolSummary();
} catch (error) {
console.error(`Failed refreshing pool accounting summary: ${error.message}`);
} finally {
this.refreshingPoolSummary = false;
}
}
}
+96 -80
View File
@@ -1,45 +1,42 @@
import { Injectable, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import * as fs from 'node:fs'; import { asyncScheduler, BehaviorSubject, delay, filter, from, interval, scheduled, shareReplay, startWith, Subject, switchMap } from 'rxjs';
import { BehaviorSubject, filter, shareReplay } from 'rxjs'; import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service';
import * as zmq from 'zeromq'; import * as zmq from 'zeromq';
import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service';
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate'; import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo'; import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
import { RedisMessagingService } from './redis-messaging.service';
@Injectable() @Injectable()
export class BitcoinRpcService implements OnModuleInit { export class BitcoinRpcService implements OnModuleInit {
private blockHeight = 0;
private client: AxiosInstance; private client: AxiosInstance;
private _newBlockTemplate$: BehaviorSubject<IBlockTemplate> = new BehaviorSubject(undefined);
private resetTemplateInterval$ = new Subject<void>();
private rpcRequestId = 0; private rpcRequestId = 0;
private _newBlock$: BehaviorSubject<IMiningInfo> = new BehaviorSubject(undefined);
public newBlock$ = this._newBlock$.pipe(filter(block => block != null), shareReplay({ refCount: true, bufferSize: 1 })); public miningInfo: IMiningInfo;
public newBlockTemplate$ = this._newBlockTemplate$.pipe(filter(block => block != null), shareReplay({ refCount: true, bufferSize: 1 }));
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
private rpcBlockService: RpcBlockService private rpcBlockService: RpcBlockService,
private readonly redisMessagingService: RedisMessagingService
) { ) {
} }
async onModuleInit() { async onModuleInit() {
const url = this.configService.get('BITCOIN_RPC_URL'); const url = this.configService.get('BITCOIN_RPC_URL');
let user = this.configService.get('BITCOIN_RPC_USER'); const user = this.configService.get('BITCOIN_RPC_USER');
let pass = this.configService.get('BITCOIN_RPC_PASSWORD'); const pass = this.configService.get('BITCOIN_RPC_PASSWORD');
const port = parseInt(this.configService.get('BITCOIN_RPC_PORT')); const port = parseInt(this.configService.get('BITCOIN_RPC_PORT'));
const timeout = parseInt(this.configService.get('BITCOIN_RPC_TIMEOUT')); const timeout = parseInt(this.configService.get('BITCOIN_RPC_TIMEOUT'));
const cookiefile = this.configService.get('BITCOIN_RPC_COOKIEFILE');
if (cookiefile != undefined && cookiefile != '') {
const cookie = fs.readFileSync(cookiefile).toString().trim().split(':');
user = cookie[0];
pass = cookie[1];
}
const baseURL = this.buildRpcUrl(url, port); const baseURL = this.buildRpcUrl(url, port);
this.client = axios.create({ this.client = axios.create({
baseURL, baseURL,
@@ -50,101 +47,115 @@ export class BitcoinRpcService implements OnModuleInit {
} }
}); });
this.callRpc('getrpcinfo').then(() => { console.log(`MASTER? ${process.env.MASTER}`)
console.log('Bitcoin RPC connected'); if (process.env.MASTER != 'true') {
}, () => { await this.loadLatestMiningInfoForReplayProcess();
console.error('Could not reach RPC host'); if (process.env.API_ONLY != 'true') {
}); await this.loadLatestTemplateForWorker();
}
await this.redisMessagingService.subscribeMiningInfoUpdates(async (miningInfo: IMiningInfo) => {
this.miningInfo = miningInfo;
if (process.env.API_ONLY != 'true') {
await this.loadTemplateForWorker(miningInfo.blocks);
}
});
if (process.env.API_ONLY == 'true') {
console.log('API-only process using Redis mining info replay');
}
return;
} else {
this.callRpc('getrpcinfo').then((res) => {
console.log('Bitcoin RPC connected');
}, () => {
console.error('Could not reach RPC host');
});
if (this.configService.get('BITCOIN_ZMQ_HOST')) { this.miningInfo = await this.getMiningInfo();
console.log('Using ZMQ'); console.log('Using ZMQ');
const sock = new zmq.Subscriber; const sock = new zmq.Subscriber;
sock.connectTimeout = 1000; sock.connectTimeout = 1000;
sock.events.on('connect', () => { sock.events.on('connect', () => {
console.log('ZMQ Connected'); console.log('ZMQ Connected');
}); });
sock.events.on('connect:retry', () => { sock.events.on('connect:retry', () => {
console.log('ZMQ Unable to connect, Retrying'); console.error('ZMQ Unable to connect, Retrying');
}); });
sock.connect(this.configService.get('BITCOIN_ZMQ_HOST')); sock.connect(this.configService.get('BITCOIN_ZMQ_HOST'));
sock.subscribe('rawblock'); sock.subscribe('rawblock');
// Don't await this, otherwise it will block the rest of the program // Don't await this, otherwise it will block the rest of the program
this.listenForNewBlocks(sock); this.listenForNewBlocks(sock);
await this.pollMiningInfo();
} else { // Between new blocks we want refresh jobs with the latest transactions
setInterval(this.pollMiningInfo.bind(this), 500); this.resetTemplateInterval$.pipe(
startWith(null),
switchMap(() =>interval(60000))
).subscribe(async () =>{
await this.getAndBroadcastLatestTemplate();
});
}
}
private async loadLatestMiningInfoForReplayProcess() {
const latestMiningInfo = await this.redisMessagingService.getLatestMiningInfo();
if (latestMiningInfo != null) {
this.miningInfo = latestMiningInfo;
} }
} }
private async listenForNewBlocks(sock: zmq.Subscriber) { private async listenForNewBlocks(sock: zmq.Subscriber) {
for await (const [topic, msg] of sock) { for await (const [topic, msg] of sock) {
console.log("New Block"); console.log("New Block");
await this.pollMiningInfo(); this.miningInfo = await this.getMiningInfo();
await this.getAndBroadcastLatestTemplate();
//Reset the block update interval
this.resetTemplateInterval$.next();
} }
} }
public async pollMiningInfo() { public async getAndBroadcastLatestTemplate() {
const miningInfo = await this.getMiningInfo(); const blockTemplate = await this.loadBlockTemplate(this.miningInfo.blocks);
if (miningInfo != null && miningInfo.blocks > this.blockHeight) { this._newBlockTemplate$.next(blockTemplate);
console.log("block height change"); await this.redisMessagingService.setLatestMiningInfo(this.miningInfo);
this._newBlock$.next(miningInfo); await this.redisMessagingService.setBlockTemplate(this.miningInfo.blocks, blockTemplate);
this.blockHeight = miningInfo.blocks; await this.redisMessagingService.publishMiningInfoUpdate(this.miningInfo);
}
private async loadLatestTemplateForWorker() {
const latestMiningInfo = await this.redisMessagingService.getLatestMiningInfo();
if (latestMiningInfo != null) {
this.miningInfo = latestMiningInfo;
await this.loadTemplateForWorker(latestMiningInfo.blocks);
return;
}
const latestBlockTemplate = await this.redisMessagingService.getLatestBlockTemplate();
if (latestBlockTemplate != null) {
this._newBlockTemplate$.next(latestBlockTemplate);
} }
} }
private async waitForBlock(blockHeight: number): Promise<IBlockTemplate> { private async loadTemplateForWorker(blockHeight: number) {
while (true) { const redisBlockTemplate = await this.redisMessagingService.getBlockTemplate(blockHeight);
await new Promise(r => setTimeout(r, 100)); if (redisBlockTemplate != null) {
this._newBlockTemplate$.next(redisBlockTemplate);
const block = await this.rpcBlockService.getBlock(blockHeight); return;
if (block != null && block.data != null) {
console.log(`promise loop resolved, block height ${blockHeight}`);
return Promise.resolve(JSON.parse(block.data));
}
console.log(`promise loop, block height ${blockHeight}`);
} }
}
public async getBlockTemplate(blockHeight: number): Promise<IBlockTemplate> { const savedBlockTemplate = await this.rpcBlockService.getSavedBlockTemplate(blockHeight);
let result: IBlockTemplate; if (savedBlockTemplate?.data != null) {
try { this._newBlockTemplate$.next(JSON.parse(savedBlockTemplate.data));
const block = await this.rpcBlockService.getBlock(blockHeight);
const completeBlock = block?.data != null;
// If the block has already been loaded, and the same instance is fetching the template again, we just need to refresh it.
if (completeBlock && block.lockedBy == process.env.NODE_APP_INSTANCE) {
result = await this.loadBlockTemplate(blockHeight);
}
else if (completeBlock) {
return Promise.resolve(JSON.parse(block.data));
} else if (!completeBlock) {
if (process.env.NODE_APP_INSTANCE != null) {
// There is a unique constraint on the block height so if another process tries to lock, it'll throw
try {
await this.rpcBlockService.lockBlock(blockHeight, process.env.NODE_APP_INSTANCE);
} catch (e) {
result = await this.waitForBlock(blockHeight);
}
}
result = await this.loadBlockTemplate(blockHeight);
} else {
//wait for block
result = await this.waitForBlock(blockHeight);
}
} catch (e) {
console.error('Error getblocktemplate:', e.message);
throw new Error('Error getblocktemplate');
} }
console.log(`getblocktemplate tx count: ${result.transactions.length}`);
return result;
} }
private async loadBlockTemplate(blockHeight: number) { private async loadBlockTemplate(blockHeight: number) {
console.log(`Master fetching block template ${blockHeight}`);
let blockTemplate: IBlockTemplate; let blockTemplate: IBlockTemplate;
while (blockTemplate == null) { while (blockTemplate == null) {
blockTemplate = await this.callRpc<IBlockTemplate>('getblocktemplate', [ blockTemplate = await this.callRpc<IBlockTemplate>('getblocktemplate', [
@@ -156,8 +167,13 @@ export class BitcoinRpcService implements OnModuleInit {
]); ]);
} }
try {
await this.rpcBlockService.saveBlock(blockHeight, JSON.stringify(blockTemplate)); console.log(`Saving block ${blockHeight}`);
await this.rpcBlockService.saveBlock(blockHeight, JSON.stringify(blockTemplate));
console.log('block saved');
} catch (e) {
console.error('Error saving block', e);
}
return blockTemplate; return blockTemplate;
} }
+5 -5
View File
@@ -34,7 +34,7 @@ export class DiscordService implements OnModuleInit {
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') { if (process.env.MASTER == 'true') {
this.token = this.configService.get('DISCORD_BOT_TOKEN'); this.token = this.configService.get('DISCORD_BOT_TOKEN');
this.clientId = this.configService.get('DISCORD_BOT_CLIENTID'); this.clientId = this.configService.get('DISCORD_BOT_CLIENTID');
this.guildId = this.configService.get('DISCORD_BOT_GUILD_ID'); this.guildId = this.configService.get('DISCORD_BOT_GUILD_ID');
@@ -61,7 +61,7 @@ export class DiscordService implements OnModuleInit {
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') { if (process.env.MASTER == 'true') {
if (this.bot == null) { if (this.bot == null) {
return; return;
} }
@@ -93,7 +93,7 @@ export class DiscordService implements OnModuleInit {
} }
private async registerCommands() { private async registerCommands() {
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') { if (process.env.MASTER == 'true') {
const rest = new REST().setToken(this.token); const rest = new REST().setToken(this.token);
try { try {
console.log(`Started refreshing ${commands.length} application (/) commands.`); console.log(`Started refreshing ${commands.length} application (/) commands.`);
@@ -113,7 +113,7 @@ export class DiscordService implements OnModuleInit {
} }
public async notifyRestarted() { public async notifyRestarted() {
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') { if (process.env.MASTER == 'true') {
if (this.bot == null) { if (this.bot == null) {
return; return;
} }
@@ -125,7 +125,7 @@ export class DiscordService implements OnModuleInit {
} }
public async notifySubscribersBlockFound(height: number, block: Block, message: string) { public async notifySubscribersBlockFound(height: number, block: Block, message: string) {
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') { if (process.env.MASTER == 'true') {
if (this.bot == null) { if (this.bot == null) {
return; return;
} }
-31
View File
@@ -1,31 +0,0 @@
import { Injectable } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { ExternalPoolShare } from '../models/ExternalPoolShare';
@Injectable()
export class ExternalSharesService {
private readonly shareApiUrl: string;
private readonly shareApiKey: string;
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService
) {
this.shareApiUrl = this.configService.get('SHARE_SUBMISSION_URL') || 'https://web.public-pool.io';
this.shareApiKey = this.configService.get('SHARE_SUBMISSION_API_KEY');
}
public submitShare(share: ExternalPoolShare): void {
this.httpService.post(`${this.shareApiUrl}/api/share`, share, {
headers: {
'x-api-key': this.shareApiKey
}
}).subscribe({
next: () =>{
console.log('External share accepted');
},
error: (error) => console.error('Failed to submit share to API:', error.message)
});
}
}

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