184 Commits
Author SHA1 Message Date
Ben a33c530268 Add SV2 DATUM payout hardening 2026-06-12 23:54:50 -04:00
Ben bcd6bb0aa7 Add accepted share rollup batches 2026-06-09 22:55:11 -04:00
Ben 1c5a3d9a94 performance and optimization 2026-06-09 17:13:27 -04:00
Ben efbd95e464 Keep SV1 client entity during cleanup 2026-06-09 12:45:33 -04:00
Ben bb0acf0395 Pass Stratum backpressure setting to container 2026-06-09 12:42:27 -04:00
Ben 9b99c5153a Harden Stratum V1 cleanup 2026-06-09 12:30:03 -04:00
Ben a693501648 Optimize latest completed accounting bucket lookup 2026-06-08 18:49:30 -04:00
Ben f259af947e Remove accepted share retention policy 2026-06-08 18:38:42 -04:00
Ben f29081de36 Use latest available completed accounting bucket 2026-06-08 18:21:44 -04:00
Ben 0acc1910db Use completed share rollups for pool accounting 2026-06-08 18:17:49 -04:00
Ben d847e2666a Align accounting last ten minute rollup window 2026-06-08 13:54:31 -04:00
Ben 294c4a6825 Expose fallback best share in client accounting 2026-06-08 13:47:43 -04:00
Ben 8252111c32 Harden accounting and stratum production settings 2026-06-08 13:27:24 -04:00
Ben deaf3e56bd Serve client accounting from rollup cache 2026-06-08 01:42:17 -04:00
Ben 9b73e33b13 Add current round best share index migration 2026-06-08 01:32:59 -04:00
Ben 0560b874dc Use current round best share for dashboard 2026-06-08 01:32:19 -04:00
Ben 7f6f42c5c9 Restore live pool accounting dashboard values 2026-06-08 01:09:42 -04:00
Ben 4c59797886 Avoid client accounting queries in API workers 2026-06-08 00:59:40 -04:00
Ben 4c91e69ca8 Use share rollups for API accounting 2026-06-08 00:55:51 -04:00
Ben 7b7dc87242 Add API timing diagnostics 2026-06-08 00:47:11 -04:00
Ben df35549f27 Avoid repeated cold info refreshes 2026-06-08 00:31:56 -04:00
Ben 8c795a3fa2 Run API as an API-only cluster 2026-06-08 00:26:48 -04:00
Ben ac7827a944 Restore in-memory API cache priority 2026-06-08 00:19:56 -04:00
Ben 17ba4afc1a Revert "Harden API process under production load"
This reverts commit 817f416b79.
2026-06-08 00:17:15 -04:00
Ben b612b50f3e Revert "Relax API connection hardening defaults"
This reverts commit b6a951bdee.
2026-06-08 00:17:15 -04:00
Ben b6a951bdee Relax API connection hardening defaults 2026-06-08 00:14:58 -04:00
Ben 817f416b79 Harden API process under production load 2026-06-08 00:10:37 -04:00
Ben d05d5f0b37 Bound info endpoint cold cache work 2026-06-07 23:32:49 -04:00
Ben 3da95b964b Restore lightweight info endpoint 2026-06-07 23:29:08 -04:00
Ben 4155335bb5 Limit background services by process role 2026-06-07 23:18:00 -04:00
Ben 13106347b2 Avoid raw pool summary on API process 2026-06-07 23:13:14 -04:00
Ben 96e0e36755 Keep API process out of SV2 sockets 2026-06-07 23:09:44 -04:00
Ben 93dde1a899 Cache pool accounting summary 2026-06-07 23:02:57 -04:00
Ben eac0fe65ab Cache live user agent report off request path 2026-06-07 22:58:00 -04:00
Ben f14d76c2ea Isolate API process from mining workers 2026-06-07 22:53:15 -04:00
Ben 51e3afb7e7 Fix client presence cleanup index 2026-06-07 22:46:46 -04:00
Ben 3c33fcd3c4 Serve API only from master process 2026-06-07 22:40:45 -04:00
Ben 78fbf12f8f Make share index migration online safe 2026-06-07 22:13:08 -04:00
Ben 3e1c303fa0 Add accepted share order index 2026-06-07 22:03:32 -04:00
Ben 4f51a4d05c Omit current chart bucket 2026-06-07 21:37:23 -04:00
Ben 2d6b01691c Cache hot API responses in Redis 2026-06-07 21:32:03 -04:00
Ben 4a5d4bbb71 Reduce dashboard Timescale query load 2026-06-07 21:26:22 -04:00
Ben c0604aaf2a Expose configurable Docker API publish port 2026-06-07 21:08:18 -04:00
Ben ec69f5fb6c Migrate pool storage to TimescaleDB and Redis 2026-06-07 20:33:13 -04:00
Ben fb367ac5f0 sv1-sv2 routing 2026-06-05 23:44:00 -04:00
Ben 3d6c8b3134 sv2 debug 2026-06-05 23:41:22 -04:00
Ben 2a37cb1f06 Stratum V2 initial commit 2026-06-05 13:14:09 -04:00
Ben f2d2283b47 fix possible dup work on diff change 2026-06-05 09:51:06 -04:00
Ben 4cab56a1a0 prevent half connections staying open 2026-05-30 19:24:34 -04:00
Ben c5169e105e add stratum worker backpressure 2026-05-07 23:56:55 -04:00
Ben 2d6d2afacb dead code 2026-05-07 23:52:56 -04:00
Ben 327be48ade update postgresql branch 2026-05-07 23:45:07 -04:00
Ben cc0ca3979e extranonce2 8 bytes 2026-05-06 18:18:34 -04:00
Benjamin WilsonandWantClue 9bf3dde862 validateHeaderCompliance (#145)
Co-authored-by: WantClue <wantclue@users.noreply.github.com>
2026-01-24 14:53:05 -05:00
Ben 8b2bea702a enable stratum TLS 2025-11-29 11:45:44 -05:00
Ben 63a1618f26 killDeadClients 2025-11-21 20:56:27 -05:00
Ben 1b0f3f52d8 index 2025-11-21 20:52:52 -05:00
Ben 78c00fd25c killDeadClients 2025-11-21 20:34:00 -05:00
Ben dd45c60eff killDeadClients 2025-11-21 20:01:55 -05:00
Ben 0481dfda6e dead clients 2025-11-21 18:56:29 -05:00
Ben 6f6abe5e2d batch kill dead clients 2025-11-21 18:42:00 -05:00
Ben ff84d8284f watch cert and reload 2025-08-22 19:01:46 -04:00
Ben cdf6eb807b other filtering 2025-04-29 15:31:28 -04:00
Benjamin Wilson d9c89347e1 remove logging 2025-04-27 13:13:05 -04:00
Ben c1d8341c22 Prevent duplicate share submissions - this is why we can't have nice things 2025-04-27 13:07:24 -04:00
Benjamin Wilson f39c0dc059 Increase miner compatibility by allowing, but stripping unknown message values rather than rejecting as validation errors 2025-04-19 13:50:50 -04:00
Benjamin Wilson 3dab14e3b7 increase timeout to 15min 2025-03-03 20:29:31 -05:00
Benjamin Wilson 5060345809 better way to calculate hashrate 2025-02-27 16:22:48 -05:00
Benjamin Wilson b44f09848e don't calculate hashrate for 1 min 2025-02-27 12:15:20 -05:00
Benjamin Wilson 9b5402f1b0 refactor hashrate calculation 2025-02-27 12:10:43 -05:00
Benjamin Wilson 17881f70d6 heartbeat 2025-02-26 20:20:37 -05:00
Benjamin Wilson e9949f4038 utc string 2025-02-26 20:15:24 -05:00
Benjamin Wilson 7c3b31f7b3 timezone 2025-02-26 19:59:33 -05:00
Benjamin Wilson 78deb3a375 logging 2025-02-26 17:52:32 -05:00
Benjamin Wilson c5c8810da6 heartbeat updates 2025-02-26 17:48:27 -05:00
Benjamin Wilson 8584c442dc bug 2025-02-26 17:07:04 -05:00
Benjamin Wilson fd24807c55 cleanup 2025-02-26 09:50:42 -05:00
Benjamin Wilson bd5333a725 fix data type 2025-02-26 09:16:46 -05:00
Benjamin Wilson 419eb09853 logging 2025-02-26 00:32:43 -05:00
Benjamin Wilson 74f5783dc3 logging 2025-02-26 00:24:34 -05:00
Benjamin Wilson 34a27d0f2f update all stats 2025-02-26 00:18:25 -05:00
Benjamin Wilson d8e087f720 server start setTimeout 2025-02-26 00:08:20 -05:00
Benjamin Wilson 34eb0ca0f2 logging 2025-02-26 00:06:25 -05:00
Benjamin Wilson a33d7e07b3 logging 2025-02-25 23:59:29 -05:00
Benjamin Wilson f8d1c61c1c bug 2025-02-25 23:40:50 -05:00
Benjamin Wilson 326a5a7b33 typeo 2025-02-25 19:27:34 -05:00
Benjamin Wilson 903aa2628c query 2025-02-25 19:17:13 -05:00
Benjamin Wilson 19f49b74e6 sql camel case 2025-02-25 19:11:10 -05:00
Benjamin Wilson 864867ffd4 remove socket server delay 2025-02-25 19:06:33 -05:00
Benjamin Wilson f465b837f5 bulk client stat updates 2025-02-25 19:02:38 -05:00
Remco Ros 637d30f1a5 patch rpc-bitcoin to be compatible with bitcoin core 28.0 (#66) 2025-02-24 19:37:47 -05:00
Benjamin Wilson df87214de9 Online device list compaction 2025-02-23 12:57:38 -05:00
Benjamin Wilson 66ce822faa Fix kill dead clients tx wantclue 2025-02-22 17:47:46 -05:00
Benjamin Wilson 78ef48b805 logging 2025-02-10 00:07:14 -05:00
Benjamin Wilson b545e74d4f delete old templates and jobs 2025-02-10 00:01:55 -05:00
Benjamin Wilson 5702e20dc0 reduce the user agent list 2025-02-09 19:38:08 -05:00
Benjamin Wilson 74dd5ba66f Use pg pub/sub for stateless app notification 2025-02-09 17:04:31 -05:00
Benjamin Wilson 674b02ca9d socket cleanup 2025-02-03 15:39:03 -05:00
Benjamin Wilson 40ee610600 logs 2025-02-01 12:36:07 -05:00
Benjamin Wilson 7e0de263f2 Revert "Fix home graph hashrate calculation (around restarts)"
This reverts commit 220a1d1558.
2025-02-01 12:35:55 -05:00
Benjamin Wilson 65232da901 block save => upsert 2025-02-01 11:57:34 -05:00
Benjamin Wilson 220a1d1558 Fix home graph hashrate calculation (around restarts) 2025-01-02 23:34:43 -05:00
Benjamin Wilson ae7ed021a3 logging 2025-01-02 11:15:46 -05:00
Benjamin Wilson 056fe7af4f logging 2025-01-01 23:06:19 -05:00
Benjamin Wilson 912ae9fe85 tweak timeout and starting diff 2024-12-22 10:03:03 -05:00
Benjamin Wilson d513dab932 user agent cleanup 2024-10-16 13:53:49 -04:00
Benjamin Wilson 3a1f3bba10 Ensure mintime is considered for jobs 2024-09-27 13:00:52 -04:00
Benjamin Wilson 8fdf1e839e save block 2024-09-16 19:48:46 -04:00
Benjamin Wilson 76d92d84b4 debug 2024-09-16 19:43:24 -04:00
Benjamin Wilson 4928f34daf debug 2024-09-16 19:39:56 -04:00
Benjamin Wilson 88e7f5f3fb Saving block error 2024-09-16 19:35:13 -04:00
Benjamin Wilson d97843af3d getBlockTemplate 2024-09-16 19:31:40 -04:00
Benjamin Wilson c1ab6a152e loadBlockTemplate 2024-09-16 19:28:00 -04:00
Benjamin Wilson 717a5c7878 getBlockTemplate fix 2024-09-16 19:24:34 -04:00
Benjamin Wilson 6038af8448 fix getBlockTemplate 2024-09-16 19:16:38 -04:00
Benjamin Wilson d6863fad28 timing adjustments 2024-09-16 18:32:43 -04:00
Benjamin Wilson b9b5fe2cf5 cluster 2024-09-09 20:27:17 -04:00
Benjamin Wilson 9db0afd9df removed NODE_APP_INSTANCE 0 dependency 2024-09-09 20:18:37 -04:00
Benjamin Wilson fb8715c23d multi port 2024-09-07 23:56:09 -04:00
Benjamin Wilson ff6d446c12 validation handling 2024-08-22 15:18:40 -04:00
Benjamin Wilson 02d718934d initialize hashrate to 0 2024-07-17 17:24:04 -04:00
Benjamin Wilson d40742ef71 bug 2024-07-15 09:16:55 -04:00
Benjamin Wilson 38ecbdc7ec logging 2024-07-14 11:55:49 -04:00
Benjamin Wilson 8905409880 null jobTemplate 2024-07-10 22:40:02 -04:00
Benjamin Wilson 2bfbc2d79a logging 2024-07-10 21:51:46 -04:00
Benjamin Wilson 2ee82a269c loadBlockTemplate 2024-07-05 20:22:06 -04:00
Benjamin Wilson fe4d58a60d StratumBaseMessage validation 2024-07-05 20:21:47 -04:00
Benjamin Wilson b858a594b8 open stratum base message to accept number or string 2024-07-05 08:19:54 -04:00
Benjamin Wilson 3e4cb4ff5d always clear jobs on diff change 2024-07-05 08:13:55 -04:00
Benjamin Wilson 14f95a7fa4 tweaks 2024-07-04 21:03:51 -04:00
Ben Wilson 522ef126d9 updated at, hashrate calculation 2024-07-04 20:42:37 -04:00
Benjamin Wilson 0c34f23284 ensure block template is fresh 2024-07-04 20:06:48 -04:00
Benjamin Wilson e52d14b0a9 handle partial socket data 2024-07-04 17:19:58 -04:00
Benjamin Wilson 3727773a2e getBlockTemplate improvements 2024-07-04 16:46:57 -04:00
Benjamin Wilson ad3035b908 ensure getblocktemplate always returns a value 2024-07-04 16:46:44 -04:00
Benjamin Wilson 3e279ee662 remove dead code 2024-07-04 16:43:02 -04:00
Ben Wilson deeb40967e decrease submission rate 2024-04-30 16:45:08 -04:00
Ben Wilson f1221651f6 diff fix 2024-03-29 12:17:30 -04:00
Ben Wilson 32a604e745 allow client to suggest diff in password field "d=1234" 2024-03-27 17:02:11 -04:00
Georges 6442bb45e0 stratum: set_difficulty should always be a notification (#32) 2024-02-23 12:54:16 -05:00
Ben Wilson f2930681d3 optional password 2024-02-23 12:54:16 -05:00
Ben Wilson 3d3d1a55a8 share stats 2024-02-18 14:38:45 -05:00
Ben Wilson 01adb0a997 ClientStatistics updatedAt 2024-02-18 12:21:52 -05:00
Ben Wilson 5ead6c605b remove server start delay 2024-02-18 10:52:28 -05:00
Ben Wilson 4fd8d9ceb6 change coinbase tag 2024-02-17 20:31:23 -05:00
Ben Wilson 9d4c3ad35e lastSave 2024-02-17 20:12:37 -05:00
Ben Wilson aab69b65fc clientid 2024-02-17 19:01:24 -05:00
Ben Wilson 9895e80a01 share updates 2024-02-17 18:40:34 -05:00
Ben Wilson 9617ec5685 statistics saving 2024-02-17 18:25:14 -05:00
Ben Wilson aefb180fb4 fix pool info 2024-02-17 16:27:37 -05:00
Ben Wilson 87508bfb69 user agent report 2024-02-16 23:49:47 -05:00
Ben Wilson 39e537a00c bug 2024-02-16 17:18:21 -05:00
Ben Wilson 9ecfc68155 bug 2024-02-16 17:17:19 -05:00
Ben Wilson 3e2de62259 db refactor 2024-02-16 17:12:36 -05:00
Ben Wilson 8e977c21a1 Merge remote-tracking branch 'origin/master' into postgresql 2024-02-14 08:38:05 -05:00
Ben Wilson 5fd9f98807 index, dead code 2024-01-22 17:32:34 -05:00
Ben Wilson 6bc42defef separate home graph into it's own table 2024-01-16 19:37:29 -05:00
Ben Wilson bbc14e2272 bug 2024-01-15 20:04:26 -05:00
Ben Wilson fc767fa770 reduce heartbeat updates 2024-01-14 14:10:47 -05:00
Ben Wilson 40e32d6e1a stratum jobs array to dictionary 2023-12-15 00:56:22 -05:00
Ben Wilson df6ec07c58 batch save share statistics 2023-12-15 00:25:01 -05:00
Ben Wilson df245f396e parameterized typeorm 2023-12-14 23:54:45 -05:00
Ben Wilson 42fc863c7c column names 2023-12-14 23:47:51 -05:00
Ben Wilson e46bb24155 raw query 2023-12-14 23:44:41 -05:00
Ben Wilson cb4a173733 add back index with time 2023-12-14 22:45:12 -05:00
Ben Wilson 83c769849d removed hash from client stats table, connection pool size 2023-12-14 22:30:55 -05:00
Ben Wilson ee5fea1df5 wrap save in a transaction 2023-12-10 17:23:08 -05:00
Ben Wilson 06acad6770 example 2023-12-08 00:12:25 -05:00
Ben Wilson f7b22bb310 delete old stats sooner 2023-12-07 09:57:20 -05:00
Ben Wilson 6cf6a007d9 remove read UNCOMMITTED 2023-12-07 09:55:36 -05:00
Ben Wilson 5b71632f8a postgres doesn't support read UNCOMMITTED 2023-12-07 09:55:07 -05:00
Ben Wilson c5ff8521ac Merge branch 'postgresql' of https://github.com/benjamin-wilson/public-pool into postgresql 2023-12-07 09:53:40 -05:00
Ben Wilson 53ee510a1e fix chart, caching 2023-12-07 09:53:16 -05:00
Ben Wilson 7be554a358 fix chart, caching 2023-12-07 09:40:32 -05:00
Ben Wilson eb5d434157 few optimizations 2023-12-07 09:07:37 -05:00
Ben Wilson 893c7c2003 revert 2023-12-07 08:33:43 -05:00
Ben Wilson 4736eadf7b row names 2023-12-07 08:19:51 -05:00
Ben Wilson 1089e6210c deadlock? 2023-12-07 08:16:48 -05:00
Ben Wilson 017978d250 userAgent 2023-12-04 08:52:49 -05:00
Ben Wilson 5a17528b8f bug 2023-12-04 08:33:47 -05:00
Ben Wilson 0b147ea5db fix numbers returned from query as string 2023-12-04 08:26:47 -05:00
Ben Wilson bffcf6c74c config 2023-12-04 01:07:45 -05:00
Ben Wilson 96a38f9d9b bug 2023-12-04 01:06:53 -05:00
Ben Wilson 39c4c033e2 postgres init 2023-12-04 00:34:33 -05:00
155 changed files with 30548 additions and 1818 deletions
+85 -9
View File
@@ -10,16 +10,55 @@ 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_WORKER_MAX_MEMORY_RESTART=4096M
STRATUM_MIN_DIFFICULTY=1
STRATUM_SOCKET_TIMEOUT_MS=3600000
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS=60000
# Optional additional SV2-only mining ports. Usually unnecessary when STRATUM_PORTS are exposed.
#STRATUM_V2_PORTS=
#SV2_START_DIFFICULTY=100000
#SV2_TARGET_SHARES_PER_MINUTE=2
#SV2_DIFFICULTY_CHECK_INTERVAL_MS=60000
#STRATUM_V2_SOCKET_TIMEOUT_MS=3600000
#SV2_AUTHORITY_PRIVKEY=
#SV2_JDP_PORTS=
#SV2_JDP_POOL_PAYOUT_ADDRESS=
#SV2_JDP_TOKEN_TTL_MS=600000
#SV2_TDP_PORTS=
#SV2_TDP_POOL_PAYOUT_ADDRESS=
# Optional DATUM Prime-compatible listener. DATUM_POOL_PAYOUT_ADDRESS is the
# required pool coinbase output DATUM gateways must include in custom templates.
#DATUM_PORTS=
#DATUM_POOL_PAYOUT_ADDRESS=
#DATUM_SHARE_DIFFICULTY=1
# 32-byte hex seed for the DATUM server identity key. DATUM clients must be
# configured with the matching logged public key.
#DATUM_IDENTITY_SEED=
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 +74,42 @@ 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
SHARE_ROLLUP_ENABLED=true
SHARE_ROLLUP_INTERVAL_MS=60000
SHARE_ROLLUP_SAFETY_LAG_SECONDS=30
SHARE_ROLLUP_MAX_SHARES_PER_BATCH=5000000
# payout snapshot accounting
PAYOUT_SNAPSHOT_ENABLED=true
PAYOUT_METHOD=pplns
PAYOUT_WINDOW_FACTOR=4
# solo preserves current one-address coinbase payouts. Set to snapshot to pay
# the latest payout snapshot directly in coinbase outputs.
PAYOUT_COINBASE_MODE=solo
PAYOUT_MAX_COINBASE_OUTPUTS=10
PAYOUT_MIN_OUTPUT_SATS=546
PAYOUT_FEE_ADDRESS=
PAYOUT_FEE_PERCENT=0
PAYOUT_COINBASE_WEIGHT_BUDGET=50000
#redis
REDIS_URL=redis://redis:6379
PRODUCTION=false
+6 -2
View File
@@ -13,10 +13,14 @@ lerna-debug.log*
# OS # OS
.DS_Store .DS_Store
._*
# Tests # Tests
/coverage /coverage
/.nyc_output /.nyc_output
/.baseline-*.json
/.test-artifacts
/.profiles
# IDEs and editors # IDEs and editors
/.idea /.idea
@@ -38,7 +42,7 @@ settings.json
#DB #DB
**.sqlite** **.sqlite**
./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
```
+99
View File
@@ -0,0 +1,99 @@
x-log-limits: &log-limits
driver: json-file
options:
max-size: ${DOCKER_LOG_MAX_SIZE:-100m}
max-file: "${DOCKER_LOG_MAX_FILES:-5}"
services:
redis:
image: redis:8-alpine
container_name: public-pool-redis
restart: unless-stopped
logging: *log-limits
ports:
- "127.0.0.1:${REDIS_PORT:-6379}:6379/tcp"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
public-pool:
container_name: public-pool
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
logging: *log-limits
depends_on:
redis:
condition: service_healthy
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "0.0.0.0:3333:3333/tcp"
- "0.0.0.0:3332:3332/tcp"
- "0.0.0.0:3331:3331/tcp"
- "0.0.0.0:3330:3330/tcp"
- "0.0.0.0:4333:4333/tcp"
- "0.0.0.0:4332:4332/tcp"
- "0.0.0.0:4331:4331/tcp"
- "0.0.0.0:4330:4330/tcp"
- "${API_BIND_HOST:-127.0.0.1}:${API_PUBLIC_PORT:-3334}:3334/tcp"
volumes:
- "./.env:/public-pool/.env:ro"
- "./secrets:/public-pool/secrets:ro"
environment:
NODE_ENV: production
DB_HOST: ${DB_HOST:?set DB_HOST to your external TimescaleDB/Postgres host}
DB_PORT: ${DB_PORT:-5432}
DB_USERNAME: ${DB_USERNAME:-public_pool}
DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD for your external database}
DB_DATABASE: ${DB_DATABASE:-public_pool}
DB_POOL_SIZE: ${DB_POOL_SIZE:-10}
DB_SSL: ${DB_SSL:-false}
DB_SSL_REJECT_UNAUTHORIZED: ${DB_SSL_REJECT_UNAUTHORIZED:-true}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
API_PORT: ${API_PORT:-3334}
API_SECURE: ${API_SECURE:-false}
API_WORKERS: ${API_WORKERS:-4}
PM2_ENABLED: ${PM2_ENABLED:-true}
STRATUM_WORKERS: ${STRATUM_WORKERS:-2}
STRATUM_PORTS: ${STRATUM_PORTS:-3333,3332,3331,3330}
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
STRATUM_SOCKET_TIMEOUT_MS: ${STRATUM_SOCKET_TIMEOUT_MS:-3600000}
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS: ${STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS:-60000}
STRATUM_SECURE: ${STRATUM_SECURE:-true}
SECURE_STRATUM_PORTS: ${SECURE_STRATUM_PORTS:-4333,4332,4331,4330}
STRATUM_MAX_CONNECTIONS_PER_LISTENER: ${STRATUM_MAX_CONNECTIONS_PER_LISTENER:-10000}
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS: ${STRATUM_TLS_HANDSHAKE_TIMEOUT_MS:-10000}
STRATUM_BACKPRESSURE_ENABLED: "${STRATUM_BACKPRESSURE_ENABLED:-false}"
STRATUM_V2_PORTS: ${STRATUM_V2_PORTS:-}
SHARE_ACCOUNTING_BATCH_SIZE: ${SHARE_ACCOUNTING_BATCH_SIZE:-500}
SHARE_ACCOUNTING_FLUSH_INTERVAL_MS: ${SHARE_ACCOUNTING_FLUSH_INTERVAL_MS:-25}
SHARE_ACCOUNTING_MAX_QUEUE_SIZE: ${SHARE_ACCOUNTING_MAX_QUEUE_SIZE:-50000}
SHARE_ACCOUNTING_SUMMARY_CACHE_MS: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MS:-2500}
SHARE_ACCOUNTING_SUMMARY_CACHE_MAX: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MAX:-10000}
SHARE_ROLLUP_ENABLED: ${SHARE_ROLLUP_ENABLED:-true}
SHARE_ROLLUP_INTERVAL_MS: ${SHARE_ROLLUP_INTERVAL_MS:-60000}
SHARE_ROLLUP_SAFETY_LAG_SECONDS: ${SHARE_ROLLUP_SAFETY_LAG_SECONDS:-30}
SHARE_ROLLUP_MAX_SHARES_PER_BATCH: ${SHARE_ROLLUP_MAX_SHARES_PER_BATCH:-5000000}
PAYOUT_SNAPSHOT_ENABLED: ${PAYOUT_SNAPSHOT_ENABLED:-true}
PAYOUT_METHOD: ${PAYOUT_METHOD:-pplns}
PAYOUT_WINDOW_FACTOR: ${PAYOUT_WINDOW_FACTOR:-4}
PAYOUT_COINBASE_MODE: ${PAYOUT_COINBASE_MODE:-solo}
PAYOUT_MAX_COINBASE_OUTPUTS: ${PAYOUT_MAX_COINBASE_OUTPUTS:-10}
PAYOUT_MIN_OUTPUT_SATS: ${PAYOUT_MIN_OUTPUT_SATS:-546}
PAYOUT_FEE_ADDRESS: ${PAYOUT_FEE_ADDRESS:-}
PAYOUT_FEE_PERCENT: ${PAYOUT_FEE_PERCENT:-0}
PAYOUT_COINBASE_WEIGHT_BUDGET: ${PAYOUT_COINBASE_WEIGHT_BUDGET:-50000}
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:
+54
View File
@@ -0,0 +1,54 @@
const dockerLogConfig = {
out_file: '/dev/stdout',
error_file: '/dev/stderr',
merge_logs: true,
};
module.exports = {
apps: [
// API instance
{
...dockerLogConfig,
name: 'api',
script: './dist/main.js',
instances: parseInt(process.env.API_WORKERS || '4', 10),
exec_mode: 'cluster',
env: {
MASTER: 'false',
API_ONLY: 'true',
API_ENABLED: 'true',
NODE_CLUSTER_SCHED_POLICY: 'none',
},
time: true
},
// Master instance
{
...dockerLogConfig,
name: 'master',
script: './dist/main.js',
instances: 1,
exec_mode: 'fork',
env: {
MASTER: 'true',
API_ENABLED: 'false',
NODE_CLUSTER_SCHED_POLICY: 'none',
},
time: true
},
// Worker instances
{
...dockerLogConfig,
name: 'workers',
script: './dist/main.js',
instances: parseInt(process.env.STRATUM_WORKERS || '2', 10),
exec_mode: "cluster",
max_memory_restart: process.env.STRATUM_WORKER_MAX_MEMORY_RESTART || '4096M',
env: {
MASTER: 'false',
API_ENABLED: 'false',
NODE_CLUSTER_SCHED_POLICY: 'none',
},
time: true
},
],
};
+25 -1
View File
@@ -1,6 +1,7 @@
# Full Setup for public pool # 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]
+93 -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,54 @@ 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}
SHARE_ROLLUP_ENABLED: ${SHARE_ROLLUP_ENABLED:-true}
SHARE_ROLLUP_INTERVAL_MS: ${SHARE_ROLLUP_INTERVAL_MS:-60000}
SHARE_ROLLUP_SAFETY_LAG_SECONDS: ${SHARE_ROLLUP_SAFETY_LAG_SECONDS:-30}
SHARE_ROLLUP_MAX_SHARES_PER_BATCH: ${SHARE_ROLLUP_MAX_SHARES_PER_BATCH:-5000000}
PAYOUT_SNAPSHOT_ENABLED: ${PAYOUT_SNAPSHOT_ENABLED:-true}
PAYOUT_METHOD: ${PAYOUT_METHOD:-pplns}
PAYOUT_WINDOW_FACTOR: ${PAYOUT_WINDOW_FACTOR:-4}
PAYOUT_COINBASE_MODE: ${PAYOUT_COINBASE_MODE:-solo}
PAYOUT_MAX_COINBASE_OUTPUTS: ${PAYOUT_MAX_COINBASE_OUTPUTS:-10}
PAYOUT_MIN_OUTPUT_SATS: ${PAYOUT_MIN_OUTPUT_SATS:-546}
PAYOUT_FEE_ADDRESS: ${PAYOUT_FEE_ADDRESS:-}
PAYOUT_FEE_PERCENT: ${PAYOUT_FEE_PERCENT:-0}
PAYOUT_COINBASE_WEIGHT_BUDGET: ${PAYOUT_COINBASE_WEIGHT_BUDGET:-50000}
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
+9928 -74
View File
File diff suppressed because it is too large Load Diff
+23 -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,12 @@
"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",
"libsodium-wrappers-sumo": "^0.8.4",
"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",
@@ -55,6 +65,7 @@
"@types/cron": "^2.0.1", "@types/cron": "^2.0.1",
"@types/express": "^4.17.25", "@types/express": "^4.17.25",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/libsodium-wrappers-sumo": "^0.7.8",
"@types/node": "^18.19.130", "@types/node": "^18.19.130",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/eslint-plugin": "^5.0.0",
@@ -87,8 +98,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,74 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AcceptedShareProtocolMetadata1781130600000 implements MigrationInterface {
public name = 'AcceptedShareProtocolMetadata1781130600000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await this.decompressAcceptedShareChunks(queryRunner);
await queryRunner.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'accepted_share_entity'
AND column_name = 'protocol'
AND COALESCE(character_maximum_length, 0) < 16
) THEN
ALTER TABLE "accepted_share_entity" ALTER COLUMN "protocol" TYPE varchar(16);
END IF;
END
$$
`);
await queryRunner.query(`ALTER TABLE "accepted_share_entity" ADD COLUMN IF NOT EXISTS "workSource" varchar(16) NOT NULL DEFAULT 'pool_template'`);
await queryRunner.query(`ALTER TABLE "accepted_share_entity" ADD COLUMN IF NOT EXISTS "workProtocol" varchar(16) NOT NULL DEFAULT 'pool'`);
await this.restoreAcceptedShareCompression(queryRunner);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_protocol_work"
ON "accepted_share_entity" ("protocol", "workSource", "workProtocol", "acceptedAt" DESC)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await this.decompressAcceptedShareChunks(queryRunner);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_protocol_work"`);
await queryRunner.query(`ALTER TABLE "accepted_share_entity" DROP COLUMN IF EXISTS "workProtocol"`);
await queryRunner.query(`ALTER TABLE "accepted_share_entity" DROP COLUMN IF EXISTS "workSource"`);
await queryRunner.query(`ALTER TABLE "accepted_share_entity" ALTER COLUMN "protocol" TYPE varchar(8)`);
await this.restoreAcceptedShareCompression(queryRunner);
}
private async decompressAcceptedShareChunks(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`SELECT remove_compression_policy('accepted_share_entity', if_exists => TRUE)`);
await queryRunner.query(`
DO $$
DECLARE
chunk_name regclass;
BEGIN
FOR chunk_name IN SELECT show_chunks('accepted_share_entity')
LOOP
PERFORM decompress_chunk(chunk_name, if_compressed => TRUE);
END LOOP;
END
$$
`);
}
private async restoreAcceptedShareCompression(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 '24 hours',
if_not_exists => TRUE
)
`);
}
}
@@ -0,0 +1,54 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AcceptedShareRetentionCompression1780966200000 implements MigrationInterface {
public name = 'AcceptedShareRetentionCompression1780966200000';
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 remove_compression_policy('accepted_share_entity', if_exists => TRUE)`);
await queryRunner.query(`
SELECT add_compression_policy(
'accepted_share_entity',
INTERVAL '24 hours',
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 '7 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 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
)
`);
}
}
@@ -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,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class BlocksSubmissionMetadata1781220000000 implements MigrationInterface {
public name = 'BlocksSubmissionMetadata1781220000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "blocks_entity" ADD COLUMN IF NOT EXISTS "blockHash" varchar`);
await queryRunner.query(`ALTER TABLE "blocks_entity" ADD COLUMN IF NOT EXISTS "blockSubmissionResult" varchar`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_blocks_successful_found"
ON "blocks_entity" ("height" DESC, "createdAt" DESC)
WHERE "blockSubmissionResult" IS NULL OR "blockSubmissionResult" = 'SUCCESS!'
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_blocks_block_hash"
ON "blocks_entity" ("blockHash")
WHERE "blockHash" IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "UQ_blocks_block_hash"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_blocks_successful_found"`);
await queryRunner.query(`ALTER TABLE "blocks_entity" DROP COLUMN IF EXISTS "blockSubmissionResult"`);
await queryRunner.query(`ALTER TABLE "blocks_entity" DROP COLUMN IF EXISTS "blockHash"`);
}
}
@@ -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,217 @@
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,
"blockHash" varchar,
"blockSubmissionResult" varchar,
"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,143 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PayoutSnapshots1780969800000 implements MigrationInterface {
public name = 'PayoutSnapshots1780969800000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "payout_snapshot" (
"id" bigserial PRIMARY KEY,
"method" varchar(32) NOT NULL,
"status" varchar(16) NOT NULL DEFAULT 'finalized',
"blockHeight" bigint NOT NULL,
"coinbaseValueSats" bigint NOT NULL,
"networkDifficulty" numeric NOT NULL,
"windowTargetDifficulty" numeric NOT NULL DEFAULT 0,
"windowFactor" numeric NOT NULL DEFAULT 4,
"feeAddress" varchar(62),
"feeSats" bigint NOT NULL DEFAULT 0,
"minPayoutSats" integer NOT NULL DEFAULT 546,
"coinbaseWeightBudget" integer NOT NULL DEFAULT 50000,
"startBatchId" bigint NOT NULL REFERENCES "share_rollup_batch" ("id"),
"endBatchId" bigint NOT NULL REFERENCES "share_rollup_batch" ("id"),
"windowStartShareIndex" bigint NOT NULL,
"windowEndShareIndex" bigint NOT NULL,
"totalCreditedDifficulty" numeric NOT NULL,
"totalAcceptedShareCount" bigint NOT NULL,
"eligibleAddressCount" integer NOT NULL,
"includedOutputCount" integer NOT NULL,
"distributedSats" bigint NOT NULL,
"unallocatedRemainderSats" bigint NOT NULL,
"createdAt" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_payout_snapshot_latest"
ON "payout_snapshot" ("status", "createdAt" DESC, "id" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_payout_snapshot_window"
ON "payout_snapshot" ("windowEndShareIndex" DESC)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_payout_snapshot_template_window"
ON "payout_snapshot" ("method", "blockHeight", "coinbaseValueSats", "windowEndShareIndex")
WHERE "status" = 'finalized'
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "payout_snapshot_entry" (
"id" bigserial PRIMARY KEY,
"snapshotId" bigint NOT NULL REFERENCES "payout_snapshot" ("id") ON DELETE CASCADE,
"address" varchar(62) NOT NULL,
"creditedDifficulty" numeric NOT NULL,
"acceptedShareCount" bigint NOT NULL,
"payoutWeight" numeric NOT NULL,
"grossPayoutSats" bigint NOT NULL,
"payoutSats" bigint NOT NULL,
"balanceBeforeSats" bigint NOT NULL DEFAULT 0,
"balanceAfterSats" bigint NOT NULL DEFAULT 0,
"includedInCoinbase" boolean NOT NULL DEFAULT true,
"rowType" varchar(16) NOT NULL DEFAULT 'coinbase',
"rank" integer NOT NULL,
"createdAt" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_payout_snapshot_entry_snapshot_address"
ON "payout_snapshot_entry" ("snapshotId", "address")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_payout_snapshot_entry_snapshot_rank"
ON "payout_snapshot_entry" ("snapshotId", "rank")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_payout_snapshot_entry_address"
ON "payout_snapshot_entry" ("address")
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "payout_balance" (
"address" varchar(62) PRIMARY KEY,
"balanceSats" bigint NOT NULL DEFAULT 0,
"totalPaidSats" bigint NOT NULL DEFAULT 0,
"lastAcceptedShareAt" timestamptz,
"updatedAt" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_payout_balance_nonzero"
ON "payout_balance" ("balanceSats")
WHERE "balanceSats" != 0
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "payout_history" (
"id" bigserial PRIMARY KEY,
"blockHeight" bigint NOT NULL,
"payoutSnapshotId" bigint NOT NULL REFERENCES "payout_snapshot" ("id") ON DELETE CASCADE,
"address" varchar(62) NOT NULL,
"paidSats" bigint NOT NULL DEFAULT 0,
"percent" numeric NOT NULL DEFAULT 0,
"balanceBeforeSats" bigint NOT NULL DEFAULT 0,
"balanceAfterSats" bigint NOT NULL DEFAULT 0,
"rowType" varchar(16) NOT NULL DEFAULT 'coinbase',
"createdAt" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_payout_history_block_address"
ON "payout_history" ("blockHeight", "address")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_payout_history_address_created"
ON "payout_history" ("address", "createdAt" DESC)
`);
await queryRunner.query(`ALTER TABLE "blocks_entity" ADD COLUMN IF NOT EXISTS "payoutSnapshotId" bigint`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_blocks_payout_snapshot"
ON "blocks_entity" ("payoutSnapshotId")
WHERE "payoutSnapshotId" IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_blocks_payout_snapshot"`);
await queryRunner.query(`ALTER TABLE "blocks_entity" DROP COLUMN IF EXISTS "payoutSnapshotId"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_history_address_created"`);
await queryRunner.query(`DROP INDEX IF EXISTS "UQ_payout_history_block_address"`);
await queryRunner.query(`DROP TABLE IF EXISTS "payout_history"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_balance_nonzero"`);
await queryRunner.query(`DROP TABLE IF EXISTS "payout_balance"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_snapshot_entry_address"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_snapshot_entry_snapshot_rank"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_snapshot_entry_snapshot_address"`);
await queryRunner.query(`DROP TABLE IF EXISTS "payout_snapshot_entry"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_snapshot_template_window"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_snapshot_window"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_snapshot_latest"`);
await queryRunner.query(`DROP TABLE IF EXISTS "payout_snapshot"`);
}
}
@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PoolAccountingDashboardIndexes1780867200000 implements MigrationInterface {
public name = 'PoolAccountingDashboardIndexes1780867200000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_accepted_at"
ON "accepted_share_entity" ("acceptedAt" DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_round_best"
ON "accepted_share_entity" ("submissionDifficulty" DESC, "acceptedAt" DESC, "blockHeight")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_round_best"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_accepted_at"`);
}
}
@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveAcceptedShareRetention1780959000000 implements MigrationInterface {
public name = 'RemoveAcceptedShareRetention1780959000000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`SELECT remove_retention_policy('accepted_share_entity', if_exists => TRUE)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`SELECT add_retention_policy('accepted_share_entity', INTERVAL '30 days', if_not_exists => TRUE)`);
}
}
@@ -0,0 +1,62 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ShareRollupBatches1780962600000 implements MigrationInterface {
public name = 'ShareRollupBatches1780962600000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "share_rollup_batch" (
"id" bigserial PRIMARY KEY,
"startShareIndex" bigint NOT NULL,
"endShareIndex" bigint NOT NULL,
"startAcceptedAt" timestamptz NOT NULL,
"endAcceptedAt" timestamptz NOT NULL,
"acceptedShareCount" bigint NOT NULL,
"creditedDifficulty" numeric NOT NULL,
"status" varchar(16) NOT NULL DEFAULT 'finalized',
"createdAt" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finalizedAt" timestamptz,
CONSTRAINT "CHK_share_rollup_batch_index_order"
CHECK ("endShareIndex" >= "startShareIndex")
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_share_rollup_batch_range"
ON "share_rollup_batch" ("startShareIndex", "endShareIndex")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_share_rollup_batch_finalized_end"
ON "share_rollup_batch" ("endShareIndex" DESC)
WHERE "status" = 'finalized'
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "share_rollup_batch_summary" (
"batchId" bigint NOT NULL REFERENCES "share_rollup_batch" ("id") ON DELETE CASCADE,
"address" varchar(62) NOT NULL,
"clientName" varchar NOT NULL,
"protocol" varchar(8) NOT NULL,
"blockHeight" integer NOT NULL,
"creditedDifficulty" numeric NOT NULL,
"acceptedShareCount" bigint NOT NULL,
"bestSubmissionDifficulty" numeric NOT NULL,
"firstShareAt" timestamptz NOT NULL,
"lastShareAt" timestamptz NOT NULL,
PRIMARY KEY ("batchId", "address", "clientName", "protocol", "blockHeight")
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_share_rollup_summary_address_batch"
ON "share_rollup_batch_summary" ("address", "clientName", "blockHeight", "batchId")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_share_rollup_summary_address_batch"`);
await queryRunner.query(`DROP TABLE IF EXISTS "share_rollup_batch_summary"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_share_rollup_batch_finalized_end"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_share_rollup_batch_range"`);
await queryRunner.query(`DROP TABLE IF EXISTS "share_rollup_batch"`);
}
}
@@ -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,77 @@
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: 16, type: 'varchar' })
protocol: 'sv1' | 'sv1_tls' | 'sv2' | 'sv2_jdp' | 'datum';
@Column({ length: 16, type: 'varchar', default: 'pool_template' })
workSource: 'pool_template' | 'miner_template';
@Column({ length: 16, type: 'varchar', default: 'pool' })
workProtocol: 'pool' | 'sv2_jdp' | 'datum';
@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();
}
}
+13 -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,13 @@ export class BlocksEntity extends TrackedEntity {
@Column() @Column()
blockData: string; blockData: string;
} @Column({ nullable: true })
blockHash?: string;
@Column({ type: 'varchar', nullable: true })
blockSubmissionResult?: string;
@Column({ type: 'bigint', nullable: true })
payoutSnapshotId?: string;
}
+87
View File
@@ -0,0 +1,87 @@
import * as bitcoinjs from 'bitcoinjs-lib';
import { BlocksService } from './blocks.service';
describe('BlocksService', () => {
it('computes block hash metadata when saving found blocks', async () => {
const repository = createRepository();
repository.findOne.mockResolvedValue(null);
const service = new BlocksService({} as any, repository as any);
const blockHex = createBlockHex();
const blockHash = bitcoinjs.Block.fromHex(blockHex).getId();
await service.save({
height: 123,
minerAddress: 'tb1qexample',
worker: 'worker',
sessionId: 'abcd1234',
blockData: blockHex,
blockSubmissionResult: 'SUCCESS!',
});
expect(repository.save).toHaveBeenCalledWith(expect.objectContaining({
blockHash,
blockSubmissionResult: 'SUCCESS!',
}));
});
it('does not downgrade an existing successful block on duplicate rejected submissions', async () => {
const repository = createRepository();
repository.findOne.mockResolvedValue({
id: 9,
blockSubmissionResult: 'SUCCESS!',
payoutSnapshotId: '7',
});
const service = new BlocksService({} as any, repository as any);
await service.save({
blockData: createBlockHex(),
blockSubmissionResult: 'duplicate',
payoutSnapshotId: '8',
});
expect(repository.update).not.toHaveBeenCalled();
expect(repository.save).not.toHaveBeenCalled();
});
it('returns one public found-block row per block hash or legacy height', async () => {
const repository = createRepository();
repository.find.mockResolvedValue([
{ height: 124, minerAddress: 'addr2', worker: 'b', sessionId: '2222', blockHash: 'hash-b' },
{ height: 124, minerAddress: 'addr2', worker: 'b-dup', sessionId: '3333', blockHash: 'hash-b' },
{ height: 123, minerAddress: 'addr1', worker: 'a', sessionId: '1111', blockHash: null },
{ height: 123, minerAddress: 'addr1', worker: 'a-dup', sessionId: '4444', blockHash: null },
]);
const service = new BlocksService({} as any, repository as any);
await expect(service.getFoundBlocks()).resolves.toEqual([
{ height: 124, minerAddress: 'addr2', worker: 'b', sessionId: '2222', blockHash: 'hash-b' },
{ height: 123, minerAddress: 'addr1', worker: 'a', sessionId: '1111', blockHash: null },
]);
});
function createRepository() {
return {
save: jest.fn().mockResolvedValue(undefined),
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue(undefined),
};
}
function createBlockHex(): string {
const tx = new bitcoinjs.Transaction();
tx.version = 1;
tx.addInput(Buffer.alloc(32), 0xffffffff, 0xffffffff, Buffer.from([0x01]));
tx.addOutput(Buffer.from('6a', 'hex'), 0);
const block = new bitcoinjs.Block();
block.version = 1;
block.prevHash = Buffer.alloc(32);
block.timestamp = 1;
block.bits = 0x207fffff;
block.nonce = 1;
block.transactions = [tx];
block.merkleRoot = bitcoinjs.Block.calculateMerkleRoot(block.transactions, false);
return block.toHex(false);
}
});
+86 -10
View File
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm'; import * as bitcoinjs from 'bitcoinjs-lib';
import { DataSource, IsNull, Repository } from 'typeorm';
import { BlocksEntity } from './blocks.entity'; import { BlocksEntity } from './blocks.entity';
@@ -19,31 +20,106 @@ export class BlocksService {
public async save(block: Partial<BlocksEntity>) { public async save(block: Partial<BlocksEntity>) {
await this.blocksRepository.save(block); const blockHash = block.blockHash ?? this.getBlockHash(block.blockData);
const existing = blockHash == null
? null
: await this.blocksRepository.findOne({
select: { id: true, blockSubmissionResult: true, payoutSnapshotId: true },
where: { blockHash },
});
if (existing != null) {
if (this.isSuccessfulBlockSubmission(block.blockSubmissionResult)) {
await this.blocksRepository.update(existing.id, {
blockSubmissionResult: 'SUCCESS!',
payoutSnapshotId: block.payoutSnapshotId ?? existing.payoutSnapshotId,
});
}
return;
}
await this.blocksRepository.save({
...block,
blockHash,
});
} }
public async getFoundBlocks() { public async getFoundBlocks() {
return await this.blocksRepository.find({ const rows = await this.blocksRepository.find({
select: { select: {
height: true, height: true,
minerAddress: true, minerAddress: true,
worker: true, worker: true,
sessionId: true sessionId: true,
} blockHash: true,
blockSubmissionResult: true,
createdAt: true,
},
where: [
{ blockSubmissionResult: 'SUCCESS!' },
{ blockSubmissionResult: IsNull() },
],
order: {
height: 'DESC',
createdAt: 'DESC',
},
}); });
return this.uniqueFoundBlocks(rows);
} }
public async getFoundBlocksByAddress(address: string) { public async getFoundBlocksByAddress(address: string) {
return await this.blocksRepository.find({ const rows = await this.blocksRepository.find({
select: { select: {
height: true, height: true,
minerAddress: true, minerAddress: true,
worker: true, worker: true,
sessionId: true sessionId: true,
blockHash: true,
blockSubmissionResult: true,
createdAt: true,
}, },
where: { where: [
minerAddress: address { minerAddress: address, blockSubmissionResult: 'SUCCESS!' },
{ minerAddress: address, blockSubmissionResult: IsNull() },
],
order: {
height: 'DESC',
createdAt: 'DESC',
},
});
return this.uniqueFoundBlocks(rows);
}
private uniqueFoundBlocks(rows: Partial<BlocksEntity>[]): Partial<BlocksEntity>[] {
const seenBlocks = new Set<string>();
const seenHeights = new Set<number>();
return rows.filter(row => {
if (row.height != null && seenHeights.has(row.height)) {
return false;
} }
const key = row.blockHash || `height:${row.height}`;
if (seenBlocks.has(key)) {
return false;
}
seenBlocks.add(key);
if (row.height != null) {
seenHeights.add(row.height);
}
return true;
}); });
} }
}
private getBlockHash(blockData?: string): string | undefined {
if (blockData == null || blockData.length < 160) {
return undefined;
}
try {
return bitcoinjs.Block.fromHex(blockData).getId();
} catch {
return undefined;
}
}
private isSuccessfulBlockSubmission(result?: string | null): boolean {
return result == null || result === 'SUCCESS!';
}
}
@@ -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({});
}
}
@@ -0,0 +1,24 @@
import { Column, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm';
const bigintTransformer = {
from: (value: string | number | null): number => Number(value ?? 0),
to: (value: number): number => Math.trunc(value ?? 0),
};
@Entity({ name: 'payout_balance' })
export class PayoutBalanceEntity {
@PrimaryColumn({ length: 62, type: 'varchar' })
address: string;
@Column({ type: 'bigint', default: 0, transformer: bigintTransformer })
balanceSats: number;
@Column({ type: 'bigint', default: 0, transformer: bigintTransformer })
totalPaidSats: number;
@Column({ type: 'timestamptz', nullable: true })
lastAcceptedShareAt: Date | null;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,135 @@
import { buildPayoutDistribution } from './payout-distribution';
const ADDRESS_A = 'bc1qs29kyaqqc0fkvj897ke9e5xa9utljjey0y5jjn';
const ADDRESS_B = 'bc1q99n3pu025yyu0jlywpmwzalyhm36tg5u37w20d';
const ADDRESS_C = 'bc1q7yx4d3xspf4xvssmsm4prf4mg2slkv8jtkc5l6';
describe('buildPayoutDistribution', () => {
it('should conserve the full coinbase reward across outputs', () => {
const result = buildPayoutDistribution({
addressWork: [
{ address: ADDRESS_A, creditedDifficulty: 2, acceptedShareCount: 2 },
{ address: ADDRESS_B, creditedDifficulty: 1, acceptedShareCount: 1 },
],
balances: [],
coinbaseValueSats: 1000,
feePercent: 0,
minOutputSats: 546,
coinbaseWeightBudget: 50_000,
});
expect(result.payoutOutputs.map(entry => ({
address: entry.address,
payoutSats: entry.payoutSats,
balanceAfterSats: entry.balanceAfterSats,
}))).toEqual([
{ address: ADDRESS_A, payoutSats: 1000, balanceAfterSats: -334 },
]);
expect(result.entries.find(entry => entry.address === ADDRESS_B))
.toEqual(expect.objectContaining({ includedInCoinbase: false, balanceAfterSats: 333 }));
expect(result.distributedSats).toBe(1000);
expect(result.unallocatedRemainderSats).toBe(0);
});
it('should carry sub-dust work forward as signed credit', () => {
const result = buildPayoutDistribution({
addressWork: [
{ address: ADDRESS_A, creditedDifficulty: 999, acceptedShareCount: 999 },
{ address: ADDRESS_B, creditedDifficulty: 1, acceptedShareCount: 1 },
],
balances: [],
coinbaseValueSats: 1000,
feePercent: 0,
minOutputSats: 546,
coinbaseWeightBudget: 50_000,
});
const pending = result.entries.find(entry => entry.address === ADDRESS_B);
const paid = result.entries.find(entry => entry.address === ADDRESS_A);
expect(pending).toEqual(expect.objectContaining({
includedInCoinbase: false,
grossPayoutSats: 1,
payoutSats: 0,
balanceAfterSats: 1,
}));
expect(paid).toEqual(expect.objectContaining({
includedInCoinbase: true,
payoutSats: 1000,
balanceAfterSats: -1,
}));
expect(result.distributedSats).toBe(1000);
});
it('should trim outputs that do not fit the coinbase weight budget', () => {
const result = buildPayoutDistribution({
addressWork: [
{ address: ADDRESS_A, creditedDifficulty: 10, acceptedShareCount: 1 },
{ address: ADDRESS_B, creditedDifficulty: 9, acceptedShareCount: 1 },
{ address: ADDRESS_C, creditedDifficulty: 8, acceptedShareCount: 1 },
],
balances: [],
coinbaseValueSats: 3000,
feePercent: 0,
minOutputSats: 546,
coinbaseWeightBudget: 850,
});
expect(result.payoutOutputs).toHaveLength(1);
expect(result.payoutOutputs[0].address).toBe(ADDRESS_A);
expect(result.entries.find(entry => entry.address === ADDRESS_B))
.toEqual(expect.objectContaining({ includedInCoinbase: false, balanceAfterSats: 1000 }));
expect(result.entries.find(entry => entry.address === ADDRESS_C))
.toEqual(expect.objectContaining({ includedInCoinbase: false, balanceAfterSats: 888 }));
});
it('should apply positive and negative carried balances to the next distribution', () => {
const result = buildPayoutDistribution({
addressWork: [
{ address: ADDRESS_A, creditedDifficulty: 10, acceptedShareCount: 1 },
{ address: ADDRESS_B, creditedDifficulty: 10, acceptedShareCount: 1 },
],
balances: [
{ address: ADDRESS_A, balanceSats: 100 },
{ address: ADDRESS_B, balanceSats: -100 },
],
coinbaseValueSats: 2000,
feePercent: 0,
minOutputSats: 546,
coinbaseWeightBudget: 50_000,
});
expect(result.payoutOutputs.map(entry => ({
address: entry.address,
payoutSats: entry.payoutSats,
balanceAfterSats: entry.balanceAfterSats,
}))).toEqual([
{ address: ADDRESS_A, payoutSats: 1100, balanceAfterSats: 0 },
{ address: ADDRESS_B, payoutSats: 900, balanceAfterSats: 0 },
]);
expect(result.unallocatedRemainderSats).toBe(0);
});
it('should reserve a configured fee output before miner distribution', () => {
const result = buildPayoutDistribution({
addressWork: [
{ address: ADDRESS_A, creditedDifficulty: 1, acceptedShareCount: 1 },
],
balances: [],
coinbaseValueSats: 100000,
feeAddress: ADDRESS_C,
feePercent: 2,
minOutputSats: 546,
coinbaseWeightBudget: 50_000,
});
expect(result.payoutOutputs[0]).toEqual(expect.objectContaining({
address: ADDRESS_C,
payoutSats: 2000,
}));
expect(result.payoutOutputs[1]).toEqual(expect.objectContaining({
address: ADDRESS_A,
payoutSats: 98000,
}));
});
});
@@ -0,0 +1,276 @@
import { AddressType, getAddressInfo } from 'bitcoin-address-validation';
export interface PayoutAddressWork {
address: string;
creditedDifficulty: number;
acceptedShareCount: number;
}
export interface PayoutBalanceInput {
address: string;
balanceSats: number;
}
export interface PayoutDistributionEntry {
address: string;
creditedDifficulty: number;
acceptedShareCount: number;
payoutWeight: number;
grossPayoutSats: number;
payoutSats: number;
balanceBeforeSats: number;
balanceAfterSats: number;
includedInCoinbase: boolean;
rank: number;
}
export interface PayoutDistributionResult {
entries: PayoutDistributionEntry[];
payoutOutputs: PayoutDistributionEntry[];
consideredAddressCount: number;
distributedSats: number;
unallocatedRemainderSats: number;
feeSats: number;
}
export interface PayoutDistributionInput {
addressWork: PayoutAddressWork[];
balances: PayoutBalanceInput[];
coinbaseValueSats: number;
feeAddress?: string;
feePercent: number;
minOutputSats: number;
coinbaseWeightBudget: number;
}
const DEFAULT_COINBASE_WEIGHT_BUDGET = 50_000;
const DUST_LIMIT_SATS = 546;
const COINBASE_BASE_WEIGHT = 328;
const COINBASE_WITNESS_COMMITMENT_WEIGHT = 188;
const COINBASE_OUTPUT_WEIGHT = 172;
const BUDGET_SAFETY_MARGIN_WEIGHT = 200;
const OUTPUT_WEIGHT_BY_TYPE: Record<string, number> = {
[AddressType.p2wpkh]: 124,
[AddressType.p2sh]: 128,
[AddressType.p2pkh]: 136,
[AddressType.p2wsh]: 172,
[AddressType.p2tr]: 172,
};
const outputWeightCache = new Map<string, number>();
interface ComputedAddress {
address: string;
creditedDifficulty: number;
acceptedShareCount: number;
payoutWeight: number;
rawFairSats: number;
balanceBeforeSats: number;
targetSats: number;
payoutSats: number;
balanceAfterSats: number;
includedInCoinbase: boolean;
}
export function buildPayoutDistribution(input: PayoutDistributionInput): PayoutDistributionResult {
const coinbaseValueSats = Math.max(0, Math.floor(input.coinbaseValueSats));
const minOutputSats = Math.max(DUST_LIMIT_SATS, Math.floor(input.minOutputSats));
const coinbaseWeightBudget = input.coinbaseWeightBudget > 0
? input.coinbaseWeightBudget
: DEFAULT_COINBASE_WEIGHT_BUDGET;
const feePercent = Number.isFinite(input.feePercent) && input.feePercent > 0
? input.feePercent
: 0;
const feeAddress = input.feeAddress?.trim() ?? '';
const wantedFeeSats = Math.floor((feePercent / 100) * coinbaseValueSats);
const feeSats = feeAddress.length > 0 && wantedFeeSats >= minOutputSats ? wantedFeeSats : 0;
const minerRewardSats = coinbaseValueSats - feeSats;
const workByAddress = new Map<string, { creditedDifficulty: number; acceptedShareCount: number }>();
for (const work of input.addressWork) {
if (!work.address || !Number.isFinite(work.creditedDifficulty) || work.creditedDifficulty <= 0) {
continue;
}
const existing = workByAddress.get(work.address) ?? { creditedDifficulty: 0, acceptedShareCount: 0 };
existing.creditedDifficulty += work.creditedDifficulty;
existing.acceptedShareCount += work.acceptedShareCount;
workByAddress.set(work.address, existing);
}
const balanceByAddress = new Map<string, number>();
for (const balance of input.balances) {
if (balance.address && Number.isFinite(balance.balanceSats) && balance.balanceSats !== 0) {
balanceByAddress.set(balance.address, Math.trunc(balance.balanceSats));
}
}
let totalCreditedDifficulty = 0;
for (const work of workByAddress.values()) {
totalCreditedDifficulty += work.creditedDifficulty;
}
if (coinbaseValueSats <= 0 || totalCreditedDifficulty <= 0) {
return {
entries: [],
payoutOutputs: [],
consideredAddressCount: 0,
distributedSats: 0,
unallocatedRemainderSats: coinbaseValueSats,
feeSats,
};
}
const computations = new Map<string, ComputedAddress>();
const considered = new Set<string>([...workByAddress.keys(), ...balanceByAddress.keys()]);
for (const address of considered) {
const work = workByAddress.get(address);
const creditedDifficulty = work?.creditedDifficulty ?? 0;
const acceptedShareCount = work?.acceptedShareCount ?? 0;
const payoutWeight = creditedDifficulty > 0 ? creditedDifficulty / totalCreditedDifficulty : 0;
const rawFairSats = Math.floor(payoutWeight * minerRewardSats);
const balanceBeforeSats = balanceByAddress.get(address) ?? 0;
const targetSats = rawFairSats + balanceBeforeSats;
computations.set(address, {
address,
creditedDifficulty,
acceptedShareCount,
payoutWeight,
rawFairSats,
balanceBeforeSats,
targetSats,
payoutSats: 0,
balanceAfterSats: targetSats,
includedInCoinbase: false,
});
}
const feeWeight = feeSats > 0 ? outputWeightForAddress(feeAddress) : 0;
let usedWeight = COINBASE_BASE_WEIGHT + COINBASE_WITNESS_COMMITMENT_WEIGHT + feeWeight;
const effectiveBudget = coinbaseWeightBudget - BUDGET_SAFETY_MARGIN_WEIGHT;
const eligible = [...computations.values()]
.filter(row => row.targetSats >= minOutputSats)
.sort((a, b) => b.targetSats - a.targetSats || a.address.localeCompare(b.address));
for (const row of eligible) {
const outputWeight = outputWeightForAddress(row.address);
if (usedWeight + outputWeight > effectiveBudget) {
continue;
}
row.includedInCoinbase = true;
row.payoutSats = row.targetSats;
row.balanceAfterSats = 0;
usedWeight += outputWeight;
}
let distributedToMiners = 0;
for (const row of computations.values()) {
distributedToMiners += row.payoutSats;
}
const remainder = minerRewardSats - distributedToMiners;
if (remainder > 0) {
const keptActive = [...computations.values()]
.filter(row => row.includedInCoinbase && row.creditedDifficulty > 0)
.sort((a, b) => b.creditedDifficulty - a.creditedDifficulty || a.address.localeCompare(b.address));
if (keptActive.length > 0) {
let assigned = 0;
for (const row of keptActive) {
const bonus = Math.floor((remainder * row.creditedDifficulty) / totalCreditedDifficulty);
row.payoutSats += bonus;
row.balanceAfterSats -= bonus;
assigned += bonus;
}
const residual = remainder - assigned;
if (residual > 0) {
keptActive[0].payoutSats += residual;
keptActive[0].balanceAfterSats -= residual;
}
}
}
const feeEntry = feeSats > 0
? {
address: feeAddress,
creditedDifficulty: 0,
acceptedShareCount: 0,
payoutWeight: 0,
grossPayoutSats: feeSats,
payoutSats: feeSats,
balanceBeforeSats: 0,
balanceAfterSats: 0,
includedInCoinbase: true,
rank: 1,
}
: null;
const minerEntries = [...computations.values()]
.filter(row => row.includedInCoinbase || row.balanceBeforeSats !== row.balanceAfterSats || row.balanceAfterSats !== 0)
.sort((a, b) => {
if (a.includedInCoinbase !== b.includedInCoinbase) {
return a.includedInCoinbase ? -1 : 1;
}
return b.payoutSats - a.payoutSats || b.targetSats - a.targetSats || a.address.localeCompare(b.address);
})
.map(row => ({
address: row.address,
creditedDifficulty: row.creditedDifficulty,
acceptedShareCount: row.acceptedShareCount,
payoutWeight: row.payoutWeight,
grossPayoutSats: row.rawFairSats,
payoutSats: row.payoutSats,
balanceBeforeSats: row.balanceBeforeSats,
balanceAfterSats: row.balanceAfterSats,
includedInCoinbase: row.includedInCoinbase,
rank: 0,
}));
const mergedEntries = feeEntry == null
? minerEntries
: mergeFeeEntry(feeEntry, minerEntries);
const entries = mergedEntries
.map((entry, index) => ({ ...entry, rank: index + 1 }));
const payoutOutputs = entries.filter(entry => entry.includedInCoinbase);
const distributedSats = payoutOutputs.reduce((sum, entry) => sum + entry.payoutSats, 0);
return {
entries,
payoutOutputs,
consideredAddressCount: considered.size,
distributedSats,
unallocatedRemainderSats: coinbaseValueSats - distributedSats,
feeSats,
};
}
function mergeFeeEntry(
feeEntry: PayoutDistributionEntry,
minerEntries: Omit<PayoutDistributionEntry, 'rank'>[],
): Omit<PayoutDistributionEntry, 'rank'>[] {
const existing = minerEntries.find(entry => entry.address === feeEntry.address && entry.includedInCoinbase);
if (existing == null) {
return [feeEntry, ...minerEntries];
}
existing.payoutSats += feeEntry.payoutSats;
existing.grossPayoutSats += feeEntry.grossPayoutSats;
return minerEntries;
}
export function outputWeightForAddress(address: string): number {
if (!address) {
return COINBASE_OUTPUT_WEIGHT;
}
const cached = outputWeightCache.get(address);
if (cached != null) {
return cached;
}
let weight = COINBASE_OUTPUT_WEIGHT;
try {
const info = getAddressInfo(address);
weight = OUTPUT_WEIGHT_BY_TYPE[info.type] ?? COINBASE_OUTPUT_WEIGHT;
} catch {
weight = COINBASE_OUTPUT_WEIGHT;
}
outputWeightCache.set(address, weight);
return weight;
}
@@ -0,0 +1,41 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
const bigintTransformer = {
from: (value: string | number | null): number => Number(value ?? 0),
to: (value: number): number => Math.trunc(value ?? 0),
};
@Entity({ name: 'payout_history' })
@Index('UQ_payout_history_block_address', ['blockHeight', 'address'], { unique: true })
@Index('IDX_payout_history_address_created', ['address', 'createdAt'])
export class PayoutHistoryEntity {
@PrimaryGeneratedColumn({ type: 'bigint' })
id: string;
@Column({ type: 'bigint' })
blockHeight: number;
@Column({ type: 'bigint' })
payoutSnapshotId: string;
@Column({ length: 62, type: 'varchar' })
address: string;
@Column({ type: 'bigint', default: 0, transformer: bigintTransformer })
paidSats: number;
@Column({ type: 'decimal', default: 0 })
percent: number;
@Column({ type: 'bigint', default: 0, transformer: bigintTransformer })
balanceBeforeSats: number;
@Column({ type: 'bigint', default: 0, transformer: bigintTransformer })
balanceAfterSats: number;
@Column({ length: 16, type: 'varchar', default: 'coinbase' })
rowType: 'coinbase' | 'pending';
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,54 @@
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { PayoutSnapshotEntity } from './payout-snapshot.entity';
@Entity({ name: 'payout_snapshot_entry' })
@Index('IDX_payout_snapshot_entry_snapshot_rank', ['snapshotId', 'rank'])
@Index('IDX_payout_snapshot_entry_address', ['address'])
export class PayoutSnapshotEntryEntity {
@PrimaryGeneratedColumn({ type: 'bigint' })
id: string;
@Column({ type: 'bigint' })
snapshotId: string;
@ManyToOne(() => PayoutSnapshotEntity, snapshot => snapshot.entries, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'snapshotId' })
snapshot: PayoutSnapshotEntity;
@Column({ length: 62, type: 'varchar' })
address: string;
@Column({ type: 'decimal' })
creditedDifficulty: number;
@Column({ type: 'bigint' })
acceptedShareCount: string;
@Column({ type: 'decimal' })
payoutWeight: number;
@Column({ type: 'bigint' })
grossPayoutSats: string;
@Column({ type: 'bigint' })
payoutSats: string;
@Column({ type: 'bigint', default: 0 })
balanceBeforeSats: string;
@Column({ type: 'bigint', default: 0 })
balanceAfterSats: string;
@Column({ type: 'boolean', default: true })
includedInCoinbase: boolean;
@Column({ length: 16, type: 'varchar', default: 'coinbase' })
rowType: 'coinbase' | 'pending';
@Column({ type: 'integer' })
rank: number;
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
}
@@ -0,0 +1,80 @@
import { Column, Entity, Index, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { PayoutSnapshotEntryEntity } from './payout-snapshot-entry.entity';
@Entity({ name: 'payout_snapshot' })
@Index('IDX_payout_snapshot_latest', ['status', 'createdAt'])
@Index('IDX_payout_snapshot_window', ['windowEndShareIndex'])
export class PayoutSnapshotEntity {
@PrimaryGeneratedColumn({ type: 'bigint' })
id: string;
@Column({ length: 32, type: 'varchar' })
method: string;
@Column({ length: 16, type: 'varchar', default: 'finalized' })
status: 'finalized';
@Column({ type: 'bigint' })
blockHeight: number;
@Column({ type: 'bigint' })
coinbaseValueSats: string;
@Column({ type: 'decimal' })
networkDifficulty: number;
@Column({ type: 'decimal', default: 0 })
windowTargetDifficulty: number;
@Column({ type: 'decimal', default: 4 })
windowFactor: number;
@Column({ length: 62, type: 'varchar', nullable: true })
feeAddress?: string | null;
@Column({ type: 'bigint', default: 0 })
feeSats: string;
@Column({ type: 'integer', default: 546 })
minPayoutSats: number;
@Column({ type: 'integer', default: 50000 })
coinbaseWeightBudget: number;
@Column({ type: 'bigint' })
startBatchId: string;
@Column({ type: 'bigint' })
endBatchId: string;
@Column({ type: 'bigint' })
windowStartShareIndex: string;
@Column({ type: 'bigint' })
windowEndShareIndex: string;
@Column({ type: 'decimal' })
totalCreditedDifficulty: number;
@Column({ type: 'bigint' })
totalAcceptedShareCount: string;
@Column({ type: 'integer' })
eligibleAddressCount: number;
@Column({ type: 'integer' })
includedOutputCount: number;
@Column({ type: 'bigint' })
distributedSats: string;
@Column({ type: 'bigint' })
unallocatedRemainderSats: string;
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
@OneToMany(() => PayoutSnapshotEntryEntity, entry => entry.snapshot)
entries: PayoutSnapshotEntryEntity[];
}
@@ -0,0 +1,21 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PayoutBalanceEntity } from './payout-balance.entity';
import { PayoutHistoryEntity } from './payout-history.entity';
import { PayoutSnapshotEntryEntity } from './payout-snapshot-entry.entity';
import { PayoutSnapshotEntity } from './payout-snapshot.entity';
import { PayoutSnapshotService } from './payout-snapshot.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([
PayoutBalanceEntity,
PayoutHistoryEntity,
PayoutSnapshotEntity,
PayoutSnapshotEntryEntity,
])],
providers: [PayoutSnapshotService],
exports: [TypeOrmModule, PayoutSnapshotService],
})
export class PayoutSnapshotModule { }
@@ -0,0 +1,213 @@
import { DataSource, EntityManager } from 'typeorm';
import { PayoutSnapshotService } from './payout-snapshot.service';
const ADDRESS_A = 'bc1qs29kyaqqc0fkvj897ke9e5xa9utljjey0y5jjn';
const ADDRESS_B = 'bc1q99n3pu025yyu0jlywpmwzalyhm36tg5u37w20d';
describe('PayoutSnapshotService', () => {
let originalEnv: NodeJS.ProcessEnv;
let manager: { query: jest.Mock };
let dataSource: { transaction: jest.Mock; query: jest.Mock };
let service: PayoutSnapshotService;
beforeEach(() => {
originalEnv = { ...process.env };
process.env.PAYOUT_SNAPSHOT_ENABLED = 'true';
process.env.PAYOUT_METHOD = 'pplns';
process.env.PAYOUT_WINDOW_FACTOR = '4';
process.env.PAYOUT_MAX_COINBASE_OUTPUTS = '10';
process.env.PAYOUT_MIN_OUTPUT_SATS = '546';
process.env.PAYOUT_FEE_PERCENT = '0';
process.env.PAYOUT_FEE_ADDRESS = '';
process.env.PAYOUT_COINBASE_WEIGHT_BUDGET = '50000';
manager = { query: jest.fn() };
dataSource = {
transaction: jest.fn(async callback => callback(manager as unknown as EntityManager)),
query: jest.fn(),
};
service = new PayoutSnapshotService(dataSource as unknown as DataSource);
});
afterEach(() => {
process.env = originalEnv;
});
it('should create a PPLNS payout snapshot from finalized rollup batches', async () => {
manager.query
.mockResolvedValueOnce([{
startBatchId: '10',
endBatchId: '12',
windowStartShareIndex: '1000',
windowEndShareIndex: '2000',
totalCreditedDifficulty: '100',
totalAcceptedShareCount: '5',
}])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ address: ADDRESS_A, creditedDifficulty: 60, acceptedShareCount: 3 },
{ address: ADDRESS_B, creditedDifficulty: 40, acceptedShareCount: 2 },
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: '55' }])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{
id: '55',
method: 'pplns',
blockHeight: 900000,
coinbaseValueSats: '1000',
windowStartShareIndex: '1000',
windowEndShareIndex: '2000',
totalCreditedDifficulty: 100,
totalAcceptedShareCount: '5',
eligibleAddressCount: 2,
includedOutputCount: 1,
distributedSats: '1000',
unallocatedRemainderSats: '0',
}])
.mockResolvedValueOnce([
{ address: ADDRESS_A, payoutSats: '1000' },
]);
const snapshot = await service.createSnapshotForTemplate({
blockHeight: 900000,
coinbaseValueSats: 1000,
networkDifficulty: 25,
});
expect(snapshot).toEqual(expect.objectContaining({
id: '55',
method: 'pplns',
blockHeight: 900000,
totalCreditedDifficulty: 100,
totalAcceptedShareCount: 5,
distributedSats: '1000',
unallocatedRemainderSats: '0',
}));
expect(snapshot.payoutOutputs).toEqual([
{ address: ADDRESS_A, amountSats: 1000, percent: 100 },
]);
expect(manager.query).toHaveBeenCalledTimes(9);
expect(manager.query.mock.calls[0][1]).toEqual([100]);
});
it('should not create a snapshot when there is no finalized rollup work', async () => {
manager.query.mockResolvedValueOnce([{
startBatchId: null,
endBatchId: null,
windowStartShareIndex: null,
windowEndShareIndex: null,
totalCreditedDifficulty: '0',
totalAcceptedShareCount: '0',
}]);
await expect(service.createSnapshotForTemplate({
blockHeight: 900000,
coinbaseValueSats: 1000,
networkDifficulty: 25,
})).resolves.toBeNull();
expect(manager.query).toHaveBeenCalledTimes(1);
});
it('should reuse an existing snapshot for the same template and rollup window', async () => {
manager.query
.mockResolvedValueOnce([{
startBatchId: '10',
endBatchId: '12',
windowStartShareIndex: '1000',
windowEndShareIndex: '2000',
totalCreditedDifficulty: '100',
totalAcceptedShareCount: '5',
}])
.mockResolvedValueOnce([{ id: '55' }])
.mockResolvedValueOnce([{
id: '55',
method: 'pplns',
blockHeight: 900000,
coinbaseValueSats: '1000',
windowStartShareIndex: '1000',
windowEndShareIndex: '2000',
totalCreditedDifficulty: 100,
totalAcceptedShareCount: '5',
eligibleAddressCount: 2,
includedOutputCount: 1,
distributedSats: '1000',
unallocatedRemainderSats: '0',
}])
.mockResolvedValueOnce([
{ address: ADDRESS_A, payoutSats: '1000' },
]);
const snapshot = await service.createSnapshotForTemplate({
blockHeight: 900000,
coinbaseValueSats: 1000,
networkDifficulty: 25,
});
expect(snapshot.id).toBe('55');
expect(snapshot.payoutOutputs).toEqual([
{ address: ADDRESS_A, amountSats: 1000, percent: 100 },
]);
expect(manager.query).toHaveBeenCalledTimes(4);
});
it('should finalize snapshot balances and payout history for a found block', async () => {
manager.query
.mockResolvedValueOnce([{ id: '55', coinbaseValueSats: '1000' }])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
address: ADDRESS_A,
payoutSats: '1000',
balanceBeforeSats: '0',
balanceAfterSats: '-400',
creditedDifficulty: 60,
includedInCoinbase: true,
rowType: 'coinbase',
},
{
address: ADDRESS_B,
payoutSats: '0',
balanceBeforeSats: '0',
balanceAfterSats: '400',
creditedDifficulty: 40,
includedInCoinbase: false,
rowType: 'pending',
},
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await expect(service.finalizeSnapshotForBlock({
payoutSnapshotId: '55',
blockHeight: 900000,
blockSubmissionResult: 'SUCCESS!',
})).resolves.toEqual({
finalized: true,
payoutSnapshotId: '55',
historyRows: 2,
balanceRows: 2,
});
expect(manager.query).toHaveBeenCalledTimes(7);
});
it('should skip finalization when a block was already processed', async () => {
manager.query
.mockResolvedValueOnce([{ id: '55', coinbaseValueSats: '1000' }])
.mockResolvedValueOnce([{ id: '99' }]);
await expect(service.finalizeSnapshotForBlock({
payoutSnapshotId: '55',
blockHeight: 900000,
blockSubmissionResult: 'SUCCESS!',
})).resolves.toEqual({
finalized: false,
reason: 'already-finalized',
payoutSnapshotId: '55',
});
expect(manager.query).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,617 @@
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import type { AddressObject } from '../../models/MiningJob';
import {
buildPayoutDistribution,
PayoutAddressWork,
PayoutBalanceInput,
PayoutDistributionEntry,
} from './payout-distribution';
export interface PayoutSnapshotForTemplate {
id: string;
method: string;
blockHeight: number;
coinbaseValueSats: string;
windowStartShareIndex: string;
windowEndShareIndex: string;
totalCreditedDifficulty: number;
totalAcceptedShareCount: number;
eligibleAddressCount: number;
includedOutputCount: number;
distributedSats: string;
unallocatedRemainderSats: string;
payoutOutputs: AddressObject[];
}
export interface PayoutFinalizationResult {
finalized: boolean;
reason?: 'disabled' | 'missing-snapshot' | 'already-finalized' | 'duplicate';
payoutSnapshotId?: string;
historyRows?: number;
balanceRows?: number;
}
const DEFAULT_MAX_COINBASE_OUTPUTS = 10;
const DEFAULT_MIN_OUTPUT_SATS = 546;
const DEFAULT_PAYOUT_METHOD = 'pplns';
const DEFAULT_PAYOUT_WINDOW_FACTOR = 4;
const DEFAULT_COINBASE_WEIGHT_BUDGET = 50_000;
@Injectable()
export class PayoutSnapshotService {
private readonly snapshotsEnabled = this.readBoolean('PAYOUT_SNAPSHOT_ENABLED', true);
private readonly method = process.env.PAYOUT_METHOD ?? DEFAULT_PAYOUT_METHOD;
private readonly windowFactor = this.readPositiveNumber('PAYOUT_WINDOW_FACTOR', DEFAULT_PAYOUT_WINDOW_FACTOR);
private readonly maxCoinbaseOutputs = this.readPositiveInt('PAYOUT_MAX_COINBASE_OUTPUTS', DEFAULT_MAX_COINBASE_OUTPUTS);
private readonly minOutputSats = this.readNonNegativeInt('PAYOUT_MIN_OUTPUT_SATS', DEFAULT_MIN_OUTPUT_SATS);
private readonly feeAddress = process.env.PAYOUT_FEE_ADDRESS?.trim() ?? '';
private readonly feePercent = this.readNonNegativeNumber('PAYOUT_FEE_PERCENT', 0);
private readonly coinbaseWeightBudget = this.readPositiveInt('PAYOUT_COINBASE_WEIGHT_BUDGET', DEFAULT_COINBASE_WEIGHT_BUDGET);
constructor(
private readonly dataSource: DataSource,
) { }
public async createSnapshotForTemplate(input: {
blockHeight: number;
coinbaseValueSats: number;
networkDifficulty: number;
}): Promise<PayoutSnapshotForTemplate | null> {
if (!this.snapshotsEnabled || input.coinbaseValueSats <= 0 || input.networkDifficulty <= 0) {
return null;
}
return this.dataSource.transaction(async manager => {
const windowTargetDifficulty = input.networkDifficulty * this.windowFactor;
const window = await this.getPplnsWindow(manager, windowTargetDifficulty);
if (window == null || this.toNumber(window.totalCreditedDifficulty) <= 0) {
return null;
}
const existing = await this.getExistingSnapshot(manager, {
blockHeight: input.blockHeight,
coinbaseValueSats: input.coinbaseValueSats,
windowEndShareIndex: window.windowEndShareIndex,
});
if (existing != null) {
return existing;
}
const addressWork = await this.getAddressWork(manager, window);
const balances = await this.getOpenBalances(manager);
if (addressWork.length === 0) {
return null;
}
const distribution = buildPayoutDistribution({
addressWork,
balances,
coinbaseValueSats: input.coinbaseValueSats,
feeAddress: this.feeAddress,
feePercent: this.feePercent,
minOutputSats: this.minOutputSats,
coinbaseWeightBudget: this.coinbaseWeightBudget,
});
const entries = this.limitCoinbaseOutputs(distribution.entries);
if (entries.every(entry => !entry.includedInCoinbase)) {
return null;
}
const includedOutputCount = entries.filter(entry => entry.includedInCoinbase).length;
const distributedSats = entries
.filter(entry => entry.includedInCoinbase)
.reduce((sum, entry) => sum + entry.payoutSats, 0);
const unallocatedRemainderSats = input.coinbaseValueSats - distributedSats;
const [snapshotRow] = await manager.query(`
INSERT INTO "payout_snapshot" (
"method",
"status",
"blockHeight",
"coinbaseValueSats",
"networkDifficulty",
"windowTargetDifficulty",
"windowFactor",
"feeAddress",
"feeSats",
"minPayoutSats",
"coinbaseWeightBudget",
"startBatchId",
"endBatchId",
"windowStartShareIndex",
"windowEndShareIndex",
"totalCreditedDifficulty",
"totalAcceptedShareCount",
"eligibleAddressCount",
"includedOutputCount",
"distributedSats",
"unallocatedRemainderSats"
) VALUES (
$1,
'finalized',
$2::bigint,
$3::bigint,
$4::numeric,
$5::numeric,
$6::numeric,
NULLIF($7, ''),
$8::bigint,
$9::int,
$10::int,
$11::bigint,
$12::bigint,
$13::bigint,
$14::bigint,
$15::numeric,
$16::bigint,
$17::int,
$18::int,
$19::bigint,
$20::bigint
)
RETURNING "id"::text AS "id"
`, [
this.method,
input.blockHeight,
input.coinbaseValueSats.toString(),
input.networkDifficulty,
windowTargetDifficulty,
this.windowFactor,
this.feeAddress,
distribution.feeSats.toString(),
this.minOutputSats,
this.coinbaseWeightBudget,
window.startBatchId,
window.endBatchId,
window.windowStartShareIndex,
window.windowEndShareIndex,
window.totalCreditedDifficulty,
window.totalAcceptedShareCount,
distribution.consideredAddressCount,
includedOutputCount,
distributedSats.toString(),
unallocatedRemainderSats.toString(),
]);
for (const entry of entries) {
await this.insertSnapshotEntry(manager, snapshotRow.id, entry);
}
return this.getSnapshotById(manager, snapshotRow.id);
});
}
public async finalizeSnapshotForBlock(input: {
payoutSnapshotId?: string | null;
blockHeight: number;
blockSubmissionResult?: string | null;
}): Promise<PayoutFinalizationResult> {
if (!this.snapshotsEnabled) {
return { finalized: false, reason: 'disabled' };
}
if (!this.isSuccessfulBlockSubmission(input.blockSubmissionResult)) {
return { finalized: false, reason: 'missing-snapshot' };
}
if (input.payoutSnapshotId == null) {
return { finalized: false, reason: 'missing-snapshot' };
}
return this.dataSource.transaction<PayoutFinalizationResult>(async manager => {
const [snapshot] = await manager.query(`
SELECT "id"::text AS "id", "coinbaseValueSats"::text AS "coinbaseValueSats"
FROM "payout_snapshot"
WHERE "id" = $1::bigint
LIMIT 1
`, [input.payoutSnapshotId]);
if (snapshot == null) {
return { finalized: false, reason: 'missing-snapshot' };
}
const [existing] = await manager.query(`
SELECT "id"::text AS "id"
FROM "payout_history"
WHERE "blockHeight" = $1::bigint
LIMIT 1
`, [input.blockHeight]);
if (existing != null) {
return {
finalized: false,
reason: 'already-finalized',
payoutSnapshotId: input.payoutSnapshotId,
};
}
const entries = await manager.query(`
SELECT
"address",
"payoutSats"::text AS "payoutSats",
"balanceBeforeSats"::text AS "balanceBeforeSats",
"balanceAfterSats"::text AS "balanceAfterSats",
"creditedDifficulty"::float AS "creditedDifficulty",
"includedInCoinbase",
"rowType"
FROM "payout_snapshot_entry"
WHERE "snapshotId" = $1::bigint
ORDER BY "rank"
`, [input.payoutSnapshotId]);
if (entries.length === 0) {
return { finalized: false, reason: 'missing-snapshot' };
}
let balanceRows = 0;
let historyRows = 0;
const coinbaseValue = this.toNumber(snapshot.coinbaseValueSats);
for (const entry of entries) {
const payoutSats = this.toNumber(entry.payoutSats);
const balanceBeforeSats = this.toNumber(entry.balanceBeforeSats);
const balanceAfterSats = this.toNumber(entry.balanceAfterSats);
const isCoinbase = entry.includedInCoinbase === true;
const wasActive = this.toNumber(entry.creditedDifficulty) > 0;
await manager.query(`
INSERT INTO "payout_balance" (
"address",
"balanceSats",
"totalPaidSats",
"lastAcceptedShareAt",
"updatedAt"
) VALUES (
$1,
$2::bigint,
$3::bigint,
CASE WHEN $4::boolean THEN NOW() ELSE NULL END,
NOW()
)
ON CONFLICT ("address") DO UPDATE SET
"balanceSats" = EXCLUDED."balanceSats",
"totalPaidSats" = "payout_balance"."totalPaidSats" + EXCLUDED."totalPaidSats",
"lastAcceptedShareAt" = CASE
WHEN $4::boolean THEN NOW()
ELSE "payout_balance"."lastAcceptedShareAt"
END,
"updatedAt" = NOW()
`, [
entry.address,
balanceAfterSats.toString(),
isCoinbase ? payoutSats.toString() : '0',
wasActive,
]);
balanceRows++;
await manager.query(`
INSERT INTO "payout_history" (
"blockHeight",
"payoutSnapshotId",
"address",
"paidSats",
"percent",
"balanceBeforeSats",
"balanceAfterSats",
"rowType"
) VALUES (
$1::bigint,
$2::bigint,
$3,
$4::bigint,
$5::numeric,
$6::bigint,
$7::bigint,
$8
)
ON CONFLICT ("blockHeight", "address") DO NOTHING
`, [
input.blockHeight,
input.payoutSnapshotId,
entry.address,
payoutSats.toString(),
coinbaseValue > 0 && isCoinbase ? (payoutSats / coinbaseValue) * 100 : 0,
balanceBeforeSats.toString(),
balanceAfterSats.toString(),
isCoinbase ? 'coinbase' : 'pending',
]);
historyRows++;
}
return {
finalized: true,
payoutSnapshotId: input.payoutSnapshotId,
historyRows,
balanceRows,
};
}).catch(error => {
if (error?.code === '23505') {
return {
finalized: false,
reason: 'duplicate',
payoutSnapshotId: input.payoutSnapshotId ?? undefined,
};
}
throw error;
});
}
public async getLatestSnapshot(): Promise<PayoutSnapshotForTemplate | null> {
const [snapshot] = await this.dataSource.query(`
SELECT "id"::text AS "id"
FROM "payout_snapshot"
WHERE "status" = 'finalized'
ORDER BY "createdAt" DESC, "id" DESC
LIMIT 1
`);
if (snapshot?.id == null) {
return null;
}
return this.dataSource.transaction(manager => this.getSnapshotById(manager, snapshot.id));
}
private async getPplnsWindow(manager: EntityManager, windowTargetDifficulty: number): Promise<{
startBatchId: string;
endBatchId: string;
windowStartShareIndex: string;
windowEndShareIndex: string;
totalCreditedDifficulty: string;
totalAcceptedShareCount: string;
} | null> {
const [window] = await manager.query(`
WITH ordered AS MATERIALIZED (
SELECT
"id",
"startShareIndex",
"endShareIndex",
"acceptedShareCount",
"creditedDifficulty",
COALESCE(
SUM("creditedDifficulty") OVER (
ORDER BY "endShareIndex" DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
),
0
) AS "difficultyAfter"
FROM "share_rollup_batch"
WHERE "status" = 'finalized'
),
selected AS MATERIALIZED (
SELECT *
FROM ordered
WHERE "difficultyAfter" < $1::numeric
)
SELECT
MIN("id")::text AS "startBatchId",
MAX("id")::text AS "endBatchId",
MIN("startShareIndex")::text AS "windowStartShareIndex",
MAX("endShareIndex")::text AS "windowEndShareIndex",
COALESCE(SUM("creditedDifficulty"), 0)::numeric AS "totalCreditedDifficulty",
COALESCE(SUM("acceptedShareCount"), 0)::bigint AS "totalAcceptedShareCount"
FROM selected
`, [windowTargetDifficulty]);
if (window?.startBatchId == null || window?.windowEndShareIndex == null) {
return null;
}
return window;
}
private async getAddressWork(
manager: EntityManager,
window: { windowStartShareIndex: string; windowEndShareIndex: string },
): Promise<PayoutAddressWork[]> {
const rows = await manager.query(`
SELECT
s."address",
COALESCE(SUM(s."creditedDifficulty"), 0)::float AS "creditedDifficulty",
COALESCE(SUM(s."acceptedShareCount"), 0)::int AS "acceptedShareCount"
FROM "share_rollup_batch_summary" s
JOIN "share_rollup_batch" b ON b."id" = s."batchId"
WHERE b."status" = 'finalized'
AND b."endShareIndex" >= $1::bigint
AND b."endShareIndex" <= $2::bigint
GROUP BY s."address"
HAVING COALESCE(SUM(s."creditedDifficulty"), 0) > 0
`, [
window.windowStartShareIndex,
window.windowEndShareIndex,
]);
return rows.map(row => ({
address: row.address,
creditedDifficulty: this.toNumber(row.creditedDifficulty),
acceptedShareCount: this.toNumber(row.acceptedShareCount),
}));
}
private async getOpenBalances(manager: EntityManager): Promise<PayoutBalanceInput[]> {
const rows = await manager.query(`
SELECT "address", "balanceSats"::text AS "balanceSats"
FROM "payout_balance"
WHERE "balanceSats" != 0
`);
return rows.map(row => ({
address: row.address,
balanceSats: this.toNumber(row.balanceSats),
}));
}
private async getExistingSnapshot(
manager: EntityManager,
input: { blockHeight: number; coinbaseValueSats: number; windowEndShareIndex: string },
): Promise<PayoutSnapshotForTemplate | null> {
const [snapshot] = await manager.query(`
SELECT "id"::text AS "id"
FROM "payout_snapshot"
WHERE "method" = $1
AND "status" = 'finalized'
AND "blockHeight" = $2::bigint
AND "coinbaseValueSats" = $3::bigint
AND "windowEndShareIndex" = $4::bigint
ORDER BY "id" DESC
LIMIT 1
`, [
this.method,
input.blockHeight,
input.coinbaseValueSats.toString(),
input.windowEndShareIndex,
]);
if (snapshot?.id == null) {
return null;
}
return this.getSnapshotById(manager, snapshot.id);
}
private async getSnapshotById(manager: EntityManager, id: string): Promise<PayoutSnapshotForTemplate | null> {
const [snapshot] = await manager.query(`
SELECT
"id"::text AS "id",
"method",
"blockHeight"::int AS "blockHeight",
"coinbaseValueSats"::text AS "coinbaseValueSats",
"windowStartShareIndex"::text AS "windowStartShareIndex",
"windowEndShareIndex"::text AS "windowEndShareIndex",
"totalCreditedDifficulty"::float AS "totalCreditedDifficulty",
"totalAcceptedShareCount"::bigint AS "totalAcceptedShareCount",
"eligibleAddressCount",
"includedOutputCount",
"distributedSats"::text AS "distributedSats",
"unallocatedRemainderSats"::text AS "unallocatedRemainderSats"
FROM "payout_snapshot"
WHERE "id" = $1::bigint
`, [id]);
if (snapshot == null) {
return null;
}
const entries = await manager.query(`
SELECT
"address",
"payoutSats"::text AS "payoutSats"
FROM "payout_snapshot_entry"
WHERE "snapshotId" = $1::bigint
AND "includedInCoinbase" = true
ORDER BY "rank"
`, [id]);
const coinbaseValue = Number(snapshot.coinbaseValueSats);
return {
id: snapshot.id,
method: snapshot.method,
blockHeight: this.toNumber(snapshot.blockHeight),
coinbaseValueSats: snapshot.coinbaseValueSats,
windowStartShareIndex: snapshot.windowStartShareIndex,
windowEndShareIndex: snapshot.windowEndShareIndex,
totalCreditedDifficulty: this.toNumber(snapshot.totalCreditedDifficulty),
totalAcceptedShareCount: this.toNumber(snapshot.totalAcceptedShareCount),
eligibleAddressCount: this.toNumber(snapshot.eligibleAddressCount),
includedOutputCount: this.toNumber(snapshot.includedOutputCount),
distributedSats: snapshot.distributedSats,
unallocatedRemainderSats: snapshot.unallocatedRemainderSats,
payoutOutputs: entries.map(entry => ({
address: entry.address,
amountSats: this.toNumber(entry.payoutSats),
percent: coinbaseValue > 0
? (this.toNumber(entry.payoutSats) / coinbaseValue) * 100
: 0,
})),
};
}
private async insertSnapshotEntry(manager: EntityManager, snapshotId: string, entry: PayoutDistributionEntry): Promise<void> {
await manager.query(`
INSERT INTO "payout_snapshot_entry" (
"snapshotId",
"address",
"creditedDifficulty",
"acceptedShareCount",
"payoutWeight",
"grossPayoutSats",
"payoutSats",
"balanceBeforeSats",
"balanceAfterSats",
"includedInCoinbase",
"rowType",
"rank"
) VALUES (
$1::bigint,
$2,
$3::numeric,
$4::bigint,
$5::numeric,
$6::bigint,
$7::bigint,
$8::bigint,
$9::bigint,
$10::boolean,
$11,
$12::int
)
`, [
snapshotId,
entry.address,
entry.creditedDifficulty,
entry.acceptedShareCount,
entry.payoutWeight,
entry.grossPayoutSats,
entry.payoutSats,
entry.balanceBeforeSats,
entry.balanceAfterSats,
entry.includedInCoinbase,
entry.includedInCoinbase ? 'coinbase' : 'pending',
entry.rank,
]);
}
private limitCoinbaseOutputs(entries: PayoutDistributionEntry[]): PayoutDistributionEntry[] {
let included = 0;
return entries.map(entry => {
if (!entry.includedInCoinbase) {
return entry;
}
included++;
if (included <= this.maxCoinbaseOutputs) {
return entry;
}
return {
...entry,
payoutSats: 0,
balanceAfterSats: entry.balanceAfterSats + entry.payoutSats,
includedInCoinbase: false,
};
});
}
private isSuccessfulBlockSubmission(result?: string | null): boolean {
return result == null || result === 'SUCCESS!';
}
private toNumber(value: unknown): number {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
}
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;
}
private readPositiveNumber(name: string, defaultValue: number): number {
const value = Number(process.env[name]);
return Number.isFinite(value) && value > 0 ? value : defaultValue;
}
private readNonNegativeNumber(name: string, defaultValue: number): number {
const value = Number(process.env[name]);
return Number.isFinite(value) && value >= 0 ? value : defaultValue;
}
private readBoolean(name: string, defaultValue: boolean): boolean {
const value = process.env[name]?.toLowerCase();
if (value == null || value.length === 0) {
return defaultValue;
}
return value === 'true' || value === '1' || value === 'yes';
}
}
-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,434 @@
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 accept TLS SV1 protocol labels for accounting', 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);
await expect(service.recordAcceptedShare({
...buildRecord('sv1-tls'),
protocol: 'sv1_tls',
})).resolves.toEqual(expect.objectContaining({
protocol: 'sv1_tls',
}));
expect(repository.insert).toHaveBeenCalledWith([expect.objectContaining({
protocol: 'sv1_tls',
})]);
});
it('should batch accepted share inserts until the batch size is reached', async () => {
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '2';
process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '1000';
const repository = {
create: jest.fn(value => value),
insert: jest.fn().mockResolvedValue({}),
};
const service = new ShareAccountingService(repository as any);
const first = service.recordAcceptedShare(buildRecord('1'));
expect(repository.insert).not.toHaveBeenCalled();
const second = service.recordAcceptedShare(buildRecord('2'));
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
expect(repository.insert).toHaveBeenCalledTimes(1);
expect(repository.insert).toHaveBeenCalledWith([
expect.objectContaining({ jobId: '1' }),
expect.objectContaining({ jobId: '2' }),
]);
});
it('should flush partial batches on the flush interval', async () => {
jest.useFakeTimers();
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '10';
process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '25';
const repository = {
create: jest.fn(value => value),
insert: jest.fn().mockResolvedValue({}),
};
const service = new ShareAccountingService(repository as any);
const pending = service.recordAcceptedShare(buildRecord('interval'));
expect(repository.insert).not.toHaveBeenCalled();
jest.advanceTimersByTime(25);
await expect(pending).resolves.toEqual(expect.objectContaining({ jobId: 'interval' }));
expect(repository.insert).toHaveBeenCalledTimes(1);
});
it('should reject accepted shares when the accounting queue is full', async () => {
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '10';
process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '1000';
process.env.SHARE_ACCOUNTING_MAX_QUEUE_SIZE = '1';
const repository = {
create: jest.fn(value => value),
insert: jest.fn().mockResolvedValue({}),
};
const service = new ShareAccountingService(repository as any);
const queued = service.recordAcceptedShare(buildRecord('queued'));
await expect(service.recordAcceptedShare(buildRecord('overflow')))
.rejects
.toThrow('Share accounting queue is full');
await service.flushPendingShares();
await expect(queued).resolves.toEqual(expect.objectContaining({ jobId: 'queued' }));
});
it('should return numeric accounting summaries from the share rollup', async () => {
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '3',
totalCreditedDifficulty: '96',
acceptedSharesLast10Minutes: '2',
creditedDifficultyLast10Minutes: '64',
acceptedSharesLastHour: '3',
creditedDifficultyLastHour: '96',
acceptedSharesLastDay: '3',
creditedDifficultyLastDay: '96',
hashRateLast10Minutes: '458129844.9',
hashRateLastHour: '114532461.2',
latestShareAt: new Date('2026-06-07T12:10:00Z'),
}]),
};
const service = new ShareAccountingService(repository as any);
await expect(service.getAddressSummary('bc1qtest')).resolves.toEqual({
totalAcceptedShares: 3,
totalCreditedDifficulty: 96,
acceptedSharesLast10Minutes: 2,
creditedDifficultyLast10Minutes: 64,
acceptedSharesLastHour: 3,
creditedDifficultyLastHour: 96,
acceptedSharesLastDay: 3,
creditedDifficultyLastDay: 96,
hashRateLast10Minutes: 458129844.9,
hashRateLastHour: 114532461.2,
bestSubmissionDifficulty: 0,
bestSubmissionDifficultyAt: null,
workSinceLastBlock: 0,
currentRoundAcceptedShares: 0,
currentRoundNetworkDifficulty: 0,
networkDifficultyPercent: 0,
blockCandidateCount: 0,
latestShareAt: '2026-06-07T12:10:00.000Z',
protocolBreakdown: [],
});
expect(repository.query).toHaveBeenNthCalledWith(
1,
expect.stringContaining('"accepted_share_10m"'),
['bc1qtest'],
);
expect(repository.query).toHaveBeenCalledTimes(1);
});
it('should serve API-only address accounting from the share rollup instead of empty summaries', async () => {
process.env.API_ONLY = 'true';
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '12',
totalCreditedDifficulty: '384',
acceptedSharesLast10Minutes: '4',
creditedDifficultyLast10Minutes: '128',
acceptedSharesLastHour: '10',
creditedDifficultyLastHour: '320',
acceptedSharesLastDay: '12',
creditedDifficultyLastDay: '384',
hashRateLast10Minutes: '916259689.8',
hashRateLastHour: '381774870.2',
latestShareAt: new Date('2026-06-07T12:30:00Z'),
}]),
};
const service = new ShareAccountingService(repository as any);
await expect(service.getAddressSummary('bc1qapi')).resolves.toEqual(expect.objectContaining({
totalAcceptedShares: 12,
totalCreditedDifficulty: 384,
acceptedSharesLast10Minutes: 4,
creditedDifficultyLast10Minutes: 128,
hashRateLast10Minutes: 916259689.8,
latestShareAt: '2026-06-07T12:30:00.000Z',
}));
expect(repository.query).toHaveBeenCalledWith(
expect.stringContaining('FROM "accepted_share_10m"'),
['bc1qapi'],
);
expect(repository.query).not.toHaveBeenCalledWith(
expect.stringContaining('FROM "accepted_share_entity"'),
expect.anything(),
);
});
it('should use Redis cache for share accounting summaries across API workers', async () => {
process.env.SHARE_ACCOUNTING_SUMMARY_CACHE_MS = '0';
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '1',
totalCreditedDifficulty: '32',
acceptedSharesLast10Minutes: '1',
creditedDifficultyLast10Minutes: '32',
acceptedSharesLastHour: '1',
creditedDifficultyLastHour: '32',
acceptedSharesLastDay: '1',
creditedDifficultyLastDay: '32',
hashRateLast10Minutes: '1',
hashRateLastHour: '1',
latestShareAt: null,
}]),
};
const redis = {
getJsonCache: jest.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
...new ShareAccountingService(repository as any).emptySummary(),
totalAcceptedShares: 1,
}),
setJsonCache: jest.fn().mockResolvedValue(undefined),
};
const service = new ShareAccountingService(repository as any, redis as any);
await service.getAddressSummary('bc1qcached');
await expect(service.getAddressSummary('bc1qcached')).resolves.toEqual(expect.objectContaining({
totalAcceptedShares: 1,
}));
expect(repository.query).toHaveBeenCalledTimes(1);
expect(redis.setJsonCache).toHaveBeenCalledWith(
expect.stringContaining('accounting:summary:'),
expect.objectContaining({ totalAcceptedShares: 1 }),
30000,
);
});
it('should refresh pool summaries from completed rollup buckets and current round rollups', async () => {
const redis = {
getJsonCache: jest.fn().mockResolvedValue(null),
setJsonCache: jest.fn().mockResolvedValue(undefined),
};
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '3',
totalCreditedDifficulty: '96',
acceptedSharesLast10Minutes: '2',
creditedDifficultyLast10Minutes: '64',
acceptedSharesLastHour: '3',
creditedDifficultyLastHour: '96',
acceptedSharesLastDay: '3',
creditedDifficultyLastDay: '96',
hashRateLast10Minutes: '458129844.9',
hashRateLastHour: '114532461.2',
latestShareAt: new Date('2026-06-07T12:10:00Z'),
}])
.mockResolvedValueOnce([{
bestSubmissionDifficulty: '4096',
bestSubmissionDifficultyAt: new Date('2026-06-07T12:10:00Z'),
currentRoundAcceptedShares: '11',
workSinceLastBlock: '352',
currentRoundNetworkDifficulty: '1000',
}]),
};
const service = new ShareAccountingService(repository as any, redis as any);
await expect(service.refreshPoolSummary()).resolves.toEqual(expect.objectContaining({
acceptedSharesLast10Minutes: 2,
creditedDifficultyLast10Minutes: 64,
hashRateLast10Minutes: 458129844.9,
bestSubmissionDifficulty: 4096,
bestSubmissionDifficultyAt: '2026-06-07T12:10:00.000Z',
workSinceLastBlock: 352,
currentRoundAcceptedShares: 11,
currentRoundNetworkDifficulty: 1000,
networkDifficultyPercent: 35.2,
latestShareAt: '2026-06-07T12:10:00.000Z',
}));
expect(repository.query).toHaveBeenNthCalledWith(
2,
expect.stringContaining('FROM "accepted_share_block_10m"'),
);
expect(repository.query).not.toHaveBeenCalledWith(
expect.stringContaining('FROM "accepted_share_entity"'),
);
});
it('should cache accounting summaries briefly to protect hot dashboard endpoints', async () => {
process.env.SHARE_ACCOUNTING_SUMMARY_CACHE_MS = '1000';
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '1',
totalCreditedDifficulty: '32',
acceptedSharesLast10Minutes: '1',
creditedDifficultyLast10Minutes: '32',
acceptedSharesLastHour: '1',
creditedDifficultyLastHour: '32',
acceptedSharesLastDay: '1',
creditedDifficultyLastDay: '32',
hashRateLast10Minutes: '1',
hashRateLastHour: '1',
latestShareAt: null,
}]),
};
const service = new ShareAccountingService(repository as any);
await service.getPoolSummary();
await service.getPoolSummary();
expect(repository.query).toHaveBeenCalledTimes(1);
});
it('should finalize accepted shares into an append-only share rollup batch', async () => {
const manager = {
query: jest.fn()
.mockResolvedValueOnce([{ locked: true }])
.mockResolvedValueOnce([{ lastProcessedShareIndex: '10' }])
.mockResolvedValueOnce([{ startShareIndex: '11', endShareIndex: '20', acceptedShareCount: 10 }])
.mockResolvedValueOnce([{
startAcceptedAt: new Date('2026-06-07T12:00:00Z'),
endAcceptedAt: new Date('2026-06-07T12:00:30Z'),
acceptedShareCount: '10',
creditedDifficulty: '2048',
}])
.mockResolvedValueOnce([{ batchId: '7' }])
.mockResolvedValueOnce([]),
};
const repository = {
manager: {
transaction: jest.fn(callback => callback(manager)),
},
};
const service = new ShareAccountingService(repository as any);
await expect(service.processPendingShareRollupBatch()).resolves.toEqual({
processed: true,
batchId: '7',
startShareIndex: '11',
endShareIndex: '20',
acceptedShareCount: 10,
creditedDifficulty: 2048,
});
expect(repository.manager.transaction).toHaveBeenCalledTimes(1);
expect(manager.query).toHaveBeenNthCalledWith(
1,
expect.stringContaining('pg_try_advisory_xact_lock'),
['1780962600'],
);
expect(manager.query).toHaveBeenNthCalledWith(
6,
expect.stringContaining('INSERT INTO "share_rollup_batch_summary"'),
['7', '11', '20'],
);
});
it('should skip share rollup work when another process holds the advisory lock', async () => {
const manager = {
query: jest.fn().mockResolvedValueOnce([{ locked: false }]),
};
const repository = {
manager: {
transaction: jest.fn(callback => callback(manager)),
},
};
const service = new ShareAccountingService(repository as any);
await expect(service.processPendingShareRollupBatch()).resolves.toEqual({
processed: false,
reason: 'locked',
});
expect(manager.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,770 @@
import { Injectable, OnModuleDestroy, OnModuleInit } 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' | 'sv1_tls' | 'sv2' | 'sv2_jdp' | 'datum';
workSource?: 'pool_template' | 'miner_template';
workProtocol?: 'pool' | 'sv2_jdp' | 'datum';
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;
}
export interface ShareRollupBatchResult {
processed: boolean;
reason?: 'disabled' | 'locked' | 'no-shares';
batchId?: string;
startShareIndex?: string;
endShareIndex?: string;
acceptedShareCount?: number;
creditedDifficulty?: 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;
const DEFAULT_ROLLUP_INTERVAL_MS = 60000;
const DEFAULT_ROLLUP_SAFETY_LAG_SECONDS = 10;
const DEFAULT_ROLLUP_MAX_SHARES_PER_BATCH = 5000000;
const SHARE_ROLLUP_ADVISORY_LOCK = '1780962600';
@Injectable()
export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
private pendingShares: PendingShare[] = [];
private flushTimer: NodeJS.Timeout | null = null;
private activeFlush: Promise<void> | null = null;
private rollupTimer: NodeJS.Timeout | null = null;
private activeRollup: 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);
private readonly shareRollupEnabled = this.readBoolean('SHARE_ROLLUP_ENABLED', true);
private readonly shareRollupIntervalMs = this.readPositiveInt('SHARE_ROLLUP_INTERVAL_MS', DEFAULT_ROLLUP_INTERVAL_MS);
private readonly shareRollupSafetyLagSeconds = this.readNonNegativeInt('SHARE_ROLLUP_SAFETY_LAG_SECONDS', DEFAULT_ROLLUP_SAFETY_LAG_SECONDS);
private readonly shareRollupMaxSharesPerBatch = this.readPositiveInt('SHARE_ROLLUP_MAX_SHARES_PER_BATCH', DEFAULT_ROLLUP_MAX_SHARES_PER_BATCH);
constructor(
@InjectRepository(AcceptedShareEntity)
private readonly acceptedShareRepository: Repository<AcceptedShareEntity>,
private readonly redisMessagingService?: RedisMessagingService,
) { }
public onModuleInit(): void {
this.startShareRollupTimer();
}
public async recordAcceptedShare(record: AcceptedShareRecord): Promise<AcceptedShareEntity> {
const acceptedShare = this.acceptedShareRepository.create({
...record,
workSource: record.workSource ?? 'pool_template',
workProtocol: record.workProtocol ?? 'pool',
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> {
if (this.rollupTimer != null) {
clearInterval(this.rollupTimer);
this.rollupTimer = null;
}
if (this.activeRollup != null) {
await this.activeRollup;
}
await this.flushPendingShares();
}
public async processPendingShareRollupBatch(): Promise<ShareRollupBatchResult> {
if (!this.shareRollupEnabled) {
return { processed: false, reason: 'disabled' };
}
return this.acceptedShareRepository.manager.transaction(async manager => {
const [lockRow] = await manager.query(`
SELECT pg_try_advisory_xact_lock($1::bigint) AS "locked"
`, [SHARE_ROLLUP_ADVISORY_LOCK]);
if (lockRow?.locked !== true) {
return { processed: false, reason: 'locked' };
}
const [lastRow] = await manager.query(`
SELECT COALESCE(MAX("endShareIndex"), 0)::text AS "lastProcessedShareIndex"
FROM "share_rollup_batch"
WHERE "status" = 'finalized'
`);
const lastProcessedShareIndex = lastRow?.lastProcessedShareIndex ?? '0';
const [rangeRow] = await manager.query(`
WITH last_state AS MATERIALIZED (
SELECT $1::bigint AS "lastProcessedShareIndex"
),
first_unstable AS MATERIALIZED (
SELECT MIN("shareIndex") AS "firstUnstableShareIndex"
FROM "accepted_share_entity", last_state
WHERE "shareIndex" > last_state."lastProcessedShareIndex"
AND "acceptedAt" > NOW() - ($2::int * INTERVAL '1 second')
),
stable_bound AS MATERIALIZED (
SELECT
CASE
WHEN (SELECT "firstUnstableShareIndex" FROM first_unstable) IS NULL THEN (
SELECT MAX("shareIndex")
FROM "accepted_share_entity", last_state
WHERE "shareIndex" > last_state."lastProcessedShareIndex"
)
ELSE (SELECT "firstUnstableShareIndex" FROM first_unstable) - 1
END AS "maxStableShareIndex"
),
bounded AS MATERIALIZED (
SELECT "shareIndex"
FROM "accepted_share_entity", last_state, stable_bound
WHERE "shareIndex" > last_state."lastProcessedShareIndex"
AND "shareIndex" <= stable_bound."maxStableShareIndex"
ORDER BY "shareIndex" ASC
LIMIT $3::int
)
SELECT
MIN("shareIndex")::text AS "startShareIndex",
MAX("shareIndex")::text AS "endShareIndex",
COUNT(*)::int AS "acceptedShareCount"
FROM bounded
`, [
lastProcessedShareIndex,
this.shareRollupSafetyLagSeconds,
this.shareRollupMaxSharesPerBatch,
]);
if (rangeRow?.startShareIndex == null || rangeRow?.endShareIndex == null || this.toNumber(rangeRow.acceptedShareCount) === 0) {
return { processed: false, reason: 'no-shares' };
}
const [statsRow] = await manager.query(`
SELECT
MIN("acceptedAt") AS "startAcceptedAt",
MAX("acceptedAt") AS "endAcceptedAt",
COUNT(*)::bigint AS "acceptedShareCount",
COALESCE(SUM("creditedDifficulty"), 0)::numeric AS "creditedDifficulty"
FROM "accepted_share_entity"
WHERE "shareIndex" >= $1::bigint
AND "shareIndex" <= $2::bigint
`, [rangeRow.startShareIndex, rangeRow.endShareIndex]);
if (statsRow?.startAcceptedAt == null || this.toNumber(statsRow.acceptedShareCount) === 0) {
return { processed: false, reason: 'no-shares' };
}
const [batchRow] = await manager.query(`
INSERT INTO "share_rollup_batch" (
"startShareIndex",
"endShareIndex",
"startAcceptedAt",
"endAcceptedAt",
"acceptedShareCount",
"creditedDifficulty",
"status",
"finalizedAt"
) VALUES (
$1::bigint,
$2::bigint,
$3::timestamptz,
$4::timestamptz,
$5::bigint,
$6::numeric,
'finalized',
NOW()
)
RETURNING "id"::text AS "batchId"
`, [
rangeRow.startShareIndex,
rangeRow.endShareIndex,
statsRow.startAcceptedAt,
statsRow.endAcceptedAt,
statsRow.acceptedShareCount,
statsRow.creditedDifficulty,
]);
await manager.query(`
INSERT INTO "share_rollup_batch_summary" (
"batchId",
"address",
"clientName",
"protocol",
"blockHeight",
"creditedDifficulty",
"acceptedShareCount",
"bestSubmissionDifficulty",
"firstShareAt",
"lastShareAt"
)
SELECT
$1::bigint AS "batchId",
"address",
"clientName",
"protocol",
"blockHeight",
SUM("creditedDifficulty") AS "creditedDifficulty",
COUNT(*)::bigint AS "acceptedShareCount",
MAX("submissionDifficulty") AS "bestSubmissionDifficulty",
MIN("acceptedAt") AS "firstShareAt",
MAX("acceptedAt") AS "lastShareAt"
FROM "accepted_share_entity"
WHERE "shareIndex" >= $2::bigint
AND "shareIndex" <= $3::bigint
GROUP BY "address", "clientName", "protocol", "blockHeight"
`, [
batchRow.batchId,
rangeRow.startShareIndex,
rangeRow.endShareIndex,
]);
return {
processed: true,
batchId: batchRow.batchId,
startShareIndex: rangeRow.startShareIndex,
endShareIndex: rangeRow.endShareIndex,
acceptedShareCount: this.toNumber(statsRow.acceptedShareCount),
creditedDifficulty: this.toNumber(statsRow.creditedDifficulty),
};
});
}
public async getPoolSummary(): Promise<ShareAccountingSummary> {
const cached = await this.redisMessagingService
?.getJsonCache<ShareAccountingSummary>(this.poolSummaryCacheKey)
.catch(error => {
console.error(`Pool accounting summary cache read failed: ${error.message}`);
return null;
});
if (cached != null) {
return cached;
}
if (process.env.API_ONLY === 'true') {
return this.emptySummary();
}
return this.getSummary({});
}
public async refreshPoolSummary(): Promise<ShareAccountingSummary> {
const summary = await this.withPoolRollupOverlay(await this.getSummary({}));
await this.redisMessagingService
?.setJsonCache(this.poolSummaryCacheKey, summary, 10 * 60 * 1000)
.catch(error => {
console.error(`Pool accounting summary cache write failed: ${error.message}`);
});
return summary;
}
private async withPoolRollupOverlay(summary: ShareAccountingSummary): Promise<ShareAccountingSummary> {
if (process.env.API_ONLY === 'true') {
return summary;
}
const [currentRoundRow] = await this.acceptedShareRepository.query(`
WITH latest_found_block AS (
SELECT COALESCE(MAX("height"), 0) AS "height"
FROM "blocks_entity"
),
filtered_rows AS (
SELECT "accepted_share_block_10m".*
FROM "accepted_share_block_10m", latest_found_block
WHERE "blockHeight" > latest_found_block."height"
),
best_share AS (
SELECT
"bestSubmissionDifficulty",
"bucket"
FROM filtered_rows
ORDER BY "bestSubmissionDifficulty" DESC, "bucket" DESC
LIMIT 1
)
SELECT
COALESCE(SUM("acceptedCount"), 0)::int AS "currentRoundAcceptedShares",
COALESCE(SUM("shares"), 0)::float AS "workSinceLastBlock",
COALESCE(MAX("networkDifficulty"), 0)::float AS "currentRoundNetworkDifficulty",
COALESCE((SELECT "bestSubmissionDifficulty" FROM best_share), 0)::float AS "bestSubmissionDifficulty",
(SELECT "bucket" FROM best_share) AS "bestSubmissionDifficultyAt"
FROM filtered_rows
`);
const currentRoundNetworkDifficulty = this.toNumber(currentRoundRow?.currentRoundNetworkDifficulty);
const workSinceLastBlock = this.toNumber(currentRoundRow?.workSinceLastBlock);
return {
...summary,
bestSubmissionDifficulty: this.toNumber(currentRoundRow?.bestSubmissionDifficulty),
bestSubmissionDifficultyAt: currentRoundRow?.bestSubmissionDifficultyAt == null
? null
: new Date(currentRoundRow.bestSubmissionDifficultyAt).toISOString(),
workSinceLastBlock,
currentRoundAcceptedShares: this.toNumber(currentRoundRow?.currentRoundAcceptedShares),
currentRoundNetworkDifficulty,
networkDifficultyPercent: currentRoundNetworkDifficulty > 0
? this.roundPercent((workSinceLastBlock / currentRoundNetworkDifficulty) * 100)
: 0,
};
}
public emptySummary(): ShareAccountingSummary {
return {
totalAcceptedShares: 0,
totalCreditedDifficulty: 0,
acceptedSharesLast10Minutes: 0,
creditedDifficultyLast10Minutes: 0,
acceptedSharesLastHour: 0,
creditedDifficultyLastHour: 0,
acceptedSharesLastDay: 0,
creditedDifficultyLastDay: 0,
hashRateLast10Minutes: 0,
hashRateLastHour: 0,
bestSubmissionDifficulty: 0,
bestSubmissionDifficultyAt: null,
workSinceLastBlock: 0,
currentRoundAcceptedShares: 0,
currentRoundNetworkDifficulty: 0,
networkDifficultyPercent: 0,
blockCandidateCount: 0,
latestShareAt: null,
protocolBreakdown: [],
};
}
public async getAddressSummary(address: string): Promise<ShareAccountingSummary> {
return this.getSummary({ address });
}
public async getWorkerGroupSummary(address: string, clientName: string): Promise<ShareAccountingSummary> {
return this.getSummary({ address, clientName });
}
public async getSessionSummary(clientId: string): Promise<ShareAccountingSummary> {
return this.getSummary({ clientId });
}
public async getSessionSummaries(clientIds: string[]): Promise<Map<string, SessionShareSummary>> {
const uniqueClientIds = [...new Set(clientIds.filter(clientId => clientId != null))];
const summaries = new Map<string, SessionShareSummary>();
if (uniqueClientIds.length === 0 || process.env.API_ONLY === 'true') {
return summaries;
}
const rows = await this.acceptedShareRepository.query(`
WITH clock AS MATERIALIZED (
SELECT
time_bucket(INTERVAL '10 minutes', NOW()) AS "currentBucket"
),
latest_bucket AS MATERIALIZED (
SELECT "bucket"
FROM "accepted_share_10m", clock
WHERE "bucket" < clock."currentBucket"
ORDER BY "bucket" DESC
LIMIT 1
),
bounds AS MATERIALIZED (
SELECT
"currentBucket",
COALESCE(
(SELECT "bucket" FROM latest_bucket),
"currentBucket" - INTERVAL '10 minutes'
) AS "latestCompletedBucket"
FROM clock
),
filtered_rows AS (
SELECT "accepted_share_10m".*, bounds."latestCompletedBucket"
FROM "accepted_share_10m", bounds
WHERE "clientId" = ANY($1::uuid[])
)
SELECT
"clientId",
MAX("bucket") AS "latestShareAt",
COALESCE((SUM("shares") FILTER (WHERE "bucket" = "latestCompletedBucket") * ${HASHES_PER_DIFFICULTY}) / ${ROLLUP_BUCKET_SECONDS}, 0)::float AS "hashRateLast10Minutes"
FROM filtered_rows
GROUP BY "clientId"
`, [uniqueClientIds]);
rows.forEach(row => {
summaries.set(row.clientId, {
clientId: row.clientId,
latestShareAt: row.latestShareAt == null
? null
: new Date(row.latestShareAt).toISOString(),
hashRateLast10Minutes: this.toNumber(row.hashRateLast10Minutes),
bestSubmissionDifficulty: 0,
});
});
return summaries;
}
private async getSummary(filter: AccountingFilter): Promise<ShareAccountingSummary> {
const cacheKey = this.getSummaryCacheKey(filter);
const cached = this.summaryCache.get(cacheKey);
const now = Date.now();
if (cached != null && cached.expiresAt > now) {
return cached.value;
}
const value = this.loadCachedSummary(filter, cacheKey).catch(error => {
this.summaryCache.delete(cacheKey);
throw error;
});
if (this.summaryCacheMs > 0) {
this.summaryCache.set(cacheKey, {
expiresAt: now + this.summaryCacheMs,
value,
});
this.trimSummaryCache();
}
return value;
}
private async loadCachedSummary(filter: AccountingFilter, cacheKey: string): Promise<ShareAccountingSummary> {
const redisCacheKey = `accounting:summary:${cacheKey}`;
const cached = await this.redisMessagingService
?.getJsonCache<ShareAccountingSummary>(redisCacheKey)
.catch(error => {
console.error(`Share accounting summary cache read failed: ${error.message}`);
return null;
});
if (cached != null) {
return cached;
}
const summary = await this.loadSummary(filter);
await this.redisMessagingService
?.setJsonCache(redisCacheKey, summary, this.redisSummaryCacheMs)
.catch(error => {
console.error(`Share accounting summary cache write failed: ${error.message}`);
});
return summary;
}
private async loadSummary(filter: AccountingFilter): Promise<ShareAccountingSummary> {
const { whereSql, params } = this.buildWhereClause(filter);
const [summary] = await this.acceptedShareRepository.query(`
WITH clock AS MATERIALIZED (
SELECT
time_bucket(INTERVAL '10 minutes', NOW()) AS "currentBucket"
),
latest_bucket AS MATERIALIZED (
SELECT "bucket"
FROM "accepted_share_10m", clock
WHERE "bucket" < clock."currentBucket"
ORDER BY "bucket" DESC
LIMIT 1
),
bounds AS MATERIALIZED (
SELECT
"currentBucket",
COALESCE(
(SELECT "bucket" FROM latest_bucket),
"currentBucket" - INTERVAL '10 minutes'
) AS "latestCompletedBucket"
FROM clock
),
filtered_rows AS (
SELECT "accepted_share_10m".*, bounds."currentBucket", bounds."latestCompletedBucket"
FROM "accepted_share_10m", bounds
${whereSql.length > 0
? `${whereSql} AND "accepted_share_10m"."bucket" <= bounds."latestCompletedBucket"`
: `WHERE "accepted_share_10m"."bucket" <= bounds."latestCompletedBucket"`}
)
SELECT
COALESCE(SUM("acceptedCount"), 0)::int AS "totalAcceptedShares",
COALESCE(SUM("shares"), 0)::float AS "totalCreditedDifficulty",
COALESCE(SUM("acceptedCount") FILTER (WHERE "bucket" = "latestCompletedBucket"), 0)::int AS "acceptedSharesLast10Minutes",
COALESCE(SUM("shares") FILTER (WHERE "bucket" = "latestCompletedBucket"), 0)::float AS "creditedDifficultyLast10Minutes",
COALESCE(SUM("acceptedCount") FILTER (WHERE "bucket" > "latestCompletedBucket" - INTERVAL '1 hour' AND "bucket" <= "latestCompletedBucket"), 0)::int AS "acceptedSharesLastHour",
COALESCE(SUM("shares") FILTER (WHERE "bucket" > "latestCompletedBucket" - INTERVAL '1 hour' AND "bucket" <= "latestCompletedBucket"), 0)::float AS "creditedDifficultyLastHour",
COALESCE(SUM("acceptedCount") FILTER (WHERE "bucket" > "latestCompletedBucket" - INTERVAL '1 day' AND "bucket" <= "latestCompletedBucket"), 0)::int AS "acceptedSharesLastDay",
COALESCE(SUM("shares") FILTER (WHERE "bucket" > "latestCompletedBucket" - INTERVAL '1 day' AND "bucket" <= "latestCompletedBucket"), 0)::float AS "creditedDifficultyLastDay",
COALESCE((SUM("shares") FILTER (WHERE "bucket" = "latestCompletedBucket") * ${HASHES_PER_DIFFICULTY}) / ${ROLLUP_BUCKET_SECONDS}, 0)::float AS "hashRateLast10Minutes",
COALESCE((SUM("shares") FILTER (WHERE "bucket" > "latestCompletedBucket" - INTERVAL '1 hour' AND "bucket" <= "latestCompletedBucket") * ${HASHES_PER_DIFFICULTY}) / 3600, 0)::float AS "hashRateLastHour",
MAX("bucket") AS "latestShareAt"
FROM filtered_rows
`, params);
return {
totalAcceptedShares: this.toNumber(summary?.totalAcceptedShares),
totalCreditedDifficulty: this.toNumber(summary?.totalCreditedDifficulty),
acceptedSharesLast10Minutes: this.toNumber(summary?.acceptedSharesLast10Minutes),
creditedDifficultyLast10Minutes: this.toNumber(summary?.creditedDifficultyLast10Minutes),
acceptedSharesLastHour: this.toNumber(summary?.acceptedSharesLastHour),
creditedDifficultyLastHour: this.toNumber(summary?.creditedDifficultyLastHour),
acceptedSharesLastDay: this.toNumber(summary?.acceptedSharesLastDay),
creditedDifficultyLastDay: this.toNumber(summary?.creditedDifficultyLastDay),
hashRateLast10Minutes: this.toNumber(summary?.hashRateLast10Minutes),
hashRateLastHour: this.toNumber(summary?.hashRateLastHour),
bestSubmissionDifficulty: 0,
bestSubmissionDifficultyAt: null,
workSinceLastBlock: 0,
currentRoundAcceptedShares: 0,
currentRoundNetworkDifficulty: 0,
networkDifficultyPercent: 0,
blockCandidateCount: 0,
latestShareAt: summary?.latestShareAt == null
? null
: new Date(summary.latestShareAt).toISOString(),
protocolBreakdown: [],
};
}
private scheduleFlush(): void {
if (this.flushTimer != null || this.activeFlush != null) {
return;
}
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
this.startFlush();
}, this.flushIntervalMs);
this.flushTimer.unref?.();
}
private startFlush(): void {
if (this.activeFlush != null) {
return;
}
if (this.flushTimer != null) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
const batch = this.pendingShares.splice(0, this.batchSize);
if (batch.length === 0) {
return;
}
this.activeFlush = this.flushBatch(batch).finally(() => {
this.activeFlush = null;
if (this.pendingShares.length >= this.batchSize) {
this.startFlush();
return;
}
if (this.pendingShares.length > 0) {
this.scheduleFlush();
}
});
}
private async flushBatch(batch: PendingShare[]): Promise<void> {
try {
await this.acceptedShareRepository.insert(batch.map(item => item.entity));
batch.forEach(item => item.resolve(item.entity));
} catch (error) {
batch.forEach(item => item.reject(error));
}
}
private startShareRollupTimer(): void {
if (!this.shareRollupEnabled || process.env.MASTER !== 'true') {
return;
}
this.rollupTimer = setInterval(() => {
if (this.activeRollup != null) {
return;
}
this.activeRollup = this.processPendingShareRollupBatch()
.then(result => {
if (result.processed) {
console.log(`Share rollup batch ${result.batchId} finalized: indexes ${result.startShareIndex}-${result.endShareIndex}, shares ${result.acceptedShareCount}, difficulty ${result.creditedDifficulty}`);
}
})
.catch(error => {
console.error(`Share rollup batch failed: ${error.message}`);
})
.finally(() => {
this.activeRollup = null;
});
}, this.shareRollupIntervalMs);
this.rollupTimer.unref?.();
}
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;
}
private readBoolean(name: string, defaultValue: boolean): boolean {
const value = process.env[name]?.toLowerCase();
if (value == null || value.length === 0) {
return defaultValue;
}
return value === 'true' || value === '1' || value === 'yes';
}
}
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;
} }
+42 -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,34 @@ 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 { PayoutSnapshotModule } from './ORM/payout-snapshot/payout-snapshot.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 { CustomWorkService } from './services/custom-work.service';
import { DatumService } from './services/datum.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 { Sv2JobDeclarationRegistryService } from './services/sv2-job-declaration-registry.service';
import { Sv2JobDeclarationService } from './services/sv2-job-declaration.service';
import { Sv2TemplateDistributionService } from './services/sv2-template-distribution.service';
import { 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 { TemplateProviderService } from './services/template-provider.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 +45,40 @@ const ORMModules = [
TelegramSubscriptionsModule, TelegramSubscriptionsModule,
BlocksModule, BlocksModule,
RpcBlocksModule, RpcBlocksModule,
ExternalSharesModule UserAgentReportModule,
ShareAccountingModule,
PayoutSnapshotModule
] ]
@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 +89,15 @@ const ORMModules = [
NotificationService, NotificationService,
BitcoinAddressValidator, BitcoinAddressValidator,
StratumV1JobsService, StratumV1JobsService,
StratumV2Service,
TemplateProviderService,
Sv2JobDeclarationRegistryService,
Sv2JobDeclarationService,
Sv2TemplateDistributionService,
CustomWorkService,
DatumService,
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));
+77
View File
@@ -0,0 +1,77 @@
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 { ShareRollupBatches1780962600000 } from './ORM/_migrations/ShareRollupBatches1780962600000';
import { AcceptedShareRetentionCompression1780966200000 } from './ORM/_migrations/AcceptedShareRetentionCompression1780966200000';
import { PayoutSnapshots1780969800000 } from './ORM/_migrations/PayoutSnapshots1780969800000';
import { AcceptedShareProtocolMetadata1781130600000 } from './ORM/_migrations/AcceptedShareProtocolMetadata1781130600000';
import { BlocksSubmissionMetadata1781220000000 } from './ORM/_migrations/BlocksSubmissionMetadata1781220000000';
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 { PayoutBalanceEntity } from './ORM/payout-snapshot/payout-balance.entity';
import { PayoutHistoryEntity } from './ORM/payout-snapshot/payout-history.entity';
import { PayoutSnapshotEntity } from './ORM/payout-snapshot/payout-snapshot.entity';
import { PayoutSnapshotEntryEntity } from './ORM/payout-snapshot/payout-snapshot-entry.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,
PayoutBalanceEntity,
PayoutHistoryEntity,
PayoutSnapshotEntity,
PayoutSnapshotEntryEntity,
];
export const databaseMigrations = [
InitialTimescaleSchema1780859300000,
ActiveOnlyUserAgentReport1780860200000,
TimescaleOperationalHardening1780861200000,
AcceptedShareIndex1780862400000,
AcceptedShareRollupIndexes1780865400000,
PoolAccountingDashboardIndexes1780867200000,
CurrentRoundBestShareIndex1780897600000,
CurrentRoundWorkRollup1780899000000,
ShareRollupBatches1780962600000,
AcceptedShareRetentionCompression1780966200000,
PayoutSnapshots1780969800000,
AcceptedShareProtocolMetadata1781130600000,
BlocksSubmissionMetadata1781220000000,
];
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;
}
+125 -273
View File
@@ -1,295 +1,147 @@
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(
bitcoinjs.networks.testnet,
'1',
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
jobTemplate
);
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
it('should split coinbase around 12 bytes of extranonce space', () => {
const notify = JSON.parse(job.response(jobTemplate));
const coinbasePart1 = notify.params[2];
const coinbasePart2 = notify.params[3];
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const coinbase = bitcoinjs.Transaction.fromHex(`${coinbasePart1}${extraNonce1}${extraNonce2}${coinbasePart2}`);
expect(Buffer.byteLength(extraNonce1 + extraNonce2, 'hex')).toBe(12);
expect(coinbase.ins[0].script.toString('hex')).toContain(`${extraNonce1}${extraNonce2}`);
expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
});
it('should expose coinbase prefix and suffix buffers for SV2 extended jobs', () => {
const notify = JSON.parse(job.response(jobTemplate));
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const fromNotify = Buffer.from(`${notify.params[2]}${extraNonce1}${extraNonce2}${notify.params[3]}`, 'hex');
const fromBuffers = Buffer.concat([
job.getCoinbasePrefixBuffer(),
Buffer.from(`${extraNonce1}${extraNonce2}`, 'hex'),
job.getCoinbaseSuffixBuffer(),
]);
expect(fromBuffers).toEqual(fromNotify);
expect(bitcoinjs.Transaction.fromBuffer(fromBuffers).ins[0].script.toString('hex'))
.toContain(`${extraNonce1}${extraNonce2}`);
});
it('should support exact satoshi payout outputs', () => {
const firstAmount = Math.floor(jobTemplate.blockData.coinbasevalue / 3);
const secondAmount = jobTemplate.blockData.coinbasevalue - firstAmount;
const exactPayoutJob = new MiningJob(
bitcoinjs.networks.testnet,
'2',
[
{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', amountSats: firstAmount },
{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', amountSats: secondAmount },
], ],
}).compile(); jobTemplate
configService = moduleRef.get<ConfigService>(ConfigService); );
const coinbase = exactPayoutJob.cloneCoinbaseTransaction();
expect(coinbase.outs[0].value).toBe(firstAmount);
expect(coinbase.outs[1].value).toBe(secondAmount);
expect(coinbase.outs[2].value).toBe(0);
}); });
describe('constructor', () => { it('should update block nonce, timestamp, version mask, and coinbase script', () => {
beforeEach(() => { const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const timestamp = parseInt(MockRecording1.TIME, 16);
const originalMerkleRoot = Buffer.from(jobTemplate.block.merkleRoot);
console.warn = jest.fn((message: string) => console.log('WARN:', message)); const updatedBlock = job.copyAndUpdateBlock(
configService.get = jest.fn(() => null); jobTemplate,
payoutInformation = [ parseInt('00002000', 16),
{ parseInt('ed460d91', 16),
address: 'tb1qr2ylpdgp9ejpt6v2uxlqrn9penp82rzz2grnns', extraNonce1,
percent: 100, extraNonce2,
} timestamp
]; );
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 expect(updatedBlock.nonce).toBe(parseInt('ed460d91', 16));
for (let i = 0; i < maxTransactions; i++) { expect(updatedBlock.timestamp).toBe(timestamp);
block.transactions.push(bitcoinjs.Transaction.fromHex("010000000001017405e391018c5e9dc79f324f9607c9c46d21b02f66dabaa870b4add871d6379f01000000171600148d7a0a3461e3891723e5fdf8129caa0075060cffffffffff01fcf60200000000001600148d7a0a3461e3891723e5fdf8129caa0075060cff0248304502210088025cffdaf69d310c6fed11832edd9c19b6a912c132262701ad0e6133227d9202207d73bbf777abd2aeae995d684e6bb1a048c5ac722e16de48bdd35643df7decf001210283409659355b6d1cc3c32decd5d561abaac86c37a353b52895a5e6c196d6f44800000000")); 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);
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 leave block version unchanged without a version mask', () => {
let job: MiningJob; const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
0,
parseInt('ed460d91', 16),
'57a6f098',
'c708000000000000',
parseInt(MockRecording1.TIME, 16)
);
beforeEach(async () => { expect(updatedBlock.version).toBe(jobTemplate.block.version);
jest.useFakeTimers(); });
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
configService.get = jest.fn(() => null);
const miningInfo$ = new BehaviorSubject<IMiningInfo>({ it('should build the same header as the full block update path', () => {
blocks: MockRecording1.BLOCK_TEMPLATE.height const versionMask = parseInt('00002000', 16);
} as IMiningInfo); const nonce = parseInt('ed460d91', 16);
const bitcoinRpcService = { const extraNonce1 = '57a6f098';
newBlock$: miningInfo$.asObservable(), const extraNonce2 = 'c708000000000000';
getBlockTemplate: jest.fn().mockResolvedValue(MockRecording1.BLOCK_TEMPLATE) const timestamp = parseInt(MockRecording1.TIME, 16);
};
jest.spyOn(console, 'log').mockImplementation(() => undefined);
const jobsService = new StratumV1JobsService(bitcoinRpcService as any); const updatedBlock = job.copyAndUpdateBlock(
jobTemplate = await firstValueFrom(jobsService.newMiningJob$); jobTemplate,
job = new MiningJob( versionMask,
configService, nonce,
bitcoinjs.networks.testnet, extraNonce1,
'1', extraNonce2,
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }], timestamp
jobTemplate );
); const fastHeader = job.buildHeaderBuffer(
}); jobTemplate,
versionMask,
nonce,
extraNonce1,
extraNonce2,
timestamp
);
afterEach(() => { expect(fastHeader.equals(updatedBlock.toBuffer(true))).toBe(true);
jest.restoreAllMocks();
jest.useRealTimers();
});
it('should split coinbase around 12 bytes of extranonce space', () => {
const notify = JSON.parse(job.response(jobTemplate));
const coinbasePart1 = notify.params[2];
const coinbasePart2 = notify.params[3];
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const coinbase = bitcoinjs.Transaction.fromHex(`${coinbasePart1}${extraNonce1}${extraNonce2}${coinbasePart2}`);
expect(Buffer.byteLength(extraNonce1 + extraNonce2, 'hex')).toBe(12);
expect(coinbase.ins[0].script.toString('hex')).toContain(`${extraNonce1}${extraNonce2}`);
expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
});
it('should update block nonce, timestamp, version mask, and coinbase script', () => {
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const timestamp = parseInt(MockRecording1.TIME, 16);
const originalMerkleRoot = Buffer.from(jobTemplate.block.merkleRoot);
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
parseInt('00002000', 16),
parseInt('ed460d91', 16),
extraNonce1,
extraNonce2,
timestamp
);
expect(updatedBlock.nonce).toBe(parseInt('ed460d91', 16));
expect(updatedBlock.timestamp).toBe(timestamp);
expect(updatedBlock.version).toBe(jobTemplate.block.version ^ parseInt('00002000', 16));
expect(updatedBlock.transactions[0].ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
expect(updatedBlock.merkleRoot.equals(originalMerkleRoot)).toBe(false);
});
it('should leave block version unchanged without a version mask', () => {
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
0,
parseInt('ed460d91', 16),
'57a6f098',
'c708000000000000',
parseInt(MockRecording1.TIME, 16)
);
expect(updatedBlock.version).toBe(jobTemplate.block.version);
});
it('should build the same header as the full block update path', () => {
const versionMask = parseInt('00002000', 16);
const nonce = parseInt('ed460d91', 16);
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const timestamp = parseInt(MockRecording1.TIME, 16);
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
versionMask,
nonce,
extraNonce1,
extraNonce2,
timestamp
);
const fastHeader = job.buildHeaderBuffer(
jobTemplate,
versionMask,
nonce,
extraNonce1,
extraNonce2,
timestamp
);
expect(fastHeader.equals(updatedBlock.toBuffer(true))).toBe(true);
});
}); });
}); });
+22 -28
View File
@@ -2,16 +2,16 @@ import { AddressType, getAddressInfo } from 'bitcoin-address-validation';
import * as bitcoinjs from 'bitcoinjs-lib'; import * as bitcoinjs from 'bitcoinjs-lib';
import { IJobTemplate } from '../services/stratum-v1-jobs.service'; import { IJobTemplate } from '../services/stratum-v1-jobs.service';
import { hash256 } from '../utils/hash.utils';
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 export interface AddressObject {
interface AddressObject {
address: string; address: string;
percent: number; percent?: number;
amountSats?: number;
} }
export class MiningJob { export class MiningJob {
@@ -27,7 +27,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 +47,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 +56,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,13 +84,21 @@ 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,
Buffer.from(`${extraNonce}${extraNonce2}`, 'hex'), Buffer.from(`${extraNonce}${extraNonce2}`, 'hex'),
this.coinbasePart2Buffer, this.coinbasePart2Buffer,
]); ]);
const coinbaseHash = bitcoinjs.crypto.hash256(coinbaseBuffer); const coinbaseHash = hash256(coinbaseBuffer);
const merkleRoot = this.calculateMerkleRootHash(coinbaseHash, this.merkleBranchBuffers); const merkleRoot = this.calculateMerkleRootHash(coinbaseHash, this.merkleBranchBuffers);
let version = jobTemplate.block.version; let version = jobTemplate.block.version;
@@ -164,7 +156,7 @@ export class MiningJob {
for (let i = 0; i < merkleBranches.length; i++) { for (let i = 0; i < merkleBranches.length; i++) {
bothMerkles.set(merkleBranches[i], 32); bothMerkles.set(merkleBranches[i], 32);
newRoot = bitcoinjs.crypto.hash256(bothMerkles); newRoot = hash256(bothMerkles);
bothMerkles.set(newRoot); bothMerkles.set(newRoot);
} }
@@ -186,7 +178,9 @@ export class MiningJob {
let rewardBalance = reward; let rewardBalance = reward;
addresses.forEach(recipientAddress => { addresses.forEach(recipientAddress => {
const amount = Math.floor((recipientAddress.percent / 100) * reward); const amount = recipientAddress.amountSats == null
? Math.floor(((recipientAddress.percent ?? 0) / 100) * reward)
: recipientAddress.amountSats;
rewardBalance -= amount; rewardBalance -= amount;
coinbaseTransaction.addOutput(this.getPaymentScript(recipientAddress.address), amount); coinbaseTransaction.addOutput(this.getPaymentScript(recipientAddress.address), amount);
}) })
+161 -110
View File
@@ -1,26 +1,16 @@
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 { DifficultyUtils } from '../utils/difficulty.utils';
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 +36,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 +51,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 +61,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 +118,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 +143,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;
@@ -200,6 +169,36 @@ describe('StratumV1Client', () => {
expect(socket.on).toHaveBeenCalled(); expect(socket.on).toHaveBeenCalled();
}); });
it('should clean up socket state only once when destroyed repeatedly', async () => {
const unsubscribe = jest.fn();
const timer = setInterval(() => undefined, 1000);
const removeListenerSpy = jest.spyOn(socket, 'removeListener');
(client as any).clientEntity = {
id: '00000000-0000-4000-8000-000000000001',
address: 'tb1qcleanup',
};
(client as any).stratumSubscription = { unsubscribe };
(client as any).backgroundWork = [timer];
(client as any).miningSubmissionHashes.add('submitted-share');
(client as any).buffer = 'partial-message';
await Promise.all([client.destroy(), client.destroy()]);
expect(redisMessagingService.removeClientPresence).toHaveBeenCalledTimes(1);
expect(redisMessagingService.removeClientPresence).toHaveBeenCalledWith(
'00000000-0000-4000-8000-000000000001',
'tb1qcleanup',
);
expect(clientService.delete).toHaveBeenCalledTimes(1);
expect(clientService.delete).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000001');
expect(unsubscribe).toHaveBeenCalledTimes(1);
expect(removeListenerSpy).toHaveBeenCalledWith('data', expect.any(Function));
expect((client as any).backgroundWork).toEqual([]);
expect((client as any).miningSubmissionHashes.size).toBe(0);
expect((client as any).buffer).toBe('');
});
it('should close socket on invalid JSON', () => { it('should close socket on invalid JSON', () => {
emitMessage('INVALID'); emitMessage('INVALID');
jest.spyOn(socket, 'destroy'); jest.spyOn(socket, 'destroy');
@@ -218,6 +217,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 +280,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 +324,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 +364,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 +393,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 +405,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 +435,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));
@@ -439,9 +472,9 @@ describe('StratumV1Client', () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({ jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
submissionDifficulty: 1024, submissionDifficulty: 1024,
submissionHash: 'share' submissionHash: 'share',
hashBuffer: DifficultyUtils.difficultyToTarget(1024),
}); });
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 +485,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));
@@ -466,6 +497,27 @@ describe('StratumV1Client', () => {
expect(getSettingsSpy).not.toHaveBeenCalled(); expect(getSettingsSpy).not.toHaveBeenCalled();
}); });
it('should reject shares by exact target even when reported difficulty is huge', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
submissionDifficulty: Number.MAX_SAFE_INTEGER,
submissionHash: 'too-easy',
hashBuffer: Buffer.alloc(32, 0xff),
});
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1024]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
jest.useRealTimers();
await new Promise((r) => setTimeout(r, 1000));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[23,"Difficulty too low",""]}\n`);
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
});
it('should reject duplicate submissions', async () => { it('should reject duplicate submissions', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
@@ -566,12 +618,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);
@@ -596,12 +646,13 @@ describe('StratumV1Client', () => {
}); });
it('should submit and persist found blocks', async () => { it('should submit and persist found blocks', async () => {
(bitcoinRpcService.SUBMIT_BLOCK as jest.Mock).mockResolvedValue('SUCCESS!');
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({ jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
submissionDifficulty: Number.MAX_SAFE_INTEGER, submissionDifficulty: Number.MAX_SAFE_INTEGER,
submissionHash: 'block-share' submissionHash: 'block-share',
hashBuffer: Buffer.alloc(32),
}); });
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);
+219 -104
View File
@@ -9,16 +9,20 @@ 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 { PayoutSnapshotService } from '../ORM/payout-snapshot/payout-snapshot.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 { DifficultyUtils } from '../utils/difficulty.utils';
import { hash256 } from '../utils/hash.utils';
import { eRequestMethod } from './enums/eRequestMethod'; import { eRequestMethod } from './enums/eRequestMethod';
import { eResponseMethod } from './enums/eResponseMethod'; import { eResponseMethod } from './enums/eResponseMethod';
import { eStratumErrorCode } from './enums/eStratumErrorCode'; import { eStratumErrorCode } from './enums/eStratumErrorCode';
import { MiningJob } from './MiningJob'; import { AddressObject, MiningJob } from './MiningJob';
import { AuthorizationMessage } from './stratum-messages/AuthorizationMessage'; import { AuthorizationMessage } from './stratum-messages/AuthorizationMessage';
import { ConfigurationMessage } from './stratum-messages/ConfigurationMessage'; import { ConfigurationMessage } from './stratum-messages/ConfigurationMessage';
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage'; import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
@@ -27,35 +31,38 @@ 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;
private stratumSubscription: Subscription; private stratumSubscription: Subscription;
private backgroundWork: NodeJS.Timeout[] = []; private backgroundWork: NodeJS.Timeout[] = [];
private readonly socketDataHandler: (data: Buffer) => void;
private destroyPromise: Promise<void> | null = null;
private statistics: StratumV1ClientStatistics; private statistics: StratumV1ClientStatistics;
private stratumInitialized = false; private stratumInitialized = false;
private usedSuggestedDifficulty = false; private usedSuggestedDifficulty = false;
private sessionDifficulty: number = 100000; private sessionDifficulty: number = 100000;
private sessionDifficultyTarget: Buffer = DifficultyUtils.difficultyToTarget(this.sessionDifficulty);
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,50 +75,77 @@ 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,
private readonly payoutSnapshotService?: PayoutSnapshotService,
private readonly accountingProtocol: 'sv1' | 'sv1_tls' = 'sv1',
) { ) {
this.socket.on('data', (data: Buffer) => { this.socketDataHandler = (data: Buffer) => {
this.buffer += data.toString(); void this.handleSocketData(data);
let lines = this.buffer.split('\n'); };
this.buffer = lines.pop() || ''; // Save the last part of the data (incomplete line) to the buffer this.socket.on('data', this.socketDataHandler);
(async () => {
for (const m of lines.filter(l => l.length > 0)) {
if (this.connectionClosed || this.socket.destroyed || this.socket.writableEnded) {
break;
}
try {
await this.handleMessage(m);
} catch (e) {
await this.socket.end();
console.error(e);
}
}
})();
});
} }
public async destroy() { public async destroy(): Promise<void> {
if (this.destroyPromise != null) {
if (this.extraNonceAndSessionId) { return this.destroyPromise;
await this.clientService.delete(this.extraNonceAndSessionId);
} }
this.destroyPromise = this.destroyInternal();
return this.destroyPromise;
}
private async destroyInternal(): Promise<void> {
this.connectionClosed = true;
this.socket.removeListener('data', this.socketDataHandler);
this.buffer = '';
if (this.stratumSubscription != null) { if (this.stratumSubscription != null) {
this.stratumSubscription.unsubscribe(); this.stratumSubscription.unsubscribe();
this.stratumSubscription = null;
} }
this.backgroundWork.forEach(work => { for (const work of this.backgroundWork) {
clearInterval(work); clearInterval(work);
}); }
this.backgroundWork = [];
this.miningSubmissionHashes.clear();
if (this.clientEntity?.id) {
const clientId = this.clientEntity.id;
const address = this.clientEntity.address;
await this.redisMessagingService?.removeClientPresence(clientId, address);
await this.clientService.delete(clientId);
}
}
private async handleSocketData(data: Buffer): Promise<void> {
if (this.connectionClosed || this.socket.destroyed || this.socket.writableEnded) {
return;
}
this.buffer += data.toString();
const lines = this.buffer.split('\n');
this.buffer = lines.pop() || ''; // Save the last part of the data (incomplete line) to the buffer
for (const m of lines.filter(l => l.length > 0)) {
if (this.connectionClosed || this.socket.destroyed || this.socket.writableEnded) {
break;
}
try {
await this.handleMessage(m);
} catch (e) {
await this.socket.end();
console.error(e);
}
}
} }
private getRandomHexString() { private getRandomHexString() {
@@ -158,9 +192,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 +241,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,
@@ -240,19 +274,19 @@ export class StratumV1Client {
this.clientAuthorization = authorizationMessage; this.clientAuthorization = authorizationMessage;
if (this.clientSuggestedDifficulty == null && this.clientAuthorization.startingDiff != null && this.clientAuthorization.startingDiff > this.sessionDifficulty) { if (this.clientSuggestedDifficulty == null && this.clientAuthorization.startingDiff != null && this.clientAuthorization.startingDiff > this.sessionDifficulty) {
this.sessionDifficulty = this.clientAuthorization.startingDiff; this.sessionDifficulty = this.clientAuthorization.startingDiff;
this.sessionDifficultyTarget = DifficultyUtils.difficultyToTarget(this.sessionDifficulty);
} }
const success = await this.write(JSON.stringify(this.clientAuthorization.response()) + '\n'); const success = await this.write(JSON.stringify(this.clientAuthorization.response()) + '\n');
if (!success) { if (!success) {
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 +315,8 @@ 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);
this.sessionDifficultyTarget = DifficultyUtils.difficultyToTarget(this.sessionDifficulty);
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 +340,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 +397,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 +412,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 +439,37 @@ 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 = this.getPayoutInformation(jobTemplate, this.clientAuthorization.address);
const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS'); // const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS');
//50Th/s // //50Th/s
this.noFee = false; // this.noFee = false;
if (this.entity) { // if (this.clientEntity) {
this.hashRate = this.statistics.hashRate; // // 250Gh/s
this.noFee = this.hashRate != 0 && this.hashRate < 50000000000000; // if(this.hashRate < 250000000000){
} // this.statistics.targetSubmitShareEveryNSeconds = 10;
if (this.noFee || devFeeAddress == null || devFeeAddress.length < 1) { // }
payoutInformation = [ // this.noFee = this.hashRate != 0 && this.hashRate < 50000000000000;
{ address: this.clientAuthorization.address, percent: 100 } // }
]; // 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 +485,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 +507,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 +521,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 +567,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) {
@@ -555,18 +616,23 @@ export class StratumV1Client {
submission.extraNonce2, submission.extraNonce2,
timestamp timestamp
); );
const { submissionDifficulty } = this.calculateDifficulty(header); const { submissionDifficulty, hashBuffer } = this.calculateDifficulty(header);
//console.log(`DIFF: ${submissionDifficulty} of ${this.sessionDifficulty} from ${this.clientAuthorization.worker + '.' + this.extraNonceAndSessionId}`); //console.log(`DIFF: ${submissionDifficulty} of ${this.sessionDifficulty} from ${this.clientAuthorization.worker + '.' + this.extraNonceAndSessionId}`);
if (submissionDifficulty >= this.sessionDifficulty) { if (DifficultyUtils.meetsTarget(hashBuffer, this.sessionDifficultyTarget)) {
const success = await this.write(JSON.stringify(submission.response()) + '\n'); const success = await this.write(JSON.stringify(submission.response()) + '\n');
if (!success) { if (!success) {
return false; return false;
} }
if (submissionDifficulty >= jobTemplate.blockData.networkDifficulty) { let blockSubmissionResult: string = null;
const isBlockCandidate = DifficultyUtils.meetsTarget(
hashBuffer,
DifficultyUtils.difficultyToTarget(jobTemplate.blockData.networkDifficulty),
);
if (isBlockCandidate) {
console.log('!!! BLOCK FOUND !!!'); console.log('!!! BLOCK FOUND !!!');
const updatedJobBlock = job.copyAndUpdateBlock( const updatedJobBlock = job.copyAndUpdateBlock(
jobTemplate, jobTemplate,
@@ -577,54 +643,69 @@ 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,
worker: this.clientAuthorization.worker, worker: this.clientAuthorization.worker,
sessionId: this.extraNonceAndSessionId, sessionId: this.extraNonceAndSessionId,
blockData: blockHex blockData: blockHex,
blockSubmissionResult,
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId ?? null,
});
await this.payoutSnapshotService?.finalizeSnapshotForBlock({
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId,
blockHeight: jobTemplate.blockData.height,
blockSubmissionResult,
}); });
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 (this.isSuccessfulBlockSubmission(blockSubmissionResult)) {
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: this.accountingProtocol,
address: this.clientAuthorization.address,
clientName: this.clientAuthorization.worker,
sessionId: this.extraNonceAndSessionId,
clientId: this.clientEntity.id,
jobId: job.jobId,
jobTemplateId: job.jobTemplateId,
blockHeight: jobTemplate.blockData.height,
creditedDifficulty: this.sessionDifficulty,
submissionDifficulty,
networkDifficulty: jobTemplate.blockData.networkDifficulty,
nonce: submission.nonce,
ntime: submission.ntime,
version: Number.isFinite(versionMask)
? (jobTemplate.block.version ^ versionMask).toString(16)
: jobTemplate.block.version.toString(16),
extraNonce2: submission.extraNonce2,
isBlockCandidate,
blockSubmissionResult,
});
await this.statistics.addShares(this.clientEntity, this.sessionDifficulty);
const now = new Date(); 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 +727,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;
} }
@@ -654,6 +735,7 @@ export class StratumV1Client {
if (targetDiff != this.sessionDifficulty) { if (targetDiff != this.sessionDifficulty) {
//console.log(`Adjusting ${this.extraNonceAndSessionId} difficulty from ${this.sessionDifficulty} to ${targetDiff}`); //console.log(`Adjusting ${this.extraNonceAndSessionId} difficulty from ${this.sessionDifficulty} to ${targetDiff}`);
this.sessionDifficulty = targetDiff; this.sessionDifficulty = targetDiff;
this.sessionDifficultyTarget = DifficultyUtils.difficultyToTarget(this.sessionDifficulty);
const data = JSON.stringify({ const data = JSON.stringify({
id: null, id: null,
@@ -684,13 +766,13 @@ export class StratumV1Client {
} }
} }
private calculateDifficulty(header: Buffer): { submissionDifficulty: number, submissionHash: string } { private calculateDifficulty(header: Buffer): { submissionDifficulty: number, submissionHash: string, hashBuffer: Buffer } {
const hashResult = bitcoinjs.crypto.hash256(header); const hashResult = hash256(header);
const target = this.le256todouble(hashResult); const target = this.le256todouble(hashResult);
const submissionDifficulty = target === 0 ? Number.POSITIVE_INFINITY : TRUE_DIFF_ONE / target; const submissionDifficulty = target === 0 ? Number.POSITIVE_INFINITY : TRUE_DIFF_ONE / target;
return { submissionDifficulty, submissionHash: hashResult.toString('hex') }; return { submissionDifficulty, submissionHash: hashResult.toString('hex'), hashBuffer: hashResult };
} }
@@ -782,6 +864,39 @@ 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 getPayoutInformation(jobTemplate: IJobTemplate, fallbackAddress: string): AddressObject[] {
if (this.configService.get('PAYOUT_COINBASE_MODE') === 'snapshot' && jobTemplate.blockData.payoutOutputs?.length > 0) {
return jobTemplate.blockData.payoutOutputs;
}
return [{ address: fallbackAddress, percent: 100 }];
}
private isSuccessfulBlockSubmission(result?: string | null): boolean {
return result == null || result === 'SUCCESS!';
}
private closeSocket() { private closeSocket() {
this.connectionClosed = true; this.connectionClosed = true;
if (!this.socket.destroyed) { if (!this.socket.destroyed) {
@@ -805,7 +920,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 +934,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;
} }
} }
+454
View File
@@ -0,0 +1,454 @@
import { ConfigService } from '@nestjs/config';
import * as bitcoinjs from 'bitcoinjs-lib';
import { Socket } from 'net';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { ClientEntity } from '../ORM/client/client.entity';
import { CustomWorkService } from '../services/custom-work.service';
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { BufferReader } from './sv2/sv2-binary-codec';
import { SV2_CHANNEL_MSG_FLAG, Sv2MiningSetupFlags, Sv2MsgType, Sv2Protocol } from './sv2/sv2-constants';
import {
deserializeOpenExtendedMiningChannelSuccess,
deserializeNewExtendedMiningJob,
serializeOpenExtendedMiningChannel,
serializeSubmitSharesExtended,
} from './sv2/sv2-extended-messages';
import { deserializeSetNewPrevHash, deserializeSubmitSharesError, serializeSetupConnection } from './sv2/sv2-messages';
import {
deserializeSetCustomMiningJobError,
deserializeSetCustomMiningJobSuccess,
serializeSetCustomMiningJob,
} from './sv2/sv2-jdp-messages';
import { MiningJob } from './MiningJob';
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(10);
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('accepts SV2 work-selection setup and SetCustomMiningJob without sending pool work', async () => {
const { client, sentFrames } = await createClient();
await (client as any).handleSetupConnection(serializeSetupConnection({
protocol: Sv2Protocol.MINING,
minVersion: 2,
maxVersion: 2,
flags: Sv2MiningSetupFlags.REQUIRES_WORK_SELECTION | Sv2MiningSetupFlags.REQUIRES_VERSION_ROLLING,
endpoint_host: 'localhost',
endpoint_port: 3333,
vendor: 'jd-client',
hardwareVersion: '',
firmwareVersion: '',
deviceId: '',
}));
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
requestId: 1,
userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker',
nominalHashRate: 0,
maxTarget: Buffer.alloc(32, 0xff),
minExtranonceSize: 8,
}));
expect(sentFrames.some(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB)).toBe(false);
sentFrames.length = 0;
await (client as any).handleSetCustomMiningJob(serializeSetCustomMiningJob({
channelId: 1,
requestId: 99,
token: Buffer.from('aa', 'hex'),
version: MockRecording1.BLOCK_TEMPLATE.version,
prevHash: Buffer.alloc(32, 1),
minNtime: parseInt(MockRecording1.TIME, 16),
nBits: parseInt(MockRecording1.BLOCK_TEMPLATE.bits, 16),
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.from('51', 'hex'),
coinbaseTxInputNSequence: 0xffffffff,
coinbaseTxOutputs: Buffer.from('010000000000000000016a', 'hex'),
coinbaseTxLocktime: 0,
merklePath: [],
}));
const successFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.SET_CUSTOM_MINING_JOB_SUCCESS);
const success = deserializeSetCustomMiningJobSuccess(new BufferReader(successFrame.payload));
expect(success.channelId).toBe(1);
expect(success.requestId).toBe(99);
expect((client as any).channels.get(1).extendedJobs.get(success.jobId).workProtocol).toBe('sv2_jdp');
});
it('rejects SetCustomMiningJob when work selection was not negotiated', 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,
}));
sentFrames.length = 0;
await (client as any).handleSetCustomMiningJob(serializeSetCustomMiningJob({
channelId: 1,
requestId: 100,
token: Buffer.from('aa', 'hex'),
version: MockRecording1.BLOCK_TEMPLATE.version,
prevHash: Buffer.alloc(32, 1),
minNtime: parseInt(MockRecording1.TIME, 16),
nBits: parseInt(MockRecording1.BLOCK_TEMPLATE.bits, 16),
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.from('51', 'hex'),
coinbaseTxInputNSequence: 0xffffffff,
coinbaseTxOutputs: Buffer.from('010000000000000000016a', 'hex'),
coinbaseTxLocktime: 0,
merklePath: [],
}));
const errorFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR);
const error = deserializeSetCustomMiningJobError(new BufferReader(errorFrame.payload));
expect(error.channelId).toBe(1);
expect(error.requestId).toBe(100);
expect(error.errorCode).toBe('work-selection-not-negotiated');
expect((client as any).channels.get(1).extendedJobs.has(100)).toBe(false);
});
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);
});
it('does not submit a block when only the reported SV2 difficulty is huge', async () => {
const { client, shareAccountingService, bitcoinRpcService, blocksService, 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';
const job = new MiningJob(
bitcoinjs.networks.testnet,
'1',
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
jobTemplate,
);
await (client as any).handleAcceptedShare(
{
nonce: 123,
ntime: parseInt(MockRecording1.TIME, 16),
version: jobTemplate.block.version,
},
{ extranoncePrefix: Buffer.from(MockRecording1.EXTRA_NONCE, 'hex') },
job,
jobTemplate,
Number.MAX_SAFE_INTEGER,
1024,
Buffer.alloc(32, 0xff),
);
expect(bitcoinRpcService.SUBMIT_BLOCK).not.toHaveBeenCalled();
expect(blocksService.save).not.toHaveBeenCalled();
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
submissionDifficulty: Number.MAX_SAFE_INTEGER,
isBlockCandidate: false,
}));
});
it('submits a block when the SV2 hash target exactly meets network target', async () => {
const { client, bitcoinRpcService, blocksService, notificationService, addressSettingsService, jobTemplate } = await createClient();
bitcoinRpcService.SUBMIT_BLOCK.mockResolvedValue('SUCCESS!');
(client as any).address = 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
(client as any).workerName = 'worker';
(client as any).sessionId = 'sv2-session';
(client as any).userAgent = 'test/sv2';
const job = new MiningJob(
bitcoinjs.networks.testnet,
'1',
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
jobTemplate,
);
await (client as any).handleAcceptedShare(
{
nonce: 123,
ntime: parseInt(MockRecording1.TIME, 16),
version: jobTemplate.block.version,
},
{ extranoncePrefix: Buffer.from(MockRecording1.EXTRA_NONCE, 'hex') },
job,
jobTemplate,
1,
1024,
Buffer.alloc(32),
);
expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledWith(expect.any(String));
expect(blocksService.save).toHaveBeenCalledWith(expect.objectContaining({
height: MockRecording1.BLOCK_TEMPLATE.height,
minerAddress: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
worker: 'worker',
sessionId: 'sv2-session',
blockData: expect.any(String),
}));
expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalled();
expect(addressSettingsService.resetBestDifficultyAndShares).toHaveBeenCalled();
});
async function createClient(): Promise<{
client: StratumV2Client;
sentFrames: any[];
bitcoinRpcService: { SUBMIT_BLOCK: jest.Mock };
blocksService: { save: jest.Mock };
notificationService: { notifySubscribersBlockFound: jest.Mock };
addressSettingsService: { resetBestDifficultyAndShares: jest.Mock; updateBestDifficultyIfHigher: jest.Mock };
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 notificationService = {
notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined),
};
const blocksService = {
save: jest.fn().mockResolvedValue(undefined),
};
const redisMessagingService = {
setClientPresence: jest.fn().mockResolvedValue(undefined),
removeClientPresence: jest.fn().mockResolvedValue(undefined),
};
const addressSettingsService = {
resetBestDifficultyAndShares: jest.fn().mockResolvedValue(undefined),
updateBestDifficultyIfHigher: 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: () => 10,
getExtendedTotalExtranonceSize: () => 14,
} as any,
stratumV1JobsService,
bitcoinRpcService as any,
clientService as any,
notificationService as any,
blocksService 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,
addressSettingsService as any,
new CustomWorkService(),
{
hasKnownToken: jest.fn().mockReturnValue(true),
getDeclaredJob: jest.fn().mockReturnValue({
validationMode: 'full_template',
job: { version: MockRecording1.BLOCK_TEMPLATE.version },
}),
} 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,
bitcoinRpcService,
blocksService,
notificationService,
addressSettingsService,
shareAccountingService,
redisMessagingService,
jobTemplate,
};
}
});
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -7,6 +7,13 @@ export interface IBlockTemplateTx {
sigops: number, // (numeric) total SigOps cost, as counted for purposes of block limits; if key is not present, sigop cost is unknown and clients MUST NOT assume it is zero sigops: number, // (numeric) total SigOps cost, as counted for purposes of block limits; if key is not present, sigop cost is unknown and clients MUST NOT assume it is zero
weight: number // (numeric) total transaction weight, as counted for purposes of block limits weight: number // (numeric) total transaction weight, as counted for purposes of block limits
} }
export interface IBlockTemplatePayoutOutput {
address: string;
percent?: number;
amountSats?: number;
}
export interface IBlockTemplate { export interface IBlockTemplate {
// (json object) // (json object)
version: number; // (numeric) The preferred block version version: number; // (numeric) The preferred block version
@@ -35,5 +42,7 @@ export interface IBlockTemplate {
height: number, // (numeric) The height of the next block height: number, // (numeric) The height of the next block
default_witness_commitment: string // (string, optional) a valid witness commitment for the unmodified block template default_witness_commitment: string // (string, optional) a valid witness commitment for the unmodified block template
capabilities: string[] capabilities: string[]
payoutSnapshotId?: string;
payoutOutputs?: IBlockTemplatePayoutOutput[];
} }
+257
View File
@@ -0,0 +1,257 @@
import {
DATUM_INITIAL_HEADER_KEY,
DATUM_EXTRANONCE_SIZE,
DatumFrameReader,
DatumMiningCommand,
DatumProtocolCommand,
DatumShareResponseStatus,
datumHeaderXorFeedback,
decodeDatumHeader,
deserializeDatumCoinbaserFetch,
deserializeDatumJobValidationResponse,
deserializeDatumMiningCommand,
deserializeDatumPowSubmit,
encodeDatumFrame,
encodeDatumHeader,
serializeDatumJobValidationFullTransactionBlobRequest,
serializeDatumJobValidationFullTransactionsRequest,
serializeDatumJobValidationShortTxIdsRequest,
serializeDatumCoinbaserFetchResponse,
serializeDatumMiningCommand,
serializeDatumShareResponse,
} from './datum-codec';
describe('datum-codec', () => {
it('round-trips DATUM packed headers with XOR key', () => {
const encoded = encodeDatumHeader({
cmdLen: 1234,
reserved: 0,
isSigned: true,
isEncryptedPubkey: false,
isEncryptedChannel: true,
protoCmd: DatumProtocolCommand.MINING,
}, DATUM_INITIAL_HEADER_KEY);
expect(decodeDatumHeader(encoded, DATUM_INITIAL_HEADER_KEY)).toEqual({
cmdLen: 1234,
reserved: 0,
isSigned: true,
isEncryptedPubkey: false,
isEncryptedChannel: true,
protoCmd: DatumProtocolCommand.MINING,
});
});
it('matches the DATUM header feedback function for stable inputs', () => {
expect(datumHeaderXorFeedback(0).toString(16)).toBe('74a55cf6');
expect(datumHeaderXorFeedback(0xdc871829).toString(16)).toBe('88e1697d');
});
it('splits framed DATUM payloads', () => {
const first = encodeDatumFrame({
header: {
cmdLen: 0,
reserved: 0,
isSigned: false,
isEncryptedPubkey: false,
isEncryptedChannel: true,
protoCmd: DatumProtocolCommand.MINING,
},
payload: serializeDatumMiningCommand(DatumMiningCommand.TEMPLATE_REFRESH),
});
const second = encodeDatumFrame({
header: {
cmdLen: 0,
reserved: 0,
isSigned: false,
isEncryptedPubkey: false,
isEncryptedChannel: true,
protoCmd: DatumProtocolCommand.MINING,
},
payload: serializeDatumMiningCommand(DatumMiningCommand.FETCH_COINBASER, Buffer.alloc(8)),
}, datumHeaderXorFeedback(DATUM_INITIAL_HEADER_KEY));
const frames = new DatumFrameReader().feed(Buffer.concat([first, second]));
expect(frames).toHaveLength(2);
expect(deserializeDatumMiningCommand(frames[0].payload).command).toBe(DatumMiningCommand.TEMPLATE_REFRESH);
expect(deserializeDatumMiningCommand(frames[1].payload).command).toBe(DatumMiningCommand.FETCH_COINBASER);
});
it('serializes coinbaser fetch responses', () => {
const response = serializeDatumCoinbaserFetchResponse(50n, [
{ value: 50n, scriptPubKey: Buffer.from('6a', 'hex') },
]);
const mining = deserializeDatumMiningCommand(response);
expect(mining.command).toBe(DatumMiningCommand.FETCH_COINBASER_RESPONSE);
expect(mining.body.readBigUInt64LE(0)).toBe(50n);
expect(mining.body.readUInt32LE(8)).toBe(11);
});
it('deserializes coinbaser fetch requests', () => {
const payload = Buffer.alloc(8);
payload.writeBigUInt64LE(123n, 0);
expect(deserializeDatumCoinbaserFetch(payload)).toEqual({ rewardValue: 123n });
});
it('deserializes fixed DATUM POW submissions', () => {
const username = Buffer.from('bc1ptest.worker\0', 'utf8');
const extranonce = Buffer.from('000102030405060708090a0b', 'hex');
const payload = Buffer.alloc(17 + DATUM_EXTRANONCE_SIZE);
payload[0] = 7;
payload[1] = 1;
payload[2] = 0x01;
payload[3] = 4;
payload.writeUInt32LE(100, 4);
payload.writeUInt32LE(200, 8);
payload.writeUInt32LE(0x20000000, 12);
payload[16] = DATUM_EXTRANONCE_SIZE;
extranonce.copy(payload, 17);
const parsed = deserializeDatumPowSubmit(Buffer.concat([
payload,
username,
Buffer.alloc(4),
Buffer.from([0xfe]),
]));
expect(parsed.jobId).toBe(7);
expect(parsed.coinbaseId).toBe(1);
expect(parsed.isBlock).toBe(true);
expect(parsed.extranonce).toEqual(extranonce);
expect(parsed.username).toBe('bc1ptest.worker');
});
it('deserializes cached DATUM POW context sections', () => {
const username = Buffer.from('bc1ptest.worker\0', 'utf8');
const extranonce = Buffer.from('000102030405060708090a0b', 'hex');
const base = Buffer.alloc(17 + DATUM_EXTRANONCE_SIZE);
base[0] = 3;
base[1] = 2;
base[2] = 0;
base[3] = 0x12;
base.writeUInt32LE(100, 4);
base.writeUInt32LE(200, 8);
base.writeUInt32LE(0x20000000, 12);
base[16] = DATUM_EXTRANONCE_SIZE;
extranonce.copy(base, 17);
const templateSection = Buffer.concat([
Buffer.from([0x01]),
Buffer.alloc(32, 0xaa),
Buffer.from([0x09, 0x00]),
Buffer.from('ffff7f20', 'hex'),
Buffer.from([7]),
Buffer.from('64000000', 'hex'),
Buffer.from('0100000000000000', 'hex'),
Buffer.from('02000000', 'hex'),
Buffer.from('03000000', 'hex'),
Buffer.from('04000000', 'hex'),
Buffer.from('05000000', 'hex'),
Buffer.from([1]),
Buffer.alloc(32, 0xbb),
]);
const coinbaseSection = Buffer.concat([
Buffer.from([0x02, 2]),
Buffer.from([0x02, 0x00]),
Buffer.from([0x03, 0x00]),
Buffer.from('abcd', 'hex'),
Buffer.from('010203', 'hex'),
]);
const parsed = deserializeDatumPowSubmit(Buffer.concat([
base,
username,
Buffer.alloc(4),
templateSection,
coinbaseSection,
Buffer.from([0xfe]),
]));
expect(parsed.jobId).toBe(3);
expect(parsed.coinbaseId).toBe(2);
expect(parsed.targetByte).toBe(0x12);
expect(parsed.prevBlockHash?.equals(Buffer.alloc(32, 0xaa))).toBe(true);
expect(parsed.targetByteIndex).toBe(9);
expect(parsed.nBits?.toString('hex')).toBe('ffff7f20');
expect(parsed.height).toBe(100);
expect(parsed.merkleBranches?.[0].equals(Buffer.alloc(32, 0xbb))).toBe(true);
expect(parsed.coinbasePairs.get(2)?.coinb1.toString('hex')).toBe('abcd');
expect(parsed.coinbasePairs.get(2)?.coinb2.toString('hex')).toBe('010203');
});
it('rejects DATUM POW submissions with non-standard extranonce sizes', () => {
const payload = Buffer.alloc(18);
payload[16] = 1;
payload[17] = 0xaa;
expect(() => deserializeDatumPowSubmit(payload)).toThrow('Unsupported DATUM extranonce size 1');
});
it('serializes share responses', () => {
const response = serializeDatumShareResponse({
status: DatumShareResponseStatus.ACCEPTED,
reasonCode: 0,
nonce: 123,
targetByte: 4,
jobId: 7,
});
expect(response.toString('hex')).toBe('8f5000007b0000000407');
});
it('serializes DATUM job validation requests', () => {
expect(serializeDatumJobValidationShortTxIdsRequest(7).toString('hex')).toBe('501007');
expect(serializeDatumJobValidationFullTransactionBlobRequest(7).toString('hex')).toBe('501207');
expect(serializeDatumJobValidationFullTransactionsRequest(7, [1, 258]).toString('hex')).toBe('501107020001000201');
});
it('deserializes DATUM short transaction ID validation responses', () => {
const payload = Buffer.concat([
Buffer.from([0x90, 7, 0x01]),
Buffer.from('0200', 'hex'),
Buffer.from('010203040506', 'hex'),
Buffer.from('111213141516', 'hex'),
Buffer.alloc(32, 0xaa),
Buffer.from([0xfe, 0x00, 0x00]),
]);
const parsed = deserializeDatumJobValidationResponse(payload);
expect(parsed.kind).toBe('short-txids');
expect(parsed.jobId).toBe(7);
expect(parsed.status).toBe(1);
expect(parsed.transactionCount).toBe(2);
if (parsed.kind !== 'short-txids') {
throw new Error(`Unexpected DATUM validation response kind ${parsed.kind}`);
}
expect(parsed.shortTxIds.map(id => id.toString('hex'))).toEqual(['010203040506', '111213141516']);
expect(parsed.crosscheck?.equals(Buffer.alloc(32, 0xaa))).toBe(true);
});
it('deserializes DATUM full transaction blob validation responses', () => {
const tx = Buffer.from(
'0100000001'
+ '0000000000000000000000000000000000000000000000000000000000000000'
+ 'ffffffff00ffffffff'
+ '01000000000000000000'
+ '00000000',
'hex',
);
const txSize = Buffer.alloc(3);
txSize.writeUIntLE(tx.length, 0, 3);
const payload = Buffer.concat([
Buffer.from([0x92, 9, 0x01]),
Buffer.from('0100', 'hex'),
txSize,
tx,
Buffer.from([0xfe]),
]);
const parsed = deserializeDatumJobValidationResponse(payload);
expect(parsed.kind).toBe('full-transaction-blob');
expect(parsed.jobId).toBe(9);
expect(parsed.status).toBe(1);
expect(parsed.transactionCount).toBe(1);
if (parsed.kind !== 'full-transaction-blob') {
throw new Error(`Unexpected DATUM validation response kind ${parsed.kind}`);
}
expect(parsed.transactions[0]).toEqual(tx);
});
});
+506
View File
@@ -0,0 +1,506 @@
import { BufferReader, BufferWriter } from '../sv2/sv2-binary-codec';
export const DATUM_PROTOCOL_VERSION = 'v0.4.1-beta';
export const DATUM_INITIAL_HEADER_KEY = 0xdc871829;
export const DATUM_MAX_PAYLOAD_SIZE = 4194304;
export const DATUM_MAX_JOBS = 8;
export const DATUM_EXTRANONCE_SIZE = 12;
export enum DatumProtocolCommand {
PING = 0x01,
HANDSHAKE_INIT = 0x01,
HANDSHAKE_RESPONSE = 0x02,
MINING = 0x05,
}
export enum DatumMiningCommand {
FETCH_COINBASER = 0x10,
FETCH_COINBASER_RESPONSE = 0x11,
SUBMIT_POW = 0x27,
JOB_VALIDATION = 0x50,
SHARE_RESPONSE = 0x8f,
CLIENT_CONFIGURE = 0x99,
TEMPLATE_REFRESH = 0xf9,
}
export enum DatumJobValidationCommand {
REQUEST_SHORT_TX_IDS = 0x10,
REQUEST_FULL_TRANSACTIONS = 0x11,
REQUEST_FULL_TRANSACTION_BLOB = 0x12,
SHORT_TX_IDS_RESPONSE = 0x90,
FULL_TRANSACTIONS_RESPONSE = 0x91,
FULL_TRANSACTION_BLOB_RESPONSE = 0x92,
}
export enum DatumJobValidationStatus {
SUCCESS = 0x01,
}
export enum DatumShareResponseStatus {
ACCEPTED = 0x50,
ACCEPTED_TENTATIVE = 0x55,
REJECTED = 0x66,
}
export enum DatumRejectReason {
BAD_JOB_ID = 10,
BAD_COINBASE_ID = 11,
BAD_EXTRANONCE_SIZE = 12,
BAD_TARGET = 13,
BAD_USERNAME = 14,
BAD_COINBASER_ID = 15,
BAD_MERKLE_COUNT = 16,
BAD_COINBASE_TOO_LARGE = 17,
COINBASE_MISSING = 18,
TARGET_MISMATCH = 19,
HIGH_HASH = 21,
BAD_NTIME = 23,
BAD_VERSION = 24,
STALE_BLOCK = 25,
DUPLICATE_WORK = 29,
OTHER = 30,
}
export interface DatumHeader {
cmdLen: number;
reserved: number;
isSigned: boolean;
isEncryptedPubkey: boolean;
isEncryptedChannel: boolean;
protoCmd: number;
}
export interface DatumFrame {
header: DatumHeader;
payload: Buffer;
}
export interface DatumCoinbaserFetch {
rewardValue: bigint;
}
export interface DatumPayoutOutput {
value: bigint;
scriptPubKey: Buffer;
}
export interface DatumPowSubmit {
jobId: number;
coinbaseId: number;
isBlock: boolean;
subsidyOnly: boolean;
quickDiff: boolean;
targetByte: number;
ntime: number;
nonce: number;
version: number;
extranonce: Buffer;
username: string;
reserved: Buffer;
prevBlockHash?: Buffer;
targetByteIndex?: number;
nBits?: Buffer;
coinbaserId?: number;
height?: number;
coinbaseValue?: bigint;
transactionCount?: number;
totalWeight?: number;
totalSize?: number;
totalSigops?: number;
merkleBranches?: Buffer[];
coinbasePairs: Map<number, { coinb1: Buffer; coinb2: Buffer }>;
subsidyOnlyCoinbase?: { coinb1: Buffer; coinb2: Buffer };
}
export interface DatumShortTxIdsResponse {
kind: 'short-txids';
jobId: number;
status: number;
transactionCount: number;
shortTxIds: Buffer[];
crosscheck: Buffer | null;
}
export interface DatumFullTransactionsResponse {
kind: 'full-transactions';
jobId: number;
status: number;
transactionCount: number;
transactions: Buffer[];
}
export interface DatumFullTransactionBlobResponse {
kind: 'full-transaction-blob';
jobId: number;
status: number;
transactionCount: number;
transactions: Buffer[];
}
export type DatumJobValidationResponse =
| DatumShortTxIdsResponse
| DatumFullTransactionsResponse
| DatumFullTransactionBlobResponse;
export function encodeDatumHeader(header: DatumHeader, xorKey = 0): Buffer {
if (header.cmdLen < 0 || header.cmdLen > DATUM_MAX_PAYLOAD_SIZE - 1) {
throw new RangeError(`DATUM payload length ${header.cmdLen} exceeds protocol limit`);
}
let raw = header.cmdLen & 0x3fffff;
raw |= (header.reserved & 0x03) << 22;
raw |= (header.isSigned ? 1 : 0) << 24;
raw |= (header.isEncryptedPubkey ? 1 : 0) << 25;
raw |= (header.isEncryptedChannel ? 1 : 0) << 26;
raw |= (header.protoCmd & 0x1f) << 27;
const out = Buffer.alloc(4);
out.writeUInt32LE((raw ^ xorKey) >>> 0, 0);
return out;
}
export function decodeDatumHeader(input: Buffer, xorKey = 0): DatumHeader {
if (input.length < 4) {
throw new RangeError('DATUM header requires 4 bytes');
}
const raw = (input.readUInt32LE(0) ^ xorKey) >>> 0;
return {
cmdLen: raw & 0x3fffff,
reserved: (raw >>> 22) & 0x03,
isSigned: ((raw >>> 24) & 0x01) !== 0,
isEncryptedPubkey: ((raw >>> 25) & 0x01) !== 0,
isEncryptedChannel: ((raw >>> 26) & 0x01) !== 0,
protoCmd: (raw >>> 27) & 0x1f,
};
}
export function datumHeaderXorFeedback(input: number): number {
let h = 0xb10cfeed >>> 0;
let k = input >>> 0;
k = Math.imul(k, 0xcc9e2d51) >>> 0;
k = (((k << 15) >>> 0) | (k >>> 17)) >>> 0;
k = Math.imul(k, 0x1b873593) >>> 0;
h = (h ^ k) >>> 0;
h = (((h << 13) >>> 0) | (h >>> 19)) >>> 0;
h = (Math.imul(h, 5) + 0xe6546b64) >>> 0;
h = (h ^ 4) >>> 0;
h = (h ^ (h >>> 16)) >>> 0;
h = Math.imul(h, 0x85ebca6b) >>> 0;
h = (h ^ (h >>> 13)) >>> 0;
h = Math.imul(h, 0xc2b2ae35) >>> 0;
h = (h ^ (h >>> 16)) >>> 0;
return h >>> 0;
}
export class DatumFrameReader {
private buffer = Buffer.alloc(0);
constructor(private headerXorKey = DATUM_INITIAL_HEADER_KEY) {}
public setHeaderXorKey(headerXorKey: number): void {
this.headerXorKey = headerXorKey >>> 0;
}
public feed(data: Buffer): DatumFrame[] {
this.buffer = Buffer.concat([this.buffer, data]);
const frames: DatumFrame[] = [];
while (this.buffer.length >= 4) {
const header = decodeDatumHeader(this.buffer.subarray(0, 4), this.headerXorKey);
if (header.cmdLen > DATUM_MAX_PAYLOAD_SIZE) {
throw new RangeError(`DATUM payload length ${header.cmdLen} exceeds protocol limit`);
}
if (this.buffer.length < 4 + header.cmdLen) {
break;
}
frames.push({
header,
payload: Buffer.from(this.buffer.subarray(4, 4 + header.cmdLen)),
});
this.buffer = this.buffer.subarray(4 + header.cmdLen);
if (header.isEncryptedChannel) {
this.headerXorKey = datumHeaderXorFeedback(this.headerXorKey);
}
}
return frames;
}
}
export function encodeDatumFrame(frame: DatumFrame, xorKey = DATUM_INITIAL_HEADER_KEY): Buffer {
return Buffer.concat([
encodeDatumHeader({ ...frame.header, cmdLen: frame.payload.length }, xorKey),
frame.payload,
]);
}
export function deserializeDatumMiningCommand(payload: Buffer): { command: DatumMiningCommand; body: Buffer } {
if (payload.length < 1) {
throw new RangeError('DATUM mining command requires a sub-command byte');
}
return {
command: payload[0],
body: Buffer.from(payload.subarray(1)),
};
}
export function serializeDatumMiningCommand(command: DatumMiningCommand, body = Buffer.alloc(0)): Buffer {
return Buffer.concat([Buffer.from([command]), body]);
}
export function serializeDatumJobValidationShortTxIdsRequest(jobId: number): Buffer {
return serializeDatumMiningCommand(DatumMiningCommand.JOB_VALIDATION, Buffer.from([
DatumJobValidationCommand.REQUEST_SHORT_TX_IDS,
jobId & 0xff,
]));
}
export function serializeDatumJobValidationFullTransactionBlobRequest(jobId: number): Buffer {
return serializeDatumMiningCommand(DatumMiningCommand.JOB_VALIDATION, Buffer.from([
DatumJobValidationCommand.REQUEST_FULL_TRANSACTION_BLOB,
jobId & 0xff,
]));
}
export function serializeDatumJobValidationFullTransactionsRequest(jobId: number, transactionIndexes: number[]): Buffer {
if (transactionIndexes.length > 0xffff) {
throw new RangeError('DATUM requested transaction index count must fit in u16');
}
const w = new BufferWriter();
w.writeU8(DatumJobValidationCommand.REQUEST_FULL_TRANSACTIONS);
w.writeU8(jobId);
w.writeU16(transactionIndexes.length);
for (const index of transactionIndexes) {
if (!Number.isInteger(index) || index < 0 || index > 0xffff) {
throw new RangeError(`Invalid DATUM transaction index ${index}`);
}
w.writeU16(index);
}
return serializeDatumMiningCommand(DatumMiningCommand.JOB_VALIDATION, Buffer.from(w.toBuffer()));
}
export function deserializeDatumJobValidationResponse(payload: Buffer): DatumJobValidationResponse {
const reader = new BufferReader(payload);
const responseCommand = reader.readU8();
const jobId = reader.readU8();
const status = reader.readU8();
if (responseCommand === DatumJobValidationCommand.SHORT_TX_IDS_RESPONSE) {
if (status !== DatumJobValidationStatus.SUCCESS) {
return {
kind: 'short-txids',
jobId,
status,
transactionCount: 0,
shortTxIds: [],
crosscheck: null,
};
}
const transactionCount = reader.readU16();
const shortTxIds: Buffer[] = [];
for (let i = 0; i < transactionCount; i++) {
shortTxIds.push(reader.readBytes(6));
}
const crosscheck = reader.remaining >= 32 ? reader.readBytes(32) : null;
consumeDatumTerminatorAndPadding(reader);
return {
kind: 'short-txids',
jobId,
status,
transactionCount,
shortTxIds,
crosscheck,
};
}
if (responseCommand === DatumJobValidationCommand.FULL_TRANSACTIONS_RESPONSE) {
return deserializeDatumTransactionListResponse('full-transactions', reader, jobId, status);
}
if (responseCommand === DatumJobValidationCommand.FULL_TRANSACTION_BLOB_RESPONSE) {
return deserializeDatumTransactionListResponse('full-transaction-blob', reader, jobId, status);
}
throw new RangeError(`Unsupported DATUM job validation response 0x${responseCommand.toString(16)}`);
}
export function deserializeDatumCoinbaserFetch(payload: Buffer): DatumCoinbaserFetch {
if (payload.length < 8) {
throw new RangeError('DATUM coinbaser fetch requires reward value');
}
return { rewardValue: payload.readBigUInt64LE(0) };
}
export function serializeDatumCoinbaserFetchResponse(rewardValue: bigint, outputs: DatumPayoutOutput[]): Buffer {
const w = new BufferWriter();
w.writeU64(rewardValue);
const outputsBuffer = serializeDatumPayoutOutputs(outputs);
w.writeU32(outputsBuffer.length);
w.writeBytes(outputsBuffer);
return serializeDatumMiningCommand(DatumMiningCommand.FETCH_COINBASER_RESPONSE, Buffer.from(w.toBuffer()));
}
export function serializeDatumPayoutOutputs(outputs: DatumPayoutOutput[]): Buffer {
if (outputs.length > 255) {
throw new RangeError('DATUM payout output count must fit in one byte');
}
const w = new BufferWriter();
w.writeU8(outputs.length);
for (const output of outputs) {
if (output.scriptPubKey.length > 255) {
throw new RangeError('DATUM scriptPubKey length must fit in one byte');
}
w.writeU64(output.value);
w.writeU8(output.scriptPubKey.length);
w.writeBytes(output.scriptPubKey);
}
return w.toBuffer();
}
export function deserializeDatumPowSubmit(payload: Buffer): DatumPowSubmit {
const reader = new BufferReader(payload);
const jobId = reader.readU8();
const coinbaseId = reader.readU8();
const flags = reader.readU8();
const targetByte = reader.readU8();
const ntime = reader.readU32();
const nonce = reader.readU32();
const version = reader.readU32();
const extranonceSize = reader.readU8();
if (extranonceSize !== DATUM_EXTRANONCE_SIZE) {
throw new RangeError(`Unsupported DATUM extranonce size ${extranonceSize}`);
}
const extranonce = reader.readBytes(extranonceSize);
const username = readNullTerminatedString(reader);
const reserved = reader.readBytes(Math.min(4, reader.remaining));
const coinbasePairs = new Map<number, { coinb1: Buffer; coinb2: Buffer }>();
const result: DatumPowSubmit = {
jobId,
coinbaseId,
isBlock: (flags & 0x01) !== 0,
subsidyOnly: (flags & 0x02) !== 0,
quickDiff: (flags & 0x04) !== 0,
targetByte,
ntime,
nonce,
version,
extranonce,
username,
reserved,
coinbasePairs,
};
while (reader.remaining > 0) {
const section = reader.readU8();
if (section === 0xfe) {
break;
}
if (section === 0x01) {
result.prevBlockHash = reader.readBytes(32);
result.targetByteIndex = reader.readU16();
result.nBits = reader.readBytes(4);
result.coinbaserId = reader.readU8();
result.height = reader.readU32();
result.coinbaseValue = reader.readU64();
result.transactionCount = reader.readU32();
result.totalWeight = reader.readU32();
result.totalSize = reader.readU32();
result.totalSigops = reader.readU32();
const merkleBranchCount = reader.readU8();
result.merkleBranches = [];
for (let i = 0; i < merkleBranchCount; i++) {
result.merkleBranches.push(reader.readBytes(32));
}
continue;
}
if (section === 0x02) {
const coinbaseType = reader.readU8();
const coinb1Len = reader.readU16();
const coinb2Len = reader.readU16();
const coinbase = {
coinb1: reader.readBytes(coinb1Len),
coinb2: reader.readBytes(coinb2Len),
};
if (coinbaseType === 255 || coinbaseId === 255) {
result.subsidyOnlyCoinbase = coinbase;
} else {
coinbasePairs.set(coinbaseType, coinbase);
}
continue;
}
throw new RangeError(`Unsupported DATUM POW section 0x${section.toString(16)}`);
}
return result;
}
export function serializeDatumShareResponse(input: {
status: DatumShareResponseStatus;
reasonCode: number;
nonce: number;
targetByte: number;
jobId: number;
}): Buffer {
const w = new BufferWriter();
w.writeU8(input.status);
w.writeU16(input.reasonCode);
w.writeU32(input.nonce);
w.writeU8(input.targetByte);
w.writeU8(input.jobId);
return serializeDatumMiningCommand(DatumMiningCommand.SHARE_RESPONSE, Buffer.from(w.toBuffer()));
}
function deserializeDatumTransactionListResponse(
kind: 'full-transactions' | 'full-transaction-blob',
reader: BufferReader,
jobId: number,
status: number,
): DatumFullTransactionsResponse | DatumFullTransactionBlobResponse {
if (status !== DatumJobValidationStatus.SUCCESS) {
return {
kind,
jobId,
status,
transactionCount: 0,
transactions: [],
};
}
const transactionCount = reader.readU16();
const transactions: Buffer[] = [];
for (let i = 0; i < transactionCount; i++) {
const transactionSize = reader.readU24();
transactions.push(reader.readBytes(transactionSize));
}
consumeDatumTerminatorAndPadding(reader);
return {
kind,
jobId,
status,
transactionCount,
transactions,
};
}
function consumeDatumTerminatorAndPadding(reader: BufferReader): void {
if (reader.remaining <= 0) {
return;
}
const terminator = reader.readU8();
if (terminator !== 0xfe) {
throw new RangeError(`DATUM job validation response missing terminator; got 0x${terminator.toString(16)}`);
}
}
function readNullTerminatedString(reader: BufferReader): string {
const bytes: number[] = [];
while (reader.remaining > 0) {
const byte = reader.readU8();
if (byte === 0) {
break;
}
bytes.push(byte);
}
return Buffer.from(bytes).toString('utf8');
}
+94
View File
@@ -0,0 +1,94 @@
import * as sodium from 'libsodium-wrappers-sumo';
import {
DATUM_INITIAL_HEADER_KEY,
DatumProtocolCommand,
datumHeaderXorFeedback,
decodeDatumHeader,
encodeDatumFrame,
} from './datum-codec';
import { DatumCryptoSession } from './datum-crypto';
describe('DatumCryptoSession', () => {
it('derives stable DATUM public keys from a configured seed', async () => {
await sodium.ready;
const seed = Buffer.from('000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', 'hex');
const first = DatumCryptoSession.generateKeyPairFromSeed(seed);
const second = DatumCryptoSession.generateKeyPairFromSeed(seed);
expect(Buffer.concat([first.edPublicKey, first.xPublicKey]).toString('hex'))
.toBe(Buffer.concat([second.edPublicKey, second.xPublicKey]).toString('hex'));
expect(first.edSecretKey).toEqual(second.edSecretKey);
});
it('opens signed sealed handshakes and builds encrypted responses', async () => {
await sodium.ready;
const serverIdentity = DatumCryptoSession.generateKeyPair();
const serverSessionKeys = DatumCryptoSession.generateKeyPair();
const clientIdentity = DatumCryptoSession.generateKeyPair();
const clientSessionKeys = DatumCryptoSession.generateKeyPair();
const server = await DatumCryptoSession.create(serverIdentity, serverSessionKeys);
const nextHeaderKey = 0x12345678;
const signedBody = Buffer.concat([
clientIdentity.edPublicKey,
clientIdentity.xPublicKey,
clientSessionKeys.edPublicKey,
clientSessionKeys.xPublicKey,
Buffer.from('datum-test/0\0', 'utf8'),
Buffer.from([0xfe]),
uint32(nextHeaderKey),
Buffer.from([1, 2, 3]),
]);
const signature = Buffer.from(sodium.crypto_sign_detached(signedBody, clientIdentity.edSecretKey));
const encrypted = Buffer.from(sodium.crypto_box_seal(
Buffer.concat([signedBody, signature]),
serverIdentity.xPublicKey,
));
const frame = encodeDatumFrame({
header: {
cmdLen: encrypted.length,
reserved: 0,
isSigned: true,
isEncryptedPubkey: true,
isEncryptedChannel: false,
protoCmd: DatumProtocolCommand.HANDSHAKE_INIT,
},
payload: encrypted,
}, DATUM_INITIAL_HEADER_KEY);
const header = decodeDatumHeader(frame.subarray(0, 4), DATUM_INITIAL_HEADER_KEY);
const hello = server.openHandshake(frame.subarray(4, 4 + header.cmdLen));
expect(hello.userAgent).toBe('datum-test/0');
expect(hello.nextHeaderKey).toBe(nextHeaderKey);
expect(hello.sessionXPublicKey).toEqual(clientSessionKeys.xPublicKey);
const response = server.buildHandshakeResponse(hello, 'motd');
const responseHeader = decodeDatumHeader(response.subarray(0, 4), datumHeaderXorFeedback((~nextHeaderKey) >>> 0));
expect(responseHeader.protoCmd).toBe(DatumProtocolCommand.HANDSHAKE_RESPONSE);
expect(responseHeader.isSigned).toBe(true);
expect(responseHeader.isEncryptedPubkey).toBe(true);
const opened = Buffer.from(sodium.crypto_box_seal_open(
response.subarray(4),
clientSessionKeys.xPublicKey,
clientSessionKeys.xSecretKey,
));
expect(opened.subarray(0, 32)).toEqual(clientIdentity.edPublicKey);
expect(opened.subarray(128, 160)).toEqual(serverSessionKeys.edPublicKey);
const ping = server.encryptChannelFrame(DatumProtocolCommand.PING, Buffer.alloc(0));
const pingHeader = decodeDatumHeader(
ping.subarray(0, 4),
datumHeaderXorFeedback(datumHeaderXorFeedback((~nextHeaderKey) >>> 0)),
);
expect(pingHeader.protoCmd).toBe(DatumProtocolCommand.PING);
expect(pingHeader.isEncryptedChannel).toBe(true);
expect(pingHeader.cmdLen).toBe(sodium.crypto_box_MACBYTES);
});
});
function uint32(value: number): Buffer {
const result = Buffer.alloc(4);
result.writeUInt32LE(value >>> 0, 0);
return result;
}
+245
View File
@@ -0,0 +1,245 @@
import * as crypto from 'crypto';
import * as sodium from 'libsodium-wrappers-sumo';
import {
DATUM_INITIAL_HEADER_KEY,
DatumFrame,
DatumProtocolCommand,
decodeDatumHeader,
encodeDatumFrame,
datumHeaderXorFeedback,
} from './datum-codec';
export interface DatumKeyPair {
edPublicKey: Buffer;
edSecretKey: Buffer;
xPublicKey: Buffer;
xSecretKey: Buffer;
}
export interface DatumHello {
identityEdPublicKey: Buffer;
identityXPublicKey: Buffer;
sessionEdPublicKey: Buffer;
sessionXPublicKey: Buffer;
userAgent: string;
nextHeaderKey: number;
}
export class DatumCryptoSession {
private initialized = false;
private sharedSecret: Uint8Array | null = null;
private receiveNonce: Buffer | null = null;
private sendNonce: Buffer | null = null;
private receiveHeaderKey = DATUM_INITIAL_HEADER_KEY;
private sendHeaderKey = DATUM_INITIAL_HEADER_KEY;
private clientIdentityXPublicKey: Buffer | null = null;
private constructor(
private readonly identityKeys: DatumKeyPair,
private readonly sessionKeys: DatumKeyPair,
) {}
public static async create(identityKeys?: DatumKeyPair, sessionKeys?: DatumKeyPair): Promise<DatumCryptoSession> {
await sodium.ready;
return new DatumCryptoSession(
identityKeys ?? DatumCryptoSession.generateKeyPair(),
sessionKeys ?? DatumCryptoSession.generateKeyPair(),
);
}
public get publicKeyHex(): string {
return Buffer.concat([this.identityKeys.edPublicKey, this.identityKeys.xPublicKey]).toString('hex');
}
public get currentReceiveHeaderKey(): number {
return this.receiveHeaderKey;
}
public get currentSendHeaderKey(): number {
return this.sendHeaderKey;
}
public openHandshake(framePayload: Buffer): DatumHello {
const opened = Buffer.from(sodium.crypto_box_seal_open(
framePayload,
this.identityKeys.xPublicKey,
this.identityKeys.xSecretKey,
));
if (opened.length < 32 * 4 + 1 + 4 + sodium.crypto_sign_BYTES) {
throw new Error('DATUM handshake payload is too short');
}
const signature = opened.subarray(opened.length - sodium.crypto_sign_BYTES);
const signedPayload = opened.subarray(0, opened.length - sodium.crypto_sign_BYTES);
const clientIdentityEdPublicKey = signedPayload.subarray(0, 32);
if (!sodium.crypto_sign_verify_detached(signature, signedPayload, clientIdentityEdPublicKey)) {
throw new Error('DATUM handshake signature verification failed');
}
let offset = 0;
const hello: DatumHello = {
identityEdPublicKey: Buffer.from(signedPayload.subarray(offset, offset += 32)),
identityXPublicKey: Buffer.from(signedPayload.subarray(offset, offset += 32)),
sessionEdPublicKey: Buffer.from(signedPayload.subarray(offset, offset += 32)),
sessionXPublicKey: Buffer.from(signedPayload.subarray(offset, offset += 32)),
userAgent: '',
nextHeaderKey: 0,
};
const userAgentStart = offset;
while (offset < signedPayload.length && signedPayload[offset] !== 0) {
offset++;
}
hello.userAgent = signedPayload.subarray(userAgentStart, offset).toString('utf8');
offset++;
if (signedPayload[offset] !== 0xfe) {
throw new Error('DATUM handshake missing key delimiter');
}
offset++;
hello.nextHeaderKey = signedPayload.readUInt32LE(offset);
this.clientIdentityXPublicKey = hello.identityXPublicKey;
this.sharedSecret = sodium.crypto_box_beforenm(hello.sessionXPublicKey, this.sessionKeys.xSecretKey);
this.receiveHeaderKey = datumHeaderXorFeedback(hello.nextHeaderKey);
this.sendHeaderKey = datumHeaderXorFeedback((~hello.nextHeaderKey) >>> 0);
this.initializeNonces(hello.nextHeaderKey, hello.sessionEdPublicKey);
this.initialized = true;
return hello;
}
public buildHandshakeResponse(hello: DatumHello, motd: string): Buffer {
if (this.clientIdentityXPublicKey == null) {
throw new Error('Cannot build DATUM handshake response before opening client hello');
}
const body = Buffer.concat([
hello.identityEdPublicKey,
hello.identityXPublicKey,
hello.sessionEdPublicKey,
hello.sessionXPublicKey,
this.sessionKeys.edPublicKey,
this.sessionKeys.xPublicKey,
Buffer.from(`${motd}\0`, 'utf8'),
]);
const signature = Buffer.from(sodium.crypto_sign_detached(body, this.identityKeys.edSecretKey));
const encrypted = Buffer.from(sodium.crypto_box_seal(
Buffer.concat([body, signature]),
hello.sessionXPublicKey,
));
const response = encodeDatumFrame({
header: {
cmdLen: encrypted.length,
reserved: 0,
isSigned: true,
isEncryptedPubkey: true,
isEncryptedChannel: false,
protoCmd: DatumProtocolCommand.HANDSHAKE_RESPONSE,
},
payload: encrypted,
}, this.sendHeaderKey);
this.sendHeaderKey = datumHeaderXorFeedback(this.sendHeaderKey);
return response;
}
public decryptChannelPayload(payload: Buffer): Buffer {
if (!this.initialized || this.sharedSecret == null || this.receiveNonce == null) {
throw new Error('DATUM channel is not initialized');
}
const opened = sodium.crypto_box_open_easy_afternm(payload, this.receiveNonce, this.sharedSecret);
this.incrementNonce(this.receiveNonce);
this.receiveHeaderKey = datumHeaderXorFeedback(this.receiveHeaderKey);
return Buffer.from(opened);
}
public encryptChannelFrame(protoCmd: DatumProtocolCommand, payload: Buffer, signed = false): Buffer {
if (!this.initialized || this.sharedSecret == null || this.sendNonce == null) {
throw new Error('DATUM channel is not initialized');
}
const signedPayload = signed
? Buffer.concat([payload, Buffer.from(sodium.crypto_sign_detached(payload, this.sessionKeys.edSecretKey))])
: payload;
const encrypted = Buffer.from(sodium.crypto_box_easy_afternm(signedPayload, this.sendNonce, this.sharedSecret));
const frame = encodeDatumFrame({
header: {
cmdLen: encrypted.length,
reserved: 0,
isSigned: signed,
isEncryptedPubkey: false,
isEncryptedChannel: true,
protoCmd,
},
payload: encrypted,
}, this.sendHeaderKey);
this.incrementNonce(this.sendNonce);
this.sendHeaderKey = datumHeaderXorFeedback(this.sendHeaderKey);
return frame;
}
public decodeHeader(headerBytes: Buffer): ReturnType<typeof decodeDatumHeader> {
return decodeDatumHeader(headerBytes, this.receiveHeaderKey);
}
private initializeNonces(nextHeaderKey: number, clientSessionEdPublicKey: Buffer): void {
const receiver = Buffer.alloc(sodium.crypto_box_NONCEBYTES);
let key = ((nextHeaderKey - 42) ^ clientSessionEdPublicKey.readUInt32LE(7)) >>> 0;
for (let i = 0; i < receiver.length; i += 4) {
receiver.writeUInt32LE(datumHeaderXorFeedback((key - 42) >>> 0), i);
key = (~receiver.readUInt32LE(i)) >>> 0;
}
const sender = Buffer.alloc(receiver.length);
for (let i = 0; i < receiver.length; i += 4) {
sender.writeUInt32LE((receiver.readUInt32LE(i) ^ 0x57575757) >>> 0, i);
}
this.sendNonce = receiver;
this.receiveNonce = sender;
}
private incrementNonce(nonce: Buffer): void {
for (let offset = 0; offset < nonce.length; offset += 4) {
const next = (nonce.readUInt32LE(offset) + 1) >>> 0;
nonce.writeUInt32LE(next, offset);
if (next !== 0) {
return;
}
}
}
public static generateKeyPair(): DatumKeyPair {
const seed = crypto.randomBytes(32);
return DatumCryptoSession.generateKeyPairFromSeed(seed);
}
public static generateKeyPairFromSeed(seed: Buffer): DatumKeyPair {
if (seed.length !== 32) {
throw new RangeError(`DATUM key seed must be 32 bytes, got ${seed.length}`);
}
const ed = sodium.crypto_sign_seed_keypair(seed);
const xPublicKey = sodium.crypto_sign_ed25519_pk_to_curve25519(ed.publicKey);
const xSecretKey = sodium.crypto_sign_ed25519_sk_to_curve25519(ed.privateKey);
return {
edPublicKey: Buffer.from(ed.publicKey),
edSecretKey: Buffer.from(ed.privateKey),
xPublicKey: Buffer.from(xPublicKey),
xSecretKey: Buffer.from(xSecretKey),
};
}
}
export function buildDatumPlainFrame(protoCmd: DatumProtocolCommand, payload: Buffer): DatumFrame {
return {
header: {
cmdLen: payload.length,
reserved: 0,
isSigned: false,
isEncryptedPubkey: false,
isEncryptedChannel: false,
protoCmd,
},
payload,
};
}
@@ -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
}; };
} }
} }
@@ -37,6 +37,10 @@ describe('MiningSubmitMessage', () => {
expect(errors).toEqual([]); expect(errors).toEqual([]);
}); });
it('should hash submissions deterministically', () => {
expect(message.hash()).toBe('t2bFzhZ6mketRxa5nOoKrNxG3RkFIZuOwY1WewFtv9k=');
});
it('should reject short extranonce2 submissions', async () => { it('should reject short extranonce2 submissions', async () => {
const shortMessage = plainToInstance( const shortMessage = plainToInstance(
MiningSubmitMessage, MiningSubmitMessage,
@@ -4,7 +4,7 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, Length } from 'class-val
import { eRequestMethod } from '../enums/eRequestMethod'; import { eRequestMethod } from '../enums/eRequestMethod';
import { EXTRANONCE2_SIZE_BYTES } from '../stratum.constants'; import { EXTRANONCE2_SIZE_BYTES } from '../stratum.constants';
import { StratumBaseMessage } from './StratumBaseMessage'; import { StratumBaseMessage } from './StratumBaseMessage';
import * as bitcoinjs from 'bitcoinjs-lib'; import { hash256 } from '../../utils/hash.utils';
export class MiningSubmitMessage extends StratumBaseMessage { export class MiningSubmitMessage extends StratumBaseMessage {
@@ -69,7 +69,7 @@ export class MiningSubmitMessage extends StratumBaseMessage {
public hash(): string{ public hash(): string{
const buffer = Buffer.from(this.versionMask + this.nonce + this.extraNonce2 + this.ntime + this.jobId); const buffer = Buffer.from(this.versionMask + this.nonce + this.extraNonce2 + this.ntime + this.jobId);
return bitcoinjs.crypto.hash256(buffer).toString('base64'); return hash256(buffer).toString('base64');
} }
+2
View File
@@ -1,3 +1,5 @@
export const EXTRANONCE1_SIZE_BYTES = 4; export const EXTRANONCE1_SIZE_BYTES = 4;
export const EXTRANONCE2_SIZE_BYTES = 8; export const EXTRANONCE2_SIZE_BYTES = 8;
export const TOTAL_EXTRANONCE_SIZE_BYTES = EXTRANONCE1_SIZE_BYTES + EXTRANONCE2_SIZE_BYTES; export const TOTAL_EXTRANONCE_SIZE_BYTES = EXTRANONCE1_SIZE_BYTES + EXTRANONCE2_SIZE_BYTES;
export const SV2_EXTENDED_EXTRANONCE2_SIZE_BYTES = 10;
export const SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES = EXTRANONCE1_SIZE_BYTES + SV2_EXTENDED_EXTRANONCE2_SIZE_BYTES;
+19
View File
@@ -0,0 +1,19 @@
import { encodeSv2AuthorityPublicKey } from './sv2-authority-key';
describe('SV2 authority key encoding', () => {
it('matches the protocol security test vector', () => {
const rawPublicKey = Buffer.from([
118, 99, 112, 0, 151, 156, 28, 17,
175, 12, 48, 11, 205, 140, 127, 228,
134, 16, 252, 233, 185, 193, 30, 61,
174, 227, 90, 224, 176, 138, 116, 85,
]);
expect(encodeSv2AuthorityPublicKey(rawPublicKey))
.toBe('9bXiEd8boQVhq7WddEcERUL5tyyJVFYdU8th3HfbNXK3Yw6GRXh');
});
it('rejects non-x-only public keys', () => {
expect(() => encodeSv2AuthorityPublicKey(Buffer.alloc(33))).toThrow(RangeError);
});
});
+19
View File
@@ -0,0 +1,19 @@
import * as bs58 from 'bs58';
import * as crypto from 'crypto';
const AUTHORITY_KEY_VERSION_PREFIX = Buffer.from([1, 0]);
export function encodeSv2AuthorityPublicKey(publicKey: Buffer): string {
if (publicKey.length !== 32) {
throw new RangeError(`SV2 authority public key must be 32 bytes, got ${publicKey.length}`);
}
const versionedKey = Buffer.concat([AUTHORITY_KEY_VERSION_PREFIX, publicKey]);
const checksum = crypto
.createHash('sha256')
.update(crypto.createHash('sha256').update(versionedKey).digest())
.digest()
.subarray(0, 4);
return bs58.encode(Buffer.concat([versionedKey, checksum]));
}

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