38 Commits
Author SHA1 Message Date
Ben 96a9202c11 Sync package lock for Node 24 2026-06-05 11:48:35 -04:00
Ben 95565eeec4 Migrate postgresql improvements to sqlite 2026-06-05 10:48:42 -04:00
Benjamin Wilson b971e9ce4c remove logging 2025-04-27 13:13:36 -04:00
Ben e1b88207d3 Prevent duplicate share submissions - this is why we can't have nice things 2025-04-27 13:06:06 -04:00
Benjamin Wilson e7f7cb52af Merge branch 'master' of https://github.com/benjamin-wilson/public-pool 2025-04-19 13:51:22 -04:00
Benjamin Wilson 6cf1c225a4 Increase miner compatibility by allowing, but stripping unknown message values rather than rejecting as validation errors 2025-04-19 13:51:16 -04:00
KillerInk fd775965e5 cherry pick hashrate fix from pqsql branch (#102) 2025-04-04 13:40:02 -04:00
Luke Childs 554a8916a7 Fix incorrect default POOL_IDENTIFIER valie in .env.example (#103) 2025-04-04 13:37:45 -04:00
fda3f21b71 feat: external pool shares (#89)
* feat:external shares
---------

Co-authored-by: mrv777 <mrv777@users.noreply.github.com>
Co-authored-by: Benjamin Wilson <admin@opensourceminer.com>
2025-02-23 15:43:01 -05:00
Benjamin Wilson 0201703e35 delete old templates and jobs 2025-02-23 13:17:54 -05:00
wario_is_here ddb7a89615 update node.js to 22.11.0 (lts) (#75) 2024-11-10 10:35:57 -05:00
Remco Ros f9e49a8955 patch rpc-bitcoin to be compatible with bitcoin core 28.0 (#66) 2024-10-30 22:28:03 -04:00
Benjamin Wilson 2912dd5071 user agent cleanup 2024-10-16 13:53:37 -04:00
Benjamin Wilson 4282233d2f Ensure mintime is considered for jobs 2024-09-27 13:01:36 -04:00
Stijn 8ce057b3b5 Add pool identifier configuration and improve script size handling (#60)
* fixed a couple tests because of imports, missing newline chars and missing constructor parameters

* Add pool identifier configuration and improve script size handling
2024-08-30 08:57:43 -04:00
Stijn 7bb436bf1f fixed a couple tests because of imports, missing newline chars and missing constructor parameters (#58) 2024-08-28 19:46:01 -04:00
Benjamin Wilson 0917911eb0 validation handling 2024-08-22 15:18:11 -04:00
Benjamin Wilson de2b41a3c1 initialize hashrate to 0 2024-07-17 17:24:50 -04:00
Benjamin Wilson 1cd5bcc97d loadBlockTemplate 2024-07-05 20:20:51 -04:00
Benjamin Wilson 277aa10144 StratumBaseMessage validation 2024-07-05 08:33:19 -04:00
Benjamin Wilson f08103bd8c open stratum base message to accept number or string 2024-07-05 08:19:37 -04:00
Benjamin Wilson 811e7972f9 always clear jobs on diff change 2024-07-05 08:14:09 -04:00
Benjamin Wilson c654a706ae ensure block template is fresh 2024-07-04 20:21:53 -04:00
Benjamin Wilson e0239d2805 handle partial socket data 2024-07-04 20:21:41 -04:00
Benjamin Wilson fc96b9ea45 getBlockTemplate improvements 2024-07-04 16:41:15 -04:00
Benjamin Wilson ad1f32cc2c ensure getblocktemplate always returns a value 2024-07-03 14:31:33 -04:00
Benjamin Wilson 917d312211 remove dead code 2024-07-03 14:28:28 -04:00
Marcel Hernandez 76e2426c30 add option to conect to Bitcoin RPC with cookie file (#40) 2024-06-10 07:43:47 -04:00
Oscar Fawkes 098fe2867a read full lines of json-rpc from clients (#46)
Lgtm, thanks for your contribution
2024-06-10 07:41:18 -04:00
Michael Schmid 6e3902b732 nodejs requires curl for some dependencies, make sure they are part of the build container (#42) 2024-05-27 18:03:58 -04:00
Georges 8c62817f73 stratum: set_difficulty should always be a notification (#32) 2024-02-23 12:51:27 -05:00
Ben Wilson 9f723782ab optional password 2024-02-20 19:17:20 -05:00
Ben Wilson 2e296c00d5 client statistics share calculation 2024-02-18 14:56:08 -05:00
Ben Wilson 437b0c9bc4 updated at, hashrate calculation 2024-02-18 12:32:21 -05:00
Ben Wilson deed775d55 change coinbase tag 2024-02-17 20:31:39 -05:00
Ben Wilson ed537658ac misc 2024-02-17 20:12:09 -05:00
Ben Wilson 29e0d884e3 share updates 2024-02-17 18:40:14 -05:00
Ben Wilson e35bacc648 optimized share calculation and updating 2024-02-17 18:22:09 -05:00
115 changed files with 1732 additions and 21505 deletions
+9 -45
View File
@@ -10,32 +10,16 @@ BITCOIN_RPC_PASSWORD=
BITCOIN_RPC_PORT=8332
BITCOIN_RPC_TIMEOUT=10000
# Enable in bitcoin.conf with:
# zmqpubrawblock=tcp://0.0.0.0:3000
BITCOIN_ZMQ_HOST=tcp://192.168.1.100:3000
# You can use this instead of BITCOIN_RPC_USER and BITCOIN_RPC_PASSWORD
BITCOIN_RPC_COOKIEFILE=
# Enable in bitcoin.conf with
# zmqpubrawblock=tcp://*:3000
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
API_PORT=3334
# 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
# Plain TCP Stratum ports accept both SV1 JSON-RPC and SV2 Noise/binary traffic.
STRATUM_PORTS=3333,3332,3331,3330
STRATUM_WORKERS=2
# 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
#SV2_AUTHORITY_PRIVKEY=
STRATUM_SECURE=true
SECURE_STRATUM_PORTS=4333,4332,4331,4330
STRATUM_PORT=3333
STRATUM_MAX_CONNECTIONS_PER_LISTENER=10000
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS=10000
#optional telegram bot
#TELEGRAM_BOT_TOKEN=
@@ -51,25 +35,5 @@ DEV_FEE_ADDRESS=
NETWORK=mainnet
API_SECURE=false
#postgresql
DB_HOST=timescaledb
DB_PORT=5432
DB_USERNAME=public_pool
DB_PASSWORD=public_pool
DB_DATABASE=public_pool
DB_POOL_SIZE=10
DB_SSL=false
DB_SSL_REJECT_UNAUTHORIZED=true
#accepted share accounting
SHARE_ACCOUNTING_BATCH_SIZE=500
SHARE_ACCOUNTING_FLUSH_INTERVAL_MS=25
SHARE_ACCOUNTING_MAX_QUEUE_SIZE=50000
SHARE_ACCOUNTING_SUMMARY_CACHE_MS=2500
SHARE_ACCOUNTING_SUMMARY_CACHE_MAX=10000
#redis
REDIS_URL=redis://redis:6379
PRODUCTION=false
# 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"
+1 -4
View File
@@ -13,13 +13,10 @@ lerna-debug.log*
# OS
.DS_Store
._*
# Tests
/coverage
/.nyc_output
/.baseline-*.json
/.test-artifacts
# IDEs and editors
/.idea
@@ -44,4 +41,4 @@ settings.json
./full-setup/data/*
#https
**.pem
**.pem
+2 -2
View File
@@ -20,7 +20,7 @@ WORKDIR /build
COPY . .
# Build Public Pool using NPM
RUN npm ci && npm run build && npm prune --omit=dev
RUN npm i && npm run build
############################
# Docker final environment #
@@ -37,4 +37,4 @@ WORKDIR /public-pool
COPY --from=build /build .
#COPY .env.example .env
CMD ["npm", "run", "start:docker"]
CMD ["/usr/local/bin/node", "dist/main"]
-19
View File
@@ -1,19 +0,0 @@
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"]
+17 -79
View File
@@ -39,13 +39,9 @@ See [public-pool-ui](https://github.com/benjamin-wilson/public-pool-ui)
## Deployment
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.
Install pm2 (https://pm2.keymetrics.io/)
```bash
$ npm run build
$ npm run migration:run:prod
$ pm2 start dist/main.js
```
@@ -65,101 +61,43 @@ with the default limit of `10000` allow up to `280000` connections on one port.
## Docker
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:
Build container:
```bash
$ docker compose up --build -d
$ docker build -t public-pool .
```
Use an external TimescaleDB/Postgres server:
Run container:
```bash
$ 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 container run --name public-pool --rm -p 3333:3333 -p 3334:3334 -p 8332:8332 -v .env:/public-pool/.env public-pool
```
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:
### Docker Compose
Build container:
```bash
$ docker compose run --rm public-pool npm run migration:run:prod
$ docker compose build
```
Watch logs:
Run container:
```bash
$ docker compose logs --tail 100 -f public-pool
$ docker compose up -d
```
Back up TimescaleDB:
```bash
$ docker compose exec timescaledb pg_dump -U public_pool public_pool > public-pool.sql
The docker-compose binds to `127.0.0.1` by default. To expose the Stratum services on your server change:
```diff
ports:
- - "127.0.0.1:3333:3333/tcp"
- - "127.0.0.1:3334:3334/tcp"
+ - "3333"
+ - "3334"
```
Redis does not hold durable accounting data. Losing Redis requires workers to
replay the latest template from Redis after reconnect or fall back to the saved
RPC block template table.
**note**: To successfully connect to the bitcoin RPC you will need to add
```
rpcallowip=172.16.0.0/12
zmqpubrawblock=tcp://0.0.0.0:3000
```
to your bitcoin.conf.
## Testing
Baseline unit regression capture:
```bash
$ npm run test:baseline
```
Unit tests:
```bash
$ npm test
```
Integration tests against real TimescaleDB and Redis:
```bash
$ docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
```
-74
View File
@@ -1,74 +0,0 @@
services:
redis:
image: redis:8-alpine
container_name: public-pool-redis
restart: unless-stopped
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
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_SECURE: ${STRATUM_SECURE:-true}
SECURE_STRATUM_PORTS: ${SECURE_STRATUM_PORTS:-4333,4332,4331,4330}
STRATUM_MAX_CONNECTIONS_PER_LISTENER: ${STRATUM_MAX_CONNECTIONS_PER_LISTENER:-10000}
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS: ${STRATUM_TLS_HANDSHAKE_TIMEOUT_MS:-10000}
STRATUM_V2_PORTS: ${STRATUM_V2_PORTS:-}
SHARE_ACCOUNTING_BATCH_SIZE: ${SHARE_ACCOUNTING_BATCH_SIZE:-500}
SHARE_ACCOUNTING_FLUSH_INTERVAL_MS: ${SHARE_ACCOUNTING_FLUSH_INTERVAL_MS:-25}
SHARE_ACCOUNTING_MAX_QUEUE_SIZE: ${SHARE_ACCOUNTING_MAX_QUEUE_SIZE:-50000}
SHARE_ACCOUNTING_SUMMARY_CACHE_MS: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MS:-2500}
SHARE_ACCOUNTING_SUMMARY_CACHE_MAX: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MAX:-10000}
healthcheck:
test: ["CMD-SHELL", "node -e \"const http=require('http'); const https=require('https'); const secure=process.env.API_SECURE==='true'; const client=secure?https:http; const req=client.get({hostname:'127.0.0.1',port:process.env.API_PORT||3334,path:'/api/network',rejectUnauthorized:false},res=>process.exit(res.statusCode<500?0:1)); req.on('error',()=>process.exit(1)); req.setTimeout(5000,()=>{req.destroy(); process.exit(1);});\""]
interval: 30s
timeout: 5s
retries: 10
volumes:
redis-data:
-56
View File
@@ -1,56 +0,0 @@
name: public-pool-test
services:
timescaledb:
image: timescale/timescaledb:latest-pg17
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
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
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
+6 -85
View File
@@ -1,99 +1,20 @@
version: '3.8'
services:
timescaledb:
image: timescale/timescaledb:latest-pg17
container_name: public-pool-timescaledb
restart: unless-stopped
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
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
depends_on:
timescaledb:
condition: service_healthy
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"
- "127.0.0.1:${STRATUM_PORT}:${STRATUM_PORT}/tcp"
- "127.0.0.1:${API_PORT}:${API_PORT}/tcp"
volumes:
- "./${NETWORK}-DB:/public-pool/DB"
- "./.env:/public-pool/.env:ro"
- "./secrets:/public-pool/secrets:ro"
environment:
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_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
- NODE_ENV=production
volumes:
timescaledb-data:
redis-data:
-44
View File
@@ -1,44 +0,0 @@
module.exports = {
apps: [
// API instance
{
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
{
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
{
name: 'workers',
script: './dist/main.js',
instances: parseInt(process.env.STRATUM_WORKERS || '2', 10),
exec_mode: "cluster",
env: {
MASTER: 'false',
API_ENABLED: 'false',
NODE_CLUSTER_SCHED_POLICY: 'none',
},
time: true
},
],
};
+1 -25
View File
@@ -1,7 +1,6 @@
# Full Setup for public pool
This setup provides Docker Compose stacks for Bitcoin Core, Public Pool,
TimescaleDB, and Redis on mainnet, testnet, and regtest.
This setup provides a docker-compose setup consisting of Bitcoin Core Node and Public-Pool running in Mainnet or Testnet.
It exposes following ports:
@@ -9,7 +8,6 @@ It exposes following ports:
- `8333/18333` Bitcoin peering on `0.0.0.0`
- `3333/13333` Public-Pool Stratum port on `0.0.0.0`
- `3334/13334` Public-Pool API port on `localhost`
- TimescaleDB and Redis only on the internal compose network
The docker-compose setups for Mainnet and Testnet can be run in parallel without any problems.
@@ -44,9 +42,6 @@ There are 4 config files for Mainnet and Testnet
- `bitcoin-testnet.conf`
**note: pruning (`prune=550`) is enabled by default in the config**
**note: Bitcoin ZMQ raw block publishing is enabled on the internal compose
network so the Public Pool master can refresh templates immediately after new
blocks.**
# Running the setup
To start the setup in foreground mode:
@@ -70,25 +65,6 @@ To stop the setup use:
docker compose -f docker-compose-mainnet.yml down
```
Database and Redis data are stored under `full-setup/data/<network>/`. To remove
all runtime data, stop the stack and delete the relevant `bitcoin`,
`timescaledb`, and `redis` directories.
# Migrations and backups
The Public Pool container waits for TimescaleDB and Redis, runs migrations, and
then starts PM2. To run migrations manually:
```bash
docker compose -f docker-compose-mainnet.yml run --rm public-pool npm run migration:run:prod
```
To back up mainnet accounting data:
```bash
docker compose -f docker-compose-mainnet.yml exec timescaledb pg_dump -U public_pool public_pool_mainnet > public-pool-mainnet.sql
```
# Regtest
After running the `regtest` setup a couple of blocks need to be generated:
-3
View File
@@ -18,9 +18,6 @@ rpcuser=bitcoin
# enable server
server=1
# notify pool master about new blocks
zmqpubrawblock=tcp://0.0.0.0:3000
[main]
[test]
rpcbind=0.0.0.0
-3
View File
@@ -20,6 +20,3 @@ rpcuser=bitcoin
# set pruning
prune=550
# notify pool master about new blocks
zmqpubrawblock=tcp://0.0.0.0:3000
+1 -4
View File
@@ -18,9 +18,6 @@ rpcuser=bitcoin
# enable server
server=1
# notify pool master about new blocks
zmqpubrawblock=tcp://0.0.0.0:3000
[main]
[test]
rpcbind=0.0.0.0
@@ -28,4 +25,4 @@ rpcport=18332
# no additional testnet settings
[signet]
[regtest]
[regtest]
+5 -68
View File
@@ -1,3 +1,5 @@
version: '3.8'
services:
bitcoin:
container_name: bitcoin
@@ -18,50 +20,6 @@ services:
- "./data/mainnet/bitcoin:/app/data"
- "./bitcoin-mainnet.conf:/app/data/bitcoin.conf:ro"
timescaledb:
image: timescale/timescaledb:latest-pg17
container_name: public-pool-mainnet-timescaledb
restart: unless-stopped
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
networks:
- bitcoin
volumes:
- "./data/mainnet/redis:/data"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
public-pool:
container_name: public-pool
build:
@@ -69,37 +27,16 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
stop_grace_period: 30s
depends_on:
timescaledb:
condition: service_healthy
redis:
condition: service_healthy
networks:
- bitcoin
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"
- "127.0.0.1:3334:3334/tcp"
volumes:
- "./data/mainnet/public-pool:/public-pool/DB"
- "./public-pool-mainnet.env:/public-pool/.env:ro"
- "../secrets:/public-pool/secrets:ro"
environment:
NODE_ENV: production
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}
- NODE_ENV=production
networks:
bitcoin:
+4 -62
View File
@@ -1,3 +1,5 @@
version: '3.8'
services:
bitcoin-regtest:
container_name: bitcoin-regtest
@@ -20,50 +22,6 @@ services:
- "./data/regtest/bitcoin:/app/data"
- "./bitcoin-regtest.conf:/app/data/bitcoin.conf:ro"
timescaledb-regtest:
image: timescale/timescaledb:latest-pg17
container_name: public-pool-regtest-timescaledb
restart: unless-stopped
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
networks:
- bitcoin-regtest
volumes:
- "./data/regtest/redis:/data"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
public-pool-regtest:
container_name: public-pool-regtest
build:
@@ -71,32 +29,16 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
stop_grace_period: 30s
depends_on:
timescaledb-regtest:
condition: service_healthy
redis-regtest:
condition: service_healthy
networks:
- bitcoin-regtest
ports:
- "0.0.0.0:23333:23333/tcp"
- "0.0.0.0:23332:23332/tcp"
- "0.0.0.0:23331:23331/tcp"
- "0.0.0.0:23330:23330/tcp"
- "127.0.0.1:23334:23334/tcp"
volumes:
- "./data/regtest/public-pool:/public-pool/DB"
- "./public-pool-regtest.env:/public-pool/.env:ro"
- "../secrets:/public-pool/secrets:ro"
environment:
NODE_ENV: production
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}
- NODE_ENV=production
networks:
bitcoin-regtest:
+4 -62
View File
@@ -1,3 +1,5 @@
version: '3.8'
services:
bitcoin-testnet:
container_name: bitcoin-testnet
@@ -19,50 +21,6 @@ services:
- "./data/testnet/bitcoin:/app/data"
- "./bitcoin-testnet.conf:/app/data/bitcoin.conf:ro"
timescaledb-testnet:
image: timescale/timescaledb:latest-pg17
container_name: public-pool-testnet-timescaledb
restart: unless-stopped
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
networks:
- bitcoin-testnet
volumes:
- "./data/testnet/redis:/data"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
public-pool-testnet:
container_name: public-pool-testnet
build:
@@ -70,32 +28,16 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
stop_grace_period: 30s
depends_on:
timescaledb-testnet:
condition: service_healthy
redis-testnet:
condition: service_healthy
networks:
- bitcoin-testnet
ports:
- "0.0.0.0:13333:13333/tcp"
- "0.0.0.0:13332:13332/tcp"
- "0.0.0.0:13331:13331/tcp"
- "0.0.0.0:13330:13330/tcp"
- "127.0.0.1:13334:13334/tcp"
volumes:
- "./data/testnet/public-pool:/public-pool/DB"
- "./public-pool-testnet.env:/public-pool/.env:ro"
- "../secrets:/public-pool/secrets:ro"
environment:
NODE_ENV: production
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}
- NODE_ENV=production
networks:
bitcoin-testnet:
+4 -14
View File
@@ -4,14 +4,12 @@ BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=8332
BITCOIN_RPC_TIMEOUT=10000
BITCOIN_ZMQ_HOST=tcp://bitcoin:3000
# Enable in bitcoin.conf with
# zmqpubrawblock=tcp://*:3000
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
API_PORT=3334
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
STRATUM_PORT=3333
#optional telegram bot
#TELEGRAM_BOT_TOKEN=
@@ -27,11 +25,3 @@ DEV_FEE_ADDRESS=
NETWORK=mainnet
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
+4 -14
View File
@@ -4,14 +4,12 @@ BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=28332
BITCOIN_RPC_TIMEOUT=10000
BITCOIN_ZMQ_HOST=tcp://bitcoin-regtest:3000
# Enable in bitcoin.conf with
# zmqpubrawblock=tcp://*:3000
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
API_PORT=23334
STRATUM_PORTS=23333,23332,23331,23330
STRATUM_SECURE=false
SECURE_STRATUM_PORTS=
STRATUM_MAX_CONNECTIONS_PER_LISTENER=10000
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS=10000
STRATUM_PORT=23333
#optional telegram bot
#TELEGRAM_BOT_TOKEN=
@@ -27,11 +25,3 @@ DEV_FEE_ADDRESS=
NETWORK=regtest
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
+5 -15
View File
@@ -4,14 +4,12 @@ BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=18332
BITCOIN_RPC_TIMEOUT=10000
BITCOIN_ZMQ_HOST=tcp://bitcoin-testnet:3000
# Enable in bitcoin.conf with
# zmqpubrawblock=tcp://*:3000
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
API_PORT=13334
STRATUM_PORTS=13333,13332,13331,13330
STRATUM_SECURE=false
SECURE_STRATUM_PORTS=
STRATUM_MAX_CONNECTIONS_PER_LISTENER=10000
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS=10000
STRATUM_PORT=13333
#optional telegram bot
#TELEGRAM_BOT_TOKEN=
@@ -26,12 +24,4 @@ DEV_FEE_ADDRESS=
# mainnet | testnet
NETWORK=testnet
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
API_SECURE=false
+74 -9866
View File
File diff suppressed because it is too large Load Diff
+2 -21
View File
@@ -16,13 +16,8 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"start:docker": "node dist/scripts/start-docker.js",
"migration:run": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d src/data-source.ts migration:run",
"migration:run:prod": "node dist/scripts/run-migrations.js",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:baseline": "jest --runInBand --json --outputFile=.baseline-jest-results.json",
"test:integration": "jest --config ./test/jest-integration.json --runInBand",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
@@ -37,7 +32,6 @@
"@nestjs/platform-fastify": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/typeorm": "^11.0.1",
"@scure/btc-signer": "^2.2.0",
"axios": "^1.16.0",
"bitcoin-address-validation": "^2.2.3",
"bitcoinjs-lib": "^6.1.7",
@@ -46,11 +40,8 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"discord.js": "^14.26.4",
"dotenv": "^16.6.1",
"merkle-lib": "^2.0.10",
"pg": "^8.20.0",
"pm2": "^7.0.1",
"redis": "^5.12.1",
"sqlite3": "^5.1.6",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"tiny-secp256k1": "^2.2.4",
@@ -96,18 +87,8 @@
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": [
"ts-jest",
{
"tsconfig": {
"allowJs": true
}
}
]
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"node_modules/(?!(@scure|@noble|micro-packed))"
],
"collectCoverageFrom": [
"**/*.(t|j)s"
],
@@ -1,41 +0,0 @@
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"`);
}
}
@@ -1,32 +0,0 @@
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"`);
}
}
@@ -1,36 +0,0 @@
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
`);
}
}
@@ -1,22 +0,0 @@
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)
`);
}
}
@@ -1,40 +0,0 @@
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
`);
}
}
@@ -1,215 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class InitialTimescaleSchema1780859300000 implements MigrationInterface {
public name = 'InitialTimescaleSchema1780859300000';
public transaction = false;
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS timescaledb`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "address_settings_entity" (
"address" varchar(62) PRIMARY KEY,
"shares" integer NOT NULL DEFAULT 0,
"bestDifficulty" numeric NOT NULL DEFAULT 0,
"bestDifficultyUserAgent" varchar,
"miscCoinbaseScriptData" varchar,
"deletedAt" timestamptz,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "client_entity" (
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
"address" varchar(62) NOT NULL,
"clientName" varchar(64) NOT NULL,
"sessionId" varchar(8) NOT NULL,
"userAgent" varchar(128),
"startTime" timestamptz NOT NULL,
"bestDifficulty" numeric NOT NULL DEFAULT 0,
"hashRate" numeric NOT NULL DEFAULT 0,
"deletedAt" timestamptz,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_client_entity_address" ON "client_entity" ("address")`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_client_cleanup" ON "client_entity" ("id") WHERE "deletedAt" IS NULL`);
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_unique_nonce" ON "client_entity" ("sessionId") WHERE "deletedAt" IS NULL`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "accepted_share_entity" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"acceptedAt" timestamptz NOT NULL,
"protocol" varchar(8) NOT NULL,
"address" varchar(62) NOT NULL,
"clientName" varchar NOT NULL,
"sessionId" varchar(8) NOT NULL,
"clientId" uuid NOT NULL,
"jobId" varchar NOT NULL,
"jobTemplateId" varchar NOT NULL,
"blockHeight" bigint NOT NULL,
"creditedDifficulty" numeric NOT NULL,
"submissionDifficulty" numeric NOT NULL,
"networkDifficulty" numeric NOT NULL,
"nonce" varchar NOT NULL,
"ntime" varchar NOT NULL,
"version" varchar NOT NULL,
"extraNonce2" varchar NOT NULL,
"isBlockCandidate" boolean NOT NULL DEFAULT false,
"blockSubmissionResult" text,
"createdAt" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id", "acceptedAt")
)
`);
await queryRunner.query(`
SELECT create_hypertable(
'"accepted_share_entity"',
'acceptedAt',
chunk_time_interval => INTERVAL '1 day',
if_not_exists => TRUE
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_accepted_share_accounting_lookup" ON "accepted_share_entity" ("address", "clientName", "acceptedAt" DESC)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_accepted_share_client_lookup" ON "accepted_share_entity" ("clientId", "acceptedAt" DESC)`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_accepted_share_unique_submission"
ON "accepted_share_entity" ("acceptedAt", "protocol", "sessionId", "jobId", "nonce", "ntime", "version", "extraNonce2")
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "blocks_entity" (
"id" bigserial PRIMARY KEY,
"height" integer NOT NULL,
"minerAddress" varchar(62) NOT NULL,
"worker" varchar NOT NULL,
"sessionId" varchar(8) NOT NULL,
"blockData" varchar NOT NULL,
"deletedAt" timestamptz,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "rpc_block_entity" (
"blockHeight" integer PRIMARY KEY,
"data" varchar
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "telegram_subscriptions_entity" (
"id" bigserial PRIMARY KEY,
"address" varchar(62) NOT NULL,
"telegramChatId" integer NOT NULL,
"deletedAt" timestamptz,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_telegram_subscriptions_address" ON "telegram_subscriptions_entity" ("address")`);
await queryRunner.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS "user_agent_report_view" AS
SELECT
"userAgent",
COUNT("userAgent") AS "count",
MAX("bestDifficulty") AS "bestDifficulty",
SUM("hashRate") AS "totalHashRate"
FROM "client_entity"
WHERE "deletedAt" IS NULL
GROUP BY "userAgent"
ORDER BY "totalHashRate" DESC
`);
await queryRunner.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_10m"
WITH (timescaledb.continuous) AS
SELECT
time_bucket(INTERVAL '10 minutes', "acceptedAt") AS "bucket",
"address",
"clientName",
"sessionId",
"clientId",
SUM("creditedDifficulty") AS "shares",
COUNT(*) AS "acceptedCount",
ROUND(((SUM("creditedDifficulty") * 4294967296) / 600)) AS "hashRate"
FROM "accepted_share_entity"
GROUP BY "bucket", "address", "clientName", "sessionId", "clientId"
WITH NO DATA
`);
await queryRunner.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_1h"
WITH (timescaledb.continuous) AS
SELECT
time_bucket(INTERVAL '1 hour', "acceptedAt") AS "bucket",
"address",
"clientName",
SUM("creditedDifficulty") AS "shares",
COUNT(*) AS "acceptedCount"
FROM "accepted_share_entity"
GROUP BY "bucket", "address", "clientName"
WITH NO DATA
`);
await queryRunner.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_1d"
WITH (timescaledb.continuous) AS
SELECT
time_bucket(INTERVAL '1 day', "acceptedAt") AS "bucket",
"address",
SUM("creditedDifficulty") AS "shares",
COUNT(*) AS "acceptedCount"
FROM "accepted_share_entity"
GROUP BY "bucket", "address"
WITH NO DATA
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_10m',
start_offset => INTERVAL '2 days',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => TRUE
)
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_1h',
start_offset => INTERVAL '30 days',
end_offset => INTERVAL '10 minutes',
schedule_interval => INTERVAL '10 minutes',
if_not_exists => TRUE
)
`);
await queryRunner.query(`
SELECT add_continuous_aggregate_policy(
'accepted_share_1d',
start_offset => INTERVAL '365 days',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour',
if_not_exists => TRUE
)
`);
await queryRunner.query(`SELECT add_retention_policy('accepted_share_entity', INTERVAL '90 days', if_not_exists => TRUE)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "accepted_share_1d"`);
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "accepted_share_1h"`);
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "accepted_share_10m"`);
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "user_agent_report_view"`);
await queryRunner.query(`DROP TABLE IF EXISTS "accepted_share_entity" CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS "telegram_subscriptions_entity"`);
await queryRunner.query(`DROP TABLE IF EXISTS "rpc_block_entity"`);
await queryRunner.query(`DROP TABLE IF EXISTS "blocks_entity"`);
await queryRunner.query(`DROP TABLE IF EXISTS "client_entity"`);
await queryRunner.query(`DROP TABLE IF EXISTS "address_settings_entity"`);
}
}
@@ -1,22 +0,0 @@
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"`);
}
}
@@ -1,33 +0,0 @@
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
@@ -1,15 +0,0 @@
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"`);
}
}
@@ -1,16 +0,0 @@
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 { }
@@ -1,120 +0,0 @@
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';
import { logTiming, timeAsync, timingStart } from '../../../utils/timing.utils';
@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 start = timingStart();
const cachedReport = await timeAsync('user agent report shared cache read', () => this.redisMessagingService
.getJsonCache<UserAgentReportView[]>(this.liveReportCacheKey)
.catch(error => {
console.error(`Live user-agent report cache read failed: ${error.message}`);
return null;
}));
if (cachedReport != null) {
logTiming('user agent report getReport', start, { cache: 'hit', rows: cachedReport.length });
return cachedReport;
}
if (process.env.API_ONLY == 'true') {
const rows = await timeAsync('user agent report materialized view read', () => this.userAgentReport.find());
logTiming('user agent report getReport', start, { cache: 'miss', source: 'materialized-view', rows: rows.length });
return rows;
}
const rows = await this.refreshLiveReport();
logTiming('user agent report getReport', start, { cache: 'miss', source: 'live-presence', rows: rows.length });
return rows;
}
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 start = timingStart();
const presences = await timeAsync('user agent report all presence load', () => 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}`);
});
logTiming('user agent report buildLiveReport', start, { presences: presences.length, rows: report.length });
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)
}
}
}
@@ -1,28 +0,0 @@
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;
}
@@ -1,71 +0,0 @@
import { Column, Entity, Index, PrimaryColumn, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
@Index('IDX_accepted_share_accounting_lookup', ['address', 'clientName', 'acceptedAt'])
@Index('IDX_accepted_share_client_lookup', ['clientId', 'acceptedAt'])
@Index('IDX_accepted_share_order', ['shareIndex'])
@Index('IDX_accepted_share_unique_submission', ['acceptedAt', 'protocol', 'sessionId', 'jobId', 'nonce', 'ntime', 'version', 'extraNonce2'], { unique: true })
export class AcceptedShareEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@PrimaryColumn({ type: 'timestamptz' })
acceptedAt: Date;
@Column({ type: 'bigint', default: () => `nextval('accepted_share_index_seq')` })
shareIndex: number;
@Column({ length: 8, type: 'varchar' })
protocol: 'sv1' | 'sv2';
@Column({ length: 62, type: 'varchar' })
address: string;
@Column()
clientName: string;
@Column({ length: 8, type: 'varchar' })
sessionId: string;
@Column({ type: 'uuid' })
clientId: string;
@Column()
jobId: string;
@Column()
jobTemplateId: string;
@Column({ type: 'bigint' })
blockHeight: number;
@Column({ type: 'decimal' })
creditedDifficulty: number;
@Column({ type: 'decimal' })
submissionDifficulty: number;
@Column({ type: 'decimal' })
networkDifficulty: number;
@Column()
nonce: string;
@Column()
ntime: string;
@Column()
version: string;
@Column()
extraNonce2: string;
@Column({ default: false })
isBlockCandidate: boolean;
@Column({ type: 'text', nullable: true })
blockSubmissionResult?: string;
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
}
@@ -11,14 +11,14 @@ export class AddressSettingsEntity extends TrackedEntity {
@Column({ default: 0 })
shares: number;
@Column({ type: 'decimal', default: 0 })
@Column({ type: 'real', default: 0 })
bestDifficulty: number;
@Column({ nullable: true })
bestDifficultyUserAgent: string;
@Column({ nullable: true })
miscCoinbaseScriptData: string;
@Column({ nullable: true })
bestDifficultyUserAgent: string;
}
@@ -49,19 +49,27 @@ export class AddressSettingsService {
.execute();
}
public async getHighScores() {
return await this.addressSettingsRepository.createQueryBuilder()
.select('"updatedAt", "bestDifficulty", "bestDifficultyUserAgent"')
.orderBy('"bestDifficulty"', 'DESC')
.limit(10)
.execute();
}
public async createNew(address: string) {
return await this.addressSettingsRepository.save({ address });
}
// public async addShares(address: string, shares: number) {
// return await this.addressSettingsRepository.createQueryBuilder()
// .update(AddressSettingsEntity)
// .set({
// shares: () => `"shares" + ${shares}` // Use the actual value of shares here
// })
// .where('address = :address', { address })
// .execute();
// }
public async addShares(address: string, shares: number) {
return await this.addressSettingsRepository.createQueryBuilder()
.update(AddressSettingsEntity)
.set({
shares: () => `"shares" + ${shares}` // Use the actual value of shares here
})
.where('address = :address', { address })
.execute();
}
public async resetBestDifficultyAndShares() {
return await this.addressSettingsRepository
@@ -73,12 +81,4 @@ export class AddressSettingsService {
})
.execute();
}
public async getHighScores() {
return await this.addressSettingsRepository.createQueryBuilder()
.select('"updatedAt", "bestDifficulty", "bestDifficultyUserAgent"')
.orderBy('"bestDifficulty"', 'DESC')
.limit(10)
.execute();
}
}
}
+3 -4
View File
@@ -1,12 +1,11 @@
import { Column, Entity } from 'typeorm';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn';
import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Entity()
export class BlocksEntity extends TrackedEntity {
@PrimaryGeneratedBigIntColumn()
@PrimaryGeneratedColumn()
id: number;
@Column()
@@ -24,4 +23,4 @@ export class BlocksEntity extends TrackedEntity {
@Column()
blockData: string;
}
}
@@ -0,0 +1,35 @@
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,11 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClientStatisticsEntity } from './client-statistics.entity';
import { ClientStatisticsService } from './client-statistics.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([ClientStatisticsEntity])],
providers: [ClientStatisticsService],
exports: [ClientStatisticsService],
exports: [TypeOrmModule, ClientStatisticsService],
})
export class ClientStatisticsModule { }
export class ClientStatisticsModule { }
@@ -1,110 +1,257 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { timeAsync } from '../../utils/timing.utils';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } 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()
export class ClientStatisticsService {
constructor(
@InjectDataSource()
private dataSource: DataSource,
@InjectRepository(ClientStatisticsEntity)
private clientStatisticsRepository: Repository<ClientStatisticsEntity>,
) {
}
public async getChartDataForSite(limit: number = 144 * 7) {
return this.getAcceptedShareChartData('', [], limit, SITE_CHART_WINDOW);
public async update(clientStatistic: Partial<ClientStatisticsEntity>) {
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) {
return this.getAcceptedShareChartData(
'AND "address" = $1',
[address],
144,
CHART_WINDOW,
);
var yesterday = new Date(new Date().getTime() - (24 * 60 * 60 * 1000));
const query = `
SELECT
time label,
(SUM(shares) * 4294967296) / 600 AS data
FROM
client_statistics_entity AS entry
WHERE
entry.address = ? AND entry.time > ${yesterday.getTime()}
GROUP BY
time
ORDER BY
time
LIMIT 144;
`;
const result = await this.clientStatisticsRepository.query(query, [address]);
return result.map(res => {
res.label = new Date(res.label).toISOString();
return res;
}).slice(0, result.length - 1);
}
public async getHashRateForGroup(address: string, clientName: string) {
const result = await timeAsync('client statistics group hashrate query', () => this.dataSource.query(`
SELECT
COALESCE((SUM("creditedDifficulty") * ${HASHES_PER_DIFFICULTY}) / ${CHART_BUCKET_SECONDS}, 0) AS "hashRate"
FROM "accepted_share_entity"
WHERE "address" = $1
AND "clientName" = $2
AND "acceptedAt" > NOW() - INTERVAL '1 hour'
`, [address, clientName]), { address, clientName });
return parseFloat(result[0]?.hashRate ?? '0');
public async getHashRateForGroup(address: string, clientName: string) {
var 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.clientName = ? AND entry.time > ${oneHour.getTime()}
`;
const result = await this.clientStatisticsRepository.query(query, [address, clientName]);
const difficultySum = result[0].difficultySum;
return (difficultySum * 4294967296) / (600);
}
public async getChartDataForGroup(address: string, clientName: string) {
return this.getAcceptedShareChartData(
'AND "address" = $1 AND "clientName" = $2',
[address, clientName],
144,
CHART_WINDOW,
);
}
var yesterday = new Date(new Date().getTime() - (24 * 60 * 60 * 1000));
public async getChartDataForSession(clientId: string) {
return this.getAcceptedShareChartData(
'AND "clientId" = $1',
[clientId],
144,
CHART_WINDOW,
);
}
private async getAcceptedShareChartData(filterSql: string, params: unknown[], limit: number, windowSql: string) {
const query = `
WITH bounds AS (
SELECT
NOW() - INTERVAL '${windowSql}' AS since,
time_bucket(INTERVAL '10 minutes', NOW()) AS current_bucket
)
SELECT
"label",
"data",
"shares",
"acceptedCount"
FROM (
SELECT
"bucket" AS "label",
ROUND((SUM("shares") * ${HASHES_PER_DIFFICULTY}) / ${CHART_BUCKET_SECONDS}) AS "data",
SUM("shares") AS "shares",
SUM("acceptedCount") AS "acceptedCount"
FROM "accepted_share_10m", bounds
WHERE "bucket" > bounds.since
AND "bucket" < bounds.current_bucket
${filterSql}
GROUP BY "bucket"
ORDER BY "bucket" DESC
LIMIT ${limit}
) AS limited_rows
ORDER BY "label"
time label,
(SUM(shares) * 4294967296) / 600 AS data
FROM
client_statistics_entity AS entry
WHERE
entry.address = ? AND entry.clientName = ? AND entry.time > ${yesterday.getTime()}
GROUP BY
time
ORDER BY
time
LIMIT 144;
`;
const result = await timeAsync('client statistics chart query', () => this.dataSource.query(query, params), {
filterSql,
params: params.length,
limit,
windowSql,
});
const result = await this.clientStatisticsRepository.query(query, [address, clientName]);
return result.map(res => {
return {
label: new Date(res.label).toISOString(),
data: res.data,
shares: Number(res.shares ?? 0),
acceptedCount: Number(res.acceptedCount ?? 0),
};
});
res.label = new Date(res.label).toISOString();
return res;
}).slice(0, result.length - 1);
}
public async getHashRateForSession(address: string, clientName: string, sessionId: string) {
const query = `
SELECT
createdAt,
updatedAt,
shares
FROM
client_statistics_entity AS entry
WHERE
entry.address = ? AND entry.clientName = ? AND entry.sessionId = ?
ORDER BY time DESC
LIMIT 2;
`;
const result = await this.clientStatisticsRepository.query(query, [address, clientName, sessionId]);
if (result.length < 1) {
return 0;
}
const latestStat = result[0];
if (result.length < 2) {
const time = new Date(latestStat.updatedAt).getTime() - new Date(latestStat.createdAt).getTime();
// 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,27 +1,25 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { DateTimeTransformer } from '../utils/DateTimeTransformer';
import { TrackedEntity } from '../utils/TrackedEntity.entity';
//https://www.sqlite.org/withoutrowid.html
@Entity()
@Index("IDX_unique_nonce", { synchronize: false })
@Index('idx_client_cleanup', ['id'], {
where: '"deletedAt" IS NULL',
// This is a partial (filtered) index — only indexes active clients
})
//The WITHOUT ROWID optimization is likely to be helpful for tables that have non-integer
// or composite (multi-column) PRIMARY KEYs and that do not store large strings or BLOBs.
//WITHOUT ROWID tables work best when individual rows are not too large.
@Entity({ withoutRowid: true })
@Index(['address', 'clientName', 'sessionId'], { unique: true })
export class ClientEntity extends TrackedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ length: 62, type: 'varchar' })
@PrimaryColumn({ length: 62, type: 'varchar' })
address: string;
@Column({ length: 64, type: 'varchar' })
@PrimaryColumn({ length: 64, type: 'varchar' })
clientName: string;
@Column({ length: 8, type: 'varchar', })
@PrimaryColumn({ length: 8, type: 'varchar' })
sessionId: string;
@@ -29,13 +27,15 @@ export class ClientEntity extends TrackedEntity {
userAgent: string;
@Column()
@Column({ type: 'datetime', transformer: new DateTimeTransformer() })
startTime: Date;
@Column({ type: 'decimal', default: 0 })
@Column({ type: 'real', default: 0 })
bestDifficulty: number
@Column({ default: 0, type: 'decimal' })
@Column({ default: 0 })
hashRate: number;
}
+77 -23
View File
@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { firstValueFrom, Subject } from 'rxjs';
import { ObjectLiteral, Repository } from 'typeorm';
import { ClientEntity } from './client.entity';
@@ -9,6 +11,10 @@ import { ClientEntity } from './client.entity';
@Injectable()
export class ClientService {
public insertQueue: { result: Subject<ObjectLiteral>, partialClient: Partial<ClientEntity> }[] = [];
constructor(
@InjectRepository(ClientEntity)
private clientRepository: Repository<ClientEntity>
@@ -16,19 +22,69 @@ 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> {
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 = {
...partialClient,
...insertResult.generatedMaps[0]
...generatedMap
};
return client as ClientEntity;
}
public async delete(id: string) {
return await this.clientRepository.softDelete({ id });
public async delete(sessionId: string) {
return await this.clientRepository.softDelete({ sessionId });
}
public async deleteOldClients() {
@@ -44,16 +100,16 @@ export class ClientService {
}
public async updateBestDifficulty(id: string, bestDifficulty: number) {
return await this.clientRepository.update({ id }, { bestDifficulty });
public async updateBestDifficulty(sessionId: string, bestDifficulty: number) {
return await this.clientRepository.update({ sessionId }, { bestDifficulty });
}
public async updateBestDifficultyIfHigher(id: string, bestDifficulty: number) {
public async updateBestDifficultyIfHigher(sessionId: string, bestDifficulty: number) {
return await this.clientRepository
.createQueryBuilder()
.update(ClientEntity)
.set({ bestDifficulty })
.where('id = :id', { id })
.where('sessionId = :sessionId', { sessionId })
.andWhere('"bestDifficulty" < :bestDifficulty', { bestDifficulty })
.execute();
}
@@ -93,22 +149,20 @@ export class ClientService {
public async deleteAll() {
return await this.clientRepository
.createQueryBuilder()
.delete()
.from(ClientEntity)
.where('"deletedAt" IS NULL')
.softDelete()
.execute();
}
// public async getUserAgents() {
// const result = await this.clientRepository.createQueryBuilder('client')
// .select('client.userAgent as "userAgent"')
// .addSelect('COUNT(client.userAgent)', 'count')
// .addSelect('MAX(client.bestDifficulty)', 'bestDifficulty')
// .addSelect('SUM(client.hashRate)', 'totalHashRate')
// .groupBy('client.userAgent')
// .orderBy('"totalHashRate"', 'DESC')
// .getRawMany();
// return result;
// }
public async getUserAgents() {
const result = await this.clientRepository.createQueryBuilder('client')
.select('client.userAgent as userAgent')
.addSelect('COUNT(client.userAgent)', 'count')
.addSelect('MAX(client.bestDifficulty)', 'bestDifficulty')
.addSelect('SUM(client.hashRate)', 'totalHashRate')
.groupBy('client.userAgent')
.orderBy('count', 'DESC')
.getRawMany();
return result;
}
}
@@ -0,0 +1,30 @@
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;
}
@@ -0,0 +1,13 @@
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 { }
@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ExternalSharesEntity } from './external-shares.entity';
@Injectable()
export class ExternalSharesService {
constructor(
@InjectRepository(ExternalSharesEntity)
private externalSharesRepository: Repository<ExternalSharesEntity>
) {}
public async insert(externalShare: Partial<ExternalSharesEntity>) {
return await this.externalSharesRepository.insert(externalShare);
}
public async getTopDifficulties(): Promise<Array<{userAgent: string, time: number, externalPoolName: string, difficulty: number}>> {
return await this.externalSharesRepository
.createQueryBuilder('share')
.select('share.userAgent', 'userAgent')
.addSelect('share.time', 'time')
.addSelect('share.externalPoolName', 'externalPoolName')
.addSelect('MAX(share.difficulty)', 'difficulty')
.groupBy('share.address')
.orderBy('MAX(share.difficulty)', 'DESC')
.limit(10)
.getRawMany();
}
public async getAddressBestDifficulty(address: string): Promise<number> {
const result = await this.externalSharesRepository
.createQueryBuilder()
.select('MAX(difficulty)', 'maxDifficulty')
.where('address = :address', { address })
.getRawOne();
return result?.maxDifficulty || 0;
}
public async deleteOldShares() {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
return await this.externalSharesRepository
.createQueryBuilder()
.delete()
.from(ExternalSharesEntity)
.where('time < :time', { time: oneDayAgo.getTime() })
.execute();
}
public async deleteAll() {
return await this.externalSharesRepository.delete({});
}
}
+3
View File
@@ -6,6 +6,9 @@ export class RpcBlockEntity {
@PrimaryColumn()
blockHeight: number;
@Column({ nullable: true })
lockedBy?: string;
@Column({ nullable: true })
data?: string;
}
+8 -4
View File
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { InsertResult, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { RpcBlockEntity } from './rpc-block.entity';
@@ -12,14 +12,18 @@ export class RpcBlockService {
) {
}
public getSavedBlockTemplate(blockHeight: number) {
public getBlock(blockHeight: number) {
return this.rpcBlockRepository.findOne({
where: { blockHeight }
});
}
public saveBlock(blockHeight: number, data: string): Promise<InsertResult> {
return this.rpcBlockRepository.upsert({ blockHeight, data }, ['blockHeight']);
public lockBlock(blockHeight: number, process: string) {
return this.rpcBlockRepository.save({ blockHeight, data: null, lockedBy: process });
}
public saveBlock(blockHeight: number, data: string) {
return this.rpcBlockRepository.update(blockHeight, { data })
}
public async deleteOldBlocks() {
@@ -1,13 +0,0 @@
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 { }
@@ -1,343 +0,0 @@
import { ShareAccountingService } from './share-accounting.service';
describe('ShareAccountingService', () => {
const originalEnv = process.env;
beforeEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
process.env = { ...originalEnv };
});
afterAll(() => {
process.env = originalEnv;
});
it('should normalize and save accepted share records', async () => {
const repository = {
create: jest.fn(value => value),
insert: jest.fn().mockResolvedValue({}),
};
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '1';
const service = new ShareAccountingService(repository as any);
const acceptedAt = new Date('2026-06-07T12:00:00Z');
await expect(service.recordAcceptedShare({
protocol: 'sv1',
acceptedAt,
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'worker',
sessionId: '57a6f098',
clientId: '6df7beea-921a-46cb-b7dc-e8075f916232',
jobId: '1',
jobTemplateId: '2',
blockHeight: 900000,
creditedDifficulty: 1024,
submissionDifficulty: 2048,
networkDifficulty: 100000,
nonce: 123,
ntime: 456,
version: 536870912,
extraNonce2: 'c708000000000000',
isBlockCandidate: false,
blockSubmissionResult: null,
})).resolves.toEqual(expect.objectContaining({
protocol: 'sv1',
creditedDifficulty: 1024,
}));
expect(repository.create).toHaveBeenCalledWith(expect.objectContaining({
acceptedAt,
nonce: '123',
ntime: '456',
version: '536870912',
blockSubmissionResult: null,
}));
expect(repository.insert).toHaveBeenCalledWith([expect.objectContaining({
protocol: 'sv1',
creditedDifficulty: 1024,
})]);
});
it('should batch accepted share inserts until the batch size is reached', async () => {
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '2';
process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '1000';
const repository = {
create: jest.fn(value => value),
insert: jest.fn().mockResolvedValue({}),
};
const service = new ShareAccountingService(repository as any);
const first = service.recordAcceptedShare(buildRecord('1'));
expect(repository.insert).not.toHaveBeenCalled();
const second = service.recordAcceptedShare(buildRecord('2'));
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
expect(repository.insert).toHaveBeenCalledTimes(1);
expect(repository.insert).toHaveBeenCalledWith([
expect.objectContaining({ jobId: '1' }),
expect.objectContaining({ jobId: '2' }),
]);
});
it('should flush partial batches on the flush interval', async () => {
jest.useFakeTimers();
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '10';
process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '25';
const repository = {
create: jest.fn(value => value),
insert: jest.fn().mockResolvedValue({}),
};
const service = new ShareAccountingService(repository as any);
const pending = service.recordAcceptedShare(buildRecord('interval'));
expect(repository.insert).not.toHaveBeenCalled();
jest.advanceTimersByTime(25);
await expect(pending).resolves.toEqual(expect.objectContaining({ jobId: 'interval' }));
expect(repository.insert).toHaveBeenCalledTimes(1);
});
it('should reject accepted shares when the accounting queue is full', async () => {
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '10';
process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '1000';
process.env.SHARE_ACCOUNTING_MAX_QUEUE_SIZE = '1';
const repository = {
create: jest.fn(value => value),
insert: jest.fn().mockResolvedValue({}),
};
const service = new ShareAccountingService(repository as any);
const queued = service.recordAcceptedShare(buildRecord('queued'));
await expect(service.recordAcceptedShare(buildRecord('overflow')))
.rejects
.toThrow('Share accounting queue is full');
await service.flushPendingShares();
await expect(queued).resolves.toEqual(expect.objectContaining({ jobId: 'queued' }));
});
it('should return numeric accounting summaries from the share rollup', async () => {
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '3',
totalCreditedDifficulty: '96',
acceptedSharesLast10Minutes: '2',
creditedDifficultyLast10Minutes: '64',
acceptedSharesLastHour: '3',
creditedDifficultyLastHour: '96',
acceptedSharesLastDay: '3',
creditedDifficultyLastDay: '96',
hashRateLast10Minutes: '458129844.9',
hashRateLastHour: '114532461.2',
latestShareAt: new Date('2026-06-07T12:10:00Z'),
}]),
};
const service = new ShareAccountingService(repository as any);
await expect(service.getAddressSummary('bc1qtest')).resolves.toEqual({
totalAcceptedShares: 3,
totalCreditedDifficulty: 96,
acceptedSharesLast10Minutes: 2,
creditedDifficultyLast10Minutes: 64,
acceptedSharesLastHour: 3,
creditedDifficultyLastHour: 96,
acceptedSharesLastDay: 3,
creditedDifficultyLastDay: 96,
hashRateLast10Minutes: 458129844.9,
hashRateLastHour: 114532461.2,
bestSubmissionDifficulty: 0,
bestSubmissionDifficultyAt: null,
blockCandidateCount: 0,
latestShareAt: '2026-06-07T12:10:00.000Z',
protocolBreakdown: [],
});
expect(repository.query).toHaveBeenNthCalledWith(
1,
expect.stringContaining('"accepted_share_10m"'),
['bc1qtest'],
);
expect(repository.query).toHaveBeenCalledTimes(1);
});
it('should serve API-only address accounting from the share rollup instead of empty summaries', async () => {
process.env.API_ONLY = 'true';
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '12',
totalCreditedDifficulty: '384',
acceptedSharesLast10Minutes: '4',
creditedDifficultyLast10Minutes: '128',
acceptedSharesLastHour: '10',
creditedDifficultyLastHour: '320',
acceptedSharesLastDay: '12',
creditedDifficultyLastDay: '384',
hashRateLast10Minutes: '916259689.8',
hashRateLastHour: '381774870.2',
latestShareAt: new Date('2026-06-07T12:30:00Z'),
}]),
};
const service = new ShareAccountingService(repository as any);
await expect(service.getAddressSummary('bc1qapi')).resolves.toEqual(expect.objectContaining({
totalAcceptedShares: 12,
totalCreditedDifficulty: 384,
acceptedSharesLast10Minutes: 4,
creditedDifficultyLast10Minutes: 128,
hashRateLast10Minutes: 916259689.8,
latestShareAt: '2026-06-07T12:30:00.000Z',
}));
expect(repository.query).toHaveBeenCalledWith(
expect.stringContaining('FROM "accepted_share_10m"'),
['bc1qapi'],
);
expect(repository.query).not.toHaveBeenCalledWith(
expect.stringContaining('FROM "accepted_share_entity"'),
expect.anything(),
);
});
it('should use Redis cache for share accounting summaries across API workers', async () => {
process.env.SHARE_ACCOUNTING_SUMMARY_CACHE_MS = '0';
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '1',
totalCreditedDifficulty: '32',
acceptedSharesLast10Minutes: '1',
creditedDifficultyLast10Minutes: '32',
acceptedSharesLastHour: '1',
creditedDifficultyLastHour: '32',
acceptedSharesLastDay: '1',
creditedDifficultyLastDay: '32',
hashRateLast10Minutes: '1',
hashRateLastHour: '1',
latestShareAt: null,
}]),
};
const redis = {
getJsonCache: jest.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
...new ShareAccountingService(repository as any).emptySummary(),
totalAcceptedShares: 1,
}),
setJsonCache: jest.fn().mockResolvedValue(undefined),
};
const service = new ShareAccountingService(repository as any, redis as any);
await service.getAddressSummary('bc1qcached');
await expect(service.getAddressSummary('bc1qcached')).resolves.toEqual(expect.objectContaining({
totalAcceptedShares: 1,
}));
expect(repository.query).toHaveBeenCalledTimes(1);
expect(redis.setJsonCache).toHaveBeenCalledWith(
expect.stringContaining('accounting:summary:'),
expect.objectContaining({ totalAcceptedShares: 1 }),
30000,
);
});
it('should overlay live pool data and best share from the current round', async () => {
const redis = {
getJsonCache: jest.fn().mockResolvedValue(null),
setJsonCache: jest.fn().mockResolvedValue(undefined),
};
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '3',
totalCreditedDifficulty: '96',
acceptedSharesLast10Minutes: '0',
creditedDifficultyLast10Minutes: '0',
acceptedSharesLastHour: '3',
creditedDifficultyLastHour: '96',
acceptedSharesLastDay: '3',
creditedDifficultyLastDay: '96',
hashRateLast10Minutes: '0',
hashRateLastHour: '114532461.2',
latestShareAt: new Date('2026-06-07T12:10:00Z'),
}])
.mockResolvedValueOnce([{
acceptedSharesLast10Minutes: '7',
creditedDifficultyLast10Minutes: '224',
hashRateLast10Minutes: '1603451170.77',
latestShareAt: new Date('2026-06-07T12:20:00Z'),
}])
.mockResolvedValueOnce([{
bestSubmissionDifficulty: '4096',
bestSubmissionDifficultyAt: new Date('2026-06-07T12:19:00Z'),
}]),
};
const service = new ShareAccountingService(repository as any, redis as any);
await expect(service.refreshPoolSummary()).resolves.toEqual(expect.objectContaining({
acceptedSharesLast10Minutes: 7,
creditedDifficultyLast10Minutes: 224,
hashRateLast10Minutes: 1603451170.77,
bestSubmissionDifficulty: 4096,
bestSubmissionDifficultyAt: '2026-06-07T12:19:00.000Z',
latestShareAt: '2026-06-07T12:20:00.000Z',
}));
expect(repository.query).toHaveBeenNthCalledWith(
3,
expect.stringContaining('WHERE "blockHeight" > latest_found_block."height"'),
);
});
it('should cache accounting summaries briefly to protect hot dashboard endpoints', async () => {
process.env.SHARE_ACCOUNTING_SUMMARY_CACHE_MS = '1000';
const repository = {
query: jest.fn()
.mockResolvedValueOnce([{
totalAcceptedShares: '1',
totalCreditedDifficulty: '32',
acceptedSharesLast10Minutes: '1',
creditedDifficultyLast10Minutes: '32',
acceptedSharesLastHour: '1',
creditedDifficultyLastHour: '32',
acceptedSharesLastDay: '1',
creditedDifficultyLastDay: '32',
hashRateLast10Minutes: '1',
hashRateLastHour: '1',
latestShareAt: null,
}]),
};
const service = new ShareAccountingService(repository as any);
await service.getPoolSummary();
await service.getPoolSummary();
expect(repository.query).toHaveBeenCalledTimes(1);
});
});
function buildRecord(jobId: string) {
return {
protocol: 'sv1' as const,
acceptedAt: new Date('2026-06-07T12:00:00Z'),
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'worker',
sessionId: '57a6f098',
clientId: '6df7beea-921a-46cb-b7dc-e8075f916232',
jobId,
jobTemplateId: 'template',
blockHeight: 900000,
creditedDifficulty: 1024,
submissionDifficulty: 2048,
networkDifficulty: 100000,
nonce: 123,
ntime: 456,
version: 536870912,
extraNonce2: 'c708000000000000',
isBlockCandidate: false,
blockSubmissionResult: null,
};
}
@@ -1,477 +0,0 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AcceptedShareEntity } from '../accepted-share/accepted-share.entity';
import { RedisMessagingService } from '../../services/redis-messaging.service';
import { timeAsync } from '../../utils/timing.utils';
export interface AcceptedShareRecord {
protocol: 'sv1' | 'sv2';
acceptedAt?: Date;
address: string;
clientName: string;
sessionId: string;
clientId: string;
jobId: string;
jobTemplateId: string;
blockHeight: number;
creditedDifficulty: number;
submissionDifficulty: number;
networkDifficulty: number;
nonce: string | number;
ntime: string | number;
version: string | number;
extraNonce2: string;
isBlockCandidate: boolean;
blockSubmissionResult?: string | null;
}
export interface ShareAccountingSummary {
totalAcceptedShares: number;
totalCreditedDifficulty: number;
acceptedSharesLast10Minutes: number;
creditedDifficultyLast10Minutes: number;
acceptedSharesLastHour: number;
creditedDifficultyLastHour: number;
acceptedSharesLastDay: number;
creditedDifficultyLastDay: number;
hashRateLast10Minutes: number;
hashRateLastHour: number;
bestSubmissionDifficulty: number;
bestSubmissionDifficultyAt: string | null;
blockCandidateCount: number;
latestShareAt: string | null;
protocolBreakdown: {
protocol: string;
acceptedShares: number;
creditedDifficulty: number;
}[];
}
export interface SessionShareSummary {
clientId: string;
latestShareAt: string | null;
hashRateLast10Minutes: number;
bestSubmissionDifficulty: number;
}
interface AccountingFilter {
address?: string;
clientName?: string;
clientId?: string;
}
const HASHES_PER_DIFFICULTY = 4294967296;
const ROLLUP_BUCKET_SECONDS = 600;
const DEFAULT_BATCH_SIZE = 500;
const DEFAULT_FLUSH_INTERVAL_MS = 25;
const DEFAULT_MAX_QUEUE_SIZE = 50000;
const DEFAULT_SUMMARY_CACHE_MS = 2500;
const DEFAULT_SUMMARY_CACHE_MAX = 10000;
const DEFAULT_REDIS_SUMMARY_CACHE_MS = 30000;
@Injectable()
export class ShareAccountingService implements OnModuleDestroy {
private pendingShares: PendingShare[] = [];
private flushTimer: NodeJS.Timeout | null = null;
private activeFlush: Promise<void> | null = null;
private summaryCache = new Map<string, SummaryCacheEntry>();
private readonly poolSummaryCacheKey = 'accounting:pool-summary';
private readonly batchSize = this.readPositiveInt('SHARE_ACCOUNTING_BATCH_SIZE', DEFAULT_BATCH_SIZE);
private readonly flushIntervalMs = this.readPositiveInt('SHARE_ACCOUNTING_FLUSH_INTERVAL_MS', DEFAULT_FLUSH_INTERVAL_MS);
private readonly maxQueueSize = this.readPositiveInt('SHARE_ACCOUNTING_MAX_QUEUE_SIZE', DEFAULT_MAX_QUEUE_SIZE);
private readonly summaryCacheMs = this.readNonNegativeInt('SHARE_ACCOUNTING_SUMMARY_CACHE_MS', DEFAULT_SUMMARY_CACHE_MS);
private readonly summaryCacheMax = this.readPositiveInt('SHARE_ACCOUNTING_SUMMARY_CACHE_MAX', DEFAULT_SUMMARY_CACHE_MAX);
private readonly redisSummaryCacheMs = this.readNonNegativeInt('SHARE_ACCOUNTING_REDIS_SUMMARY_CACHE_MS', DEFAULT_REDIS_SUMMARY_CACHE_MS);
constructor(
@InjectRepository(AcceptedShareEntity)
private readonly acceptedShareRepository: Repository<AcceptedShareEntity>,
private readonly redisMessagingService?: RedisMessagingService,
) { }
public async recordAcceptedShare(record: AcceptedShareRecord): Promise<AcceptedShareEntity> {
const acceptedShare = this.acceptedShareRepository.create({
...record,
acceptedAt: record.acceptedAt ?? new Date(),
nonce: record.nonce.toString(),
ntime: record.ntime.toString(),
version: record.version.toString(),
blockSubmissionResult: record.blockSubmissionResult == null
? null
: record.blockSubmissionResult.toString(),
});
if (this.pendingShares.length >= this.maxQueueSize) {
throw new Error(`Share accounting queue is full (${this.maxQueueSize})`);
}
return await new Promise<AcceptedShareEntity>((resolve, reject) => {
this.pendingShares.push({ entity: acceptedShare, resolve, reject });
if (this.pendingShares.length >= this.batchSize) {
this.startFlush();
return;
}
this.scheduleFlush();
});
}
public async flushPendingShares(): Promise<void> {
while (this.pendingShares.length > 0 || this.activeFlush != null) {
if (this.activeFlush != null) {
await this.activeFlush;
continue;
}
this.startFlush();
}
}
public async onModuleDestroy(): Promise<void> {
await this.flushPendingShares();
}
public async getPoolSummary(): Promise<ShareAccountingSummary> {
const cached = await this.redisMessagingService
?.getJsonCache<ShareAccountingSummary>(this.poolSummaryCacheKey)
.catch(error => {
console.error(`Pool accounting summary cache read failed: ${error.message}`);
return null;
});
if (cached != null) {
return cached;
}
if (process.env.API_ONLY === 'true') {
return this.emptySummary();
}
return this.getSummary({});
}
public async refreshPoolSummary(): Promise<ShareAccountingSummary> {
const summary = await this.withPoolLiveOverlay(await this.getSummary({}));
await this.redisMessagingService
?.setJsonCache(this.poolSummaryCacheKey, summary, 10 * 60 * 1000)
.catch(error => {
console.error(`Pool accounting summary cache write failed: ${error.message}`);
});
return summary;
}
private async withPoolLiveOverlay(summary: ShareAccountingSummary): Promise<ShareAccountingSummary> {
if (process.env.API_ONLY === 'true') {
return summary;
}
const [[liveWindow], [bestDifficultyRow]] = await Promise.all([
timeAsync('share accounting live 10m pool query', () => this.acceptedShareRepository.query(`
SELECT
COUNT(*)::int AS "acceptedSharesLast10Minutes",
COALESCE(SUM("creditedDifficulty"), 0)::float AS "creditedDifficultyLast10Minutes",
COALESCE((SUM("creditedDifficulty") * ${HASHES_PER_DIFFICULTY}) / 600, 0)::float AS "hashRateLast10Minutes",
MAX("acceptedAt") AS "latestShareAt"
FROM "accepted_share_entity"
WHERE "acceptedAt" > NOW() - INTERVAL '10 minutes'
`)),
timeAsync('share accounting current round best share query', () => this.acceptedShareRepository.query(`
WITH latest_found_block AS (
SELECT COALESCE(MAX("height"), 0) AS "height"
FROM "blocks_entity"
)
SELECT
COALESCE("submissionDifficulty", 0)::float AS "bestSubmissionDifficulty",
"acceptedAt" AS "bestSubmissionDifficultyAt"
FROM "accepted_share_entity", latest_found_block
WHERE "blockHeight" > latest_found_block."height"
ORDER BY "submissionDifficulty" DESC, "acceptedAt" DESC
LIMIT 1
`)),
]);
return {
...summary,
acceptedSharesLast10Minutes: this.toNumber(liveWindow?.acceptedSharesLast10Minutes),
creditedDifficultyLast10Minutes: this.toNumber(liveWindow?.creditedDifficultyLast10Minutes),
hashRateLast10Minutes: this.toNumber(liveWindow?.hashRateLast10Minutes),
bestSubmissionDifficulty: this.toNumber(bestDifficultyRow?.bestSubmissionDifficulty),
bestSubmissionDifficultyAt: bestDifficultyRow?.bestSubmissionDifficultyAt == null
? null
: new Date(bestDifficultyRow.bestSubmissionDifficultyAt).toISOString(),
latestShareAt: liveWindow?.latestShareAt == null
? summary.latestShareAt
: new Date(liveWindow.latestShareAt).toISOString(),
};
}
public emptySummary(): ShareAccountingSummary {
return {
totalAcceptedShares: 0,
totalCreditedDifficulty: 0,
acceptedSharesLast10Minutes: 0,
creditedDifficultyLast10Minutes: 0,
acceptedSharesLastHour: 0,
creditedDifficultyLastHour: 0,
acceptedSharesLastDay: 0,
creditedDifficultyLastDay: 0,
hashRateLast10Minutes: 0,
hashRateLastHour: 0,
bestSubmissionDifficulty: 0,
bestSubmissionDifficultyAt: null,
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 timeAsync('share accounting session summaries query', () => this.acceptedShareRepository.query(`
SELECT
"clientId",
MAX("bucket") AS "latestShareAt",
COALESCE((SUM("shares") FILTER (WHERE "bucket" > NOW() - INTERVAL '10 minutes') * ${HASHES_PER_DIFFICULTY}) / ${ROLLUP_BUCKET_SECONDS}, 0)::float AS "hashRateLast10Minutes"
FROM "accepted_share_10m"
WHERE "clientId" = ANY($1::uuid[])
GROUP BY "clientId"
`, [uniqueClientIds]), { clientIds: uniqueClientIds.length });
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 timeAsync('share accounting summary query', () => this.acceptedShareRepository.query(`
SELECT
COALESCE(SUM("acceptedCount"), 0)::int AS "totalAcceptedShares",
COALESCE(SUM("shares"), 0)::float AS "totalCreditedDifficulty",
COALESCE(SUM("acceptedCount") FILTER (WHERE "bucket" > NOW() - INTERVAL '10 minutes'), 0)::int AS "acceptedSharesLast10Minutes",
COALESCE(SUM("shares") FILTER (WHERE "bucket" > NOW() - INTERVAL '10 minutes'), 0)::float AS "creditedDifficultyLast10Minutes",
COALESCE(SUM("acceptedCount") FILTER (WHERE "bucket" > NOW() - INTERVAL '1 hour'), 0)::int AS "acceptedSharesLastHour",
COALESCE(SUM("shares") FILTER (WHERE "bucket" > NOW() - INTERVAL '1 hour'), 0)::float AS "creditedDifficultyLastHour",
COALESCE(SUM("acceptedCount") FILTER (WHERE "bucket" > NOW() - INTERVAL '1 day'), 0)::int AS "acceptedSharesLastDay",
COALESCE(SUM("shares") FILTER (WHERE "bucket" > NOW() - INTERVAL '1 day'), 0)::float AS "creditedDifficultyLastDay",
COALESCE((SUM("shares") FILTER (WHERE "bucket" > NOW() - INTERVAL '10 minutes') * ${HASHES_PER_DIFFICULTY}) / ${ROLLUP_BUCKET_SECONDS}, 0)::float AS "hashRateLast10Minutes",
COALESCE((SUM("shares") FILTER (WHERE "bucket" > NOW() - INTERVAL '1 hour') * ${HASHES_PER_DIFFICULTY}) / 3600, 0)::float AS "hashRateLastHour",
MAX("bucket") AS "latestShareAt"
FROM "accepted_share_10m"
${whereSql}
`, params), { filter, params: params.length });
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,
blockCandidateCount: 0,
latestShareAt: summary?.latestShareAt == null
? null
: new Date(summary.latestShareAt).toISOString(),
protocolBreakdown: [],
};
}
private scheduleFlush(): void {
if (this.flushTimer != null || this.activeFlush != null) {
return;
}
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
this.startFlush();
}, this.flushIntervalMs);
this.flushTimer.unref?.();
}
private startFlush(): void {
if (this.activeFlush != null) {
return;
}
if (this.flushTimer != null) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
const batch = this.pendingShares.splice(0, this.batchSize);
if (batch.length === 0) {
return;
}
this.activeFlush = this.flushBatch(batch).finally(() => {
this.activeFlush = null;
if (this.pendingShares.length >= this.batchSize) {
this.startFlush();
return;
}
if (this.pendingShares.length > 0) {
this.scheduleFlush();
}
});
}
private async flushBatch(batch: PendingShare[]): Promise<void> {
try {
await this.acceptedShareRepository.insert(batch.map(item => item.entity));
batch.forEach(item => item.resolve(item.entity));
} catch (error) {
batch.forEach(item => item.reject(error));
}
}
private buildWhereClause(filter: AccountingFilter): { whereSql: string; params: string[] } {
const where: string[] = [];
const params: string[] = [];
if (filter.address != null) {
params.push(filter.address);
where.push(`"address" = $${params.length}`);
}
if (filter.clientName != null) {
params.push(filter.clientName);
where.push(`"clientName" = $${params.length}`);
}
if (filter.clientId != null) {
params.push(filter.clientId);
where.push(`"clientId" = $${params.length}`);
}
return {
whereSql: where.length > 0 ? `WHERE ${where.join(' AND ')}` : '',
params,
};
}
private toNumber(value: unknown): number {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
}
private getSummaryCacheKey(filter: AccountingFilter): string {
return JSON.stringify({
address: filter.address ?? null,
clientName: filter.clientName ?? null,
clientId: filter.clientId ?? null,
});
}
private trimSummaryCache(): void {
while (this.summaryCache.size > this.summaryCacheMax) {
const firstKey = this.summaryCache.keys().next().value;
if (firstKey == null) {
return;
}
this.summaryCache.delete(firstKey);
}
}
private readPositiveInt(name: string, defaultValue: number): number {
const value = Number(process.env[name]);
return Number.isInteger(value) && value > 0 ? value : defaultValue;
}
private readNonNegativeInt(name: string, defaultValue: number): number {
const value = Number(process.env[name]);
return Number.isInteger(value) && value >= 0 ? value : defaultValue;
}
}
interface PendingShare {
entity: AcceptedShareEntity;
resolve: (entity: AcceptedShareEntity) => void;
reject: (error: unknown) => void;
}
interface SummaryCacheEntry {
expiresAt: number;
value: Promise<ShareAccountingSummary>;
}
@@ -1,12 +1,11 @@
import { Column, Entity, Index } from 'typeorm';
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn';
import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Entity()
export class TelegramSubscriptionsEntity extends TrackedEntity {
@PrimaryGeneratedBigIntColumn()
@PrimaryGeneratedColumn()
id: number;
@Index()
@@ -17,4 +16,4 @@ export class TelegramSubscriptionsEntity extends TrackedEntity {
telegramChatId: number;
}
}
@@ -1,5 +0,0 @@
import { PrimaryGeneratedColumn } from 'typeorm';
export const PrimaryGeneratedBigIntColumn = () => PrimaryGeneratedColumn({
type: process.env.NODE_ENV === 'test' ? 'integer' : 'bigint'
});
+6 -4
View File
@@ -1,12 +1,14 @@
import { CreateDateColumn, DeleteDateColumn, UpdateDateColumn } from 'typeorm';
import { DateTimeTransformer } from './DateTimeTransformer';
export abstract class TrackedEntity {
@DeleteDateColumn({ nullable: true })
@DeleteDateColumn({ nullable: true, type: 'datetime', transformer: new DateTimeTransformer() })
public deletedAt?: Date;
@CreateDateColumn()
@CreateDateColumn({ type: 'datetime', transformer: new DateTimeTransformer() })
public createdAt?: Date
@UpdateDateColumn()
@UpdateDateColumn({ type: 'datetime', transformer: new DateTimeTransformer() })
public updatedAt?: Date
}
}
+19 -207
View File
@@ -3,23 +3,16 @@ import { Controller, Get, Inject } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { firstValueFrom } from 'rxjs';
import { UserAgentReportService } from './ORM/_views/user-agent-report/user-agent-report.service';
import { AddressSettingsService } from './ORM/address-settings/address-settings.service';
import { BlocksService } from './ORM/blocks/blocks.service';
import { ClientStatisticsService } from './ORM/client-statistics/client-statistics.service';
import { ClientService } from './ORM/client/client.service';
import { BitcoinRpcService } from './services/bitcoin-rpc.service';
import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
import { StratumV2Service } from './services/stratum-v2.service';
import { ShareAccountingService } from './ORM/share-accounting/share-accounting.service';
import { RedisMessagingService } from './services/redis-messaging.service';
import { logTiming, timeAsync, timingStart } from './utils/timing.utils';
@Controller()
export class AppController {
private uptime = new Date();
private siteInfoRefreshPromise: Promise<SiteInfoResponse> | null = null;
constructor(
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
@@ -28,153 +21,54 @@ export class AppController {
private readonly blocksService: BlocksService,
private readonly bitcoinRpcService: BitcoinRpcService,
private readonly addressSettingsService: AddressSettingsService,
private readonly userAgentReportService: UserAgentReportService,
private readonly stratumV2Service: StratumV2Service,
private readonly shareAccountingService: ShareAccountingService,
private readonly redisMessagingService: RedisMessagingService
) { }
@Get('info')
public async info() {
const start = timingStart();
const CACHE_KEY = 'SITE_INFO';
const STALE_CACHE_KEY = 'SITE_INFO_STALE';
const cachedResult = await this.getCached(CACHE_KEY, 5 * 60 * 1000);
const cachedResult = await this.cacheManager.get(CACHE_KEY);
if (cachedResult != null) {
logTiming('GET /api/info', start, { cache: 'fresh' });
return cachedResult;
}
const staleResult = await this.getCached<SiteInfoResponse>(STALE_CACHE_KEY, 60 * 60 * 1000);
if (staleResult != null) {
void this.refreshSiteInfo(staleResult);
logTiming('GET /api/info', start, { cache: 'stale' });
return staleResult;
}
const response = await this.refreshSiteInfo(null);
logTiming('GET /api/info', start, { cache: 'miss' });
return response;
const blockData = await this.blocksService.getFoundBlocks();
const userAgents = await this.clientService.getUserAgents();
const highScores = await this.addressSettingsService.getHighScores();
}
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', timeAsync('/api/info found blocks', () => this.blocksService.getFoundBlocks()), staleInfo?.blockData ?? []),
withInfoTimeout('high scores', timeAsync('/api/info high scores', () => this.addressSettingsService.getHighScores()), staleInfo?.highScores ?? []),
withInfoTimeout('SV2 authority', timeAsync('/api/info SV2 authority', () => this.stratumV2Service.getPoolAuthorityPublicKey()), {
publicKey: staleInfo?.sv2?.poolAuthorityPublicKey ?? '',
configured: staleInfo?.sv2?.authorityKeyConfigured ?? false
}),
withInfoTimeout<UserAgentReportView[]>('user agent report', timeAsync('/api/info 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 = {
const data = {
blockData,
userAgents,
highScores,
sv2: {
poolAuthorityPublicKey: poolAuthority.publicKey,
authorityKeyConfigured: poolAuthority.configured
},
uptime: this.uptime
};
// Cache a complete response even when one slow component falls back to stale data.
// A short retry loop here causes repeated DB work because timed-out TypeORM queries are not cancelled.
await this.setCached(CACHE_KEY, data, 5 * 60 * 1000);
await this.setCached(STALE_CACHE_KEY, data, 60 * 60 * 1000);
//1 min
await this.cacheManager.set(CACHE_KEY, data, 1 * 60 * 1000);
return data;
}
@Get('info/accounting')
public async infoAccounting() {
const start = timingStart();
const CACHE_KEY = 'SITE_ACCOUNTING';
const cachedResult = await this.getCached(CACHE_KEY, 15 * 1000);
if (cachedResult != null) {
logTiming('GET /api/info/accounting', start, { cache: 'hit' });
return cachedResult;
}
const data = await timeAsync('/api/info/accounting getPoolSummary', () => this.shareAccountingService.getPoolSummary());
//15 sec
await this.setCached(CACHE_KEY, data, 15 * 1000);
logTiming('GET /api/info/accounting', start, { cache: 'miss' });
return data;
}
@Get('pool')
public async pool() {
const start = timingStart();
const CACHE_KEY = 'POOL_INFO';
const cachedResult = await this.getCached(CACHE_KEY, 15 * 1000);
const cachedResult = await this.cacheManager.get(CACHE_KEY);
if (cachedResult != null) {
logTiming('GET /api/pool', start, { cache: 'hit' });
return cachedResult;
}
const userAgents = await timeAsync('/api/pool user agent report', () => this.userAgentReportService.getReport());
const userAgents = await this.clientService.getUserAgents();
const totalHashRate = userAgents.reduce((acc, userAgent) => acc + parseFloat(userAgent.totalHashRate), 0);
const totalMiners = userAgents.reduce((acc, userAgent) => acc + parseFloat(userAgent.count), 0);
const blockHeight = this.bitcoinRpcService.miningInfo.blocks;
const blocksFound = await timeAsync('/api/pool found blocks', () => this.blocksService.getFoundBlocks());
const totalMiners = userAgents.reduce((acc, userAgent) => acc + parseInt(userAgent.count), 0);
const blockHeight = (await firstValueFrom(this.bitcoinRpcService.newBlock$)).blocks;
const blocksFound = await this.blocksService.getFoundBlocks();
const data = {
totalHashRate,
@@ -184,119 +78,37 @@ export class AppController {
fee: 0
}
// Keep online miner counts responsive after reconnect cleanup.
await this.setCached(CACHE_KEY, data, 15 * 1000);
//5 min
await this.cacheManager.set(CACHE_KEY, data, 5 * 60 * 1000);
logTiming('GET /api/pool', start, { cache: 'miss', userAgentCount: userAgents.length });
return data;
}
@Get('network')
public async network() {
const start = timingStart();
logTiming('GET /api/network', start);
return this.bitcoinRpcService.miningInfo ?? {};
const miningInfo = await firstValueFrom(this.bitcoinRpcService.newBlock$);
return miningInfo;
}
@Get('info/chart')
public async infoChart() {
const start = timingStart();
const CACHE_KEY = 'SITE_HASHRATE_GRAPH';
const cachedResult = await this.getCached(CACHE_KEY, 10 * 60 * 1000);
const cachedResult = await this.cacheManager.get(CACHE_KEY);
if (cachedResult != null) {
logTiming('GET /api/info/chart', start, { cache: 'hit' });
return cachedResult;
}
const chartData = await timeAsync('/api/info/chart query', () => this.clientStatisticsService.getChartDataForSite());
const chartData = await this.clientStatisticsService.getChartDataForSite();
//10 min
await this.setCached(CACHE_KEY, chartData, 10 * 60 * 1000);
await this.cacheManager.set(CACHE_KEY, chartData, 10 * 60 * 1000);
logTiming('GET /api/info/chart', start, { cache: 'miss', points: chartData.length });
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;
}
+18 -28
View File
@@ -1,7 +1,7 @@
import { HttpModule } from '@nestjs/axios';
import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
@@ -9,27 +9,24 @@ import { AppController } from './app.controller';
import { AddressController } from './controllers/address/address.controller';
import { ClientController } from './controllers/client/client.controller';
import { BitcoinAddressValidator } from './models/validators/bitcoin-address.validator';
import { UserAgentReportModule } from './ORM/_views/user-agent-report/user-agent-report.module';
import { AddressSettingsModule } from './ORM/address-settings/address-settings.module';
import { BlocksModule } from './ORM/blocks/blocks.module';
import { ClientStatisticsModule } from './ORM/client-statistics/client-statistics.module';
import { ClientModule } from './ORM/client/client.module';
import { RpcBlocksModule } from './ORM/rpc-block/rpc-block.module';
import { ShareAccountingModule } from './ORM/share-accounting/share-accounting.module';
import { TelegramSubscriptionsModule } from './ORM/telegram-subscriptions/telegram-subscriptions.module';
import { createDatabaseOptions } from './database.config';
import { AppService } from './services/app.service';
import { BitcoinRpcService } from './services/bitcoin-rpc.service';
import { BraiinsService } from './services/braiins.service';
import { BTCPayService } from './services/btc-pay.service';
import { DiscordService } from './services/discord.service';
import { NotificationService } from './services/notification.service';
import { RedisMessagingModule } from './services/redis-messaging.module';
import { StratumV1JobsService } from './services/stratum-v1-jobs.service';
import { StratumV1Service } from './services/stratum-v1.service';
import { StratumV2Service } from './services/stratum-v2.service';
import { TelegramService } from './services/telegram.service';
import { ExternalSharesService } from './services/external-shares.service';
import { ExternalShareController } from './controllers/external-share/external-share.controller';
import { ExternalSharesModule } from './ORM/external-shares/external-shares.module';
const ORMModules = [
ClientStatisticsModule,
@@ -38,39 +35,32 @@ const ORMModules = [
TelegramSubscriptionsModule,
BlocksModule,
RpcBlocksModule,
UserAgentReportModule,
ShareAccountingModule
ExternalSharesModule
]
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => {
return createDatabaseOptions({
...process.env,
DB_HOST: configService.get('DB_HOST'),
DB_PORT: configService.get('DB_PORT'),
DB_USERNAME: configService.get('DB_USERNAME'),
DB_PASSWORD: configService.get('DB_PASSWORD'),
DB_DATABASE: configService.get('DB_DATABASE'),
DB_LOGGING: configService.get('DB_LOGGING'),
DB_POOL_SIZE: configService.get('DB_POOL_SIZE'),
});
},
imports: [ConfigModule],
inject: [ConfigService]
TypeOrmModule.forRoot({
type: 'sqlite',
database: './DB/public-pool.sqlite',
synchronize: true,
autoLoadEntities: true,
logging: false,
enableWAL: true,
busyTimeout: 30 * 1000,
}),
CacheModule.register(),
ScheduleModule.forRoot(),
HttpModule,
RedisMessagingModule,
...ORMModules
],
controllers: [
AppController,
ClientController,
AddressController
AddressController,
ExternalShareController
],
providers: [
DiscordService,
@@ -81,9 +71,9 @@ const ORMModules = [
NotificationService,
BitcoinAddressValidator,
StratumV1JobsService,
StratumV2Service,
BTCPayService,
BraiinsService
BraiinsService,
ExternalSharesService,
],
})
export class AppModule {
@@ -1,59 +1,32 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AddressSettingsService } from '../../ORM/address-settings/address-settings.service';
import { ClientStatisticsService } from '../../ORM/client-statistics/client-statistics.service';
import { ClientService } from '../../ORM/client/client.service';
import { ShareAccountingService } from '../../ORM/share-accounting/share-accounting.service';
import { RedisMessagingService } from '../../services/redis-messaging.service';
import { AddressSettingsModule } from '../../ORM/address-settings/address-settings.module';
import { ClientStatisticsModule } from '../../ORM/client-statistics/client-statistics.module';
import { ClientModule } from '../../ORM/client/client.module';
import { ClientController } from './client.controller';
describe('ClientController', () => {
let controller: ClientController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ClientController],
providers: [
{
provide: ClientService,
useValue: {
getByAddress: jest.fn(),
getByName: jest.fn(),
getBySessionId: jest.fn(),
},
},
{
provide: ClientStatisticsService,
useValue: {
getChartDataForAddress: jest.fn(),
getChartDataForGroup: jest.fn(),
getChartDataForSession: jest.fn(),
},
},
{
provide: AddressSettingsService,
useValue: {
getSettings: jest.fn(),
},
},
{
provide: ShareAccountingService,
useValue: {
getAddressSummary: jest.fn(),
getWorkerGroupSummary: jest.fn(),
getSessionSummary: jest.fn(),
getSessionSummaries: jest.fn().mockResolvedValue(new Map()),
},
},
{
provide: RedisMessagingService,
useValue: {
getClientPresenceByAddress: jest.fn().mockResolvedValue([]),
},
},
],
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: ':memory:',
synchronize: true,
autoLoadEntities: true,
cache: true,
logging: false
}),
AddressSettingsModule,
ClientModule,
ClientStatisticsModule
],
controllers: [ClientController],
}).compile();
}).compile();
controller = module.get<ClientController>(ClientController);
});
+15 -62
View File
@@ -3,9 +3,6 @@ import { Controller, Get, NotFoundException, Param } from '@nestjs/common';
import { AddressSettingsService } from '../../ORM/address-settings/address-settings.service';
import { ClientStatisticsService } from '../../ORM/client-statistics/client-statistics.service';
import { ClientService } from '../../ORM/client/client.service';
import { ShareAccountingService } from '../../ORM/share-accounting/share-accounting.service';
import { RedisMessagingService } from '../../services/redis-messaging.service';
import { logTiming, timeAsync, timingStart } from '../../utils/timing.utils';
@Controller('client')
@@ -14,68 +11,45 @@ export class ClientController {
constructor(
private readonly clientService: ClientService,
private readonly clientStatisticsService: ClientStatisticsService,
private readonly addressSettingsService: AddressSettingsService,
private readonly shareAccountingService: ShareAccountingService,
private readonly redisMessagingService: RedisMessagingService
private readonly addressSettingsService: AddressSettingsService
) { }
@Get(':address')
async getClientInfo(@Param('address') address: string) {
const start = timingStart();
const workers = await timeAsync('/api/client/:address presence', () => this.redisMessagingService.getClientPresenceByAddress(address), { address });
const sessionSummaries = await timeAsync('/api/client/:address session summaries', () => this.shareAccountingService.getSessionSummaries(workers.map(worker => worker.clientId)), { address, workers: workers.length });
const workers = await this.clientService.getByAddress(address);
const addressSettings = process.env.API_ONLY === 'true'
? null
: await timeAsync('/api/client/:address address settings', () => this.addressSettingsService.getSettings(address, false), { address });
const accounting = await timeAsync('/api/client/:address accounting', () => this.shareAccountingService.getAddressSummary(address), { address });
const bestDifficulty = addressSettings?.bestDifficulty ?? workers.reduce((best, worker) => {
return Math.max(best, Number(worker.bestDifficulty ?? 0));
}, 0);
const addressSettings = await this.addressSettingsService.getSettings(address, false);
const response = {
bestDifficulty,
return {
bestDifficulty: addressSettings?.bestDifficulty,
workersCount: workers.length,
accounting,
workers: await Promise.all(
workers.map(async (worker) => {
const sessionSummary = sessionSummaries.get(worker.clientId);
const bestDifficulty = Math.max(
Number(worker.bestDifficulty ?? 0),
Number(sessionSummary?.bestSubmissionDifficulty ?? 0),
);
return {
sessionId: worker.sessionId,
name: worker.clientName,
bestDifficulty: bestDifficulty.toFixed(2),
hashRate: sessionSummary?.hashRateLast10Minutes ?? worker.hashRate,
bestDifficulty: worker.bestDifficulty.toFixed(2),
hashRate: worker.hashRate,
startTime: worker.startTime,
lastSeen: sessionSummary?.latestShareAt ?? worker.lastSeen
lastSeen: worker.updatedAt
};
})
)
}
logTiming('GET /api/client/:address', start, { address, workers: workers.length });
return response;
}
@Get(':address/chart')
async getClientInfoChart(@Param('address') address: string) {
const start = timingStart();
const chartData = await timeAsync('/api/client/:address/chart query', () => this.clientStatisticsService.getChartDataForAddress(address), { address });
logTiming('GET /api/client/:address/chart', start, { address, points: chartData.length });
const chartData = await this.clientStatisticsService.getChartDataForAddress(address);
return chartData;
}
@Get(':address/:workerName')
async getWorkerGroupInfo(@Param('address') address: string, @Param('workerName') workerName: string) {
const start = timingStart();
const addressWorkers = await timeAsync('/api/client/:address/:workerName address presence', () => this.redisMessagingService.getClientPresenceByAddress(address), { address, workerName });
const workers = addressWorkers
.filter(worker => worker.clientName === workerName);
const workers = await this.clientService.getByName(address, workerName);
const bestDifficulty = workers.reduce((pre, cur, idx, arr) => {
if (cur.bestDifficulty > pre) {
@@ -84,52 +58,31 @@ export class ClientController {
return pre;
}, 0);
const chartData = await timeAsync('/api/client/:address/:workerName chart', () => this.clientStatisticsService.getChartDataForGroup(address, workerName), { address, workerName });
const accounting = await timeAsync('/api/client/:address/:workerName accounting', () => this.shareAccountingService.getWorkerGroupSummary(address, workerName), { address, workerName });
const response = {
const chartData = await this.clientStatisticsService.getChartDataForGroup(address, workerName);
return {
name: workerName,
bestDifficulty: Math.floor(bestDifficulty),
accounting,
chartData: chartData,
}
logTiming('GET /api/client/:address/:workerName', start, { address, workerName, addressWorkers: addressWorkers.length, matchedWorkers: workers.length, points: chartData.length });
return response;
}
@Get(':address/:workerName/:sessionId')
async getWorkerInfo(@Param('address') address: string, @Param('workerName') workerName: string, @Param('sessionId') sessionId: string) {
const start = timingStart();
const addressWorkers = await timeAsync('/api/client/:address/:workerName/:sessionId address presence', () => this.redisMessagingService.getClientPresenceByAddress(address), { address, workerName, sessionId });
const presenceWorker = addressWorkers
.find(worker => worker.clientName === workerName && worker.sessionId === sessionId);
const worker = presenceWorker == null
? await timeAsync('/api/client/:address/:workerName/:sessionId DB fallback', () => this.clientService.getBySessionId(address, workerName, sessionId), { address, workerName, sessionId })
: {
id: presenceWorker.clientId,
sessionId: presenceWorker.sessionId,
clientName: presenceWorker.clientName,
bestDifficulty: presenceWorker.bestDifficulty,
startTime: presenceWorker.startTime,
};
const worker = await this.clientService.getBySessionId(address, workerName, sessionId);
if (worker == null) {
logTiming('GET /api/client/:address/:workerName/:sessionId', start, { address, workerName, sessionId, found: false, addressWorkers: addressWorkers.length });
return new NotFoundException();
}
const chartData = await timeAsync('/api/client/:address/:workerName/:sessionId chart', () => this.clientStatisticsService.getChartDataForSession(worker.id), { address, workerName, sessionId, clientId: worker.id });
const accounting = await timeAsync('/api/client/:address/:workerName/:sessionId accounting', () => this.shareAccountingService.getSessionSummary(worker.id), { address, workerName, sessionId, clientId: worker.id });
const chartData = await this.clientStatisticsService.getChartDataForSession(worker.address, worker.clientName, worker.sessionId);
const response = {
return {
sessionId: worker.sessionId,
name: worker.clientName,
bestDifficulty: Math.floor(worker.bestDifficulty),
accounting,
chartData: chartData,
startTime: worker.startTime
}
logTiming('GET /api/client/:address/:workerName/:sessionId', start, { address, workerName, sessionId, found: true, addressWorkers: addressWorkers.length, points: chartData.length });
return response;
}
}
@@ -0,0 +1,72 @@
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
@@ -1,7 +0,0 @@
import 'dotenv/config';
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { createDatabaseOptions } from './database.config';
export const AppDataSource = new DataSource(createDatabaseOptions(process.env));
-57
View File
@@ -1,57 +0,0 @@
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 { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
import { AcceptedShareEntity } from './ORM/accepted-share/accepted-share.entity';
import { AddressSettingsEntity } from './ORM/address-settings/address-settings.entity';
import { BlocksEntity } from './ORM/blocks/blocks.entity';
import { ClientEntity } from './ORM/client/client.entity';
import { RpcBlockEntity } from './ORM/rpc-block/rpc-block.entity';
import { TelegramSubscriptionsEntity } from './ORM/telegram-subscriptions/telegram-subscriptions.entity';
export const databaseEntities = [
ClientEntity,
AddressSettingsEntity,
BlocksEntity,
RpcBlockEntity,
TelegramSubscriptionsEntity,
UserAgentReportView,
AcceptedShareEntity,
];
export const databaseMigrations = [
InitialTimescaleSchema1780859300000,
ActiveOnlyUserAgentReport1780860200000,
TimescaleOperationalHardening1780861200000,
AcceptedShareIndex1780862400000,
AcceptedShareRollupIndexes1780865400000,
PoolAccountingDashboardIndexes1780867200000,
CurrentRoundBestShareIndex1780897600000,
];
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',
};
}
+9 -60
View File
@@ -3,37 +3,32 @@ import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import * as bitcoinjs from 'bitcoinjs-lib';
import { useContainer } from 'class-validator';
import { readFileSync, watch } from 'fs';
import * as path from 'path';
import { readFileSync } from 'fs';
import * as ecc from 'tiny-secp256k1';
import { AppModule } from './app.module';
async function bootstrap() {
if (process.env.API_PORT == null) {
console.error('It appears your environment is not configured, create and populate an .env file.');
return;
}
const apiEnabled = process.env.API_ENABLED?.toLowerCase();
const serveApi = apiEnabled == null ? process.env.MASTER !== 'false' : apiEnabled === 'true';
const secure = serveApi && process.env.API_SECURE?.toLowerCase() === 'true';
const currentDirectory = process.cwd();
const keyPath = path.join(currentDirectory, 'secrets', 'key.pem');
const certPath = path.join(currentDirectory, 'secrets', 'cert.pem');
let options: any = {};
let options = {};
const secure = process.env.API_SECURE?.toLowerCase() == 'true';
if (secure) {
const currentDirectory = process.cwd();
options = {
https: {
key: readFileSync(keyPath),
cert: readFileSync(certPath),
key: readFileSync(`${currentDirectory}/secrets/key.pem`),
cert: readFileSync(`${currentDirectory}/secrets/cert.pem`),
}
};
}
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(options));
app.setGlobalPrefix('api');
app.setGlobalPrefix('api')
app.useGlobalPipes(
new ValidationPipe({
transform: true,
@@ -56,59 +51,13 @@ async function bootstrap() {
app.enableCors();
useContainer(app.select(AppModule), { fallbackOnErrors: true });
// Taproot
//Taproot
bitcoinjs.initEccLib(ecc);
if (!serveApi) {
await app.init();
console.log('Worker process skipping API listener');
return;
}
await app.listen(process.env.API_PORT, '0.0.0.0', (err, address) => {
if (err) {
console.error(err);
process.exit(1);
}
console.log(`API listening on ${address}`);
});
// --- Live-reload TLS certs/keys when they change on disk ---
if (secure) {
// Fastify's underlying Node https server
const server: any = app.getHttpServer();
// Guard: only HTTPS servers expose setSecureContext
if (typeof server?.setSecureContext === 'function') {
let reloadTimer: NodeJS.Timeout | null = null;
const scheduleReload = () => {
if (reloadTimer) clearTimeout(reloadTimer);
// Debounce multiple fs events during a single write/replace
reloadTimer = setTimeout(() => {
try {
const key = readFileSync(keyPath);
const cert = readFileSync(certPath);
server.setSecureContext({ key, cert });
console.log(`[TLS] Reloaded certificate @ ${new Date().toISOString()}`);
} catch (e) {
console.error('[TLS] Failed to reload certificate:', e);
}
}, 500);
};
// Watch both files; handle 'change' and 'rename' (rename often fired on atomic replace)
try {
watch(keyPath, { persistent: true }, scheduleReload);
watch(certPath, { persistent: true }, scheduleReload);
console.log('[TLS] Watching cert/key for changes');
} catch (e) {
console.error('[TLS] Failed to watch cert/key files:', e);
}
} else {
console.warn('[TLS] Dynamic cert reload not available (non-HTTPS server?)');
}
}
}
bootstrap();
+27
View File
@@ -0,0 +1,27 @@
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;
}
+269 -102
View File
@@ -1,128 +1,295 @@
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import * as bitcoinjs from 'bitcoinjs-lib';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { IMiningInfo } from './bitcoin-rpc/IMiningInfo';
import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service';
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', () => {
let jobTemplate;
let job: MiningJob;
let moduleRef: TestingModule;
let configService: ConfigService;
let block: bitcoinjs.Block;
let jobTemplate: IJobTemplate;
let payoutInformation: any[];
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
const blockTemplate$ = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
const bitcoinRpcService = {
newBlockTemplate$: blockTemplate$.asObservable(),
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height }
};
jest.spyOn(console, 'log').mockImplementation(() => undefined);
const jobsService = new StratumV1JobsService(bitcoinRpcService as any);
jobTemplate = await firstValueFrom(jobsService.newMiningJob$);
job = new MiningJob(
bitcoinjs.networks.testnet,
'1',
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
jobTemplate
);
beforeAll(async () => {
moduleRef = await Test.createTestingModule({
providers: [
{
provide: ConfigService,
useValue: {
get: jest.fn(() => null)
}
}
],
}).compile();
configService = moduleRef.get<ConfigService>(ConfigService);
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
describe('constructor', () => {
beforeEach(() => {
console.warn = jest.fn((message: string) => console.log('WARN:', message));
configService.get = jest.fn(() => null);
payoutInformation = [
{
address: 'tb1qr2ylpdgp9ejpt6v2uxlqrn9penp82rzz2grnns',
percent: 100,
}
];
block = new bitcoinjs.Block();
block.witnessCommit = Buffer.from('000');
block.prevHash = Buffer.from('000');
block.merkleRoot = Buffer.from('000');
block.transactions = [];
const maxTransactions = 7545; // this is only for block weight calculation, other metrics like operations are not considered because they are not affected by the size of the pool identifier
for (let i = 0; i < maxTransactions; i++) {
block.transactions.push(bitcoinjs.Transaction.fromHex("010000000001017405e391018c5e9dc79f324f9607c9c46d21b02f66dabaa870b4add871d6379f01000000171600148d7a0a3461e3891723e5fdf8129caa0075060cffffffffff01fcf60200000000001600148d7a0a3461e3891723e5fdf8129caa0075060cff0248304502210088025cffdaf69d310c6fed11832edd9c19b6a912c132262701ad0e6133227d9202207d73bbf777abd2aeae995d684e6bb1a048c5ac722e16de48bdd35643df7decf001210283409659355b6d1cc3c32decd5d561abaac86c37a353b52895a5e6c196d6f44800000000"));
}
jobTemplate = {
block: block,
merkle_branch: [],
blockData: {
id: '1',
creation: Date.now(),
coinbasevalue: 0,
networkDifficulty: 0,
height: 0,
clearJobs: false,
}
} as IJobTemplate;
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should create a new MiningJob if POOL_IDENTIFIER is not set and use the default', () => {
const expectedMiningIdentifier = 'Public-Pool';
expect(jobTemplate.block).toBeDefined();
const miningJob = new MiningJob(configService, bitcoinjs.networks.testnet, '1', payoutInformation, jobTemplate);
const response = JSON.parse(miningJob.response(jobTemplate));
const miningIdentifier = extractPoolIdentifierFromScript(response.params[2]);
expect(miningIdentifier).toBe(expectedMiningIdentifier);
expect(console.warn).not.toBeCalled();
});
it('should use the POOL_IDENTIFIER if it doesn\'t make the script size too big', () => {
const expectedMiningIdentifier = 'My Mining Pool';
configService.get = jest.fn((key: string) => {
switch (key) {
case 'POOL_IDENTIFIER': return expectedMiningIdentifier;
}
return null;
});
const miningJob = new MiningJob(configService, bitcoinjs.networks.testnet, '1', payoutInformation, jobTemplate);
const response = JSON.parse(miningJob.response(jobTemplate));
const miningIdentifier = extractPoolIdentifierFromScript(response.params[2]);
expect(miningIdentifier).toBe(expectedMiningIdentifier);
expect(console.warn).not.toBeCalled();
});
it('should remove pool identifier if block is too big with identifier', () => {
const expectedMiningIdentifier = '';
configService.get = jest.fn((key: string) => {
switch (key) {
case 'POOL_IDENTIFIER': return 'A'.repeat(84);
}
return null;
});
const miningJob = new MiningJob(configService, bitcoinjs.networks.testnet, '1', payoutInformation, jobTemplate);
const response = JSON.parse(miningJob.response(jobTemplate));
const miningIdentifier = extractPoolIdentifierFromScript(response.params[2]);
expect(console.warn).toBeCalledWith('Block weight exceeds the maximum allowed weight, removing the pool identifier');
expect(miningIdentifier).toBe(expectedMiningIdentifier);
});
it('should use the POOL_IDENTIFIER if it doesn\'t make the script size too big with identifier abcabc', () => {
jobTemplate.block.transactions = []; // remove transactions because we only want to test the script size
const expectedMiningIdentifier = 'A'.repeat(84); // 84 chars fits after reserving 12 bytes for extranonce space
configService.get = jest.fn((key: string) => {
switch (key) {
case 'POOL_IDENTIFIER': return expectedMiningIdentifier;
}
return null;
});
const miningJob = new MiningJob(configService, bitcoinjs.networks.testnet, '1', payoutInformation, jobTemplate);
const response = JSON.parse(miningJob.response(jobTemplate));
const miningIdentifier = extractPoolIdentifierFromScript(response.params[2]);
expect(miningIdentifier).toBe(expectedMiningIdentifier);
expect(console.warn).not.toBeCalled();
});
it('should remove pool identifier if script is too big with identifier', () => {
const expectedMiningIdentifier = '';
jobTemplate.block.transactions = []; // remove transactions because we only want to test the script size
configService.get = jest.fn((key: string) => {
switch (key) {
case 'POOL_IDENTIFIER': return 'A'.repeat(85);
}
return null;
});
const miningJob = new MiningJob(configService, bitcoinjs.networks.testnet, '1', payoutInformation, jobTemplate);
const response = JSON.parse(miningJob.response(jobTemplate));
const miningIdentifier = extractPoolIdentifierFromScript(response.params[2]);
expect(console.warn).toBeCalledWith('Pool identifier is too long, removing the pool identifier');
expect(miningIdentifier).toBe(expectedMiningIdentifier);
});
});
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}`);
describe('block updates', () => {
let job: MiningJob;
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);
});
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
configService.get = jest.fn(() => null);
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(),
]);
const miningInfo$ = new BehaviorSubject<IMiningInfo>({
blocks: MockRecording1.BLOCK_TEMPLATE.height
} as IMiningInfo);
const bitcoinRpcService = {
newBlock$: miningInfo$.asObservable(),
getBlockTemplate: jest.fn().mockResolvedValue(MockRecording1.BLOCK_TEMPLATE)
};
jest.spyOn(console, 'log').mockImplementation(() => undefined);
expect(fromBuffers).toEqual(fromNotify);
expect(bitcoinjs.Transaction.fromBuffer(fromBuffers).ins[0].script.toString('hex'))
.toContain(`${extraNonce1}${extraNonce2}`);
});
const jobsService = new StratumV1JobsService(bitcoinRpcService as any);
jobTemplate = await firstValueFrom(jobsService.newMiningJob$);
job = new MiningJob(
configService,
bitcoinjs.networks.testnet,
'1',
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
jobTemplate
);
});
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);
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
parseInt('00002000', 16),
parseInt('ed460d91', 16),
extraNonce1,
extraNonce2,
timestamp
);
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(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);
});
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 leave block version unchanged without a version mask', () => {
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
0,
parseInt('ed460d91', 16),
'57a6f098',
'c708000000000000',
parseInt(MockRecording1.TIME, 16)
);
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);
expect(updatedBlock.version).toBe(jobTemplate.block.version);
});
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
parseInt('00002000', 16),
parseInt('ed460d91', 16),
extraNonce1,
extraNonce2,
timestamp
);
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);
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);
});
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
versionMask,
nonce,
extraNonce1,
extraNonce2,
timestamp
);
const fastHeader = job.buildHeaderBuffer(
jobTemplate,
versionMask,
nonce,
extraNonce1,
extraNonce2,
timestamp
);
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(fastHeader.equals(updatedBlock.toBuffer(true))).toBe(true);
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);
});
});
});
+23 -13
View File
@@ -4,9 +4,11 @@ import * as bitcoinjs from 'bitcoinjs-lib';
import { IJobTemplate } from '../services/stratum-v1-jobs.service';
import { eResponseMethod } from './enums/eResponseMethod';
import { IMiningNotify } from './stratum-messages/IMiningNotify';
import { ConfigService } from '@nestjs/config';
import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants';
const MAX_BLOCK_WEIGHT = 4000000;
const MAX_SCRIPT_SIZE = 100; // https://github.com/bitcoin/bitcoin/blob/ffdc3d6060f6e65e69cf115a13b83e6eb4a0a0a8/src/consensus/tx_check.cpp#L49
interface AddressObject {
address: string;
percent: number;
@@ -25,6 +27,7 @@ export class MiningJob {
public creation: number;
constructor(
configService: ConfigService,
private network: bitcoinjs.networks.Network,
public jobId: string,
payoutInformation: AddressObject[],
@@ -45,7 +48,9 @@ export class MiningJob {
// 32-byte - Commitment hash: Double-SHA256(witness root hash|witness reserved value)
// 39th byte onwards: Optional data with no consensus meaning
const extra = Buffer.from('Public-Pool');
// Initial pool identifier
let poolIdentifier = configService.get('POOL_IDENTIFIER') || 'Public-Pool';
let extra = Buffer.from(poolIdentifier);
// Encode the block height
// https://github.com/bitcoin/bips/blob/master/bip-0034.mediawiki
@@ -54,14 +59,27 @@ export class MiningJob {
// Get the length of the block height encoding
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)
// build the script
this.coinbaseTransaction.ins[0].script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, extra, padding])
// Build the script
let 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);
// 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
//@ts-ignore
const serializedCoinbaseTx = this.coinbaseTransaction.__toBuffer().toString('hex');
@@ -82,14 +100,6 @@ export class MiningJob {
return bitcoinjs.Transaction.fromBuffer(this.coinbaseTransaction.toBuffer());
}
public getCoinbasePrefixBuffer(): Buffer {
return Buffer.from(this.coinbasePart1Buffer);
}
public getCoinbaseSuffixBuffer(): Buffer {
return Buffer.from(this.coinbasePart2Buffer);
}
public buildHeaderBuffer(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): Buffer {
const coinbaseBuffer = Buffer.concat([
this.coinbasePart1Buffer,
+108 -73
View File
@@ -1,15 +1,26 @@
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Socket } from 'net';
import { BehaviorSubject } from 'rxjs';
import { DataSource } from 'typeorm';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { AddressSettingsModule } from '../ORM/address-settings/address-settings.module';
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
import { BlocksEntity } from '../ORM/blocks/blocks.entity';
import { BlocksService } from '../ORM/blocks/blocks.service';
import { ClientStatisticsEntity } from '../ORM/client-statistics/client-statistics.entity';
import { ClientStatisticsModule } from '../ORM/client-statistics/client-statistics.module';
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
import { ClientEntity } from '../ORM/client/client.entity';
import { ClientModule } from '../ORM/client/client.module';
import { ClientService } from '../ORM/client/client.service';
import { BitcoinRpcService as MockBitcoinRpcService } from '../services/bitcoin-rpc.service';
import { NotificationService } from '../services/notification.service';
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { IBlockTemplate } from './bitcoin-rpc/IBlockTemplate';
import { IMiningInfo } from './bitcoin-rpc/IMiningInfo';
import { MiningJob } from './MiningJob';
import { StratumV1Client } from './StratumV1Client';
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
@@ -35,12 +46,11 @@ describe('StratumV1Client', () => {
let bitcoinRpcService: MockBitcoinRpcService;
let clientService: ClientService;
let clientStatisticsService: ClientStatisticsService;
let notificationService: NotificationService;
let blocksService: BlocksService;
let externalSharesService;
let configService: ConfigService;
let addressSettings: AddressSettingsService;
let shareAccountingService: { recordAcceptedShare: jest.Mock };
let redisMessagingService: { setClientPresence: jest.Mock; removeClientPresence: jest.Mock };
let client: StratumV1Client;
@@ -50,7 +60,49 @@ describe('StratumV1Client', () => {
let consoleErrorSpy: jest.SpyInstance;
let consoleWarnSpy: jest.SpyInstance;
let newBlockEmitter: BehaviorSubject<IBlockTemplate> = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
let newBlockEmitter: BehaviorSubject<IMiningInfo> = new BehaviorSubject({
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 () => {
@@ -60,46 +112,34 @@ describe('StratumV1Client', () => {
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
const clients = new Map<string, any>();
let nextClientId = 1;
clientService = {
insert: jest.fn(async (partialClient) => {
const clientEntity = {
id: `00000000-0000-4000-8000-${String(nextClientId++).padStart(12, '0')}`,
hashRate: 0,
updatedAt: new Date(),
deletedAt: null,
...partialClient,
};
clients.set(clientEntity.id, clientEntity);
return clientEntity;
}),
delete: jest.fn(async (id: string) => {
clients.delete(id);
}),
connectedClientCount: jest.fn(async () => clients.size),
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue({ affected: 1 }),
} as any;
clientService = moduleRef.get<ClientService>(ClientService);
clientService.insertQueue = [];
configService = {
get: jest.fn((key: string) => {
switch (key) {
case 'DEV_FEE_ADDRESS':
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
case 'NETWORK':
return 'testnet';
}
return null;
})
} as any;
const dataSource = moduleRef.get<DataSource>(DataSource);
await dataSource.getRepository(ClientStatisticsEntity).clear();
await dataSource.getRepository(ClientEntity).clear();
await dataSource.getRepository(BlocksEntity).clear();
clientStatisticsService = moduleRef.get<ClientStatisticsService>(ClientStatisticsService);
configService = moduleRef.get<ConfigService>(ConfigService);
(configService.get as jest.Mock).mockImplementation((key: string) => {
switch (key) {
case 'DEV_FEE_ADDRESS':
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
case 'NETWORK':
return 'testnet';
}
return null;
});
(StratumV1Client as any).blockedUserAgentLogState.clear();
(StratumV1Client as any).validationErrorLogState.clear();
bitcoinRpcService = {
newBlockTemplate$: newBlockEmitter.asObservable(),
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height },
SUBMIT_BLOCK: jest.fn().mockResolvedValue(null)
} as any;
bitcoinRpcService = new MockBitcoinRpcService(configService,null);
jest.spyOn(bitcoinRpcService, 'getBlockTemplate').mockReturnValue(Promise.resolve(MockRecording1.BLOCK_TEMPLATE));
bitcoinRpcService.newBlock$ = newBlockEmitter.asObservable();
stratumV1JobsService = new StratumV1JobsService(bitcoinRpcService);
@@ -117,23 +157,15 @@ describe('StratumV1Client', () => {
socket.end = jest.fn();
jest.spyOn(socket, 'destroy').mockImplementation(() => socket);
addressSettings = {
getSettings: jest.fn().mockResolvedValue(null),
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue({ affected: 1 }),
resetBestDifficultyAndShares: jest.fn().mockResolvedValue(undefined),
} as any;
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
notificationService = {
notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined)
} as any;
blocksService = {
save: jest.fn().mockResolvedValue(undefined)
} as any;
shareAccountingService = {
recordAcceptedShare: jest.fn().mockResolvedValue(undefined),
};
redisMessagingService = {
setClientPresence: jest.fn().mockResolvedValue(undefined),
removeClientPresence: jest.fn().mockResolvedValue(undefined),
externalSharesService = {
submitShare: jest.fn().mockResolvedValue(undefined)
};
@@ -142,12 +174,12 @@ describe('StratumV1Client', () => {
stratumV1JobsService,
bitcoinRpcService,
clientService,
clientStatisticsService,
notificationService,
blocksService,
configService,
addressSettings,
shareAccountingService as any,
redisMessagingService as any
externalSharesService
);
client.extraNonceAndSessionId = MockRecording1.EXTRA_NONCE;
@@ -238,10 +270,12 @@ describe('StratumV1Client', () => {
stratumV1JobsService,
bitcoinRpcService,
clientService,
clientStatisticsService,
notificationService,
blocksService,
configService,
addressSettings
moduleRef.get<AddressSettingsService>(AddressSettingsService),
externalSharesService
);
socketEmitter(Buffer.from(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}\n`));
@@ -302,6 +336,8 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
await clientService.insertClients();
await new Promise((r) => setTimeout(r, 100));
const clientCount = await clientService.connectedClientCount();
expect(clientCount).toBe(1);
@@ -331,10 +367,16 @@ describe('StratumV1Client', () => {
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((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[0]).toBe('1');
expect(notify.params[5]).toBe('20000000');
expect(notify.params[6]).toBe('192495f8');
expect(notify.params[7]).toBe(MockRecording1.TIME);
expect(notify.params[8]).toBe(true);
emitMessage(MockRecording1.MINING_SUBMIT);
@@ -343,21 +385,6 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 1000));
expect((client as any).write).lastCalledWith(`{\"id\":5,\"error\":null,\"result\":true}\n`);
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
protocol: 'sv1',
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'bitaxe3',
sessionId: MockRecording1.EXTRA_NONCE,
jobId: '1',
jobTemplateId: '1',
creditedDifficulty: 0,
isBlockCandidate: false,
}));
expect(redisMessagingService.setClientPresence).toHaveBeenCalledWith(expect.objectContaining({
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'bitaxe3',
sessionId: MockRecording1.EXTRA_NONCE,
}));
});
@@ -373,6 +400,8 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
await clientService.insertClients();
jest.useRealTimers();
await new Promise((r) => setTimeout(r, 1000));
@@ -412,6 +441,7 @@ describe('StratumV1Client', () => {
submissionDifficulty: 1024,
submissionHash: 'share'
});
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
const getSettingsSpy = jest.spyOn(addressSettings, 'getSettings');
const updateIfHigherSpy = jest.spyOn(addressSettings as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 });
const clientUpdateIfHigherSpy = jest.spyOn(clientService as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 });
@@ -422,6 +452,8 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
await clientService.insertClients();
jest.useRealTimers();
await new Promise((r) => setTimeout(r, 1000));
@@ -534,10 +566,12 @@ describe('StratumV1Client', () => {
stratumV1JobsService,
bitcoinRpcService,
clientService,
clientStatisticsService,
notificationService,
blocksService,
configService,
addressSettings
moduleRef.get<AddressSettingsService>(AddressSettingsService),
externalSharesService
);
jest.spyOn(secondClient as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(secondClient as any, 'getRandomHexString').mockReturnValue(MockRecording1.EXTRA_NONCE);
@@ -567,6 +601,7 @@ describe('StratumV1Client', () => {
submissionDifficulty: Number.MAX_SAFE_INTEGER,
submissionHash: 'block-share'
});
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined);
emitMessage(MockRecording1.MINING_SUBSCRIBE);
+65 -107
View File
@@ -9,12 +9,11 @@ import { clearInterval } from 'timers';
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
import { BlocksService } from '../ORM/blocks/blocks.service';
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
import { ClientEntity } from '../ORM/client/client.entity';
import { ClientService } from '../ORM/client/client.service';
import { ShareAccountingService } from '../ORM/share-accounting/share-accounting.service';
import { BitcoinRpcService } from '../services/bitcoin-rpc.service';
import { NotificationService } from '../services/notification.service';
import { RedisMessagingService } from '../services/redis-messaging.service';
import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { eRequestMethod } from './enums/eRequestMethod';
import { eResponseMethod } from './enums/eResponseMethod';
@@ -28,6 +27,7 @@ import { SubscriptionMessage } from './stratum-messages/SubscriptionMessage';
import { EXTRANONCE1_SIZE_BYTES } from './stratum.constants';
import { SuggestDifficulty } from './stratum-messages/SuggestDifficultyMessage';
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
import { ExternalSharesService } from '../services/external-shares.service';
const TRUE_DIFF_ONE = 2.695953529101131e67;
const BLOCKED_USER_AGENT_LOG_INTERVAL_MS = 60 * 1000;
@@ -37,7 +37,7 @@ export class StratumV1Client {
private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>();
private static validationErrorLogState = new Map<string, { nextLogAt: number, suppressed: number, sample: string }>();
public clientSubscription: SubscriptionMessage;
private clientSubscription: SubscriptionMessage;
private clientConfiguration: ConfigurationMessage;
private clientAuthorization: AuthorizationMessage;
private clientSuggestedDifficulty: SuggestDifficulty;
@@ -49,13 +49,13 @@ export class StratumV1Client {
private usedSuggestedDifficulty = false;
private sessionDifficulty: number = 100000;
private clientEntity: ClientEntity;
private entity: ClientEntity;
private creatingEntity: Promise<void>;
public extraNonceAndSessionId: string;
public sessionStart: Date;
//public noFee: boolean;
//public hashRate: number = 0;
public noFee: boolean;
public hashRate: number = 0;
private buffer: string = '';
private connectionClosed = false;
@@ -68,12 +68,12 @@ export class StratumV1Client {
private readonly stratumV1JobsService: StratumV1JobsService,
private readonly bitcoinRpcService: BitcoinRpcService,
private readonly clientService: ClientService,
private readonly clientStatisticsService: ClientStatisticsService,
private readonly notificationService: NotificationService,
private readonly blocksService: BlocksService,
private readonly configService: ConfigService,
private readonly addressSettingsService: AddressSettingsService,
private readonly shareAccountingService?: ShareAccountingService,
private readonly redisMessagingService?: RedisMessagingService
private readonly externalSharesService: ExternalSharesService
) {
this.socket.on('data', (data: Buffer) => {
@@ -101,9 +101,8 @@ export class StratumV1Client {
public async destroy() {
if (this.clientEntity?.id) {
await this.redisMessagingService?.removeClientPresence(this.clientEntity.id, this.clientEntity.address);
await this.clientService.delete(this.clientEntity.id);
if (this.extraNonceAndSessionId) {
await this.clientService.delete(this.extraNonceAndSessionId);
}
if (this.stratumSubscription != null) {
@@ -159,9 +158,9 @@ export class StratumV1Client {
if (this.sessionStart == null) {
this.sessionStart = new Date();
this.statistics = new StratumV1ClientStatistics();
this.statistics = new StratumV1ClientStatistics(this.clientStatisticsService);
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;
@@ -208,7 +207,7 @@ export class StratumV1Client {
}
} else {
console.log('Configuration validation error');
console.error('Configuration validation error');
const err = new StratumErrorMessage(
configurationMessage.id,
eStratumErrorCode.OtherUnknown,
@@ -247,12 +246,13 @@ export class StratumV1Client {
return;
}
} else {
console.error('Authorization validation error');
const err = new StratumErrorMessage(
authorizationMessage.id,
eStratumErrorCode.OtherUnknown,
'Authorization validation error',
errors).response();
//console.log(err);
console.error(err);
const success = await this.write(err);
if (!success) {
return;
@@ -305,7 +305,7 @@ export class StratumV1Client {
case eRequestMethod.SUBMIT: {
if (this.stratumInitialized == false) {
//console.log('Submit before initalized');
console.log('Submit before initalized');
await this.socket.end();
return;
}
@@ -362,7 +362,7 @@ export class StratumV1Client {
&& this.clientAuthorization != null
&& this.stratumInitialized == false) {
this.initStratum();
await this.initStratum();
}
}
@@ -409,39 +409,29 @@ export class StratumV1Client {
}, 60 * 1000)
);
// this.backgroundWork.push(
// setInterval(async () => {
// await this.statistics.saveShares(this.clientEntity);
// }, 60 * 1000)
// );
}
private async sendNewMiningJob(jobTemplate: IJobTemplate) {
let payoutInformation= [
{ address: this.clientAuthorization.address, percent: 100 }
];
// const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS');
// //50Th/s
// this.noFee = false;
// if (this.clientEntity) {
// // 250Gh/s
// if(this.hashRate < 250000000000){
// this.statistics.targetSubmitShareEveryNSeconds = 10;
// }
// this.noFee = this.hashRate != 0 && this.hashRate < 50000000000000;
// }
// if (this.noFee || devFeeAddress == null || devFeeAddress.length < 1) {
// payoutInformation = [
// { address: this.clientAuthorization.address, percent: 100 }
// ];
let payoutInformation;
const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS');
//50Th/s
this.noFee = false;
if (this.entity) {
this.hashRate = this.statistics.hashRate;
this.noFee = this.hashRate != 0 && this.hashRate < 50000000000000;
}
if (this.noFee || devFeeAddress == null || devFeeAddress.length < 1) {
payoutInformation = [
{ address: this.clientAuthorization.address, percent: 100 }
];
// } else {
// payoutInformation = [
// { address: devFeeAddress, percent: 1.5 },
// { address: this.clientAuthorization.address, percent: 98.5 }
// ];
// }
} else {
payoutInformation = [
{ address: devFeeAddress, percent: 1.5 },
{ address: this.clientAuthorization.address, percent: 98.5 }
];
}
const networkConfig = this.configService.get('NETWORK');
let network;
@@ -457,6 +447,7 @@ export class StratumV1Client {
}
const job = new MiningJob(
this.configService,
network,
this.stratumV1JobsService.getNextId(),
payoutInformation,
@@ -479,13 +470,13 @@ export class StratumV1Client {
private async ensureClientEntity() {
if (this.clientEntity != null) {
if (this.entity != null) {
return;
}
if (this.creatingEntity == null) {
this.creatingEntity = (async () => {
this.clientEntity = await this.clientService.insert({
this.entity = await this.clientService.insert({
sessionId: this.extraNonceAndSessionId,
address: this.clientAuthorization.address,
clientName: this.clientAuthorization.worker,
@@ -493,35 +484,12 @@ export class StratumV1Client {
startTime: new Date(),
bestDifficulty: 0
});
await this.updateClientPresence(new Date());
})();
}
await this.creatingEntity;
}
private async updateClientPresence(lastSeen: Date): Promise<void> {
if (this.clientEntity == null) {
return;
}
try {
await this.redisMessagingService?.setClientPresence({
clientId: this.clientEntity.id,
address: this.clientEntity.address,
clientName: this.clientEntity.clientName,
sessionId: this.clientEntity.sessionId,
userAgent: this.clientEntity.userAgent,
startTime: new Date(this.clientEntity.startTime).toISOString(),
lastSeen: lastSeen.toISOString(),
hashRate: this.statistics?.hashRate ?? 0,
bestDifficulty: Number(this.clientEntity.bestDifficulty ?? 0),
});
} catch (error) {
console.error(`Failed to update SV1 client presence: ${error.message}`);
}
}
private async handleMiningSubmission(submission: MiningSubmitMessage) {
const job = this.stratumV1JobsService.getJobById(submission.jobId);
@@ -539,7 +507,6 @@ export class StratumV1Client {
}
return false;
}
const jobTemplate = this.stratumV1JobsService.getJobTemplateById(job.jobTemplateId);
if (jobTemplate == null) {
@@ -599,7 +566,6 @@ export class StratumV1Client {
return false;
}
let blockSubmissionResult: string = null;
if (submissionDifficulty >= jobTemplate.blockData.networkDifficulty) {
console.log('!!! BLOCK FOUND !!!');
const updatedJobBlock = job.copyAndUpdateBlock(
@@ -611,7 +577,7 @@ export class StratumV1Client {
timestamp
);
const blockHex = updatedJobBlock.toHex(false);
blockSubmissionResult = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
const result = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
await this.blocksService.save({
height: jobTemplate.blockData.height,
minerAddress: this.clientAuthorization.address,
@@ -620,53 +586,45 @@ export class StratumV1Client {
blockData: blockHex
});
await this.notificationService.notifySubscribersBlockFound(this.clientAuthorization.address, jobTemplate.blockData.height, updatedJobBlock, blockSubmissionResult);
await this.notificationService.notifySubscribersBlockFound(this.clientAuthorization.address, jobTemplate.blockData.height, updatedJobBlock, result);
//success
if (blockSubmissionResult == null) {
if (result == null) {
await this.addressSettingsService.resetBestDifficultyAndShares();
}
}
await this.ensureClientEntity();
try {
await this.shareAccountingService?.recordAcceptedShare({
protocol: 'sv1',
address: this.clientAuthorization.address,
clientName: this.clientAuthorization.worker,
sessionId: this.extraNonceAndSessionId,
clientId: this.clientEntity.id,
jobId: job.jobId,
jobTemplateId: job.jobTemplateId,
blockHeight: jobTemplate.blockData.height,
creditedDifficulty: this.sessionDifficulty,
submissionDifficulty,
networkDifficulty: jobTemplate.blockData.networkDifficulty,
nonce: submission.nonce,
ntime: submission.ntime,
version: Number.isFinite(versionMask)
? (jobTemplate.block.version ^ versionMask).toString(16)
: jobTemplate.block.version.toString(16),
extraNonce2: submission.extraNonce2,
isBlockCandidate: submissionDifficulty >= jobTemplate.blockData.networkDifficulty,
blockSubmissionResult,
});
await this.statistics.addShares(this.clientEntity, this.sessionDifficulty);
await this.statistics.addShares(this.entity, this.sessionDifficulty);
const now = new Date();
this.clientEntity.updatedAt = now;
this.clientEntity.hashRate = this.statistics.hashRate;
await this.updateClientPresence(now);
// only update every minute
if (this.entity.updatedAt == null || now.getTime() - this.entity.updatedAt.getTime() > 1000 * 60) {
await this.clientService.heartbeat(this.entity.address, this.entity.clientName, this.entity.sessionId, this.hashRate, now);
this.entity.updatedAt = now;
}
} catch (e) {
console.log(e);
}
if (submissionDifficulty > this.clientEntity.bestDifficulty) {
await this.clientService.updateBestDifficultyIfHigher(this.clientEntity.id, submissionDifficulty);
this.clientEntity.bestDifficulty = submissionDifficulty;
await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.clientEntity.userAgent);
await this.updateClientPresence(new Date());
if (submissionDifficulty > this.entity.bestDifficulty) {
await this.clientService.updateBestDifficultyIfHigher(this.extraNonceAndSessionId, submissionDifficulty);
this.entity.bestDifficulty = submissionDifficulty;
await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.entity.userAgent);
}
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 {
const err = new StratumErrorMessage(
@@ -847,7 +805,7 @@ export class StratumV1Client {
return true;
} else {
//console.error(`Error: Cannot write to closed or ended socket. ${this.extraNonceAndSessionId} ${message}`);
console.error(`Error: Cannot write to closed or ended socket. ${this.extraNonceAndSessionId} ${message}`);
this.destroy();
if (!this.socket.destroyed) {
this.socket.destroy();
@@ -861,7 +819,7 @@ export class StratumV1Client {
} else if (!this.socket.destroyed) {
this.socket.destroy();
}
//console.error(`Error occurred while writing to socket: ${this.extraNonceAndSessionId}`, error);
console.error(`Error occurred while writing to socket: ${this.extraNonceAndSessionId}`, error);
return false;
}
}
+56 -11
View File
@@ -8,31 +8,76 @@ describe('StratumV1ClientStatistics', () => {
sessionId: '57a6f098'
} as any;
let clientStatisticsService: {
insert: jest.Mock,
update: jest.Mock
};
let statistics: StratumV1ClientStatistics;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-06T12:00:00Z'));
statistics = new StratumV1ClientStatistics();
clientStatisticsService = {
insert: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined)
};
statistics = new StratumV1ClientStatistics(clientStatisticsService as any);
});
afterEach(() => {
jest.useRealTimers();
});
it('should keep runtime hashrate at zero before enough time passes', async () => {
it('should insert the first share bucket', async () => {
await statistics.addShares(client, 64);
expect(statistics.hashRate).toBe(0);
expect(clientStatisticsService.insert).toHaveBeenCalledWith({
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 runtime hashrate from accepted shares', async () => {
for (let i = 0; i < 3; i++) {
jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 31000)));
await statistics.addShares(client, 64);
}
it('should update the current share bucket for additional shares', async () => {
await statistics.addShares(client, 64);
jest.setSystemTime(new Date('2026-05-06T12:01:01Z'));
await statistics.addShares(client, 32);
expect(statistics.hashRate).toBeGreaterThan(0);
expect(clientStatisticsService.update).toHaveBeenCalledWith({
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', () => {
@@ -51,7 +96,7 @@ describe('StratumV1ClientStatistics', () => {
await statistics.addShares(client, 64);
}
expect(statistics.getSuggestedDifficulty(64)).toBe(2048);
expect(statistics.getSuggestedDifficulty(64)).toBe(512);
});
it('should decrease difficulty for slow submissions', async () => {
@@ -60,6 +105,6 @@ describe('StratumV1ClientStatistics', () => {
await statistics.addShares(client, 64);
}
expect(statistics.getSuggestedDifficulty(128)).toBe(16);
expect(statistics.getSuggestedDifficulty(128)).toBe(4);
});
});
+96 -15
View File
@@ -1,45 +1,126 @@
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
import { ClientEntity } from '../ORM/client/client.entity';
const CACHE_SIZE = 30;
const MIN_DIFF = 0.001;
const TARGET_SUBMISSION_PER_SECOND = 10;
const MIN_DIFF = 0.00001;
export class StratumV1ClientStatistics {
public targetSubmitShareEveryNSeconds: number = 30;
public hashRate = 0;
private shares: number = 0;
private acceptedCount: number = 0;
private submissionCacheStart: Date;
private submissionCache: { time: Date, difficulty: number }[] = [];
private submissionCacheDifficultySum = 0;
constructor() {
private currentTimeSlot: number = null;
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();
}
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 timeSlot = new Date(Math.floor(date.getTime() / coeff) * coeff).getTime();
if (this.submissionCache.length > CACHE_SIZE) {
this.submissionCacheDifficultySum -= this.submissionCache[0].difficulty;
this.submissionCache.shift();
}
this.submissionCache.push({
time: date,
difficulty: targetDifficulty,
});
this.submissionCacheDifficultySum += targetDifficulty;
const time = new Date().getTime() - this.submissionCache[0].time.getTime();
if(time > 60000 && this.submissionCache.length > 2) {
this.hashRate = (this.submissionCacheDifficultySum * 4294967296) / (time / 1000);
if (this.currentTimeSlot == null) {
// First record, insert it
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) {
// miner hasn't submitted shares in one minute
if (this.submissionCache.length < 5) {
if ((new Date().getTime() - this.submissionCacheStart.getTime()) / 5000 > 60) {
if ((new Date().getTime() - this.submissionCacheStart.getTime()) / 1000 > 60) {
return this.nearestPowerOfTwo(clientDifficulty / 6);
} else {
return null;
@@ -54,7 +135,7 @@ export class StratumV1ClientStatistics {
const difficultyPerSecond = sum / diffSeconds;
const targetDifficulty = difficultyPerSecond * this.targetSubmitShareEveryNSeconds;
const targetDifficulty = difficultyPerSecond * TARGET_SUBMISSION_PER_SECOND;
if ((clientDifficulty * 2) < targetDifficulty || (clientDifficulty / 2) > targetDifficulty) {
return this.nearestPowerOfTwo(targetDifficulty)
@@ -86,4 +167,4 @@ export class StratumV1ClientStatistics {
return res;
}
}
}
-256
View File
@@ -1,256 +0,0 @@
import { ConfigService } from '@nestjs/config';
import { Socket } from 'net';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { ClientEntity } from '../ORM/client/client.entity';
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { BufferReader } from './sv2/sv2-binary-codec';
import { SV2_CHANNEL_MSG_FLAG, Sv2MsgType } from './sv2/sv2-constants';
import {
deserializeOpenExtendedMiningChannelSuccess,
deserializeNewExtendedMiningJob,
serializeOpenExtendedMiningChannel,
serializeSubmitSharesExtended,
} from './sv2/sv2-extended-messages';
import { deserializeSetNewPrevHash, deserializeSubmitSharesError } from './sv2/sv2-messages';
import { StratumV2Client } from './StratumV2Client';
describe('StratumV2Client extended channels', () => {
let consoleLogSpy: jest.SpyInstance;
let consoleWarnSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
consoleLogSpy.mockRestore();
consoleWarnSpy.mockRestore();
consoleErrorSpy.mockRestore();
jest.useRealTimers();
});
it('opens an extended channel and sends initial extended work', async () => {
const { client, sentFrames } = await createClient();
const maxTarget = Buffer.alloc(32, 0xff);
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
requestId: 7,
userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker',
nominalHashRate: 0,
maxTarget,
minExtranonceSize: 8,
}));
const successFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.OPEN_EXTENDED_MINING_CHANNEL_SUCCESS);
const jobFrames = sentFrames.filter(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB);
const jobFrame = jobFrames[jobFrames.length - 1];
const prevHashFrames = sentFrames.filter(frame => frame.msgType === Sv2MsgType.SET_NEW_PREV_HASH);
const prevHashFrame = prevHashFrames[prevHashFrames.length - 1];
expect(successFrame?.extensionType).toBe(0);
const success = deserializeOpenExtendedMiningChannelSuccess(new BufferReader(successFrame.payload));
expect(success.requestId).toBe(7);
expect(success.channelId).toBe(1);
expect(success.extranonceSize).toBe(8);
expect(success.extranoncePrefix).toEqual(Buffer.from('00000001', 'hex'));
expect(jobFrame?.extensionType).toBe(SV2_CHANNEL_MSG_FLAG);
const job = deserializeNewExtendedMiningJob(new BufferReader(jobFrame.payload));
expect(job.channelId).toBe(1);
expect(job.minNtime).toBeNull();
expect(job.coinbasePrefix.length).toBeGreaterThan(0);
expect(job.coinbaseSuffix.length).toBeGreaterThan(0);
expect(job.merklePath.length).toBeGreaterThan(0);
expect(prevHashFrame?.extensionType).toBe(SV2_CHANNEL_MSG_FLAG);
const prevHash = deserializeSetNewPrevHash(new BufferReader(prevHashFrame.payload));
expect(prevHash.channelId).toBe(1);
expect(prevHash.jobId).toBe(job.jobId);
});
it('rejects extended shares with the wrong negotiated extranonce size', async () => {
const { client, sentFrames } = await createClient();
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
requestId: 1,
userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker',
nominalHashRate: 0,
maxTarget: Buffer.alloc(32, 0xff),
minExtranonceSize: 8,
}));
const jobFrames = sentFrames.filter(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB);
const jobFrame = jobFrames[jobFrames.length - 1];
const job = deserializeNewExtendedMiningJob(new BufferReader(jobFrame.payload));
sentFrames.length = 0;
await (client as any).handleSubmitSharesExtended(serializeSubmitSharesExtended({
channelId: 1,
sequenceNumber: 42,
jobId: job.jobId,
nonce: 0,
ntime: parseInt(MockRecording1.TIME, 16),
version: job.version,
extranonce: Buffer.alloc(7),
}));
const errorFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.SUBMIT_SHARES_ERROR);
expect(errorFrame?.extensionType).toBe(SV2_CHANNEL_MSG_FLAG);
const error = deserializeSubmitSharesError(new BufferReader(errorFrame.payload));
expect(error.channelId).toBe(1);
expect(error.sequenceNumber).toBe(42);
expect(error.errorCode).toBe('invalid-extranonce-size');
});
it('records accepted SV2 shares before presence updates', async () => {
const { client, shareAccountingService, redisMessagingService, jobTemplate } = await createClient();
(client as any).address = 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
(client as any).workerName = 'worker';
(client as any).sessionId = 'sv2-session';
(client as any).userAgent = 'test/sv2';
await (client as any).recordAcceptedShare(
2048,
1024,
jobTemplate,
null,
{
jobId: '1',
nonce: 123,
ntime: parseInt(MockRecording1.TIME, 16),
version: jobTemplate.block.version,
extraNonce2: 'c708000000000000',
},
);
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
protocol: 'sv2',
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'worker',
sessionId: 'sv2-session',
jobId: '1',
creditedDifficulty: 1024,
submissionDifficulty: 2048,
nonce: 123,
extraNonce2: 'c708000000000000',
}));
const accountingCallOrder = shareAccountingService.recordAcceptedShare.mock.invocationCallOrder[0];
const postAccountingPresenceUpdates = redisMessagingService.setClientPresence.mock.invocationCallOrder
.filter(callOrder => callOrder > accountingCallOrder);
expect(postAccountingPresenceUpdates.length).toBeGreaterThan(0);
});
async function createClient(): Promise<{
client: StratumV2Client;
sentFrames: any[];
shareAccountingService: { recordAcceptedShare: jest.Mock };
redisMessagingService: { setClientPresence: jest.Mock; removeClientPresence: jest.Mock };
jobTemplate: any;
}> {
const blockTemplate$ = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
const bitcoinRpcService = {
newBlockTemplate$: blockTemplate$.asObservable(),
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height },
SUBMIT_BLOCK: jest.fn().mockResolvedValue(null),
};
const stratumV1JobsService = new StratumV1JobsService(bitcoinRpcService as any);
const jobTemplate = await firstValueFrom(stratumV1JobsService.newMiningJob$);
const mockSocket: any = {
destroyed: false,
writableEnded: false,
on: jest.fn().mockReturnThis(),
write: jest.fn((data, callback) => {
callback?.();
return true;
}),
destroy: jest.fn(function destroy() {
mockSocket.destroyed = true;
return mockSocket;
}),
};
const socket = mockSocket as Socket;
const clientEntity = {
id: 1,
sessionId: 'sv2-session',
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'worker',
userAgent: 'test/sv2',
startTime: new Date(),
bestDifficulty: 0,
} as unknown as ClientEntity;
let nextChannelId = 1;
const sentFrames: any[] = [];
const clientService = {
insert: jest.fn().mockResolvedValue(clientEntity),
delete: jest.fn().mockResolvedValue(undefined),
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
};
const shareAccountingService = {
recordAcceptedShare: jest.fn().mockResolvedValue(undefined),
};
const redisMessagingService = {
setClientPresence: jest.fn().mockResolvedValue(undefined),
removeClientPresence: jest.fn().mockResolvedValue(undefined),
};
const client = new StratumV2Client(
socket,
Buffer.alloc(0),
{
getNoiseConfig: () => ({
staticKeypair: {
privateKey: Buffer.alloc(32, 1),
publicKey: Buffer.alloc(64, 2),
},
certificateMessage: {
version: 0,
validFrom: 0,
notValidAfter: 1,
signature: Buffer.alloc(64),
},
}),
getNextChannelId: () => nextChannelId++,
generateExtranoncePrefix: () => Buffer.from('00000002', 'hex'),
allocateExtendedExtranoncePrefix: jest.fn(() => Buffer.from('00000001', 'hex')),
releaseExtendedExtranoncePrefix: jest.fn(),
getExtendedMinerExtranonceSize: () => 8,
} as any,
stratumV1JobsService,
bitcoinRpcService as any,
clientService as any,
{ notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined) } as any,
{ save: jest.fn().mockResolvedValue(undefined) } as any,
{
get: jest.fn((key: string) => {
switch (key) {
case 'NETWORK':
return 'testnet';
case 'SV2_START_DIFFICULTY':
return '1';
default:
return null;
}
}),
} as unknown as ConfigService,
{
resetBestDifficultyAndShares: jest.fn().mockResolvedValue(undefined),
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
} as any,
shareAccountingService as any,
redisMessagingService as any,
);
(client as any).sendFrame = jest.fn((msgType: number, payload: Buffer, extensionType = 0) => {
sentFrames.push({ msgType, payload, extensionType });
return Promise.resolve();
});
return { client, sentFrames, shareAccountingService, redisMessagingService, jobTemplate };
}
});
File diff suppressed because it is too large Load Diff
@@ -25,6 +25,15 @@ describe('AuthorizationMessage', () => {
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', () => {
const message = plainToInstance(
AuthorizationMessage,
@@ -24,27 +24,23 @@ export class AuthorizationMessage extends StratumBaseMessage {
@IsString()
@MaxLength(64)
@Transform(({ value, key, obj, type }) => {
const workerName = obj.params[0].split('.')[1];
if (workerName == null) {
return 'worker';
}
return workerName;
return obj.params[0].split('.')[1] == null ? 'worker' : obj.params[0].split('.')[1];
})
public worker: string;
@Expose()
@IsNumber()
@Transform(({ value, key, obj, type }) => {
const password: string | null = obj.params[1];
if (password?.includes('d=')) {
return parseInt(password.split('d=')[1]);
const difficultyMatch = password?.match(/(?:^|,)d=(\d+)(?:,|$)/);
if (difficultyMatch != null) {
const startingDiff = Number.parseInt(difficultyMatch[1], 10);
return Number.isFinite(startingDiff) ? startingDiff : null;
}
return null;
})
@IsOptional()
public startingDiff: number;
public startingDiff?: number;
@Expose()
@IsString()
@@ -69,4 +65,4 @@ export class AuthorizationMessage extends StratumBaseMessage {
result: true
};
}
}
}
-19
View File
@@ -1,19 +0,0 @@
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
@@ -1,19 +0,0 @@
import * as bs58 from 'bs58';
import * as crypto from 'crypto';
const AUTHORITY_KEY_VERSION_PREFIX = Buffer.from([1, 0]);
export function encodeSv2AuthorityPublicKey(publicKey: Buffer): string {
if (publicKey.length !== 32) {
throw new RangeError(`SV2 authority public key must be 32 bytes, got ${publicKey.length}`);
}
const versionedKey = Buffer.concat([AUTHORITY_KEY_VERSION_PREFIX, publicKey]);
const checksum = crypto
.createHash('sha256')
.update(crypto.createHash('sha256').update(versionedKey).digest())
.digest()
.subarray(0, 4);
return bs58.encode(Buffer.concat([versionedKey, checksum]));
}
-249
View File
@@ -1,249 +0,0 @@
import { BufferReader, BufferWriter } from './sv2-binary-codec';
describe('SV2 Binary Codec', () => {
// ── Round-trip Tests ────────────────────────────────────────────────
describe('BufferWriter + BufferReader round-trips', () => {
it('should round-trip Bool', () => {
const w = new BufferWriter();
w.writeBool(true);
w.writeBool(false);
const r = new BufferReader(w.toBuffer());
expect(r.readBool()).toBe(true);
expect(r.readBool()).toBe(false);
});
it('should round-trip U8', () => {
const w = new BufferWriter();
w.writeU8(0);
w.writeU8(255);
w.writeU8(42);
const r = new BufferReader(w.toBuffer());
expect(r.readU8()).toBe(0);
expect(r.readU8()).toBe(255);
expect(r.readU8()).toBe(42);
});
it('should round-trip U16', () => {
const w = new BufferWriter();
w.writeU16(0);
w.writeU16(65535);
w.writeU16(1234);
const r = new BufferReader(w.toBuffer());
expect(r.readU16()).toBe(0);
expect(r.readU16()).toBe(65535);
expect(r.readU16()).toBe(1234);
});
it('should round-trip U24', () => {
const w = new BufferWriter();
w.writeU24(0);
w.writeU24(0xffffff);
w.writeU24(123456);
const r = new BufferReader(w.toBuffer());
expect(r.readU24()).toBe(0);
expect(r.readU24()).toBe(0xffffff);
expect(r.readU24()).toBe(123456);
});
it('should round-trip U32', () => {
const w = new BufferWriter();
w.writeU32(0);
w.writeU32(0xffffffff);
w.writeU32(305419896);
const r = new BufferReader(w.toBuffer());
expect(r.readU32()).toBe(0);
expect(r.readU32()).toBe(0xffffffff);
expect(r.readU32()).toBe(305419896);
});
it('should round-trip U64', () => {
const w = new BufferWriter();
w.writeU64(0n);
w.writeU64(0xffffffffffffffffn);
w.writeU64(123456789012345678n);
const r = new BufferReader(w.toBuffer());
expect(r.readU64()).toBe(0n);
expect(r.readU64()).toBe(0xffffffffffffffffn);
expect(r.readU64()).toBe(123456789012345678n);
});
it('should round-trip U256', () => {
const val = Buffer.alloc(32);
for (let i = 0; i < 32; i++) val[i] = i;
const w = new BufferWriter();
w.writeU256(val);
const r = new BufferReader(w.toBuffer());
expect(r.readU256()).toEqual(val);
});
it('should round-trip F32', () => {
const w = new BufferWriter();
w.writeF32(3.14);
w.writeF32(0);
w.writeF32(-1.5);
const r = new BufferReader(w.toBuffer());
expect(r.readF32()).toBeCloseTo(3.14, 2);
expect(r.readF32()).toBe(0);
expect(r.readF32()).toBe(-1.5);
});
it('should round-trip Str0_255', () => {
const w = new BufferWriter();
w.writeStr0_255('');
w.writeStr0_255('hello');
w.writeStr0_255('mining/pool/1.0');
const r = new BufferReader(w.toBuffer());
expect(r.readStr0_255()).toBe('');
expect(r.readStr0_255()).toBe('hello');
expect(r.readStr0_255()).toBe('mining/pool/1.0');
});
it('should round-trip B0_32', () => {
const val = Buffer.from([1, 2, 3, 4]);
const w = new BufferWriter();
w.writeB0_32(Buffer.alloc(0));
w.writeB0_32(val);
const r = new BufferReader(w.toBuffer());
expect(r.readB0_32()).toEqual(Buffer.alloc(0));
expect(r.readB0_32()).toEqual(val);
});
it('should round-trip B0_255', () => {
const val = Buffer.alloc(100, 0xab);
const w = new BufferWriter();
w.writeB0_255(val);
const r = new BufferReader(w.toBuffer());
expect(r.readB0_255()).toEqual(val);
});
it('should round-trip B0_64K', () => {
const val = Buffer.alloc(1000, 0xcd);
const w = new BufferWriter();
w.writeB0_64K(val);
const r = new BufferReader(w.toBuffer());
expect(r.readB0_64K()).toEqual(val);
});
it('should round-trip Seq0_255', () => {
const items = [10, 20, 30];
const w = new BufferWriter();
w.writeSeq0_255(items, (wr, v) => wr.writeU32(v));
const r = new BufferReader(w.toBuffer());
const result = r.readSeq0_255((rd) => rd.readU32());
expect(result).toEqual(items);
});
it('should round-trip Seq0_64K', () => {
const items = ['alpha', 'beta'];
const w = new BufferWriter();
w.writeSeq0_64K(items, (wr, v) => wr.writeStr0_255(v));
const r = new BufferReader(w.toBuffer());
const result = r.readSeq0_64K((rd) => rd.readStr0_255());
expect(result).toEqual(items);
});
});
// ── Little-endian Verification ──────────────────────────────────────
describe('little-endian encoding', () => {
it('U16 should be little-endian', () => {
const w = new BufferWriter();
w.writeU16(0x0102);
const buf = w.toBuffer();
expect(buf[0]).toBe(0x02); // low byte first
expect(buf[1]).toBe(0x01);
});
it('U32 should be little-endian', () => {
const w = new BufferWriter();
w.writeU32(0x01020304);
const buf = w.toBuffer();
expect(buf[0]).toBe(0x04);
expect(buf[1]).toBe(0x03);
expect(buf[2]).toBe(0x02);
expect(buf[3]).toBe(0x01);
});
});
// ── Bounds Checking ─────────────────────────────────────────────────
describe('bounds checking', () => {
it('BufferReader should throw on underflow', () => {
const r = new BufferReader(Buffer.alloc(1));
r.readU8(); // consumes the one byte
expect(() => r.readU8()).toThrow(RangeError);
});
it('BufferReader should throw when reading U32 from 2-byte buffer', () => {
const r = new BufferReader(Buffer.alloc(2));
expect(() => r.readU32()).toThrow(RangeError);
});
it('B0_32 should reject payloads > 32 bytes', () => {
const w = new BufferWriter();
expect(() => w.writeB0_32(Buffer.alloc(33))).toThrow(RangeError);
});
it('B0_32 reader should reject length > 32', () => {
// Craft a buffer with length prefix = 33
const buf = Buffer.alloc(34);
buf[0] = 33;
const r = new BufferReader(buf);
expect(() => r.readB0_32()).toThrow(RangeError);
});
it('U256 writer should reject non-32-byte buffers', () => {
const w = new BufferWriter();
expect(() => w.writeU256(Buffer.alloc(31))).toThrow(RangeError);
});
it('Str0_255 should reject strings > 255 bytes', () => {
const w = new BufferWriter();
expect(() => w.writeStr0_255('a'.repeat(256))).toThrow(RangeError);
});
it('remaining should track consumed bytes', () => {
const r = new BufferReader(Buffer.alloc(10));
expect(r.remaining).toBe(10);
r.readU32();
expect(r.remaining).toBe(6);
expect(r.position).toBe(4);
});
});
// ── Edge Cases ──────────────────────────────────────────────────────
describe('edge cases', () => {
it('empty buffer reader has 0 remaining', () => {
const r = new BufferReader(Buffer.alloc(0));
expect(r.remaining).toBe(0);
});
it('empty Seq0_255 round-trips', () => {
const w = new BufferWriter();
w.writeSeq0_255([], (wr, v) => wr.writeU8(v as number));
const r = new BufferReader(w.toBuffer());
expect(r.readSeq0_255((rd) => rd.readU8())).toEqual([]);
});
it('maximum B0_32 (32 bytes) round-trips', () => {
const val = Buffer.alloc(32, 0xff);
const w = new BufferWriter();
w.writeB0_32(val);
const r = new BufferReader(w.toBuffer());
expect(r.readB0_32()).toEqual(val);
});
it('writeU32 handles signed int32 from bitcoinjs block.version', () => {
// bitcoinjs-lib stores block.version as signed Int32 (readInt32).
// Version rolling or BIP9 signaling can set bit 31, producing
// a negative JS number. writeU32 must handle this via >>> 0.
const signedVersion = -1554660091; // 0xA355C505 unsigned
const w = new BufferWriter();
w.writeU32(signedVersion);
const r = new BufferReader(w.toBuffer());
expect(r.readU32()).toBe(0xA355C505);
});
});
});
-301
View File
@@ -1,301 +0,0 @@
// ── SV2 Binary Codec ────────────────────────────────────────────────
// Sequential Buffer reader / accumulator-pattern writer for all SV2
// data types. All multi-byte integers are little-endian per spec.
// ── BufferReader ────────────────────────────────────────────────────
export class BufferReader {
private offset = 0;
constructor(private readonly buf: Buffer) {}
get remaining(): number {
return this.buf.length - this.offset;
}
get position(): number {
return this.offset;
}
private ensure(n: number): void {
if (this.offset + n > this.buf.length) {
throw new RangeError(
`BufferReader: need ${n} bytes at offset ${this.offset}, only ${this.remaining} available`,
);
}
}
readBool(): boolean {
this.ensure(1);
return this.buf[this.offset++] !== 0;
}
readU8(): number {
this.ensure(1);
return this.buf[this.offset++];
}
readU16(): number {
this.ensure(2);
const v = this.buf.readUInt16LE(this.offset);
this.offset += 2;
return v;
}
readU24(): number {
this.ensure(3);
const v =
this.buf[this.offset] |
(this.buf[this.offset + 1] << 8) |
(this.buf[this.offset + 2] << 16);
this.offset += 3;
return v;
}
readU32(): number {
this.ensure(4);
const v = this.buf.readUInt32LE(this.offset);
this.offset += 4;
return v;
}
/** Read Sv2Option<u32>: 1-byte flag (0=None, 1=Some) + optional 4-byte u32 LE */
readOptionU32(): number | null {
this.ensure(1);
const flag = this.buf[this.offset++];
if (flag === 0) return null;
this.ensure(4);
const v = this.buf.readUInt32LE(this.offset);
this.offset += 4;
return v;
}
readU64(): bigint {
this.ensure(8);
const v = this.buf.readBigUInt64LE(this.offset);
this.offset += 8;
return v;
}
readU256(): Buffer {
this.ensure(32);
const v = Buffer.from(this.buf.subarray(this.offset, this.offset + 32));
this.offset += 32;
return v;
}
readF32(): number {
this.ensure(4);
const v = this.buf.readFloatLE(this.offset);
this.offset += 4;
return v;
}
readBytes(n: number): Buffer {
this.ensure(n);
const v = Buffer.from(this.buf.subarray(this.offset, this.offset + n));
this.offset += n;
return v;
}
/** STR0_255: 1-byte length prefix + UTF-8 payload */
readStr0_255(): string {
const len = this.readU8();
return this.readBytes(len).toString('utf8');
}
/** B0_32: 1-byte length prefix, max 32 bytes */
readB0_32(): Buffer {
const len = this.readU8();
if (len > 32) throw new RangeError(`B0_32: length ${len} > 32`);
return this.readBytes(len);
}
/** B0_255: 1-byte length prefix, max 255 bytes */
readB0_255(): Buffer {
const len = this.readU8();
return this.readBytes(len);
}
/** B0_64K: 2-byte length prefix */
readB0_64K(): Buffer {
const len = this.readU16();
return this.readBytes(len);
}
/** B0_16M: 3-byte length prefix, max 16,777,215 bytes */
readB0_16M(): Buffer {
const len = this.readU24();
return this.readBytes(len);
}
/** SEQ0_255: 1-byte count + repeated elements */
readSeq0_255<T>(readFn: (reader: BufferReader) => T): T[] {
const count = this.readU8();
const result: T[] = [];
for (let i = 0; i < count; i++) {
result.push(readFn(this));
}
return result;
}
/** SEQ0_64K: 2-byte count + repeated elements */
readSeq0_64K<T>(readFn: (reader: BufferReader) => T): T[] {
const count = this.readU16();
const result: T[] = [];
for (let i = 0; i < count; i++) {
result.push(readFn(this));
}
return result;
}
}
// ── BufferWriter ────────────────────────────────────────────────────
export class BufferWriter {
private buf: Buffer;
private offset = 0;
constructor(initialSize = 256) {
this.buf = Buffer.allocUnsafe(initialSize);
}
private ensureCapacity(n: number): void {
if (this.offset + n > this.buf.length) {
let newSize = this.buf.length * 2;
while (newSize < this.offset + n) newSize *= 2;
const newBuf = Buffer.allocUnsafe(newSize);
this.buf.copy(newBuf, 0, 0, this.offset);
this.buf = newBuf;
}
}
writeBool(v: boolean): void {
this.ensureCapacity(1);
this.buf[this.offset++] = v ? 1 : 0;
}
writeU8(v: number): void {
this.ensureCapacity(1);
this.buf[this.offset++] = v & 0xff;
}
/** Write Sv2Option<u32>: 1-byte flag + optional 4-byte u32 LE */
writeOptionU32(v: number | null): void {
if (v === null) {
this.writeU8(0);
} else {
this.writeU8(1);
this.writeU32(v);
}
}
writeU16(v: number): void {
this.ensureCapacity(2);
this.buf.writeUInt16LE(v, this.offset);
this.offset += 2;
}
writeU24(v: number): void {
this.ensureCapacity(3);
this.buf[this.offset] = v & 0xff;
this.buf[this.offset + 1] = (v >> 8) & 0xff;
this.buf[this.offset + 2] = (v >> 16) & 0xff;
this.offset += 3;
}
writeU32(v: number): void {
this.ensureCapacity(4);
this.buf.writeUInt32LE(v >>> 0, this.offset);
this.offset += 4;
}
writeU64(v: bigint): void {
this.ensureCapacity(8);
this.buf.writeBigUInt64LE(v, this.offset);
this.offset += 8;
}
writeU256(v: Buffer): void {
if (v.length !== 32) throw new RangeError('U256 must be 32 bytes');
this.ensureCapacity(32);
v.copy(this.buf, this.offset);
this.offset += 32;
}
writeF32(v: number): void {
this.ensureCapacity(4);
this.buf.writeFloatLE(v, this.offset);
this.offset += 4;
}
writeBytes(v: Buffer): void {
this.ensureCapacity(v.length);
v.copy(this.buf, this.offset);
this.offset += v.length;
}
/** STR0_255: 1-byte length prefix + UTF-8 payload */
writeStr0_255(v: string): void {
const payload = Buffer.from(v, 'utf8');
if (payload.length > 255)
throw new RangeError(`STR0_255: length ${payload.length} > 255`);
this.writeU8(payload.length);
this.writeBytes(payload);
}
/** B0_32: 1-byte length prefix, max 32 bytes */
writeB0_32(v: Buffer): void {
if (v.length > 32) throw new RangeError(`B0_32: length ${v.length} > 32`);
this.writeU8(v.length);
this.writeBytes(v);
}
/** B0_255: 1-byte length prefix, max 255 bytes */
writeB0_255(v: Buffer): void {
if (v.length > 255)
throw new RangeError(`B0_255: length ${v.length} > 255`);
this.writeU8(v.length);
this.writeBytes(v);
}
/** B0_64K: 2-byte length prefix */
writeB0_64K(v: Buffer): void {
if (v.length > 65535)
throw new RangeError(`B0_64K: length ${v.length} > 65535`);
this.writeU16(v.length);
this.writeBytes(v);
}
/** B0_16M: 3-byte length prefix, max 16,777,215 bytes */
writeB0_16M(v: Buffer): void {
if (v.length > 16777215)
throw new RangeError(`B0_16M: length ${v.length} > 16777215`);
this.writeU24(v.length);
this.writeBytes(v);
}
/** SEQ0_255: 1-byte count + repeated elements */
writeSeq0_255<T>(items: T[], writeFn: (writer: BufferWriter, item: T) => void): void {
if (items.length > 255)
throw new RangeError(`SEQ0_255: count ${items.length} > 255`);
this.writeU8(items.length);
for (const item of items) {
writeFn(this, item);
}
}
/** SEQ0_64K: 2-byte count + repeated elements */
writeSeq0_64K<T>(items: T[], writeFn: (writer: BufferWriter, item: T) => void): void {
if (items.length > 65535)
throw new RangeError(`SEQ0_64K: count ${items.length} > 65535`);
this.writeU16(items.length);
for (const item of items) {
writeFn(this, item);
}
}
toBuffer(): Buffer {
return Buffer.from(this.buf.subarray(0, this.offset));
}
}
-138
View File
@@ -1,138 +0,0 @@
// ── SV2 Protocol Constants ──────────────────────────────────────────
/** Standard (unencrypted) frame header: 2-byte ext+type + 3-byte length + 1-byte channel bit = 6 */
export const SV2_HEADER_SIZE = 6;
/** Poly1305 MAC appended to every AEAD ciphertext block */
export const SV2_MAC_SIZE = 16;
/** Encrypted frame header size: SV2_HEADER_SIZE + SV2_MAC_SIZE = 22 */
export const SV2_ENCRYPTED_HEADER_SIZE = SV2_HEADER_SIZE + SV2_MAC_SIZE;
/** Max plaintext bytes in a single AEAD chunk (2^16 - 1 - MAC) */
export const SV2_MAX_PLAINTEXT_CHUNK = 65519;
/** Max ciphertext bytes in a single AEAD chunk (2^16 - 1) */
export const SV2_MAX_CIPHERTEXT_CHUNK = 65535;
/** EllSwift-encoded public key size (64 bytes) */
export const SV2_ELLSWIFT_KEY_SIZE = 64;
/** Bit flag in extension_type indicating a channel message */
export const SV2_CHANNEL_MSG_FLAG = 0x8000;
/** Protocol identifier string used in SetupConnection */
export const SV2_PROTOCOL_NAME = 'MiningProtocol';
// ── Noise NX Handshake Constants ────────────────────────────────────
/** Noise NX protocol name used to initialize h (ChaCha20-Poly1305) */
export const SV2_NOISE_PROTOCOL_NAME = 'Noise_NX_Secp256k1+EllSwift_ChaChaPoly_SHA256';
/** Noise NX protocol name for AES-256-GCM cipher */
export const SV2_NOISE_PROTOCOL_NAME_AESGCM = 'Noise_NX_Secp256k1+EllSwift_AESGCM_SHA256';
/** Noise NX Act 1 size: 64-byte initiator ephemeral EllSwift key */
export const SV2_NOISE_ACT1_SIZE = 64;
/** Noise NX Act 2 size: 64 (server ephemeral) + 80 (encrypted static) + 90 (encrypted cert) = 234 */
export const SV2_NOISE_ACT2_SIZE = 234;
/** SIGNATURE_NOISE_MESSAGE payload size (without MAC): version(2) + validFrom(4) + notValidAfter(4) + signature(64) = 74 */
export const SV2_SIGNATURE_NOISE_MSG_SIZE = 74;
// ── Message Type Enum ───────────────────────────────────────────────
export enum Sv2MsgType {
// Common messages
SETUP_CONNECTION = 0x00,
SETUP_CONNECTION_SUCCESS = 0x01,
SETUP_CONNECTION_ERROR = 0x02,
CHANNEL_ENDPOINT_CHANGED = 0x03,
// Mining channel messages
OPEN_STANDARD_MINING_CHANNEL = 0x10,
OPEN_STANDARD_MINING_CHANNEL_SUCCESS = 0x11,
OPEN_STANDARD_MINING_CHANNEL_ERROR = 0x12,
CLOSE_CHANNEL = 0x18,
// Share submission
SUBMIT_SHARES_STANDARD = 0x1a,
SUBMIT_SHARES_SUCCESS = 0x1c,
SUBMIT_SHARES_ERROR = 0x1d,
// Extended Mining Channels
OPEN_EXTENDED_MINING_CHANNEL = 0x13,
OPEN_EXTENDED_MINING_CHANNEL_SUCCESS = 0x14,
SET_EXTRANONCE_PREFIX = 0x19,
SET_GROUP_CHANNEL = 0x25,
// Share submission (extended)
SUBMIT_SHARES_EXTENDED = 0x1b,
// Job & target
NEW_MINING_JOB = 0x15,
NEW_EXTENDED_MINING_JOB = 0x1f,
SET_NEW_PREV_HASH = 0x20,
SET_TARGET = 0x21,
// Channel management
UPDATE_CHANNEL = 0x16,
UPDATE_CHANNEL_ERROR = 0x17,
// Mining Protocol - Custom Job Bridge
SET_CUSTOM_MINING_JOB = 0x22,
SET_CUSTOM_MINING_JOB_SUCCESS = 0x23,
SET_CUSTOM_MINING_JOB_ERROR = 0x24,
// Reconnect
RECONNECT = 0x04,
// Job Declaration Protocol
JDP_ALLOCATE_MINING_JOB_TOKEN = 0x50,
JDP_ALLOCATE_MINING_JOB_TOKEN_SUCCESS = 0x51,
JDP_PROVIDE_MISSING_TRANSACTIONS = 0x55,
JDP_PROVIDE_MISSING_TRANSACTIONS_SUCCESS = 0x56,
JDP_DECLARE_MINING_JOB = 0x57,
JDP_DECLARE_MINING_JOB_SUCCESS = 0x58,
JDP_DECLARE_MINING_JOB_ERROR = 0x59,
JDP_PUSH_SOLUTION = 0x60,
// Template Distribution Protocol
TDP_COINBASE_OUTPUT_CONSTRAINTS = 0x70,
TDP_NEW_TEMPLATE = 0x71,
TDP_SET_NEW_PREV_HASH = 0x72,
TDP_REQUEST_TRANSACTION_DATA = 0x73,
TDP_REQUEST_TRANSACTION_DATA_SUCCESS = 0x74,
TDP_REQUEST_TRANSACTION_DATA_ERROR = 0x75,
TDP_SUBMIT_SOLUTION = 0x76,
}
// ── Protocol Enum ───────────────────────────────────────────────────
export enum Sv2Protocol {
MINING = 0,
JOB_DECLARATION = 1,
TEMPLATE_DISTRIBUTION = 2,
}
// ── Mining Setup Flags ──────────────────────────────────────────────
export enum Sv2MiningSetupFlags {
REQUIRES_STANDARD_JOBS = 1 << 0,
REQUIRES_WORK_SELECTION = 1 << 1,
REQUIRES_VERSION_ROLLING = 1 << 2,
}
// ── SetupConnection.Success Flags (Mining Protocol) ─────────────────
export enum Sv2MiningSetupSuccessFlags {
REQUIRES_FIXED_VERSION = 1 << 0,
REQUIRES_EXTENDED_CHANNELS = 1 << 1,
}
// ── SetupConnection Flags (Job Declaration Protocol) ────────────────
export enum Sv2JdpSetupFlags {
DECLARE_TX_DATA = 1 << 0,
}
@@ -1,188 +0,0 @@
import { BufferReader } from './sv2-binary-codec';
import {
Sv2OpenExtendedMiningChannel,
serializeOpenExtendedMiningChannel,
deserializeOpenExtendedMiningChannel,
Sv2OpenExtendedMiningChannelSuccess,
serializeOpenExtendedMiningChannelSuccess,
deserializeOpenExtendedMiningChannelSuccess,
Sv2NewExtendedMiningJob,
serializeNewExtendedMiningJob,
deserializeNewExtendedMiningJob,
Sv2SubmitSharesExtended,
serializeSubmitSharesExtended,
deserializeSubmitSharesExtended,
Sv2SetExtranoncePrefix,
serializeSetExtranoncePrefix,
deserializeSetExtranoncePrefix,
Sv2SetGroupChannel,
serializeSetGroupChannel,
deserializeSetGroupChannel,
} from './sv2-extended-messages';
function roundTrip<T>(
msg: T,
serialize: (m: T) => Buffer,
deserialize: (r: BufferReader) => T,
): T {
const buf = serialize(msg);
return deserialize(new BufferReader(buf));
}
describe('SV2 Extended Mining Channel Messages', () => {
it('OpenExtendedMiningChannel round-trips', () => {
const maxTarget = Buffer.alloc(32, 0x00);
maxTarget[0] = 0xff;
const msg: Sv2OpenExtendedMiningChannel = {
requestId: 1,
userIdentity: 'bc1qtest.worker1',
nominalHashRate: 500000.0,
maxTarget,
minExtranonceSize: 8,
};
const result = roundTrip(msg, serializeOpenExtendedMiningChannel, deserializeOpenExtendedMiningChannel);
expect(result.requestId).toBe(msg.requestId);
expect(result.userIdentity).toBe(msg.userIdentity);
expect(result.nominalHashRate).toBeCloseTo(msg.nominalHashRate, 0);
expect(result.maxTarget).toEqual(msg.maxTarget);
expect(result.minExtranonceSize).toBe(msg.minExtranonceSize);
});
it('OpenExtendedMiningChannelSuccess round-trips', () => {
const target = Buffer.alloc(32);
target[0] = 0xff;
const msg: Sv2OpenExtendedMiningChannelSuccess = {
requestId: 1,
channelId: 42,
target,
extranonceSize: 8,
extranoncePrefix: Buffer.from([0x00, 0x00, 0x00, 0x01]),
groupChannelId: 0,
};
const result = roundTrip(
msg,
serializeOpenExtendedMiningChannelSuccess,
deserializeOpenExtendedMiningChannelSuccess,
);
expect(result.requestId).toBe(msg.requestId);
expect(result.channelId).toBe(msg.channelId);
expect(result.target).toEqual(msg.target);
expect(result.extranonceSize).toBe(msg.extranonceSize);
expect(result.extranoncePrefix).toEqual(msg.extranoncePrefix);
expect(result.groupChannelId).toBe(msg.groupChannelId);
});
it('NewExtendedMiningJob round-trips with minNtime (mine immediately)', () => {
const hash1 = Buffer.alloc(32, 0xaa);
const hash2 = Buffer.alloc(32, 0xbb);
const msg: Sv2NewExtendedMiningJob = {
channelId: 1,
jobId: 100,
minNtime: 1700000000,
version: 0x20000000,
versionRollingAllowed: true,
merklePath: [hash1, hash2],
coinbasePrefix: Buffer.from('deadbeef', 'hex'),
coinbaseSuffix: Buffer.from('cafebabe', 'hex'),
};
const result = roundTrip(msg, serializeNewExtendedMiningJob, deserializeNewExtendedMiningJob);
expect(result.channelId).toBe(msg.channelId);
expect(result.jobId).toBe(msg.jobId);
expect(result.minNtime).toBe(msg.minNtime);
expect(result.version).toBe(msg.version);
expect(result.versionRollingAllowed).toBe(msg.versionRollingAllowed);
expect(result.merklePath).toHaveLength(2);
expect(result.merklePath[0]).toEqual(hash1);
expect(result.merklePath[1]).toEqual(hash2);
expect(result.coinbasePrefix).toEqual(msg.coinbasePrefix);
expect(result.coinbaseSuffix).toEqual(msg.coinbaseSuffix);
});
it('NewExtendedMiningJob with null minNtime (future job)', () => {
const msg: Sv2NewExtendedMiningJob = {
channelId: 1,
jobId: 1,
minNtime: null,
version: 0x20000000,
versionRollingAllowed: false,
merklePath: [],
coinbasePrefix: Buffer.alloc(0),
coinbaseSuffix: Buffer.alloc(0),
};
const result = roundTrip(msg, serializeNewExtendedMiningJob, deserializeNewExtendedMiningJob);
expect(result.merklePath).toHaveLength(0);
expect(result.minNtime).toBeNull();
expect(result.versionRollingAllowed).toBe(false);
});
it('SubmitSharesExtended round-trips', () => {
const msg: Sv2SubmitSharesExtended = {
channelId: 1,
sequenceNumber: 42,
jobId: 100,
nonce: 0xdeadbeef,
ntime: 1700000000,
version: 0x20000000,
extranonce: Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]),
};
const result = roundTrip(msg, serializeSubmitSharesExtended, deserializeSubmitSharesExtended);
expect(result.channelId).toBe(msg.channelId);
expect(result.sequenceNumber).toBe(msg.sequenceNumber);
expect(result.jobId).toBe(msg.jobId);
expect(result.nonce).toBe(msg.nonce);
expect(result.ntime).toBe(msg.ntime);
expect(result.version).toBe(msg.version);
expect(result.extranonce).toEqual(msg.extranonce);
});
it('SubmitSharesExtended with short extranonce', () => {
const msg: Sv2SubmitSharesExtended = {
channelId: 1,
sequenceNumber: 1,
jobId: 1,
nonce: 0,
ntime: 1700000000,
version: 0x20000000,
extranonce: Buffer.from([0xab]),
};
const result = roundTrip(msg, serializeSubmitSharesExtended, deserializeSubmitSharesExtended);
expect(result.extranonce).toEqual(Buffer.from([0xab]));
});
it('SetExtranoncePrefix round-trips', () => {
const msg: Sv2SetExtranoncePrefix = {
channelId: 5,
extranoncePrefix: Buffer.from([0x00, 0x00, 0x00, 0x02]),
};
const result = roundTrip(msg, serializeSetExtranoncePrefix, deserializeSetExtranoncePrefix);
expect(result.channelId).toBe(msg.channelId);
expect(result.extranoncePrefix).toEqual(msg.extranoncePrefix);
});
it('SetGroupChannel round-trips', () => {
const msg: Sv2SetGroupChannel = {
groupChannelId: 10,
channelIds: [42, 43, 44],
};
const result = roundTrip(msg, serializeSetGroupChannel, deserializeSetGroupChannel);
expect(result).toEqual(msg);
});
it('serialized SubmitSharesExtended has correct byte layout', () => {
const msg: Sv2SubmitSharesExtended = {
channelId: 1,
sequenceNumber: 2,
jobId: 3,
nonce: 4,
ntime: 5,
version: 6,
extranonce: Buffer.from([0xab, 0xcd]),
};
const buf = serializeSubmitSharesExtended(msg);
// 6 * U32(4) = 24 + B0_32(1+2) = 27
expect(buf.length).toBe(27);
expect(buf.readUInt32LE(0)).toBe(1);
expect(buf.readUInt32LE(4)).toBe(2);
expect(buf.readUInt32LE(8)).toBe(3);
});
});
-184
View File
@@ -1,184 +0,0 @@
// ── SV2 Extended Mining Channel Messages ────────────────────────────
// Interfaces + serialize/deserialize for extended channel message types.
import { BufferReader, BufferWriter } from './sv2-binary-codec';
// ── OpenExtendedMiningChannel (0x13) ───────────────────────────────
export interface Sv2OpenExtendedMiningChannel {
requestId: number; // U32
userIdentity: string; // STR0_255
nominalHashRate: number; // F32
maxTarget: Buffer; // U256 (32 bytes LE)
minExtranonceSize: number; // U16
}
export function serializeOpenExtendedMiningChannel(msg: Sv2OpenExtendedMiningChannel): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeStr0_255(msg.userIdentity);
w.writeF32(msg.nominalHashRate);
w.writeU256(msg.maxTarget);
w.writeU16(msg.minExtranonceSize);
return w.toBuffer();
}
export function deserializeOpenExtendedMiningChannel(reader: BufferReader): Sv2OpenExtendedMiningChannel {
return {
requestId: reader.readU32(),
userIdentity: reader.readStr0_255(),
nominalHashRate: reader.readF32(),
maxTarget: reader.readU256(),
minExtranonceSize: reader.readU16(),
};
}
// ── OpenExtendedMiningChannel.Success (0x14) ───────────────────────
export interface Sv2OpenExtendedMiningChannelSuccess {
requestId: number; // U32
channelId: number; // U32
target: Buffer; // U256
extranonceSize: number; // U16
extranoncePrefix: Buffer; // B0_32
groupChannelId: number; // U32
}
export function serializeOpenExtendedMiningChannelSuccess(msg: Sv2OpenExtendedMiningChannelSuccess): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeU32(msg.channelId);
w.writeU256(msg.target);
w.writeU16(msg.extranonceSize);
w.writeB0_32(msg.extranoncePrefix);
w.writeU32(msg.groupChannelId);
return w.toBuffer();
}
export function deserializeOpenExtendedMiningChannelSuccess(reader: BufferReader): Sv2OpenExtendedMiningChannelSuccess {
return {
requestId: reader.readU32(),
channelId: reader.readU32(),
target: reader.readU256(),
extranonceSize: reader.readU16(),
extranoncePrefix: reader.readB0_32(),
groupChannelId: reader.readU32(),
};
}
// ── NewExtendedMiningJob (0x1f) ────────────────────────────────────
export interface Sv2NewExtendedMiningJob {
channelId: number; // U32
jobId: number; // U32
minNtime: number | null; // Sv2Option<U32> - null = future job (activated by SetNewPrevHash), Some(ts) = mine immediately
version: number; // U32
versionRollingAllowed: boolean; // BOOL
merklePath: Buffer[]; // SEQ0_255<U256>
coinbasePrefix: Buffer; // B0_64K
coinbaseSuffix: Buffer; // B0_64K
}
export function serializeNewExtendedMiningJob(msg: Sv2NewExtendedMiningJob): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.jobId);
w.writeOptionU32(msg.minNtime);
w.writeU32(msg.version);
w.writeBool(msg.versionRollingAllowed);
w.writeSeq0_255(msg.merklePath, (writer, hash) => writer.writeU256(hash));
w.writeB0_64K(msg.coinbasePrefix);
w.writeB0_64K(msg.coinbaseSuffix);
return w.toBuffer();
}
export function deserializeNewExtendedMiningJob(reader: BufferReader): Sv2NewExtendedMiningJob {
return {
channelId: reader.readU32(),
jobId: reader.readU32(),
minNtime: reader.readOptionU32(),
version: reader.readU32(),
versionRollingAllowed: reader.readBool(),
merklePath: reader.readSeq0_255((r) => r.readU256()),
coinbasePrefix: reader.readB0_64K(),
coinbaseSuffix: reader.readB0_64K(),
};
}
// ── SubmitSharesExtended (0x1b) ────────────────────────────────────
export interface Sv2SubmitSharesExtended {
channelId: number; // U32
sequenceNumber: number; // U32
jobId: number; // U32
nonce: number; // U32
ntime: number; // U32
version: number; // U32
extranonce: Buffer; // B0_32
}
export function serializeSubmitSharesExtended(msg: Sv2SubmitSharesExtended): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.sequenceNumber);
w.writeU32(msg.jobId);
w.writeU32(msg.nonce);
w.writeU32(msg.ntime);
w.writeU32(msg.version);
w.writeB0_32(msg.extranonce);
return w.toBuffer();
}
export function deserializeSubmitSharesExtended(reader: BufferReader): Sv2SubmitSharesExtended {
return {
channelId: reader.readU32(),
sequenceNumber: reader.readU32(),
jobId: reader.readU32(),
nonce: reader.readU32(),
ntime: reader.readU32(),
version: reader.readU32(),
extranonce: reader.readB0_32(),
};
}
// ── SetExtranoncePrefix (0x19) ─────────────────────────────────────
export interface Sv2SetExtranoncePrefix {
channelId: number; // U32
extranoncePrefix: Buffer; // B0_32
}
export function serializeSetExtranoncePrefix(msg: Sv2SetExtranoncePrefix): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeB0_32(msg.extranoncePrefix);
return w.toBuffer();
}
export function deserializeSetExtranoncePrefix(reader: BufferReader): Sv2SetExtranoncePrefix {
return {
channelId: reader.readU32(),
extranoncePrefix: reader.readB0_32(),
};
}
// ── SetGroupChannel (0x25) ─────────────────────────────────────────
export interface Sv2SetGroupChannel {
groupChannelId: number; // U32
channelIds: number[]; // SEQ0_64K[U32]
}
export function serializeSetGroupChannel(msg: Sv2SetGroupChannel): Buffer {
const w = new BufferWriter();
w.writeU32(msg.groupChannelId);
w.writeSeq0_64K(msg.channelIds, (writer, id) => writer.writeU32(id));
return w.toBuffer();
}
export function deserializeSetGroupChannel(reader: BufferReader): Sv2SetGroupChannel {
return {
groupChannelId: reader.readU32(),
channelIds: reader.readSeq0_64K((r) => r.readU32()),
};
}
@@ -1,114 +0,0 @@
import { Sv2ExtranonceManager } from './sv2-extranonce-manager';
describe('Sv2ExtranonceManager', () => {
it('allocates unique prefixes for different channels', () => {
const mgr = new Sv2ExtranonceManager();
const p1 = mgr.allocate(1);
const p2 = mgr.allocate(2);
const p3 = mgr.allocate(3);
expect(p1).not.toEqual(p2);
expect(p2).not.toEqual(p3);
expect(p1).not.toEqual(p3);
});
it('returns same prefix for same channel on re-allocation', () => {
const mgr = new Sv2ExtranonceManager();
const p1a = mgr.allocate(1);
const p1b = mgr.allocate(1);
expect(p1a).toEqual(p1b);
});
it('prefix is 4 bytes by default', () => {
const mgr = new Sv2ExtranonceManager();
const p = mgr.allocate(1);
expect(p.length).toBe(4);
});
it('minerExtranonceSize is total minus prefix', () => {
const mgr = new Sv2ExtranonceManager(4, 8);
expect(mgr.minerExtranonceSize).toBe(4);
const mgr2 = new Sv2ExtranonceManager(2, 6);
expect(mgr2.minerExtranonceSize).toBe(4);
});
it('releases prefix and allows reuse', () => {
const mgr = new Sv2ExtranonceManager();
const p1 = mgr.allocate(1);
expect(mgr.allocatedCount).toBe(1);
mgr.release(1);
expect(mgr.allocatedCount).toBe(0);
expect(mgr.getPrefix(1)).toBeUndefined();
// Should be able to allocate again (may get same or different prefix)
const p2 = mgr.allocate(10);
expect(p2.length).toBe(4);
expect(mgr.allocatedCount).toBe(1);
});
it('release is idempotent for unknown channels', () => {
const mgr = new Sv2ExtranonceManager();
expect(() => mgr.release(999)).not.toThrow();
});
it('getPrefix returns undefined for unallocated channel', () => {
const mgr = new Sv2ExtranonceManager();
expect(mgr.getPrefix(42)).toBeUndefined();
});
it('getPrefix returns the allocated prefix', () => {
const mgr = new Sv2ExtranonceManager();
const p = mgr.allocate(1);
expect(mgr.getPrefix(1)).toEqual(p);
});
it('handles many allocations without collision', () => {
const mgr = new Sv2ExtranonceManager();
const prefixes = new Set<string>();
for (let i = 1; i <= 1000; i++) {
const p = mgr.allocate(i);
const hex = p.toString('hex');
expect(prefixes.has(hex)).toBe(false);
prefixes.add(hex);
}
expect(mgr.allocatedCount).toBe(1000);
});
it('works with 2-byte prefix size', () => {
const mgr = new Sv2ExtranonceManager(2, 4);
const p = mgr.allocate(1);
expect(p.length).toBe(2);
expect(mgr.minerExtranonceSize).toBe(2);
});
it('reuses released prefix slot', () => {
const mgr = new Sv2ExtranonceManager();
const p1 = mgr.allocate(1);
mgr.allocate(2);
mgr.release(1);
// Allocate a new channel - p1's prefix should be available
const p3 = mgr.allocate(3);
expect(p3.length).toBe(4);
expect(mgr.allocatedCount).toBe(2);
});
it('tracks allocatedCount correctly', () => {
const mgr = new Sv2ExtranonceManager();
expect(mgr.allocatedCount).toBe(0);
mgr.allocate(1);
expect(mgr.allocatedCount).toBe(1);
mgr.allocate(2);
expect(mgr.allocatedCount).toBe(2);
mgr.release(1);
expect(mgr.allocatedCount).toBe(1);
mgr.release(2);
expect(mgr.allocatedCount).toBe(0);
});
});
-113
View File
@@ -1,113 +0,0 @@
// ── SV2 Extranonce Manager ──────────────────────────────────────────
// Manages extranonce prefix allocation for extended mining channels.
// Ensures no two channels share the same prefix to prevent hash collisions.
export class Sv2ExtranonceManager {
private nextPrefix: number;
private readonly workerOffset: number;
private readonly maxPrefix: number;
private allocatedPrefixes = new Map<number, number>(); // channelId → prefix
private usedPrefixes = new Set<number>();
private readonly prefixSize: number;
private readonly totalExtranonceSize: number;
/**
* @param prefixSize Bytes used for pool-assigned prefix (default 4)
* @param totalExtranonceSize Total extranonce bytes (default 12: 4 prefix + 8 miner-controlled).
* Must match MiningJob.ts's coinbase slot size (12 bytes) — both
* protocols share the same coinbase template. Bumped from the
* previous 8-byte total because the Braiins Hashpower marketplace
* requires extranonce2_size >= 7 (compatibility spec).
*/
constructor(prefixSize = 4, totalExtranonceSize = 12) {
this.prefixSize = prefixSize;
this.totalExtranonceSize = totalExtranonceSize;
const workerId = 0;
const bitsPerWorker = (prefixSize - 1) * 8;
this.workerOffset = workerId * Math.pow(2, bitsPerWorker);
this.maxPrefix = Math.pow(2, bitsPerWorker) - 1; // max within this worker's slice
this.nextPrefix = 1;
}
get minerExtranonceSize(): number {
return this.totalExtranonceSize - this.prefixSize;
}
/**
* Allocate a unique extranonce prefix for a channel.
* Returns a Buffer of `prefixSize` bytes.
*/
allocate(channelId: number): Buffer {
// Check if already allocated
if (this.allocatedPrefixes.has(channelId)) {
const existing = this.allocatedPrefixes.get(channelId)!;
return this.prefixToBuffer(existing);
}
// Find next available prefix within this worker's partition
let localPrefix = this.nextPrefix;
let attempts = 0;
const globalPrefix = () => this.workerOffset + localPrefix;
while (this.usedPrefixes.has(globalPrefix()) && attempts <= this.maxPrefix) {
localPrefix = localPrefix + 1;
if (localPrefix > this.maxPrefix) localPrefix = 1; // Wrap within partition, skip 0
attempts++;
}
if (this.usedPrefixes.has(globalPrefix())) {
throw new Error('Extranonce prefix space exhausted');
}
const prefix = globalPrefix();
this.allocatedPrefixes.set(channelId, prefix);
this.usedPrefixes.add(prefix);
this.nextPrefix = localPrefix + 1;
if (this.nextPrefix > this.maxPrefix) this.nextPrefix = 1;
return this.prefixToBuffer(prefix);
}
/**
* Release the prefix allocated to a channel.
*/
release(channelId: number): void {
const prefix = this.allocatedPrefixes.get(channelId);
if (prefix !== undefined) {
this.allocatedPrefixes.delete(channelId);
this.usedPrefixes.delete(prefix);
}
}
/**
* Get the prefix for a channel, or undefined if not allocated.
*/
getPrefix(channelId: number): Buffer | undefined {
const prefix = this.allocatedPrefixes.get(channelId);
if (prefix === undefined) return undefined;
return this.prefixToBuffer(prefix);
}
get allocatedCount(): number {
return this.allocatedPrefixes.size;
}
private prefixToBuffer(prefix: number): Buffer {
const buf = Buffer.alloc(this.prefixSize);
// Write as big-endian so prefix 1 = 0x00000001
if (this.prefixSize === 4) {
buf.writeUInt32BE(prefix, 0);
} else if (this.prefixSize === 2) {
buf.writeUInt16BE(prefix, 0);
} else {
// Generic: write big-endian
let val = prefix;
for (let i = this.prefixSize - 1; i >= 0; i--) {
buf[i] = val & 0xff;
val = val >>> 8;
}
}
return buf;
}
}
-243
View File
@@ -1,243 +0,0 @@
import {
encodeFrameHeader,
decodeFrameHeader,
isChannelMessage,
plaintextToCiphertextLength,
ciphertextToPlaintextLength,
Sv2FrameReader,
Sv2FrameWriter,
Sv2FrameHeader,
} from './sv2-frame';
import { SV2_CHANNEL_MSG_FLAG, SV2_MAC_SIZE, SV2_MAX_PLAINTEXT_CHUNK } from './sv2-constants';
describe('SV2 Frame', () => {
// ── Header Encode / Decode ──────────────────────────────────────────
describe('frame header', () => {
it('should encode and decode a basic header', () => {
const header: Sv2FrameHeader = {
extensionType: 0x0000,
msgType: 0x10,
msgLength: 42,
};
const buf = encodeFrameHeader(header);
expect(buf.length).toBe(6);
const decoded = decodeFrameHeader(buf);
expect(decoded).toEqual(header);
});
it('should handle max msgLength (24-bit)', () => {
const header: Sv2FrameHeader = {
extensionType: 0,
msgType: 0xff,
msgLength: 0xffffff,
};
const buf = encodeFrameHeader(header);
const decoded = decodeFrameHeader(buf);
expect(decoded.msgLength).toBe(0xffffff);
});
it('should preserve channel message flag', () => {
const header: Sv2FrameHeader = {
extensionType: SV2_CHANNEL_MSG_FLAG | 0x01,
msgType: 0x1a,
msgLength: 24,
};
const buf = encodeFrameHeader(header);
const decoded = decodeFrameHeader(buf);
expect(decoded.extensionType).toBe(SV2_CHANNEL_MSG_FLAG | 0x01);
expect(isChannelMessage(decoded)).toBe(true);
});
it('should detect non-channel messages', () => {
const header: Sv2FrameHeader = {
extensionType: 0x0000,
msgType: 0x00,
msgLength: 10,
};
expect(isChannelMessage(header)).toBe(false);
});
it('should throw on buffer too short', () => {
expect(() => decodeFrameHeader(Buffer.alloc(5))).toThrow(RangeError);
});
});
// ── Ciphertext Length Calculations ──────────────────────────────────
describe('ciphertext length calculations', () => {
it('should return 0 for 0-length plaintext', () => {
expect(plaintextToCiphertextLength(0)).toBe(0);
expect(ciphertextToPlaintextLength(0)).toBe(0);
});
it('should add one MAC for small payload', () => {
expect(plaintextToCiphertextLength(100)).toBe(100 + SV2_MAC_SIZE);
});
it('should handle exactly one full chunk', () => {
const ct = plaintextToCiphertextLength(SV2_MAX_PLAINTEXT_CHUNK);
expect(ct).toBe(SV2_MAX_PLAINTEXT_CHUNK + SV2_MAC_SIZE);
});
it('should handle one full chunk + 1 byte remainder', () => {
const ct = plaintextToCiphertextLength(SV2_MAX_PLAINTEXT_CHUNK + 1);
expect(ct).toBe(SV2_MAX_PLAINTEXT_CHUNK + SV2_MAC_SIZE + 1 + SV2_MAC_SIZE);
});
it('plaintext -> ciphertext -> plaintext round-trips', () => {
const sizes = [0, 1, 100, SV2_MAX_PLAINTEXT_CHUNK, SV2_MAX_PLAINTEXT_CHUNK + 1, SV2_MAX_PLAINTEXT_CHUNK * 2 + 500];
for (const pt of sizes) {
const ct = plaintextToCiphertextLength(pt);
expect(ciphertextToPlaintextLength(ct)).toBe(pt);
}
});
it('should throw for invalid ciphertext length (remainder too small)', () => {
// A remainder of exactly SV2_MAC_SIZE would mean 0 plaintext bytes but still a chunk
// Actually that's valid (0 plaintext + 16 MAC). Let's test remainder < MAC
expect(() => ciphertextToPlaintextLength(5)).toThrow(RangeError);
});
});
// ── Plaintext Frame Reader / Writer ─────────────────────────────────
describe('plaintext framing', () => {
it('should write and read a complete plaintext frame', () => {
const writer = new Sv2FrameWriter(null);
const header: Sv2FrameHeader = {
extensionType: 0,
msgType: 0x00,
msgLength: 5,
};
const payload = Buffer.from('hello');
const wire = writer.writeFrame(header, payload);
const reader = new Sv2FrameReader(null);
const frames = reader.feed(wire);
expect(frames.length).toBe(1);
expect(frames[0].header.msgType).toBe(0x00);
expect(frames[0].payload.toString()).toBe('hello');
});
it('should handle fragmented TCP delivery', () => {
const writer = new Sv2FrameWriter(null);
const header: Sv2FrameHeader = {
extensionType: 0,
msgType: 0x10,
msgLength: 10,
};
const payload = Buffer.alloc(10, 0xab);
const wire = writer.writeFrame(header, payload);
const reader = new Sv2FrameReader(null);
// Feed byte by byte
let frames: any[] = [];
for (let i = 0; i < wire.length; i++) {
const chunk = wire.subarray(i, i + 1);
frames = frames.concat(reader.feed(Buffer.from(chunk)));
}
expect(frames.length).toBe(1);
expect(frames[0].header.msgType).toBe(0x10);
expect(frames[0].payload).toEqual(payload);
});
it('should parse multiple frames in one feed', () => {
const writer = new Sv2FrameWriter(null);
const frames: Buffer[] = [];
for (let i = 0; i < 3; i++) {
const header: Sv2FrameHeader = {
extensionType: 0,
msgType: i,
msgLength: 4,
};
frames.push(writer.writeFrame(header, Buffer.alloc(4, i)));
}
const reader = new Sv2FrameReader(null);
const result = reader.feed(Buffer.concat(frames));
expect(result.length).toBe(3);
for (let i = 0; i < 3; i++) {
expect(result[i].header.msgType).toBe(i);
expect(result[i].payload).toEqual(Buffer.alloc(4, i));
}
});
it('should handle zero-length payload', () => {
const writer = new Sv2FrameWriter(null);
const header: Sv2FrameHeader = {
extensionType: 0,
msgType: 0x01,
msgLength: 0,
};
const wire = writer.writeFrame(header, Buffer.alloc(0));
const reader = new Sv2FrameReader(null);
const frames = reader.feed(wire);
expect(frames.length).toBe(1);
expect(frames[0].payload.length).toBe(0);
});
});
// ── Encrypted Frame Reader / Writer ─────────────────────────────────
describe('encrypted framing', () => {
it('should write and read encrypted frames with mock cipher', () => {
// Simple XOR "encryption" for testing frame logic
const xorKey = 0x42;
const encrypt = (pt: Buffer) => {
const ct = Buffer.alloc(pt.length + SV2_MAC_SIZE);
for (let i = 0; i < pt.length; i++) ct[i] = pt[i] ^ xorKey;
// Fake MAC: zeros
return ct;
};
const decrypt = (ct: Buffer) => {
const pt = Buffer.alloc(ct.length - SV2_MAC_SIZE);
for (let i = 0; i < pt.length; i++) pt[i] = ct[i] ^ xorKey;
return pt;
};
const writer = new Sv2FrameWriter(encrypt);
const header: Sv2FrameHeader = {
extensionType: 0,
msgType: 0x10,
msgLength: 8,
};
const payload = Buffer.from('testdata');
const wire = writer.writeFrame(header, payload);
const reader = new Sv2FrameReader(decrypt);
const frames = reader.feed(wire);
expect(frames.length).toBe(1);
expect(frames[0].header.msgType).toBe(0x10);
expect(frames[0].payload.toString()).toBe('testdata');
});
it('should switch from plaintext to encrypted mode', () => {
const encrypt = (pt: Buffer) => Buffer.concat([pt, Buffer.alloc(SV2_MAC_SIZE)]);
const decrypt = (ct: Buffer) => ct.subarray(0, ct.length - SV2_MAC_SIZE);
// Start in plaintext mode
const writer = new Sv2FrameWriter(null);
const reader = new Sv2FrameReader(null);
const plainHeader: Sv2FrameHeader = { extensionType: 0, msgType: 0x00, msgLength: 3 };
const plainWire = writer.writeFrame(plainHeader, Buffer.from('abc'));
let frames = reader.feed(plainWire);
expect(frames.length).toBe(1);
expect(frames[0].payload.toString()).toBe('abc');
// Switch to encrypted
writer.setEncryptFn(encrypt);
reader.setDecryptFn(decrypt);
const encHeader: Sv2FrameHeader = { extensionType: 0, msgType: 0x01, msgLength: 3 };
const encWire = writer.writeFrame(encHeader, Buffer.from('xyz'));
frames = reader.feed(encWire);
expect(frames.length).toBe(1);
expect(frames[0].payload.toString()).toBe('xyz');
});
});
});
-232
View File
@@ -1,232 +0,0 @@
// ── SV2 Frame Encoding / Decoding ───────────────────────────────────
// Handles 6-byte frame headers, encrypted framing with ChaCha20-Poly1305
// AEAD chunking, and incremental TCP reassembly.
import {
SV2_HEADER_SIZE,
SV2_MAC_SIZE,
SV2_ENCRYPTED_HEADER_SIZE,
SV2_MAX_PLAINTEXT_CHUNK,
SV2_CHANNEL_MSG_FLAG,
} from './sv2-constants';
// ── Frame Header ────────────────────────────────────────────────────
export interface Sv2FrameHeader {
extensionType: number; // U16 (includes channel msg bit)
msgType: number; // U8
msgLength: number; // U24 (payload byte count)
}
/**
* Encode a 6-byte SV2 frame header.
* extension_type[0..1] | msg_type[2] | msg_length[3..5]
*/
export function encodeFrameHeader(header: Sv2FrameHeader): Buffer {
const buf = Buffer.alloc(SV2_HEADER_SIZE);
buf.writeUInt16LE(header.extensionType, 0);
buf[2] = header.msgType & 0xff;
buf[3] = header.msgLength & 0xff;
buf[4] = (header.msgLength >> 8) & 0xff;
buf[5] = (header.msgLength >> 16) & 0xff;
return buf;
}
/**
* Decode a 6-byte SV2 frame header.
*/
export function decodeFrameHeader(buf: Buffer): Sv2FrameHeader {
if (buf.length < SV2_HEADER_SIZE) {
throw new RangeError(`Frame header requires ${SV2_HEADER_SIZE} bytes, got ${buf.length}`);
}
const extensionType = buf.readUInt16LE(0);
const msgType = buf[2];
const msgLength = buf[3] | (buf[4] << 8) | (buf[5] << 16);
return { extensionType, msgType, msgLength };
}
/**
* Check if a frame header indicates a channel message.
*/
export function isChannelMessage(header: Sv2FrameHeader): boolean {
return (header.extensionType & SV2_CHANNEL_MSG_FLAG) !== 0;
}
// ── Ciphertext / Plaintext Size Calculations ────────────────────────
/**
* Calculate ciphertext length from plaintext length.
* Each chunk of up to 65519 plaintext bytes gets a 16-byte MAC.
*/
export function plaintextToCiphertextLength(plaintextLen: number): number {
if (plaintextLen === 0) return 0;
const fullChunks = Math.floor(plaintextLen / SV2_MAX_PLAINTEXT_CHUNK);
const remainder = plaintextLen % SV2_MAX_PLAINTEXT_CHUNK;
let len = fullChunks * (SV2_MAX_PLAINTEXT_CHUNK + SV2_MAC_SIZE);
if (remainder > 0) {
len += remainder + SV2_MAC_SIZE;
}
return len;
}
/**
* Calculate plaintext length from ciphertext length.
*/
export function ciphertextToPlaintextLength(ciphertextLen: number): number {
if (ciphertextLen === 0) return 0;
const chunkSize = SV2_MAX_PLAINTEXT_CHUNK + SV2_MAC_SIZE;
const fullChunks = Math.floor(ciphertextLen / chunkSize);
const remainder = ciphertextLen % chunkSize;
let len = fullChunks * SV2_MAX_PLAINTEXT_CHUNK;
if (remainder > 0) {
if (remainder <= SV2_MAC_SIZE) {
throw new RangeError('Invalid ciphertext length: remainder chunk too small for MAC');
}
len += remainder - SV2_MAC_SIZE;
}
return len;
}
// ── Decryption / Encryption Function Types ──────────────────────────
export type DecryptFn = (ciphertext: Buffer) => Buffer;
export type EncryptFn = (plaintext: Buffer) => Buffer;
// ── Frame Reader (TCP reassembly + decryption) ──────────────────────
interface ParsedFrame {
header: Sv2FrameHeader;
payload: Buffer;
}
const enum ReaderState {
READING_HEADER,
READING_PAYLOAD,
}
export class Sv2FrameReader {
private decryptFn: DecryptFn | null;
private buf: Buffer = Buffer.alloc(0);
private state = ReaderState.READING_HEADER;
private currentHeader: Sv2FrameHeader | null = null;
constructor(decryptFn: DecryptFn | null) {
this.decryptFn = decryptFn;
}
/** Switch to encrypted mode (after handshake completes). */
setDecryptFn(fn: DecryptFn): void {
this.decryptFn = fn;
}
/** Expected header size: 22 bytes encrypted, 6 bytes plaintext. */
private get headerSize(): number {
return this.decryptFn ? SV2_ENCRYPTED_HEADER_SIZE : SV2_HEADER_SIZE;
}
/**
* Feed incoming TCP data and return any complete frames.
*/
feed(data: Buffer): ParsedFrame[] {
this.buf = this.buf.length === 0 ? data : Buffer.concat([this.buf, data]);
const frames: ParsedFrame[] = [];
while (true) {
if (this.state === ReaderState.READING_HEADER) {
if (this.buf.length < this.headerSize) break;
let headerBytes: Buffer;
if (this.decryptFn) {
headerBytes = this.decryptFn(this.buf.subarray(0, SV2_ENCRYPTED_HEADER_SIZE));
} else {
headerBytes = this.buf.subarray(0, SV2_HEADER_SIZE);
}
this.currentHeader = decodeFrameHeader(headerBytes);
this.buf = Buffer.from(this.buf.subarray(this.headerSize));
this.state = ReaderState.READING_PAYLOAD;
}
if (this.state === ReaderState.READING_PAYLOAD) {
const payloadWireLen = this.decryptFn
? plaintextToCiphertextLength(this.currentHeader!.msgLength)
: this.currentHeader!.msgLength;
if (this.buf.length < payloadWireLen) break;
let payload: Buffer;
if (this.decryptFn && payloadWireLen > 0) {
payload = this.decryptPayloadChunks(this.buf.subarray(0, payloadWireLen));
} else {
payload = Buffer.from(this.buf.subarray(0, payloadWireLen));
}
frames.push({ header: this.currentHeader!, payload });
this.buf = Buffer.from(this.buf.subarray(payloadWireLen));
this.currentHeader = null;
this.state = ReaderState.READING_HEADER;
}
}
return frames;
}
private decryptPayloadChunks(ciphertext: Buffer): Buffer {
const chunkSize = SV2_MAX_PLAINTEXT_CHUNK + SV2_MAC_SIZE;
const parts: Buffer[] = [];
let offset = 0;
while (offset < ciphertext.length) {
const remaining = ciphertext.length - offset;
const thisChunkLen = Math.min(remaining, chunkSize);
const chunk = ciphertext.subarray(offset, offset + thisChunkLen);
parts.push(this.decryptFn!(Buffer.from(chunk)));
offset += thisChunkLen;
}
return Buffer.concat(parts);
}
}
// ── Frame Writer (serialization + encryption) ───────────────────────
export class Sv2FrameWriter {
private encryptFn: EncryptFn | null;
constructor(encryptFn: EncryptFn | null) {
this.encryptFn = encryptFn;
}
/** Switch to encrypted mode (after handshake completes). */
setEncryptFn(fn: EncryptFn): void {
this.encryptFn = fn;
}
/**
* Encode a complete frame (header + payload) ready for the wire.
*/
writeFrame(header: Sv2FrameHeader, payload: Buffer): Buffer {
const headerBuf = encodeFrameHeader(header);
if (!this.encryptFn) {
return Buffer.concat([headerBuf, payload]);
}
// Encrypt header
const encHeader = this.encryptFn(headerBuf);
// Encrypt payload in chunks
const parts: Buffer[] = [encHeader];
let offset = 0;
while (offset < payload.length) {
const remaining = payload.length - offset;
const chunkLen = Math.min(remaining, SV2_MAX_PLAINTEXT_CHUNK);
const chunk = payload.subarray(offset, offset + chunkLen);
parts.push(this.encryptFn(Buffer.from(chunk)));
offset += chunkLen;
}
return Buffer.concat(parts);
}
}
-312
View File
@@ -1,312 +0,0 @@
import { BufferReader } from './sv2-binary-codec';
import {
Sv2AllocateMiningJobToken,
serializeAllocateMiningJobToken,
deserializeAllocateMiningJobToken,
Sv2AllocateMiningJobTokenSuccess,
serializeAllocateMiningJobTokenSuccess,
deserializeAllocateMiningJobTokenSuccess,
Sv2DeclareMiningJob,
serializeDeclareMiningJob,
deserializeDeclareMiningJob,
Sv2DeclareMiningJobSuccess,
serializeDeclareMiningJobSuccess,
deserializeDeclareMiningJobSuccess,
Sv2DeclareMiningJobError,
serializeDeclareMiningJobError,
deserializeDeclareMiningJobError,
Sv2ProvideMissingTransactions,
serializeProvideMissingTransactions,
deserializeProvideMissingTransactions,
Sv2ProvideMissingTransactionsSuccess,
serializeProvideMissingTransactionsSuccess,
deserializeProvideMissingTransactionsSuccess,
Sv2SetCustomMiningJob,
serializeSetCustomMiningJob,
deserializeSetCustomMiningJob,
Sv2SetCustomMiningJobSuccess,
serializeSetCustomMiningJobSuccess,
deserializeSetCustomMiningJobSuccess,
Sv2SetCustomMiningJobError,
serializeSetCustomMiningJobError,
deserializeSetCustomMiningJobError,
Sv2PushSolution,
serializePushSolution,
deserializePushSolution,
} from './sv2-jdp-messages';
function roundTrip<T>(
msg: T,
serialize: (m: T) => Buffer,
deserialize: (r: BufferReader) => T,
): T {
const buf = serialize(msg);
return deserialize(new BufferReader(buf));
}
describe('SV2 JDP Messages', () => {
it('AllocateMiningJobToken round-trips', () => {
const msg: Sv2AllocateMiningJobToken = {
userIdentifier: 'miner-001',
requestId: 1,
};
const result = roundTrip(msg, serializeAllocateMiningJobToken, deserializeAllocateMiningJobToken);
expect(result.userIdentifier).toBe(msg.userIdentifier);
expect(result.requestId).toBe(msg.requestId);
});
it('AllocateMiningJobTokenSuccess round-trips', () => {
const msg: Sv2AllocateMiningJobTokenSuccess = {
requestId: 1,
miningJobToken: Buffer.from('token123', 'utf8'),
coinbaseOutputs: Buffer.from('cafebabe', 'hex'),
};
const result = roundTrip(msg, serializeAllocateMiningJobTokenSuccess, deserializeAllocateMiningJobTokenSuccess);
expect(result.requestId).toBe(msg.requestId);
expect(result.miningJobToken).toEqual(msg.miningJobToken);
expect(result.coinbaseOutputs).toEqual(msg.coinbaseOutputs);
});
it('AllocateMiningJobTokenSuccess with empty coinbaseOutputs', () => {
const msg: Sv2AllocateMiningJobTokenSuccess = {
requestId: 42,
miningJobToken: Buffer.from('tok'),
coinbaseOutputs: Buffer.alloc(0),
};
const result = roundTrip(msg, serializeAllocateMiningJobTokenSuccess, deserializeAllocateMiningJobTokenSuccess);
expect(result.coinbaseOutputs.length).toBe(0);
});
it('DeclareMiningJob round-trips', () => {
const wtxid1 = Buffer.alloc(32, 0x11);
const wtxid2 = Buffer.alloc(32, 0x22);
const msg: Sv2DeclareMiningJob = {
requestId: 42,
miningJobToken: Buffer.from('mytoken'),
version: 0x20000000,
coinbaseTxPrefix: Buffer.from('deadbeef', 'hex'),
coinbaseTxSuffix: Buffer.from('cafebabe', 'hex'),
wtxidList: [wtxid1, wtxid2],
excessData: Buffer.from('extra'),
};
const result = roundTrip(msg, serializeDeclareMiningJob, deserializeDeclareMiningJob);
expect(result.requestId).toBe(msg.requestId);
expect(result.miningJobToken).toEqual(msg.miningJobToken);
expect(result.version).toBe(msg.version);
expect(result.coinbaseTxPrefix).toEqual(msg.coinbaseTxPrefix);
expect(result.coinbaseTxSuffix).toEqual(msg.coinbaseTxSuffix);
expect(result.wtxidList).toHaveLength(2);
expect(result.wtxidList[0]).toEqual(wtxid1);
expect(result.wtxidList[1]).toEqual(wtxid2);
expect(result.excessData).toEqual(msg.excessData);
});
it('DeclareMiningJob with empty tx list', () => {
const msg: Sv2DeclareMiningJob = {
requestId: 1,
miningJobToken: Buffer.alloc(0),
version: 0x20000000,
coinbaseTxPrefix: Buffer.alloc(0),
coinbaseTxSuffix: Buffer.alloc(0),
wtxidList: [],
excessData: Buffer.alloc(0),
};
const result = roundTrip(msg, serializeDeclareMiningJob, deserializeDeclareMiningJob);
expect(result.wtxidList).toHaveLength(0);
});
it('DeclareMiningJobSuccess round-trips', () => {
const msg: Sv2DeclareMiningJobSuccess = {
requestId: 42,
newMiningJobToken: Buffer.from('newtoken'),
};
const result = roundTrip(msg, serializeDeclareMiningJobSuccess, deserializeDeclareMiningJobSuccess);
expect(result).toEqual(msg);
});
it('DeclareMiningJobError round-trips', () => {
const msg: Sv2DeclareMiningJobError = {
requestId: 42,
errorCode: 'invalid-mining-job-token',
errorDetails: Buffer.from('Token expired'),
};
const result = roundTrip(msg, serializeDeclareMiningJobError, deserializeDeclareMiningJobError);
expect(result.requestId).toBe(msg.requestId);
expect(result.errorCode).toBe(msg.errorCode);
expect(result.errorDetails).toEqual(msg.errorDetails);
});
it('ProvideMissingTransactions round-trips', () => {
const msg: Sv2ProvideMissingTransactions = {
requestId: 7,
unknownTxPositionList: [0, 3, 7, 15],
};
const result = roundTrip(msg, serializeProvideMissingTransactions, deserializeProvideMissingTransactions);
expect(result.requestId).toBe(msg.requestId);
expect(result.unknownTxPositionList).toEqual([0, 3, 7, 15]);
});
it('ProvideMissingTransactionsSuccess round-trips (B0_16M inner type)', () => {
const tx1 = Buffer.from('0100000001', 'hex');
const tx2 = Buffer.from('0200000002abcdef', 'hex');
const msg: Sv2ProvideMissingTransactionsSuccess = {
requestId: 7,
transactionList: [tx1, tx2],
};
const result = roundTrip(msg, serializeProvideMissingTransactionsSuccess, deserializeProvideMissingTransactionsSuccess);
expect(result.requestId).toBe(msg.requestId);
expect(result.transactionList).toHaveLength(2);
expect(result.transactionList[0]).toEqual(tx1);
expect(result.transactionList[1]).toEqual(tx2);
});
it('ProvideMissingTransactionsSuccess with large transaction (>64KB)', () => {
const largeTx = Buffer.alloc(100000, 0xab); // 100KB > 64KB limit of B0_64K
const msg: Sv2ProvideMissingTransactionsSuccess = {
requestId: 1,
transactionList: [largeTx],
};
const result = roundTrip(msg, serializeProvideMissingTransactionsSuccess, deserializeProvideMissingTransactionsSuccess);
expect(result.transactionList[0].length).toBe(100000);
expect(result.transactionList[0]).toEqual(largeTx);
});
it('SetCustomMiningJob round-trips', () => {
const hash1 = Buffer.alloc(32, 0xaa);
const msg: Sv2SetCustomMiningJob = {
channelId: 1,
requestId: 100,
token: Buffer.from('mytoken'),
version: 0x20000000,
prevHash: Buffer.alloc(32, 0xbb),
minNtime: 1700000000,
nBits: 0x1d00ffff,
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.from('deadbeef', 'hex'),
coinbaseTxInputNSequence: 0xffffffff,
coinbaseTxOutputs: Buffer.from('outputs'),
coinbaseTxLocktime: 0,
merklePath: [hash1],
};
const result = roundTrip(msg, serializeSetCustomMiningJob, deserializeSetCustomMiningJob);
expect(result.channelId).toBe(msg.channelId);
expect(result.requestId).toBe(msg.requestId);
expect(result.token).toEqual(msg.token);
expect(result.version).toBe(msg.version);
expect(result.prevHash).toEqual(msg.prevHash);
expect(result.minNtime).toBe(msg.minNtime);
expect(result.nBits).toBe(msg.nBits);
expect(result.coinbaseTxVersion).toBe(msg.coinbaseTxVersion);
expect(result.coinbasePrefix).toEqual(msg.coinbasePrefix);
expect(result.coinbaseTxInputNSequence).toBe(msg.coinbaseTxInputNSequence);
expect(result.coinbaseTxOutputs).toEqual(msg.coinbaseTxOutputs);
expect(result.coinbaseTxLocktime).toBe(msg.coinbaseTxLocktime);
expect(result.merklePath).toHaveLength(1);
expect(result.merklePath[0]).toEqual(hash1);
});
it('SetCustomMiningJobSuccess round-trips', () => {
const msg: Sv2SetCustomMiningJobSuccess = {
channelId: 1,
requestId: 100,
jobId: 42,
};
const result = roundTrip(msg, serializeSetCustomMiningJobSuccess, deserializeSetCustomMiningJobSuccess);
expect(result).toEqual(msg);
});
it('SetCustomMiningJobError round-trips', () => {
const msg: Sv2SetCustomMiningJobError = {
channelId: 1,
requestId: 100,
errorCode: 'invalid-mining-job-token',
};
const result = roundTrip(msg, serializeSetCustomMiningJobError, deserializeSetCustomMiningJobError);
expect(result).toEqual(msg);
});
it('serialized DeclareMiningJob has correct structure', () => {
const msg: Sv2DeclareMiningJob = {
requestId: 1,
miningJobToken: Buffer.from([0xab]),
version: 2,
coinbaseTxPrefix: Buffer.alloc(0),
coinbaseTxSuffix: Buffer.alloc(0),
wtxidList: [],
excessData: Buffer.alloc(0),
};
const buf = serializeDeclareMiningJob(msg);
// U32(4) + B0_255(1+1) + U32(4) + B0_64K(2+0) + B0_64K(2+0) + SEQ0_64K(2+0) + B0_64K(2+0)
// = 4 + 2 + 4 + 2 + 2 + 2 + 2 = 18
expect(buf.length).toBe(18);
});
it('PushSolution round-trips', () => {
const msg: Sv2PushSolution = {
extranonce: Buffer.from('aabbccdd', 'hex'),
prevHash: Buffer.alloc(32, 0x11),
nonce: 0xdeadbeef,
ntime: 1700000000,
nBits: 0x1d00ffff,
version: 0x20000000,
};
const result = roundTrip(msg, serializePushSolution, deserializePushSolution);
expect(result.extranonce).toEqual(msg.extranonce);
expect(result.prevHash).toEqual(msg.prevHash);
expect(result.nonce).toBe(msg.nonce);
expect(result.ntime).toBe(msg.ntime);
expect(result.nBits).toBe(msg.nBits);
expect(result.version).toBe(msg.version);
});
it('PushSolution with empty extranonce', () => {
const msg: Sv2PushSolution = {
extranonce: Buffer.alloc(0),
prevHash: Buffer.alloc(32, 0xff),
nonce: 0,
ntime: 0,
nBits: 0,
version: 0,
};
const result = roundTrip(msg, serializePushSolution, deserializePushSolution);
expect(result.extranonce.length).toBe(0);
expect(result.prevHash).toEqual(msg.prevHash);
});
it('serialized PushSolution has correct size', () => {
const msg: Sv2PushSolution = {
extranonce: Buffer.alloc(8),
prevHash: Buffer.alloc(32),
nonce: 0,
ntime: 0,
nBits: 0,
version: 0,
};
const buf = serializePushSolution(msg);
// B0_32(1+8) + U256(32) + U32(4) + U32(4) + U32(4) + U32(4) = 57
expect(buf.length).toBe(57);
});
it('serialized SetCustomMiningJob has correct fixed overhead', () => {
const msg: Sv2SetCustomMiningJob = {
channelId: 1,
requestId: 2,
token: Buffer.alloc(0),
version: 0x20000000,
prevHash: Buffer.alloc(32),
minNtime: 0,
nBits: 0,
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.alloc(0),
coinbaseTxInputNSequence: 0xffffffff,
coinbaseTxOutputs: Buffer.alloc(0),
coinbaseTxLocktime: 0,
merklePath: [],
};
const buf = serializeSetCustomMiningJob(msg);
// U32(4) + U32(4) + B0_255(1+0) + U32(4) + U256(32) + U32(4) + U32(4) + U32(4) +
// B0_255(1+0) + U32(4) + B0_64K(2+0) + U32(4) + SEQ0_255(1+0)
// = 4+4+1+4+32+4+4+4+1+4+2+4+1 = 69
expect(buf.length).toBe(69);
});
});
-309
View File
@@ -1,309 +0,0 @@
// ── SV2 Job Declaration Protocol (JDP) Messages ────────────────────
// Interfaces + serialize/deserialize for all JDP message types and
// the Mining Protocol custom job bridge messages.
// Aligned with Rust SV2 reference (stratum/sv2/subprotocols/job-declaration).
import { BufferReader, BufferWriter } from './sv2-binary-codec';
// ── AllocateMiningJobToken (0x50) ──────────────────────────────────
export interface Sv2AllocateMiningJobToken {
userIdentifier: string; // STR0_255
requestId: number; // U32
}
export function serializeAllocateMiningJobToken(msg: Sv2AllocateMiningJobToken): Buffer {
const w = new BufferWriter();
w.writeStr0_255(msg.userIdentifier);
w.writeU32(msg.requestId);
return w.toBuffer();
}
export function deserializeAllocateMiningJobToken(reader: BufferReader): Sv2AllocateMiningJobToken {
return {
userIdentifier: reader.readStr0_255(),
requestId: reader.readU32(),
};
}
// ── AllocateMiningJobToken.Success (0x51) ──────────────────────────
export interface Sv2AllocateMiningJobTokenSuccess {
requestId: number; // U32
miningJobToken: Buffer; // B0_255
coinbaseOutputs: Buffer; // B0_64K
}
export function serializeAllocateMiningJobTokenSuccess(msg: Sv2AllocateMiningJobTokenSuccess): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeB0_255(msg.miningJobToken);
w.writeB0_64K(msg.coinbaseOutputs);
return w.toBuffer();
}
export function deserializeAllocateMiningJobTokenSuccess(reader: BufferReader): Sv2AllocateMiningJobTokenSuccess {
return {
requestId: reader.readU32(),
miningJobToken: reader.readB0_255(),
coinbaseOutputs: reader.readB0_64K(),
};
}
// ── DeclareMiningJob (0x57) ────────────────────────────────────────
export interface Sv2DeclareMiningJob {
requestId: number; // U32
miningJobToken: Buffer; // B0_255
version: number; // U32
coinbaseTxPrefix: Buffer; // B0_64K
coinbaseTxSuffix: Buffer; // B0_64K
wtxidList: Buffer[]; // SEQ0_64K<U256>
excessData: Buffer; // B0_64K
}
export function serializeDeclareMiningJob(msg: Sv2DeclareMiningJob): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeB0_255(msg.miningJobToken);
w.writeU32(msg.version);
w.writeB0_64K(msg.coinbaseTxPrefix);
w.writeB0_64K(msg.coinbaseTxSuffix);
w.writeSeq0_64K(msg.wtxidList, (writer, hash) => writer.writeU256(hash));
w.writeB0_64K(msg.excessData);
return w.toBuffer();
}
export function deserializeDeclareMiningJob(reader: BufferReader): Sv2DeclareMiningJob {
return {
requestId: reader.readU32(),
miningJobToken: reader.readB0_255(),
version: reader.readU32(),
coinbaseTxPrefix: reader.readB0_64K(),
coinbaseTxSuffix: reader.readB0_64K(),
wtxidList: reader.readSeq0_64K((r) => r.readU256()),
excessData: reader.readB0_64K(),
};
}
// ── DeclareMiningJob.Success (0x58) ────────────────────────────────
export interface Sv2DeclareMiningJobSuccess {
requestId: number; // U32
newMiningJobToken: Buffer; // B0_255
}
export function serializeDeclareMiningJobSuccess(msg: Sv2DeclareMiningJobSuccess): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeB0_255(msg.newMiningJobToken);
return w.toBuffer();
}
export function deserializeDeclareMiningJobSuccess(reader: BufferReader): Sv2DeclareMiningJobSuccess {
return {
requestId: reader.readU32(),
newMiningJobToken: reader.readB0_255(),
};
}
// ── DeclareMiningJob.Error (0x59) ──────────────────────────────────
export interface Sv2DeclareMiningJobError {
requestId: number; // U32
errorCode: string; // STR0_255
errorDetails: Buffer; // B0_64K
}
export function serializeDeclareMiningJobError(msg: Sv2DeclareMiningJobError): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeStr0_255(msg.errorCode);
w.writeB0_64K(msg.errorDetails);
return w.toBuffer();
}
export function deserializeDeclareMiningJobError(reader: BufferReader): Sv2DeclareMiningJobError {
return {
requestId: reader.readU32(),
errorCode: reader.readStr0_255(),
errorDetails: reader.readB0_64K(),
};
}
// ── ProvideMissingTransactions (0x55) ──────────────────────────────
export interface Sv2ProvideMissingTransactions {
requestId: number; // U32
unknownTxPositionList: number[]; // SEQ0_64K<U16>
}
export function serializeProvideMissingTransactions(msg: Sv2ProvideMissingTransactions): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeSeq0_64K(msg.unknownTxPositionList, (writer, pos) => writer.writeU16(pos));
return w.toBuffer();
}
export function deserializeProvideMissingTransactions(reader: BufferReader): Sv2ProvideMissingTransactions {
return {
requestId: reader.readU32(),
unknownTxPositionList: reader.readSeq0_64K((r) => r.readU16()),
};
}
// ── ProvideMissingTransactions.Success (0x56) ──────────────────────
export interface Sv2ProvideMissingTransactionsSuccess {
requestId: number; // U32
transactionList: Buffer[]; // SEQ0_64K<B0_16M>
}
export function serializeProvideMissingTransactionsSuccess(msg: Sv2ProvideMissingTransactionsSuccess): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeSeq0_64K(msg.transactionList, (writer, tx) => writer.writeB0_16M(tx));
return w.toBuffer();
}
export function deserializeProvideMissingTransactionsSuccess(reader: BufferReader): Sv2ProvideMissingTransactionsSuccess {
return {
requestId: reader.readU32(),
transactionList: reader.readSeq0_64K((r) => r.readB0_16M()),
};
}
// ── PushSolution (0x60) ────────────────────────────────────────
export interface Sv2PushSolution {
extranonce: Buffer; // B0_32
prevHash: Buffer; // U256
nonce: number; // U32
ntime: number; // U32
nBits: number; // U32
version: number; // U32
}
export function serializePushSolution(msg: Sv2PushSolution): Buffer {
const w = new BufferWriter();
w.writeB0_32(msg.extranonce);
w.writeU256(msg.prevHash);
w.writeU32(msg.nonce);
w.writeU32(msg.ntime);
w.writeU32(msg.nBits);
w.writeU32(msg.version);
return w.toBuffer();
}
export function deserializePushSolution(reader: BufferReader): Sv2PushSolution {
return {
extranonce: reader.readB0_32(),
prevHash: reader.readU256(),
nonce: reader.readU32(),
ntime: reader.readU32(),
nBits: reader.readU32(),
version: reader.readU32(),
};
}
// ── SetCustomMiningJob (0x22) ──────────────────────────────────────
export interface Sv2SetCustomMiningJob {
channelId: number; // U32
requestId: number; // U32
token: Buffer; // B0_255
version: number; // U32
prevHash: Buffer; // U256
minNtime: number; // U32
nBits: number; // U32
coinbaseTxVersion: number; // U32
coinbasePrefix: Buffer; // B0_255
coinbaseTxInputNSequence: number; // U32
coinbaseTxOutputs: Buffer; // B0_64K
coinbaseTxLocktime: number; // U32
merklePath: Buffer[]; // SEQ0_255<U256>
}
export function serializeSetCustomMiningJob(msg: Sv2SetCustomMiningJob): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.requestId);
w.writeB0_255(msg.token);
w.writeU32(msg.version);
w.writeU256(msg.prevHash);
w.writeU32(msg.minNtime);
w.writeU32(msg.nBits);
w.writeU32(msg.coinbaseTxVersion);
w.writeB0_255(msg.coinbasePrefix);
w.writeU32(msg.coinbaseTxInputNSequence);
w.writeB0_64K(msg.coinbaseTxOutputs);
w.writeU32(msg.coinbaseTxLocktime);
w.writeSeq0_255(msg.merklePath, (writer, hash) => writer.writeU256(hash));
return w.toBuffer();
}
export function deserializeSetCustomMiningJob(reader: BufferReader): Sv2SetCustomMiningJob {
return {
channelId: reader.readU32(),
requestId: reader.readU32(),
token: reader.readB0_255(),
version: reader.readU32(),
prevHash: reader.readU256(),
minNtime: reader.readU32(),
nBits: reader.readU32(),
coinbaseTxVersion: reader.readU32(),
coinbasePrefix: reader.readB0_255(),
coinbaseTxInputNSequence: reader.readU32(),
coinbaseTxOutputs: reader.readB0_64K(),
coinbaseTxLocktime: reader.readU32(),
merklePath: reader.readSeq0_255((r) => r.readU256()),
};
}
// ── SetCustomMiningJob.Success (0x23) ──────────────────────────────
export interface Sv2SetCustomMiningJobSuccess {
channelId: number; // U32
requestId: number; // U32
jobId: number; // U32
}
export function serializeSetCustomMiningJobSuccess(msg: Sv2SetCustomMiningJobSuccess): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.requestId);
w.writeU32(msg.jobId);
return w.toBuffer();
}
export function deserializeSetCustomMiningJobSuccess(reader: BufferReader): Sv2SetCustomMiningJobSuccess {
return {
channelId: reader.readU32(),
requestId: reader.readU32(),
jobId: reader.readU32(),
};
}
// ── SetCustomMiningJob.Error (0x24) ────────────────────────────────
export interface Sv2SetCustomMiningJobError {
channelId: number; // U32
requestId: number; // U32
errorCode: string; // STR0_255
}
export function serializeSetCustomMiningJobError(msg: Sv2SetCustomMiningJobError): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.requestId);
w.writeStr0_255(msg.errorCode);
return w.toBuffer();
}
export function deserializeSetCustomMiningJobError(reader: BufferReader): Sv2SetCustomMiningJobError {
return {
channelId: reader.readU32(),
requestId: reader.readU32(),
errorCode: reader.readStr0_255(),
};
}
-250
View File
@@ -1,250 +0,0 @@
import { BufferReader } from './sv2-binary-codec';
import {
Sv2SetupConnection, serializeSetupConnection, deserializeSetupConnection,
Sv2SetupConnectionSuccess, serializeSetupConnectionSuccess, deserializeSetupConnectionSuccess,
Sv2SetupConnectionError, serializeSetupConnectionError, deserializeSetupConnectionError,
Sv2ChannelEndpointChanged, serializeChannelEndpointChanged, deserializeChannelEndpointChanged,
Sv2OpenStandardMiningChannel, serializeOpenStandardMiningChannel, deserializeOpenStandardMiningChannel,
Sv2OpenStandardMiningChannelSuccess, serializeOpenStandardMiningChannelSuccess, deserializeOpenStandardMiningChannelSuccess,
Sv2NewMiningJob, serializeNewMiningJob, deserializeNewMiningJob,
Sv2SetNewPrevHash, serializeSetNewPrevHash, deserializeSetNewPrevHash,
Sv2SetTarget, serializeSetTarget, deserializeSetTarget,
Sv2SubmitSharesStandard, serializeSubmitSharesStandard, deserializeSubmitSharesStandard,
Sv2SubmitSharesSuccess, serializeSubmitSharesSuccess, deserializeSubmitSharesSuccess,
Sv2SubmitSharesError, serializeSubmitSharesError, deserializeSubmitSharesError,
Sv2CloseChannel, serializeCloseChannel, deserializeCloseChannel,
Sv2UpdateChannel, serializeUpdateChannel, deserializeUpdateChannel,
Sv2UpdateChannelError, serializeUpdateChannelError, deserializeUpdateChannelError,
Sv2Reconnect, serializeReconnect, deserializeReconnect,
} from './sv2-messages';
function roundTrip<T>(
msg: T,
serialize: (m: T) => Buffer,
deserialize: (r: BufferReader) => T,
): T {
const buf = serialize(msg);
return deserialize(new BufferReader(buf));
}
describe('SV2 Messages', () => {
it('SetupConnection round-trips', () => {
const msg: Sv2SetupConnection = {
protocol: 0,
minVersion: 2,
maxVersion: 2,
flags: 0x07,
endpoint_host: 'pool.example.com',
endpoint_port: 3333,
vendor: 'TestMiner',
hardwareVersion: '1.0',
firmwareVersion: '2.1',
deviceId: 'device-001',
};
const result = roundTrip(msg, serializeSetupConnection, deserializeSetupConnection);
expect(result).toEqual(msg);
});
it('SetupConnectionSuccess round-trips', () => {
const msg: Sv2SetupConnectionSuccess = {
usedVersion: 2,
flags: 0x05,
};
const result = roundTrip(msg, serializeSetupConnectionSuccess, deserializeSetupConnectionSuccess);
expect(result).toEqual(msg);
});
it('SetupConnectionError round-trips', () => {
const msg: Sv2SetupConnectionError = {
flags: 0x00,
errorCode: 'unsupported-feature-flags',
};
const result = roundTrip(msg, serializeSetupConnectionError, deserializeSetupConnectionError);
expect(result).toEqual(msg);
});
it('ChannelEndpointChanged round-trips', () => {
const msg: Sv2ChannelEndpointChanged = {
channelId: 7,
};
const result = roundTrip(msg, serializeChannelEndpointChanged, deserializeChannelEndpointChanged);
expect(result).toEqual(msg);
});
it('OpenStandardMiningChannel round-trips', () => {
const maxTarget = Buffer.alloc(32, 0x00);
maxTarget[0] = 0xff;
maxTarget[28] = 0x1d;
const msg: Sv2OpenStandardMiningChannel = {
requestId: 1,
user_identity: 'worker1',
nominalHashRate: 1000000.0,
maxTarget,
};
const result = roundTrip(msg, serializeOpenStandardMiningChannel, deserializeOpenStandardMiningChannel);
expect(result.requestId).toBe(msg.requestId);
expect(result.user_identity).toBe(msg.user_identity);
expect(result.nominalHashRate).toBeCloseTo(msg.nominalHashRate, 0);
expect(result.maxTarget).toEqual(msg.maxTarget);
});
it('OpenStandardMiningChannelSuccess round-trips', () => {
const target = Buffer.alloc(32);
target[0] = 0xff;
target[31] = 0x01;
const msg: Sv2OpenStandardMiningChannelSuccess = {
requestId: 1,
channelId: 42,
target,
extranonce_prefix: Buffer.from([0x01, 0x02, 0x03]),
groupChannelId: 0,
};
const result = roundTrip(
msg,
serializeOpenStandardMiningChannelSuccess,
deserializeOpenStandardMiningChannelSuccess,
);
expect(result.requestId).toBe(msg.requestId);
expect(result.channelId).toBe(msg.channelId);
expect(result.target).toEqual(msg.target);
expect(result.extranonce_prefix).toEqual(msg.extranonce_prefix);
expect(result.groupChannelId).toBe(msg.groupChannelId);
});
it('NewMiningJob round-trips', () => {
const merkleRoot = Buffer.alloc(32, 0xab);
const msg: Sv2NewMiningJob = {
channelId: 1,
jobId: 100,
minNtime: null, // Sv2Option<U32>: null = future job
version: 0x20000000,
merkleRoot,
};
const result = roundTrip(msg, serializeNewMiningJob, deserializeNewMiningJob);
expect(result).toEqual(msg);
});
it('NewMiningJob round-trips with minNtime value', () => {
const merkleRoot = Buffer.alloc(32, 0xab);
const msg: Sv2NewMiningJob = {
channelId: 1,
jobId: 101,
minNtime: 1700000000, // Sv2Option<U32>: Some(timestamp) = ready to mine
version: 0x20000000,
merkleRoot,
};
const result = roundTrip(msg, serializeNewMiningJob, deserializeNewMiningJob);
expect(result).toEqual(msg);
});
it('SetNewPrevHash round-trips', () => {
const prevHash = Buffer.alloc(32, 0xcd);
const msg: Sv2SetNewPrevHash = {
channelId: 1,
jobId: 100,
prevHash,
minNtime: 1700000000,
nBits: 0x1d00ffff,
};
const result = roundTrip(msg, serializeSetNewPrevHash, deserializeSetNewPrevHash);
expect(result).toEqual(msg);
});
it('SetTarget round-trips', () => {
const maxTarget = Buffer.alloc(32, 0x00);
maxTarget[0] = 0xff;
const msg: Sv2SetTarget = {
channelId: 5,
maxTarget,
};
const result = roundTrip(msg, serializeSetTarget, deserializeSetTarget);
expect(result).toEqual(msg);
});
it('SubmitSharesStandard round-trips', () => {
const msg: Sv2SubmitSharesStandard = {
channelId: 1,
sequenceNumber: 42,
jobId: 100,
nonce: 0xdeadbeef,
ntime: 1700000000,
version: 0x20000000,
};
const result = roundTrip(msg, serializeSubmitSharesStandard, deserializeSubmitSharesStandard);
expect(result).toEqual(msg);
});
it('SubmitSharesSuccess round-trips', () => {
const msg: Sv2SubmitSharesSuccess = {
channelId: 1,
lastSequenceNumber: 42,
newSubmitsAcceptedCount: 10,
newSharesSum: 123456789012345n,
};
const result = roundTrip(msg, serializeSubmitSharesSuccess, deserializeSubmitSharesSuccess);
expect(result).toEqual(msg);
});
it('SubmitSharesError round-trips', () => {
const msg: Sv2SubmitSharesError = {
channelId: 1,
sequenceNumber: 42,
errorCode: 'stale-share',
};
const result = roundTrip(msg, serializeSubmitSharesError, deserializeSubmitSharesError);
expect(result).toEqual(msg);
});
it('CloseChannel round-trips', () => {
const msg: Sv2CloseChannel = {
channelId: 7,
reasonCode: 'client-disconnect',
};
const result = roundTrip(msg, serializeCloseChannel, deserializeCloseChannel);
expect(result).toEqual(msg);
});
it('UpdateChannel round-trips', () => {
const maximumTarget = Buffer.alloc(32, 0x00);
maximumTarget[0] = 0xff;
const msg: Sv2UpdateChannel = {
channelId: 3,
nominalHashRate: 500000.0,
maximumTarget,
};
const result = roundTrip(msg, serializeUpdateChannel, deserializeUpdateChannel);
expect(result.channelId).toBe(msg.channelId);
expect(result.nominalHashRate).toBeCloseTo(msg.nominalHashRate, 0);
expect(result.maximumTarget).toEqual(msg.maximumTarget);
});
it('UpdateChannelError round-trips', () => {
const msg: Sv2UpdateChannelError = {
channelId: 3,
errorCode: 'invalid-channel',
};
const result = roundTrip(msg, serializeUpdateChannelError, deserializeUpdateChannelError);
expect(result).toEqual(msg);
});
it('Reconnect round-trips', () => {
const msg: Sv2Reconnect = {
newHost: 'pool2.example.com',
newPort: 3334,
};
const result = roundTrip(msg, serializeReconnect, deserializeReconnect);
expect(result).toEqual(msg);
});
it('serialized bytes match expected layout (SetupConnectionSuccess)', () => {
const msg: Sv2SetupConnectionSuccess = {
usedVersion: 2,
flags: 0x01,
};
const buf = serializeSetupConnectionSuccess(msg);
// U16 LE(2) + U32 LE(1) = 6 bytes
expect(buf.length).toBe(6);
expect(buf.readUInt16LE(0)).toBe(2);
expect(buf.readUInt32LE(2)).toBe(1);
});
});
-478
View File
@@ -1,478 +0,0 @@
// ── SV2 Mining Protocol Messages ────────────────────────────────────
// Interfaces + serialize/deserialize for each message needed for
// standard mining channels.
import { BufferReader, BufferWriter } from './sv2-binary-codec';
// ── SetupConnection (0x00) ──────────────────────────────────────────
export interface Sv2SetupConnection {
protocol: number; // U8 (0 = Mining, 1 = Job Declaration, 2 = Template Distribution)
minVersion: number; // U16
maxVersion: number; // U16
flags: number; // U32
endpoint_host: string; // STR0_255
endpoint_port: number; // U16
vendor: string; // STR0_255
hardwareVersion: string; // STR0_255
firmwareVersion: string; // STR0_255
deviceId: string; // STR0_255
}
export function serializeSetupConnection(msg: Sv2SetupConnection): Buffer {
const w = new BufferWriter();
w.writeU8(msg.protocol);
w.writeU16(msg.minVersion);
w.writeU16(msg.maxVersion);
w.writeU32(msg.flags);
w.writeStr0_255(msg.endpoint_host);
w.writeU16(msg.endpoint_port);
w.writeStr0_255(msg.vendor);
w.writeStr0_255(msg.hardwareVersion);
w.writeStr0_255(msg.firmwareVersion);
w.writeStr0_255(msg.deviceId);
return w.toBuffer();
}
export function deserializeSetupConnection(reader: BufferReader): Sv2SetupConnection {
return {
protocol: reader.readU8(),
minVersion: reader.readU16(),
maxVersion: reader.readU16(),
flags: reader.readU32(),
endpoint_host: reader.readStr0_255(),
endpoint_port: reader.readU16(),
vendor: reader.readStr0_255(),
hardwareVersion: reader.readStr0_255(),
firmwareVersion: reader.readStr0_255(),
deviceId: reader.readStr0_255(),
};
}
// ── SetupConnectionSuccess (0x01) ───────────────────────────────────
export interface Sv2SetupConnectionSuccess {
usedVersion: number; // U16
flags: number; // U32
}
export function serializeSetupConnectionSuccess(msg: Sv2SetupConnectionSuccess): Buffer {
const w = new BufferWriter();
w.writeU16(msg.usedVersion);
w.writeU32(msg.flags);
return w.toBuffer();
}
export function deserializeSetupConnectionSuccess(reader: BufferReader): Sv2SetupConnectionSuccess {
return {
usedVersion: reader.readU16(),
flags: reader.readU32(),
};
}
// ── SetupConnectionError (0x02) ─────────────────────────────────────
export interface Sv2SetupConnectionError {
flags: number; // U32
errorCode: string; // STR0_255
}
export function serializeSetupConnectionError(msg: Sv2SetupConnectionError): Buffer {
const w = new BufferWriter();
w.writeU32(msg.flags);
w.writeStr0_255(msg.errorCode);
return w.toBuffer();
}
export function deserializeSetupConnectionError(reader: BufferReader): Sv2SetupConnectionError {
return {
flags: reader.readU32(),
errorCode: reader.readStr0_255(),
};
}
// ── ChannelEndpointChanged (0x03) ───────────────────────────────────
export interface Sv2ChannelEndpointChanged {
channelId: number; // U32
}
export function serializeChannelEndpointChanged(msg: Sv2ChannelEndpointChanged): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
return w.toBuffer();
}
export function deserializeChannelEndpointChanged(reader: BufferReader): Sv2ChannelEndpointChanged {
return {
channelId: reader.readU32(),
};
}
// ── OpenStandardMiningChannel (0x10) ────────────────────────────────
export interface Sv2OpenStandardMiningChannel {
requestId: number; // U32
user_identity: string; // STR0_255
nominalHashRate: number; // F32
maxTarget: Buffer; // U256 (32 bytes LE)
}
export function serializeOpenStandardMiningChannel(msg: Sv2OpenStandardMiningChannel): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeStr0_255(msg.user_identity);
w.writeF32(msg.nominalHashRate);
w.writeU256(msg.maxTarget);
return w.toBuffer();
}
export function deserializeOpenStandardMiningChannel(
reader: BufferReader,
): Sv2OpenStandardMiningChannel {
return {
requestId: reader.readU32(),
user_identity: reader.readStr0_255(),
nominalHashRate: reader.readF32(),
maxTarget: reader.readU256(),
};
}
// ── OpenStandardMiningChannelSuccess (0x11) ─────────────────────────
export interface Sv2OpenStandardMiningChannelSuccess {
requestId: number; // U32
channelId: number; // U32
target: Buffer; // U256 (32 bytes LE)
extranonce_prefix: Buffer; // B0_32
groupChannelId: number; // U32
}
export function serializeOpenStandardMiningChannelSuccess(
msg: Sv2OpenStandardMiningChannelSuccess,
): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeU32(msg.channelId);
w.writeU256(msg.target);
w.writeB0_32(msg.extranonce_prefix);
w.writeU32(msg.groupChannelId);
return w.toBuffer();
}
export function deserializeOpenStandardMiningChannelSuccess(
reader: BufferReader,
): Sv2OpenStandardMiningChannelSuccess {
return {
requestId: reader.readU32(),
channelId: reader.readU32(),
target: reader.readU256(),
extranonce_prefix: reader.readB0_32(),
groupChannelId: reader.readU32(),
};
}
// ── OpenMiningChannel.Error (0x12) ──────────────────────────────────
export interface Sv2OpenMiningChannelError {
requestId: number; // U32
errorCode: string; // STR0_255
}
export function serializeOpenMiningChannelError(msg: Sv2OpenMiningChannelError): Buffer {
const w = new BufferWriter();
w.writeU32(msg.requestId);
w.writeStr0_255(msg.errorCode);
return w.toBuffer();
}
export function deserializeOpenMiningChannelError(reader: BufferReader): Sv2OpenMiningChannelError {
return {
requestId: reader.readU32(),
errorCode: reader.readStr0_255(),
};
}
// ── NewMiningJob (0x15) ─────────────────────────────────────────────
export interface Sv2NewMiningJob {
channelId: number; // U32
jobId: number; // U32
minNtime: number | null; // Sv2Option<U32> - null = future job, number = min ntime value
version: number; // U32
merkleRoot: Buffer; // U256 (32 bytes)
}
export function serializeNewMiningJob(msg: Sv2NewMiningJob): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.jobId);
w.writeOptionU32(msg.minNtime);
w.writeU32(msg.version);
w.writeU256(msg.merkleRoot);
return w.toBuffer();
}
export function deserializeNewMiningJob(reader: BufferReader): Sv2NewMiningJob {
return {
channelId: reader.readU32(),
jobId: reader.readU32(),
minNtime: reader.readOptionU32(),
version: reader.readU32(),
merkleRoot: reader.readU256(),
};
}
// ── SetNewPrevHash (0x20) ───────────────────────────────────────────
export interface Sv2SetNewPrevHash {
channelId: number; // U32
jobId: number; // U32
prevHash: Buffer; // U256 (32 bytes)
minNtime: number; // U32
nBits: number; // U32
}
export function serializeSetNewPrevHash(msg: Sv2SetNewPrevHash): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.jobId);
w.writeU256(msg.prevHash);
w.writeU32(msg.minNtime);
w.writeU32(msg.nBits);
return w.toBuffer();
}
export function deserializeSetNewPrevHash(reader: BufferReader): Sv2SetNewPrevHash {
return {
channelId: reader.readU32(),
jobId: reader.readU32(),
prevHash: reader.readU256(),
minNtime: reader.readU32(),
nBits: reader.readU32(),
};
}
// ── SetTarget (0x21) ────────────────────────────────────────────────
export interface Sv2SetTarget {
channelId: number; // U32
maxTarget: Buffer; // U256 (32 bytes)
}
export function serializeSetTarget(msg: Sv2SetTarget): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU256(msg.maxTarget);
return w.toBuffer();
}
export function deserializeSetTarget(reader: BufferReader): Sv2SetTarget {
return {
channelId: reader.readU32(),
maxTarget: reader.readU256(),
};
}
// ── SubmitSharesStandard (0x1a) ─────────────────────────────────────
export interface Sv2SubmitSharesStandard {
channelId: number; // U32
sequenceNumber: number; // U32
jobId: number; // U32
nonce: number; // U32
ntime: number; // U32
version: number; // U32
}
export function serializeSubmitSharesStandard(msg: Sv2SubmitSharesStandard): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.sequenceNumber);
w.writeU32(msg.jobId);
w.writeU32(msg.nonce);
w.writeU32(msg.ntime);
w.writeU32(msg.version);
return w.toBuffer();
}
export function deserializeSubmitSharesStandard(reader: BufferReader): Sv2SubmitSharesStandard {
return {
channelId: reader.readU32(),
sequenceNumber: reader.readU32(),
jobId: reader.readU32(),
nonce: reader.readU32(),
ntime: reader.readU32(),
version: reader.readU32(),
};
}
// ── SubmitSharesSuccess (0x1c) ──────────────────────────────────────
export interface Sv2SubmitSharesSuccess {
channelId: number; // U32
lastSequenceNumber: number; // U32
newSubmitsAcceptedCount: number; // U32
newSharesSum: bigint; // U64
}
export function serializeSubmitSharesSuccess(msg: Sv2SubmitSharesSuccess): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.lastSequenceNumber);
w.writeU32(msg.newSubmitsAcceptedCount);
w.writeU64(msg.newSharesSum);
return w.toBuffer();
}
export function deserializeSubmitSharesSuccess(reader: BufferReader): Sv2SubmitSharesSuccess {
return {
channelId: reader.readU32(),
lastSequenceNumber: reader.readU32(),
newSubmitsAcceptedCount: reader.readU32(),
newSharesSum: reader.readU64(),
};
}
// ── SubmitSharesError (0x1d) ────────────────────────────────────────
export interface Sv2SubmitSharesError {
channelId: number; // U32
sequenceNumber: number; // U32
errorCode: string; // STR0_255
}
export function serializeSubmitSharesError(msg: Sv2SubmitSharesError): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeU32(msg.sequenceNumber);
w.writeStr0_255(msg.errorCode);
return w.toBuffer();
}
export function deserializeSubmitSharesError(reader: BufferReader): Sv2SubmitSharesError {
return {
channelId: reader.readU32(),
sequenceNumber: reader.readU32(),
errorCode: reader.readStr0_255(),
};
}
// ── CloseChannel (0x18) ─────────────────────────────────────────────
export interface Sv2CloseChannel {
channelId: number; // U32
reasonCode: string; // STR0_255
}
export function serializeCloseChannel(msg: Sv2CloseChannel): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeStr0_255(msg.reasonCode);
return w.toBuffer();
}
export function deserializeCloseChannel(reader: BufferReader): Sv2CloseChannel {
return {
channelId: reader.readU32(),
reasonCode: reader.readStr0_255(),
};
}
// ── UpdateChannel (0x16) ────────────────────────────────────────────
export interface Sv2UpdateChannel {
channelId: number; // U32
nominalHashRate: number; // F32
maximumTarget: Buffer; // U256 (32 bytes LE)
}
export function serializeUpdateChannel(msg: Sv2UpdateChannel): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeF32(msg.nominalHashRate);
w.writeU256(msg.maximumTarget);
return w.toBuffer();
}
export function deserializeUpdateChannel(reader: BufferReader): Sv2UpdateChannel {
return {
channelId: reader.readU32(),
nominalHashRate: reader.readF32(),
maximumTarget: reader.readU256(),
};
}
// ── UpdateChannel.Error (0x17) ──────────────────────────────────────
export interface Sv2UpdateChannelError {
channelId: number; // U32
errorCode: string; // STR0_255
}
export function serializeUpdateChannelError(msg: Sv2UpdateChannelError): Buffer {
const w = new BufferWriter();
w.writeU32(msg.channelId);
w.writeStr0_255(msg.errorCode);
return w.toBuffer();
}
export function deserializeUpdateChannelError(reader: BufferReader): Sv2UpdateChannelError {
return {
channelId: reader.readU32(),
errorCode: reader.readStr0_255(),
};
}
// ── Extension 1: Extensions Negotiation (extension_type = 0x0001) ───
// Spec: extensions/0x0001-extensions-negotiation.md
// RequestExtensions msgType 0x00 (client → pool)
// RequestExtensions.Success msgType 0x01 (pool → client)
// RequestExtensions.Error msgType 0x02 (pool → client)
export const SV2_EXTENSION_NEGOTIATION_ID = 0x0001;
export interface Sv2RequestExtensions {
requestId: number; // U16
requestedExtensions: number[]; // SEQ0_64K[U16]
}
export function deserializeRequestExtensions(reader: BufferReader): Sv2RequestExtensions {
return {
requestId: reader.readU16(),
requestedExtensions: reader.readSeq0_64K((r) => r.readU16()),
};
}
export interface Sv2RequestExtensionsSuccess {
requestId: number; // U16
supportedExtensions: number[]; // SEQ0_64K[U16]
}
export function serializeRequestExtensionsSuccess(msg: Sv2RequestExtensionsSuccess): Buffer {
const w = new BufferWriter();
w.writeU16(msg.requestId);
w.writeSeq0_64K(msg.supportedExtensions, (writer, ext) => writer.writeU16(ext));
return w.toBuffer();
}
// ── Reconnect (0x04) ────────────────────────────────────────────────
export interface Sv2Reconnect {
newHost: string; // STR0_255
newPort: number; // U16
}
export function serializeReconnect(msg: Sv2Reconnect): Buffer {
const w = new BufferWriter();
w.writeStr0_255(msg.newHost);
w.writeU16(msg.newPort);
return w.toBuffer();
}
export function deserializeReconnect(reader: BufferReader): Sv2Reconnect {
return {
newHost: reader.readStr0_255(),
newPort: reader.readU16(),
};
}
-500
View File
@@ -1,500 +0,0 @@
import * as crypto from 'crypto';
import {
hmacHash,
sha256,
hkdf2,
Sv2CipherState,
Sv2CipherAlgorithm,
Sv2NoiseSession,
Sv2NoiseInitiator,
generateServerKeypair,
xOnlyPubKeyFromPriv,
createSignatureNoiseMessage,
validateSignatureNoiseMessage,
serializeSignatureNoiseMessage,
deserializeSignatureNoiseMessage,
loadEllSwift,
} from './sv2-noise';
import { SV2_MAC_SIZE } from './sv2-constants';
describe('SV2 Noise', () => {
// ── HMAC-SHA256 Test Vectors ──────────────────────────────────────
describe('HMAC-SHA256', () => {
it('should match known test vector (RFC 4231 test 1)', () => {
const key = Buffer.from('0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b', 'hex');
const data = Buffer.from('Hi There');
const expected = 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7';
expect(hmacHash(key, data).toString('hex')).toBe(expected);
});
it('should match known test vector (RFC 4231 test 2)', () => {
const key = Buffer.from('Jefe');
const data = Buffer.from('what do ya want for nothing?');
const expected = '5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843';
expect(hmacHash(key, data).toString('hex')).toBe(expected);
});
});
// ── HKDF2 ─────────────────────────────────────────────────────────
describe('HKDF2', () => {
it('should produce two 32-byte outputs', () => {
const ck = Buffer.alloc(32, 0x01);
const ikm = Buffer.alloc(32, 0x02);
const [out1, out2] = hkdf2(ck, ikm);
expect(out1.length).toBe(32);
expect(out2.length).toBe(32);
expect(out1.equals(out2)).toBe(false);
});
it('should be deterministic', () => {
const ck = crypto.randomBytes(32);
const ikm = crypto.randomBytes(32);
const [a1, a2] = hkdf2(ck, ikm);
const [b1, b2] = hkdf2(ck, ikm);
expect(a1.equals(b1)).toBe(true);
expect(a2.equals(b2)).toBe(true);
});
it('should work with empty IKM', () => {
const ck = Buffer.alloc(32, 0xaa);
const [out1, out2] = hkdf2(ck, Buffer.alloc(0));
expect(out1.length).toBe(32);
expect(out2.length).toBe(32);
});
});
// ── CipherState ───────────────────────────────────────────────────
describe('CipherState', () => {
it('should encrypt and decrypt with matching keys', () => {
const key = crypto.randomBytes(32);
const enc = new Sv2CipherState();
const dec = new Sv2CipherState();
enc.initializeKey(key);
dec.initializeKey(key);
const ad = Buffer.alloc(0);
const plaintext = Buffer.from('test message for cipher state');
const ciphertext = enc.encryptWithAd(ad, plaintext);
expect(ciphertext.length).toBe(plaintext.length + SV2_MAC_SIZE);
expect(ciphertext.subarray(0, plaintext.length).equals(plaintext)).toBe(false);
const decrypted = dec.decryptWithAd(ad, ciphertext);
expect(decrypted.equals(plaintext)).toBe(true);
});
it('should fail decryption with wrong key', () => {
const enc = new Sv2CipherState();
const dec = new Sv2CipherState();
enc.initializeKey(crypto.randomBytes(32));
dec.initializeKey(crypto.randomBytes(32));
const ct = enc.encryptWithAd(Buffer.alloc(0), Buffer.from('secret'));
expect(() => dec.decryptWithAd(Buffer.alloc(0), ct)).toThrow();
});
it('should fail decryption with wrong AD', () => {
const key = crypto.randomBytes(32);
const enc = new Sv2CipherState();
const dec = new Sv2CipherState();
enc.initializeKey(key);
dec.initializeKey(key);
const ct = enc.encryptWithAd(Buffer.from('ad1'), Buffer.from('data'));
expect(() => dec.decryptWithAd(Buffer.from('ad2'), ct)).toThrow();
});
it('should increment nonce (different ciphertext for same plaintext)', () => {
const key = crypto.randomBytes(32);
const enc = new Sv2CipherState();
enc.initializeKey(key);
const ad = Buffer.alloc(0);
const pt = Buffer.from('same');
const ct1 = enc.encryptWithAd(ad, pt);
const ct2 = enc.encryptWithAd(ad, pt);
expect(ct1.equals(ct2)).toBe(false);
});
it('should pass through plaintext when key is not set', () => {
const cs = new Sv2CipherState();
const ad = Buffer.alloc(0);
const pt = Buffer.from('hello');
expect(cs.encryptWithAd(ad, pt).equals(pt)).toBe(true);
expect(cs.decryptWithAd(ad, pt).equals(pt)).toBe(true);
});
it('should track hasKey', () => {
const cs = new Sv2CipherState();
expect(cs.hasKey).toBe(false);
cs.initializeKey(crypto.randomBytes(32));
expect(cs.hasKey).toBe(true);
});
it('should reject non-32-byte keys', () => {
const cs = new Sv2CipherState();
expect(() => cs.initializeKey(Buffer.alloc(16))).toThrow();
});
it('should reject ciphertext shorter than MAC', () => {
const cs = new Sv2CipherState();
cs.initializeKey(crypto.randomBytes(32));
expect(() => cs.decryptWithAd(Buffer.alloc(0), Buffer.alloc(10))).toThrow();
});
it('should handle multiple sequential encryptions and decryptions', () => {
const key = crypto.randomBytes(32);
const enc = new Sv2CipherState();
const dec = new Sv2CipherState();
enc.initializeKey(key);
dec.initializeKey(key);
const ad = Buffer.alloc(0);
for (let i = 0; i < 10; i++) {
const pt = Buffer.from(`message ${i}`);
const ct = enc.encryptWithAd(ad, pt);
const result = dec.decryptWithAd(ad, ct);
expect(result.toString()).toBe(`message ${i}`);
}
});
});
// ── Certificate / Signature ───────────────────────────────────────
describe('Certificate', () => {
it('should create and validate a signature noise message', () => {
const authorityPrivKey = crypto.randomBytes(32);
const authorityPubKey = xOnlyPubKeyFromPriv(authorityPrivKey);
const staticPubKeyXOnly = crypto.randomBytes(32);
const now = Math.floor(Date.now() / 1000);
const cert = createSignatureNoiseMessage(
authorityPrivKey,
staticPubKeyXOnly,
now - 3600,
now + 86400,
);
expect(cert.version).toBe(0);
expect(cert.signature.length).toBe(64);
expect(validateSignatureNoiseMessage(cert, authorityPubKey, staticPubKeyXOnly)).toBe(true);
});
it('should fail validation with wrong authority key', () => {
const authorityPrivKey = crypto.randomBytes(32);
const wrongPubKey = xOnlyPubKeyFromPriv(crypto.randomBytes(32));
const staticPubKeyXOnly = crypto.randomBytes(32);
const now = Math.floor(Date.now() / 1000);
const cert = createSignatureNoiseMessage(authorityPrivKey, staticPubKeyXOnly, now, now + 86400);
expect(validateSignatureNoiseMessage(cert, wrongPubKey, staticPubKeyXOnly)).toBe(false);
});
it('should serialize and deserialize signature noise message', () => {
const msg = {
version: 0,
validFrom: 1700000000,
notValidAfter: 1700086400,
signature: crypto.randomBytes(64),
};
const buf = serializeSignatureNoiseMessage(msg);
expect(buf.length).toBe(74);
const result = deserializeSignatureNoiseMessage(buf);
expect(result.version).toBe(msg.version);
expect(result.validFrom).toBe(msg.validFrom);
expect(result.notValidAfter).toBe(msg.notValidAfter);
expect(result.signature.equals(msg.signature)).toBe(true);
});
});
// ── Key Generation ────────────────────────────────────────────────
describe('Key Generation', () => {
it('should generate server keypair with correct sizes', async () => {
const kp = await generateServerKeypair();
expect(kp.privateKey.length).toBe(32);
expect(kp.publicKey.length).toBe(64);
});
it('should produce unique keypairs', async () => {
const kp1 = await generateServerKeypair();
const kp2 = await generateServerKeypair();
expect(kp1.privateKey.equals(kp2.privateKey)).toBe(false);
});
it('should derive x-only pubkey from privkey', () => {
const privKey = crypto.randomBytes(32);
const xOnly = xOnlyPubKeyFromPriv(privKey);
expect(xOnly.length).toBe(32);
});
});
// ── EllSwift Loader ───────────────────────────────────────────────
describe('EllSwift Loader', () => {
it('should load EllSwift API', async () => {
const es = await loadEllSwift();
expect(typeof es.keygen).toBe('function');
expect(typeof es.getSharedSecret).toBe('function');
});
it('should cache the loaded module', async () => {
const es1 = await loadEllSwift();
const es2 = await loadEllSwift();
expect(es1).toBe(es2);
});
});
// ── Full Noise NX Handshake Self-Test ─────────────────────────────
describe('Full Handshake', () => {
it('should complete NX handshake and exchange encrypted messages', async () => {
// 1. Generate server config
const serverKeypair = await generateServerKeypair();
const authorityPrivKey = crypto.randomBytes(32);
const authorityPubKey = xOnlyPubKeyFromPriv(authorityPrivKey);
const staticXOnly = xOnlyPubKeyFromPriv(serverKeypair.privateKey);
const now = Math.floor(Date.now() / 1000);
const cert = createSignatureNoiseMessage(
authorityPrivKey,
staticXOnly,
now - 3600,
now + 86400,
);
// 2. Create sessions
const responder = new Sv2NoiseSession({
staticKeypair: serverKeypair,
certificateMessage: cert,
});
const initiator = new Sv2NoiseInitiator();
// 3. Act 1: Initiator -> Responder (64 bytes)
const act1 = await initiator.generateAct1();
expect(act1.length).toBe(64);
// 4. Act 2: Responder -> Initiator (234 bytes)
const act2 = await responder.processAct1(act1);
expect(act2.length).toBe(234);
// 5. Initiator processes Act 2
const { serverStaticKey, certificate } = initiator.processAct2(act2);
expect(serverStaticKey.length).toBe(64);
expect(certificate.version).toBe(0);
// 6. Validate certificate
expect(
validateSignatureNoiseMessage(certificate, authorityPubKey, staticXOnly),
).toBe(true);
// 7. Both sides should be ready
expect(responder.isHandshakeComplete).toBe(true);
expect(initiator.isHandshakeComplete).toBe(true);
// 8. Exchange encrypted messages: initiator -> responder
const msg1 = Buffer.from('hello from initiator');
const enc1 = initiator.encrypt(msg1);
expect(enc1.length).toBe(msg1.length + SV2_MAC_SIZE);
const dec1 = responder.decrypt(enc1);
expect(dec1.toString()).toBe('hello from initiator');
// 9. Exchange encrypted messages: responder -> initiator
const msg2 = Buffer.from('hello from responder');
const enc2 = responder.encrypt(msg2);
const dec2 = initiator.decrypt(enc2);
expect(dec2.toString()).toBe('hello from responder');
// 10. Multiple messages with nonce increment
for (let i = 0; i < 5; i++) {
const pt = Buffer.from(`bidirectional msg ${i}`);
const ct = responder.encrypt(pt);
expect(initiator.decrypt(ct).toString()).toBe(`bidirectional msg ${i}`);
const pt2 = Buffer.from(`reply ${i}`);
const ct2 = initiator.encrypt(pt2);
expect(responder.decrypt(ct2).toString()).toBe(`reply ${i}`);
}
});
it('should fail handshake with wrong Act 1 size', async () => {
const serverKeypair = await generateServerKeypair();
const authorityPrivKey = crypto.randomBytes(32);
const staticXOnly = xOnlyPubKeyFromPriv(serverKeypair.privateKey);
const now = Math.floor(Date.now() / 1000);
const cert = createSignatureNoiseMessage(authorityPrivKey, staticXOnly, now, now + 86400);
const responder = new Sv2NoiseSession({
staticKeypair: serverKeypair,
certificateMessage: cert,
});
await expect(responder.processAct1(Buffer.alloc(32))).rejects.toThrow();
});
it('should not encrypt before handshake is complete', () => {
const session = new Sv2NoiseInitiator();
expect(() => session.encrypt(Buffer.from('test'))).toThrow('Handshake not complete');
expect(() => session.decrypt(Buffer.alloc(32))).toThrow('Handshake not complete');
});
it('should complete NX handshake with AES-256-GCM and exchange encrypted messages', async () => {
// 1. Generate server config
const serverKeypair = await generateServerKeypair();
const authorityPrivKey = crypto.randomBytes(32);
const authorityPubKey = xOnlyPubKeyFromPriv(authorityPrivKey);
const staticXOnly = xOnlyPubKeyFromPriv(serverKeypair.privateKey);
const now = Math.floor(Date.now() / 1000);
const cert = createSignatureNoiseMessage(
authorityPrivKey,
staticXOnly,
now - 3600,
now + 86400,
);
const cipher: Sv2CipherAlgorithm = 'aes-256-gcm';
// 2. Create sessions with AES-GCM
const responder = new Sv2NoiseSession(
{ staticKeypair: serverKeypair, certificateMessage: cert },
cipher,
);
const initiator = new Sv2NoiseInitiator(cipher);
// 3. Act 1: Initiator -> Responder (64 bytes)
const act1 = await initiator.generateAct1();
expect(act1.length).toBe(64);
// 4. Act 2: Responder -> Initiator (234 bytes)
const act2 = await responder.processAct1(act1);
expect(act2.length).toBe(234);
// 5. Initiator processes Act 2
const { serverStaticKey, certificate } = initiator.processAct2(act2);
expect(serverStaticKey.length).toBe(64);
expect(certificate.version).toBe(0);
// 6. Validate certificate
expect(
validateSignatureNoiseMessage(certificate, authorityPubKey, staticXOnly),
).toBe(true);
// 7. Both sides should be ready
expect(responder.isHandshakeComplete).toBe(true);
expect(initiator.isHandshakeComplete).toBe(true);
// 8. Exchange encrypted messages: initiator -> responder
const msg1 = Buffer.from('hello from AES-GCM initiator');
const enc1 = initiator.encrypt(msg1);
expect(enc1.length).toBe(msg1.length + SV2_MAC_SIZE);
const dec1 = responder.decrypt(enc1);
expect(dec1.toString()).toBe('hello from AES-GCM initiator');
// 9. Exchange encrypted messages: responder -> initiator
const msg2 = Buffer.from('hello from AES-GCM responder');
const enc2 = responder.encrypt(msg2);
const dec2 = initiator.decrypt(enc2);
expect(dec2.toString()).toBe('hello from AES-GCM responder');
// 10. Multiple messages with nonce increment
for (let i = 0; i < 5; i++) {
const pt = Buffer.from(`AES-GCM bidirectional msg ${i}`);
const ct = responder.encrypt(pt);
expect(initiator.decrypt(ct).toString()).toBe(`AES-GCM bidirectional msg ${i}`);
const pt2 = Buffer.from(`AES-GCM reply ${i}`);
const ct2 = initiator.encrypt(pt2);
expect(responder.decrypt(ct2).toString()).toBe(`AES-GCM reply ${i}`);
}
});
it('should not cross-decrypt between ChaCha20 and AES-GCM sessions', async () => {
const serverKeypair = await generateServerKeypair();
const authorityPrivKey = crypto.randomBytes(32);
const staticXOnly = xOnlyPubKeyFromPriv(serverKeypair.privateKey);
const now = Math.floor(Date.now() / 1000);
const cert = createSignatureNoiseMessage(authorityPrivKey, staticXOnly, now, now + 86400);
// ChaCha session
const chachaResp = new Sv2NoiseSession(
{ staticKeypair: serverKeypair, certificateMessage: cert },
);
const chachaInit = new Sv2NoiseInitiator();
const chachaAct1 = await chachaInit.generateAct1();
const chachaAct2 = await chachaResp.processAct1(chachaAct1);
chachaInit.processAct2(chachaAct2);
// AES-GCM session (different keys, so ciphertexts are incompatible)
const serverKeypair2 = await generateServerKeypair();
const staticXOnly2 = xOnlyPubKeyFromPriv(serverKeypair2.privateKey);
const cert2 = createSignatureNoiseMessage(authorityPrivKey, staticXOnly2, now, now + 86400);
const aesResp = new Sv2NoiseSession(
{ staticKeypair: serverKeypair2, certificateMessage: cert2 },
'aes-256-gcm',
);
const aesInit = new Sv2NoiseInitiator('aes-256-gcm');
const aesAct1 = await aesInit.generateAct1();
const aesAct2 = await aesResp.processAct1(aesAct1);
aesInit.processAct2(aesAct2);
// Encrypt with ChaCha, try to decrypt with AES-GCM -> should fail
const ct = chachaInit.encrypt(Buffer.from('test'));
expect(() => aesResp.decrypt(ct)).toThrow();
});
});
// ── AES-GCM CipherState ─────────────────────────────────────────────
describe('CipherState AES-256-GCM', () => {
it('should encrypt and decrypt with AES-256-GCM', () => {
const key = crypto.randomBytes(32);
const enc = new Sv2CipherState('aes-256-gcm');
const dec = new Sv2CipherState('aes-256-gcm');
enc.initializeKey(key);
dec.initializeKey(key);
const ad = Buffer.alloc(0);
const plaintext = Buffer.from('test message for AES-GCM cipher state');
const ciphertext = enc.encryptWithAd(ad, plaintext);
expect(ciphertext.length).toBe(plaintext.length + SV2_MAC_SIZE);
const decrypted = dec.decryptWithAd(ad, ciphertext);
expect(decrypted.equals(plaintext)).toBe(true);
});
it('should handle multiple sequential encryptions with AES-256-GCM', () => {
const key = crypto.randomBytes(32);
const enc = new Sv2CipherState('aes-256-gcm');
const dec = new Sv2CipherState('aes-256-gcm');
enc.initializeKey(key);
dec.initializeKey(key);
const ad = Buffer.alloc(0);
for (let i = 0; i < 10; i++) {
const pt = Buffer.from(`AES-GCM message ${i}`);
const ct = enc.encryptWithAd(ad, pt);
const result = dec.decryptWithAd(ad, ct);
expect(result.toString()).toBe(`AES-GCM message ${i}`);
}
});
it('should encrypt and decrypt with non-empty AD using AES-256-GCM', () => {
const key = crypto.randomBytes(32);
const enc = new Sv2CipherState('aes-256-gcm');
const dec = new Sv2CipherState('aes-256-gcm');
enc.initializeKey(key);
dec.initializeKey(key);
const ad = Buffer.from('additional data');
const plaintext = Buffer.from('secret payload');
const ciphertext = enc.encryptWithAd(ad, plaintext);
const decrypted = dec.decryptWithAd(ad, ciphertext);
expect(decrypted.equals(plaintext)).toBe(true);
});
});
});
-601
View File
@@ -1,601 +0,0 @@
// ── SV2 Noise NX Handshake & Encrypted Transport ────────────────────
// Implements Noise_NX_Secp256k1+EllSwift_ChaChaPoly_SHA256 for SV2.
// Server (responder) side: receives Act 1, sends Act 2, then
// encrypted transport in both directions.
import * as crypto from 'crypto';
import * as ecc from 'tiny-secp256k1';
import {
SV2_NOISE_PROTOCOL_NAME,
SV2_NOISE_PROTOCOL_NAME_AESGCM,
SV2_ELLSWIFT_KEY_SIZE,
SV2_MAC_SIZE,
SV2_SIGNATURE_NOISE_MSG_SIZE,
} from './sv2-constants';
// ── Cipher Algorithm Type ───────────────────────────────────────────
export type Sv2CipherAlgorithm = 'chacha20-poly1305' | 'aes-256-gcm';
function getNoiseProtocolName(cipher: Sv2CipherAlgorithm): string {
return cipher === 'aes-256-gcm'
? SV2_NOISE_PROTOCOL_NAME_AESGCM
: SV2_NOISE_PROTOCOL_NAME;
}
// ── EllSwift Loader (ESM dynamic import) ────────────────────────────
interface EllSwiftApi {
keygen(): { privateKey: Uint8Array; publicKey: Uint8Array };
getSharedSecret(privKey: Uint8Array, pubKey: Uint8Array): Uint8Array;
getSharedSecretBip324(
privateKeyOurs: Uint8Array,
publicKeyTheirs: Uint8Array,
publicKeyOurs: Uint8Array,
initiating: boolean,
): Uint8Array;
}
let ellSwiftCache: EllSwiftApi | null = null;
export async function loadEllSwift(): Promise<EllSwiftApi> {
if (ellSwiftCache) return ellSwiftCache;
let mod: any;
try {
// Works in Jest (ts-jest transforms ESM to CJS) and bundled environments
mod = require('@scure/btc-signer/p2p.js');
} catch {
// Runtime fallback: dynamic import for ESM-only packages in Node CJS mode
const importFn = new Function('specifier', 'return import(specifier)');
mod = await importFn('@scure/btc-signer/p2p.js');
}
ellSwiftCache = mod.elligatorSwift as EllSwiftApi;
return ellSwiftCache;
}
// ── Crypto Primitives ───────────────────────────────────────────────
export function hmacHash(key: Buffer, data: Buffer): Buffer {
return crypto.createHmac('sha256', key).update(data).digest();
}
export function sha256(data: Buffer): Buffer {
return crypto.createHash('sha256').update(data).digest();
}
/**
* SV2 HKDF with 2 outputs (not RFC 5869).
* temp_key = HMAC(chaining_key, input_key_material)
* output1 = HMAC(temp_key, 0x01)
* output2 = HMAC(temp_key, output1 || 0x02)
*/
export function hkdf2(chainingKey: Buffer, ikm: Buffer): [Buffer, Buffer] {
const tempKey = hmacHash(chainingKey, ikm);
const out1 = hmacHash(tempKey, Buffer.from([0x01]));
const out2 = hmacHash(tempKey, Buffer.concat([out1, Buffer.from([0x02])]));
return [out1, out2];
}
// ── CipherState ─────────────────────────────────────────────────────
/**
* Nonce encoding per Noise spec:
* - ChaCha20-Poly1305 (Section 12.3): 4 zero bytes + LE64(n)
* - AES-256-GCM (Section 12.4): 4 zero bytes + BE64(n)
*
* Set SV2_NONCE_LE=true to force LE for all ciphers (matches SRI Rust behavior).
*/
function encodeNonce(n: bigint, algorithm: Sv2CipherAlgorithm): Buffer {
const buf = Buffer.alloc(12);
if (algorithm === 'aes-256-gcm' && process.env.SV2_NONCE_LE !== 'true') {
buf.writeBigUInt64BE(n, 4);
} else {
buf.writeBigUInt64LE(n, 4);
}
return buf;
}
export class Sv2CipherState {
private k: Buffer | null = null;
private n = 0n;
private readonly algorithm: Sv2CipherAlgorithm;
constructor(algorithm: Sv2CipherAlgorithm = 'chacha20-poly1305') {
this.algorithm = algorithm;
}
get hasKey(): boolean {
return this.k !== null;
}
initializeKey(key: Buffer): void {
if (key.length !== 32) throw new Error('CipherState key must be 32 bytes');
this.k = key;
this.n = 0n;
}
encryptWithAd(ad: Buffer, plaintext: Buffer): Buffer {
if (!this.k) return plaintext;
const nonce = encodeNonce(this.n, this.algorithm);
const cipher = crypto.createCipheriv(this.algorithm, this.k, nonce, {
authTagLength: SV2_MAC_SIZE,
} as any) as crypto.CipherGCM;
if (this.algorithm === 'aes-256-gcm') {
cipher.setAAD(ad, { plaintextLength: plaintext.length } as any);
} else {
cipher.setAAD(ad, {} as any);
}
const encrypted = cipher.update(plaintext);
cipher.final();
const tag = cipher.getAuthTag();
this.n++;
return Buffer.concat([encrypted, tag]);
}
decryptWithAd(ad: Buffer, ciphertext: Buffer): Buffer {
if (!this.k) return ciphertext;
if (ciphertext.length < SV2_MAC_SIZE) {
throw new Error('Ciphertext too short for MAC');
}
const nonce = encodeNonce(this.n, this.algorithm);
const encData = ciphertext.subarray(0, ciphertext.length - SV2_MAC_SIZE);
const tag = ciphertext.subarray(ciphertext.length - SV2_MAC_SIZE);
const decipher = crypto.createDecipheriv(this.algorithm, this.k, nonce, {
authTagLength: SV2_MAC_SIZE,
} as any) as crypto.DecipherGCM;
if (this.algorithm === 'aes-256-gcm') {
decipher.setAAD(ad, { plaintextLength: encData.length } as any);
} else {
decipher.setAAD(ad, {} as any);
}
decipher.setAuthTag(tag);
const decrypted = decipher.update(encData);
decipher.final();
this.n++;
return Buffer.from(decrypted);
}
}
// ── Certificate / Signature Noise Message ───────────────────────────
export interface Sv2SignatureNoiseMessage {
version: number; // U16
validFrom: number; // U32 (unix timestamp)
notValidAfter: number; // U32 (unix timestamp)
signature: Buffer; // 64-byte Schnorr signature
}
/**
* Serialize a SignatureNoiseMessage to 74 bytes.
*/
export function serializeSignatureNoiseMessage(msg: Sv2SignatureNoiseMessage): Buffer {
const buf = Buffer.alloc(SV2_SIGNATURE_NOISE_MSG_SIZE);
buf.writeUInt16LE(msg.version, 0);
buf.writeUInt32LE(msg.validFrom, 2);
buf.writeUInt32LE(msg.notValidAfter, 6);
msg.signature.copy(buf, 10);
return buf;
}
/**
* Deserialize a 74-byte SignatureNoiseMessage.
*/
export function deserializeSignatureNoiseMessage(buf: Buffer): Sv2SignatureNoiseMessage {
if (buf.length < SV2_SIGNATURE_NOISE_MSG_SIZE) {
throw new Error(`SignatureNoiseMessage requires ${SV2_SIGNATURE_NOISE_MSG_SIZE} bytes`);
}
return {
version: buf.readUInt16LE(0),
validFrom: buf.readUInt32LE(2),
notValidAfter: buf.readUInt32LE(6),
signature: Buffer.from(buf.subarray(10, 74)),
};
}
/**
* Create a SignatureNoiseMessage that certifies a static key.
* Signs: version || validFrom || notValidAfter || staticPubKey (x-only, 32 bytes)
* using the authority's private key (BIP-340 Schnorr).
*/
export function createSignatureNoiseMessage(
authorityPrivKey: Buffer,
staticPubKeyXOnly: Buffer,
validFrom: number,
notValidAfter: number,
): Sv2SignatureNoiseMessage {
const version = 0;
const msgBuf = Buffer.alloc(2 + 4 + 4 + 32);
msgBuf.writeUInt16LE(version, 0);
msgBuf.writeUInt32LE(validFrom, 2);
msgBuf.writeUInt32LE(notValidAfter, 6);
staticPubKeyXOnly.copy(msgBuf, 10);
const msgHash = sha256(msgBuf);
const sig = ecc.signSchnorr(msgHash, authorityPrivKey);
if (!sig) throw new Error('Schnorr signing failed');
return {
version,
validFrom,
notValidAfter,
signature: Buffer.from(sig),
};
}
/**
* Validate a SignatureNoiseMessage against an authority public key.
*/
export function validateSignatureNoiseMessage(
msg: Sv2SignatureNoiseMessage,
authorityPubKeyXOnly: Buffer,
staticPubKeyXOnly: Buffer,
): boolean {
const msgBuf = Buffer.alloc(2 + 4 + 4 + 32);
msgBuf.writeUInt16LE(msg.version, 0);
msgBuf.writeUInt32LE(msg.validFrom, 2);
msgBuf.writeUInt32LE(msg.notValidAfter, 6);
staticPubKeyXOnly.copy(msgBuf, 10);
const msgHash = sha256(msgBuf);
return ecc.verifySchnorr(msgHash, authorityPubKeyXOnly, msg.signature);
}
// ── Server Keypair Generation ───────────────────────────────────────
export interface Sv2ServerKeypair {
privateKey: Buffer; // 32-byte secret scalar
publicKey: Buffer; // 64-byte EllSwift-encoded public key
}
export async function generateServerKeypair(): Promise<Sv2ServerKeypair> {
const es = await loadEllSwift();
const kp = es.keygen();
return {
privateKey: Buffer.from(kp.privateKey),
publicKey: Buffer.from(kp.publicKey),
};
}
/**
* Get the x-only (32-byte) public key from a 32-byte private key.
*/
export function xOnlyPubKeyFromPriv(privKey: Buffer): Buffer {
const xOnly = ecc.xOnlyPointFromScalar(privKey);
if (!xOnly) throw new Error('Failed to derive x-only public key');
return Buffer.from(xOnly);
}
// ── Noise NX Session (Responder / Server side) ──────────────────────
export interface Sv2NoiseConfig {
/** Server's static keypair (EllSwift). */
staticKeypair: Sv2ServerKeypair;
/** Pre-computed certificate message (signed by authority). */
certificateMessage: Sv2SignatureNoiseMessage;
}
export class Sv2NoiseSession {
private h: Buffer; // handshake hash
private ck: Buffer; // chaining key
private tempCipher: Sv2CipherState;
private sendCipher: Sv2CipherState;
private recvCipher: Sv2CipherState;
private handshakeComplete = false;
private ellSwift!: EllSwiftApi;
private readonly cipherAlgorithm: Sv2CipherAlgorithm;
private serverEphemeralPriv!: Buffer;
private serverEphemeralPub!: Buffer;
constructor(
private readonly config: Sv2NoiseConfig,
cipherAlgorithm: Sv2CipherAlgorithm = 'chacha20-poly1305',
prologue: Buffer = Buffer.alloc(0),
) {
this.cipherAlgorithm = cipherAlgorithm;
const protocolNameStr = getNoiseProtocolName(cipherAlgorithm);
this.tempCipher = new Sv2CipherState(cipherAlgorithm);
this.sendCipher = new Sv2CipherState(cipherAlgorithm);
this.recvCipher = new Sv2CipherState(cipherAlgorithm);
// Initialize h = HASH(protocol_name) if len > 32, else pad
const protocolName = Buffer.from(protocolNameStr, 'ascii');
if (protocolName.length <= 32) {
this.h = Buffer.alloc(32);
protocolName.copy(this.h);
} else {
this.h = sha256(protocolName);
}
// ck = h
this.ck = Buffer.from(this.h);
// MixHash(prologue) per Noise spec: h = HASH(h || prologue)
this.h = sha256(Buffer.concat([this.h, prologue]));
}
// ── Noise Symmetric State Helpers ─────────────────────────────────
private mixHash(data: Buffer): void {
this.h = sha256(Buffer.concat([this.h, data]));
}
private mixKey(ikm: Buffer): void {
const [newCk, tempK] = hkdf2(this.ck, ikm);
this.ck = newCk;
this.tempCipher = new Sv2CipherState(this.cipherAlgorithm);
this.tempCipher.initializeKey(tempK);
}
private encryptAndHash(plaintext: Buffer): Buffer {
const ciphertext = this.tempCipher.encryptWithAd(this.h, plaintext);
this.mixHash(ciphertext);
return ciphertext;
}
private decryptAndHash(ciphertext: Buffer): Buffer {
const plaintext = this.tempCipher.decryptWithAd(this.h, ciphertext);
this.mixHash(ciphertext);
return plaintext;
}
// ── ECDH via EllSwift ─────────────────────────────────────────────
/**
* Compute BIP-324 EllSwift ECDH.
* @param localPriv - Our private key (32 bytes)
* @param localPub - Our EllSwift public key (64 bytes)
* @param remotePub - Their EllSwift public key (64 bytes)
* @param weAreInitiator - Party bit: true for initiator, false for responder
*/
private ecdh(localPriv: Buffer, localPub: Buffer, remotePub: Buffer, weAreInitiator: boolean): Buffer {
const secret = this.ellSwift.getSharedSecretBip324(
new Uint8Array(localPriv),
new Uint8Array(remotePub),
new Uint8Array(localPub),
weAreInitiator,
);
return Buffer.from(secret);
}
// ── Handshake: Process Act 1, Generate Act 2 ─────────────────────
/**
* Process the initiator's Act 1 ephemeral key and produce Act 2.
* EllSwift mode: 64-byte Act 1 -> 234-byte Act 2
*/
async processAct1(act1: Buffer): Promise<Buffer> {
if (act1.length !== SV2_ELLSWIFT_KEY_SIZE) {
throw new Error(`Act 1 must be ${SV2_ELLSWIFT_KEY_SIZE} bytes, got ${act1.length}`);
}
const debug = process.env.SV2_NOISE_DEBUG === 'true';
if (debug) {
const protocolNameStr = getNoiseProtocolName(this.cipherAlgorithm);
console.log(`[NOISE] Protocol name: "${protocolNameStr}" (${protocolNameStr.length} bytes)`);
console.log(`[NOISE] Cipher: ${this.cipherAlgorithm}`);
console.log(`[NOISE] Init ck=${this.ck.toString('hex')}`);
console.log(`[NOISE] Init h=${this.h.toString('hex')}`);
}
this.ellSwift = await loadEllSwift();
const remoteEphemeral = act1;
this.mixHash(remoteEphemeral);
if (debug) console.log(`[NOISE] After MixHash(re): h=${this.h.toString('hex')}`);
this.decryptAndHash(Buffer.alloc(0));
if (debug) console.log(`[NOISE] After DecryptAndHash(empty): h=${this.h.toString('hex')}`);
const serverEph = this.ellSwift.keygen();
this.serverEphemeralPriv = Buffer.from(serverEph.privateKey);
this.serverEphemeralPub = Buffer.from(serverEph.publicKey);
if (debug) console.log(`[NOISE] Server eph pub: ${this.serverEphemeralPub.toString('hex')}`);
this.mixHash(this.serverEphemeralPub);
if (debug) console.log(`[NOISE] After MixHash(se): h=${this.h.toString('hex')}`);
const eeDH = this.ecdh(
this.serverEphemeralPriv,
this.serverEphemeralPub,
remoteEphemeral,
false,
);
if (debug) console.log(`[NOISE] ee DH shared secret: ${eeDH.toString('hex')}`);
this.mixKey(eeDH);
if (debug) console.log(`[NOISE] After MixKey(ee): ck=${this.ck.toString('hex')}`);
const staticPubKey = this.config.staticKeypair.publicKey;
if (debug) {
console.log(`[NOISE] h (AD for encrypt static): ${this.h.toString('hex')}`);
console.log(`[NOISE] Static pub (plaintext): ${staticPubKey.toString('hex')}`);
}
const encryptedStatic = this.encryptAndHash(staticPubKey);
if (debug) console.log(`[NOISE] Encrypted static (${encryptedStatic.length} bytes): ${encryptedStatic.toString('hex')}`);
const esDH = this.ecdh(
this.config.staticKeypair.privateKey,
this.config.staticKeypair.publicKey,
remoteEphemeral,
false,
);
if (debug) console.log(`[NOISE] es DH shared secret: ${esDH.toString('hex')}`);
this.mixKey(esDH);
if (debug) console.log(`[NOISE] After MixKey(es): ck=${this.ck.toString('hex')}`);
const certPayload = serializeSignatureNoiseMessage(this.config.certificateMessage);
if (debug) console.log(`[NOISE] h (AD for encrypt cert): ${this.h.toString('hex')}`);
if (debug) console.log(`[NOISE] Cert payload (${certPayload.length} bytes): ${certPayload.toString('hex')}`);
const encryptedCert = this.encryptAndHash(certPayload);
if (debug) console.log(`[NOISE] Encrypted cert (${encryptedCert.length} bytes)`);
const [k1, k2] = hkdf2(this.ck, Buffer.alloc(0));
this.recvCipher.initializeKey(k1);
this.sendCipher.initializeKey(k2);
this.handshakeComplete = true;
const act2 = Buffer.concat([this.serverEphemeralPub, encryptedStatic, encryptedCert]);
if (debug) console.log(`[NOISE] Act 2 total: ${act2.length} bytes`);
return act2;
}
// ── Post-Handshake Transport ──────────────────────────────────────
encrypt(plaintext: Buffer): Buffer {
if (!this.handshakeComplete) throw new Error('Handshake not complete');
return this.sendCipher.encryptWithAd(Buffer.alloc(0), plaintext);
}
decrypt(ciphertext: Buffer): Buffer {
if (!this.handshakeComplete) throw new Error('Handshake not complete');
return this.recvCipher.decryptWithAd(Buffer.alloc(0), ciphertext);
}
get isHandshakeComplete(): boolean {
return this.handshakeComplete;
}
}
// ── Initiator Session (for testing) ─────────────────────────────────
export class Sv2NoiseInitiator {
private h: Buffer;
private ck: Buffer;
private tempCipher: Sv2CipherState;
private sendCipher: Sv2CipherState;
private recvCipher: Sv2CipherState;
private handshakeComplete = false;
private ellSwift!: EllSwiftApi;
private readonly cipherAlgorithm: Sv2CipherAlgorithm;
private ephemeralPriv!: Buffer;
private ephemeralPub!: Buffer;
constructor(
cipherAlgorithm: Sv2CipherAlgorithm = 'chacha20-poly1305',
prologue: Buffer = Buffer.alloc(0),
) {
this.cipherAlgorithm = cipherAlgorithm;
const protocolNameStr = getNoiseProtocolName(cipherAlgorithm);
this.tempCipher = new Sv2CipherState(cipherAlgorithm);
this.sendCipher = new Sv2CipherState(cipherAlgorithm);
this.recvCipher = new Sv2CipherState(cipherAlgorithm);
const protocolName = Buffer.from(protocolNameStr, 'ascii');
if (protocolName.length <= 32) {
this.h = Buffer.alloc(32);
protocolName.copy(this.h);
} else {
this.h = sha256(protocolName);
}
this.ck = Buffer.from(this.h);
// MixHash(prologue) per Noise spec: h = HASH(h || prologue)
this.h = sha256(Buffer.concat([this.h, prologue]));
}
private mixHash(data: Buffer): void {
this.h = sha256(Buffer.concat([this.h, data]));
}
private mixKey(ikm: Buffer): void {
const [newCk, tempK] = hkdf2(this.ck, ikm);
this.ck = newCk;
this.tempCipher = new Sv2CipherState(this.cipherAlgorithm);
this.tempCipher.initializeKey(tempK);
}
private decryptAndHash(ciphertext: Buffer): Buffer {
const plaintext = this.tempCipher.decryptWithAd(this.h, ciphertext);
this.mixHash(ciphertext);
return plaintext;
}
private ecdh(localPriv: Buffer, localPub: Buffer, remotePub: Buffer, weAreInitiator: boolean): Buffer {
const secret = this.ellSwift.getSharedSecretBip324(
new Uint8Array(localPriv),
new Uint8Array(remotePub),
new Uint8Array(localPub),
weAreInitiator,
);
return Buffer.from(secret);
}
/** Generate Act 1: 64-byte EllSwift ephemeral key. */
async generateAct1(): Promise<Buffer> {
this.ellSwift = await loadEllSwift();
const kp = this.ellSwift.keygen();
this.ephemeralPriv = Buffer.from(kp.privateKey);
this.ephemeralPub = Buffer.from(kp.publicKey);
this.mixHash(this.ephemeralPub);
this.mixHash(Buffer.alloc(0));
return this.ephemeralPub;
}
/** Process Act 2 from responder, returns the decrypted server static key and cert. */
processAct2(act2: Buffer): {
serverStaticKey: Buffer;
certificate: Sv2SignatureNoiseMessage;
} {
if (act2.length !== 234) {
throw new Error(`Act 2 must be 234 bytes, got ${act2.length}`);
}
const serverEphemeral = act2.subarray(0, 64);
const encryptedStatic = act2.subarray(64, 144);
const encryptedCert = act2.subarray(144, 234);
this.mixHash(Buffer.from(serverEphemeral));
const eeDH = this.ecdh(
this.ephemeralPriv,
this.ephemeralPub,
Buffer.from(serverEphemeral),
true,
);
this.mixKey(eeDH);
const serverStaticKey = this.decryptAndHash(Buffer.from(encryptedStatic));
const esDH = this.ecdh(
this.ephemeralPriv,
this.ephemeralPub,
serverStaticKey,
true,
);
this.mixKey(esDH);
const certPayload = this.decryptAndHash(Buffer.from(encryptedCert));
const certificate = deserializeSignatureNoiseMessage(certPayload);
const [k1, k2] = hkdf2(this.ck, Buffer.alloc(0));
this.sendCipher.initializeKey(k1);
this.recvCipher.initializeKey(k2);
this.handshakeComplete = true;
return { serverStaticKey, certificate };
}
encrypt(plaintext: Buffer): Buffer {
if (!this.handshakeComplete) throw new Error('Handshake not complete');
return this.sendCipher.encryptWithAd(Buffer.alloc(0), plaintext);
}
decrypt(ciphertext: Buffer): Buffer {
if (!this.handshakeComplete) throw new Error('Handshake not complete');
return this.recvCipher.decryptWithAd(Buffer.alloc(0), ciphertext);
}
get isHandshakeComplete(): boolean {
return this.handshakeComplete;
}
}
-107
View File
@@ -1,107 +0,0 @@
import { sipHash24, shortTxId, sipHashKeyFromNonce } from './sv2-siphash';
describe('SipHash-2-4', () => {
// Official SipHash-2-4 test vectors from the reference paper
// Key: 00 01 02 ... 0f
// Input: empty, 00, 00 01, 00 01 02, ...
// Expected outputs (first few from Appendix A of the paper)
const testKey = Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);
// Reference vectors from vectors.h: uint8_t vectors_sip64[64][8]
// These are the raw output bytes, so we compare directly with our output buffer.
const expectedOutputs = [
'310e0edd47db6f72', // empty input
'fd67dc93c539f874', // [0x00]
'5a4fa9d909806c0d', // [0x00, 0x01]
'2d7efbd796666785', // [0x00, 0x01, 0x02]
'b7877127e09427cf', // [0x00, 0x01, 0x02, 0x03]
'8da699cd64557618', // [0x00, ..., 0x04]
'cee3fe586e46c9cb', // [0x00, ..., 0x05]
'37d1018bf50002ab', // [0x00, ..., 0x06]
'6224939a79f5f593', // [0x00, ..., 0x07]
'b0e4a90bdf82009e', // [0x00, ..., 0x08]
'f3b9dd94c5bb5d7a', // [0x00, ..., 0x09]
'a7ad6b22462fb3f4', // [0x00, ..., 0x0a]
'fbe50e86bc8f1e75', // [0x00, ..., 0x0b]
'903d84c02756ea14', // [0x00, ..., 0x0c]
'eef27a8e90ca23f7', // [0x00, ..., 0x0d]
'e545be4961ca29a1', // [0x00, ..., 0x0e]
];
expectedOutputs.forEach((expected, i) => {
it(`matches test vector for ${i}-byte input`, () => {
const input = Buffer.from(Array.from({ length: i }, (_, j) => j));
const result = sipHash24(testKey, input);
// Compare raw output bytes directly
expect(result.toString('hex')).toBe(expected);
});
});
it('throws for invalid key length', () => {
expect(() => sipHash24(Buffer.alloc(8), Buffer.alloc(0))).toThrow('SipHash key must be 16 bytes');
expect(() => sipHash24(Buffer.alloc(32), Buffer.alloc(0))).toThrow('SipHash key must be 16 bytes');
});
it('produces 8-byte output', () => {
const result = sipHash24(testKey, Buffer.from('hello'));
expect(result.length).toBe(8);
});
it('produces consistent results', () => {
const data = Buffer.from('test data');
const r1 = sipHash24(testKey, data);
const r2 = sipHash24(testKey, data);
expect(r1).toEqual(r2);
});
it('different keys produce different results', () => {
const key2 = Buffer.alloc(16, 0xff);
const data = Buffer.from('hello');
const r1 = sipHash24(testKey, data);
const r2 = sipHash24(key2, data);
expect(r1).not.toEqual(r2);
});
describe('shortTxId', () => {
it('produces 6-byte output', () => {
const txHash = Buffer.alloc(32, 0xab);
const result = shortTxId(testKey, txHash);
expect(result.length).toBe(6);
});
it('is the first 6 bytes of sipHash24', () => {
const txHash = Buffer.alloc(32, 0xcd);
const full = sipHash24(testKey, txHash);
const short = shortTxId(testKey, txHash);
expect(short).toEqual(full.subarray(0, 6));
});
it('different tx hashes produce different short IDs', () => {
const tx1 = Buffer.alloc(32, 0x01);
const tx2 = Buffer.alloc(32, 0x02);
const s1 = shortTxId(testKey, tx1);
const s2 = shortTxId(testKey, tx2);
expect(s1).not.toEqual(s2);
});
});
describe('sipHashKeyFromNonce', () => {
it('produces 16-byte key', () => {
const key = sipHashKeyFromNonce(42n);
expect(key.length).toBe(16);
});
it('nonce is stored in first 8 bytes LE', () => {
const key = sipHashKeyFromNonce(0x0102030405060708n);
expect(key.readBigUInt64LE(0)).toBe(0x0102030405060708n);
// Second 8 bytes should be zero
expect(key.readBigUInt64LE(8)).toBe(0n);
});
it('produces valid SipHash key', () => {
const key = sipHashKeyFromNonce(12345n);
const result = sipHash24(key, Buffer.from('test'));
expect(result.length).toBe(8);
});
});
});
-154
View File
@@ -1,154 +0,0 @@
// ── SipHash-2-4 ────────────────────────────────────────────────────
// Pure TypeScript implementation of SipHash-2-4 for SV2 JDP short
// transaction IDs. Produces 8-byte (64-bit) hashes, truncated to
// 6 bytes per the SV2 spec for SHORT_TX_ID.
//
// Reference: https://131002.net/siphash/siphash.pdf
function add64(a: [number, number], b: [number, number]): [number, number] {
const lo = (a[1] + b[1]) >>> 0;
const hi = ((a[0] + b[0]) >>> 0) + (lo < a[1] ? 1 : 0);
return [(hi >>> 0), lo];
}
function rotl64(v: [number, number], n: number): [number, number] {
if (n === 0) return v;
if (n === 32) return [v[1], v[0]];
if (n < 32) {
return [
((v[0] << n) | (v[1] >>> (32 - n))) >>> 0,
((v[1] << n) | (v[0] >>> (32 - n))) >>> 0,
];
}
const s = n - 32;
return [
((v[1] << s) | (v[0] >>> (32 - s))) >>> 0,
((v[0] << s) | (v[1] >>> (32 - s))) >>> 0,
];
}
function xor64(a: [number, number], b: [number, number]): [number, number] {
return [(a[0] ^ b[0]) >>> 0, (a[1] ^ b[1]) >>> 0];
}
function sipRound(
v0: [number, number],
v1: [number, number],
v2: [number, number],
v3: [number, number],
): [[number, number], [number, number], [number, number], [number, number]] {
v0 = add64(v0, v1);
v1 = rotl64(v1, 13);
v1 = xor64(v1, v0);
v0 = rotl64(v0, 32);
v2 = add64(v2, v3);
v3 = rotl64(v3, 16);
v3 = xor64(v3, v2);
v2 = add64(v2, v1);
v1 = rotl64(v1, 17);
v1 = xor64(v1, v2);
v2 = rotl64(v2, 32);
v0 = add64(v0, v3);
v3 = rotl64(v3, 21);
v3 = xor64(v3, v0);
return [v0, v1, v2, v3];
}
function readU64LE(buf: Buffer, offset: number): [number, number] {
const lo = buf.readUInt32LE(offset);
const hi = buf.readUInt32LE(offset + 4);
return [hi, lo];
}
/**
* SipHash-2-4 producing an 8-byte hash.
* @param key 16-byte key
* @param data input data
* @returns 8-byte Buffer (little-endian hash)
*/
export function sipHash24(key: Buffer, data: Buffer): Buffer {
if (key.length !== 16) throw new Error('SipHash key must be 16 bytes');
const k0 = readU64LE(key, 0);
const k1 = readU64LE(key, 8);
let v0: [number, number] = xor64(k0, [0x736f6d65, 0x70736575]); // "somepseu"
let v1: [number, number] = xor64(k1, [0x646f7261, 0x6e646f6d]); // "dorandom"
let v2: [number, number] = xor64(k0, [0x6c796765, 0x6e657261]); // "lygenera"
let v3: [number, number] = xor64(k1, [0x74656462, 0x79746573]); // "tedbytes"
const len = data.length;
const blocks = Math.floor(len / 8);
// Process 8-byte blocks
for (let i = 0; i < blocks; i++) {
const m = readU64LE(data, i * 8);
v3 = xor64(v3, m);
// 2 rounds (SipHash-2-4)
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
v0 = xor64(v0, m);
}
// Last block: remaining bytes + length in the high byte
const remaining = len - blocks * 8;
let last: [number, number] = [0, 0];
// High byte of hi word = (len & 0xff) << 24
last[0] = ((len & 0xff) << 24) >>> 0;
// Read remaining bytes
const offset = blocks * 8;
if (remaining >= 7) last[0] |= (data[offset + 6] << 16) >>> 0;
if (remaining >= 6) last[0] |= (data[offset + 5] << 8) >>> 0;
if (remaining >= 5) last[0] |= data[offset + 4];
if (remaining >= 4) last[1] = (last[1] | ((data[offset + 3] << 24) >>> 0)) >>> 0;
if (remaining >= 3) last[1] = (last[1] | ((data[offset + 2] << 16) >>> 0)) >>> 0;
if (remaining >= 2) last[1] = (last[1] | ((data[offset + 1] << 8) >>> 0)) >>> 0;
if (remaining >= 1) last[1] = (last[1] | data[offset]) >>> 0;
v3 = xor64(v3, last);
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
v0 = xor64(v0, last);
// Finalization
v2 = xor64(v2, [0, 0xff]);
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
const result = xor64(xor64(v0, v1), xor64(v2, v3));
const out = Buffer.alloc(8);
out.writeUInt32LE(result[1], 0);
out.writeUInt32LE(result[0], 4);
return out;
}
/**
* Compute a 6-byte SHORT_TX_ID per SV2 spec.
* SipHash-2-4(key, txid) truncated to first 6 bytes.
* @param key 16-byte key (derived from nonce)
* @param txHash 32-byte transaction hash
* @returns 6-byte Buffer
*/
export function shortTxId(key: Buffer, txHash: Buffer): Buffer {
const hash = sipHash24(key, txHash);
return hash.subarray(0, 6);
}
/**
* Build a 16-byte SipHash key from a U64 nonce.
* First 8 bytes = nonce LE, second 8 bytes = zeros.
*/
export function sipHashKeyFromNonce(nonce: bigint): Buffer {
const key = Buffer.alloc(16);
key.writeBigUInt64LE(nonce, 0);
return key;
}
-226
View File
@@ -1,226 +0,0 @@
import { BufferReader } from './sv2-binary-codec';
import {
Sv2TdpCoinbaseOutputConstraints,
serializeTdpCoinbaseOutputConstraints,
deserializeTdpCoinbaseOutputConstraints,
Sv2TdpNewTemplate,
serializeTdpNewTemplate,
deserializeTdpNewTemplate,
Sv2TdpSetNewPrevHash,
serializeTdpSetNewPrevHash,
deserializeTdpSetNewPrevHash,
Sv2TdpRequestTransactionData,
serializeTdpRequestTransactionData,
deserializeTdpRequestTransactionData,
Sv2TdpRequestTransactionDataSuccess,
serializeTdpRequestTransactionDataSuccess,
deserializeTdpRequestTransactionDataSuccess,
Sv2TdpRequestTransactionDataError,
serializeTdpRequestTransactionDataError,
deserializeTdpRequestTransactionDataError,
Sv2TdpSubmitSolution,
serializeTdpSubmitSolution,
deserializeTdpSubmitSolution,
} from './sv2-tdp-messages';
function roundTrip<T>(
msg: T,
serialize: (m: T) => Buffer,
deserialize: (r: BufferReader) => T,
): T {
const buf = serialize(msg);
return deserialize(new BufferReader(buf));
}
describe('SV2 TDP Messages', () => {
it('CoinbaseOutputConstraints round-trips', () => {
const msg: Sv2TdpCoinbaseOutputConstraints = {
coinbaseOutputMaxAdditionalSize: 128,
coinbaseOutputMaxAdditionalSigops: 400,
};
const result = roundTrip(msg, serializeTdpCoinbaseOutputConstraints, deserializeTdpCoinbaseOutputConstraints);
expect(result).toEqual(msg);
});
it('NewTemplate round-trips with merkle path', () => {
const hash1 = Buffer.alloc(32, 0xaa);
const hash2 = Buffer.alloc(32, 0xbb);
const msg: Sv2TdpNewTemplate = {
templateId: 42n,
futureTemplate: false,
version: 0x20000000,
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.from('deadbeef', 'hex'),
coinbaseTxInputSequence: 0xffffffff,
coinbaseTxValueRemaining: 625000000n,
coinbaseTxOutputsCount: 1,
coinbaseTxOutputs: Buffer.from('cafebabe', 'hex'),
coinbaseTxLocktime: 0,
merklePath: [hash1, hash2],
};
const result = roundTrip(msg, serializeTdpNewTemplate, deserializeTdpNewTemplate);
expect(result.templateId).toBe(msg.templateId);
expect(result.futureTemplate).toBe(msg.futureTemplate);
expect(result.version).toBe(msg.version);
expect(result.coinbasePrefix).toEqual(msg.coinbasePrefix);
expect(result.coinbaseTxVersion).toBe(msg.coinbaseTxVersion);
expect(result.coinbaseTxInputSequence).toBe(msg.coinbaseTxInputSequence);
expect(result.coinbaseTxValueRemaining).toBe(msg.coinbaseTxValueRemaining);
expect(result.coinbaseTxOutputsCount).toBe(msg.coinbaseTxOutputsCount);
expect(result.coinbaseTxOutputs).toEqual(msg.coinbaseTxOutputs);
expect(result.coinbaseTxLocktime).toBe(msg.coinbaseTxLocktime);
expect(result.merklePath).toHaveLength(2);
expect(result.merklePath[0]).toEqual(hash1);
expect(result.merklePath[1]).toEqual(hash2);
});
it('NewTemplate round-trips with empty merkle path', () => {
const msg: Sv2TdpNewTemplate = {
templateId: 1n,
futureTemplate: true,
version: 0x20000000,
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.alloc(0),
coinbaseTxInputSequence: 0xffffffff,
coinbaseTxValueRemaining: 0n,
coinbaseTxOutputsCount: 0,
coinbaseTxOutputs: Buffer.alloc(0),
coinbaseTxLocktime: 0,
merklePath: [],
};
const result = roundTrip(msg, serializeTdpNewTemplate, deserializeTdpNewTemplate);
expect(result.merklePath).toHaveLength(0);
expect(result.futureTemplate).toBe(true);
});
it('NewTemplate round-trips with large coinbase data', () => {
const largeCoinbase = Buffer.alloc(100, 0xcc); // B0_255 max 255 bytes
const largeOutputs = Buffer.alloc(2000, 0xdd);
const msg: Sv2TdpNewTemplate = {
templateId: 999n,
futureTemplate: false,
version: 0x20400000,
coinbaseTxVersion: 2,
coinbasePrefix: largeCoinbase,
coinbaseTxInputSequence: 0xfffffffe,
coinbaseTxValueRemaining: 312500000n,
coinbaseTxOutputsCount: 3,
coinbaseTxOutputs: largeOutputs,
coinbaseTxLocktime: 500000,
merklePath: Array.from({ length: 12 }, (_, i) => Buffer.alloc(32, i)),
};
const result = roundTrip(msg, serializeTdpNewTemplate, deserializeTdpNewTemplate);
expect(result.coinbasePrefix).toEqual(largeCoinbase); // B0_255, 100 bytes
expect(result.coinbaseTxOutputs).toEqual(largeOutputs);
expect(result.merklePath).toHaveLength(12);
});
it('SetNewPrevHash round-trips', () => {
const prevHash = Buffer.alloc(32, 0xab);
const target = Buffer.alloc(32, 0x00);
target[0] = 0xff;
const msg: Sv2TdpSetNewPrevHash = {
templateId: 42n,
prevHash,
headerTimestamp: 1700000000,
nBits: 0x1d00ffff,
target,
};
const result = roundTrip(msg, serializeTdpSetNewPrevHash, deserializeTdpSetNewPrevHash);
expect(result).toEqual(msg);
});
it('RequestTransactionData round-trips', () => {
const msg: Sv2TdpRequestTransactionData = {
templateId: 100n,
};
const result = roundTrip(msg, serializeTdpRequestTransactionData, deserializeTdpRequestTransactionData);
expect(result).toEqual(msg);
});
it('RequestTransactionData.Success round-trips with transactions', () => {
const tx1 = Buffer.from('0100000001', 'hex');
const tx2 = Buffer.from('0200000002abcd', 'hex');
const msg: Sv2TdpRequestTransactionDataSuccess = {
templateId: 100n,
excessData: Buffer.from('extra', 'utf8'),
transactionList: [tx1, tx2],
};
const result = roundTrip(
msg,
serializeTdpRequestTransactionDataSuccess,
deserializeTdpRequestTransactionDataSuccess,
);
expect(result.templateId).toBe(msg.templateId);
expect(result.excessData).toEqual(msg.excessData);
expect(result.transactionList).toHaveLength(2);
expect(result.transactionList[0]).toEqual(tx1);
expect(result.transactionList[1]).toEqual(tx2);
});
it('RequestTransactionData.Success round-trips with empty transaction list', () => {
const msg: Sv2TdpRequestTransactionDataSuccess = {
templateId: 1n,
excessData: Buffer.alloc(0),
transactionList: [],
};
const result = roundTrip(
msg,
serializeTdpRequestTransactionDataSuccess,
deserializeTdpRequestTransactionDataSuccess,
);
expect(result.transactionList).toHaveLength(0);
});
it('RequestTransactionData.Error round-trips', () => {
const msg: Sv2TdpRequestTransactionDataError = {
templateId: 42n,
errorCode: 'template-not-found',
};
const result = roundTrip(
msg,
serializeTdpRequestTransactionDataError,
deserializeTdpRequestTransactionDataError,
);
expect(result).toEqual(msg);
});
it('SubmitSolution round-trips', () => {
const coinbaseTx = Buffer.alloc(250, 0xef);
const msg: Sv2TdpSubmitSolution = {
templateId: 42n,
version: 0x20000000,
headerTimestamp: 1700000000,
headerNonce: 0xdeadbeef,
coinbaseTx,
};
const result = roundTrip(msg, serializeTdpSubmitSolution, deserializeTdpSubmitSolution);
expect(result.templateId).toBe(msg.templateId);
expect(result.version).toBe(msg.version);
expect(result.headerTimestamp).toBe(msg.headerTimestamp);
expect(result.headerNonce).toBe(msg.headerNonce);
expect(result.coinbaseTx).toEqual(coinbaseTx);
});
it('serialized NewTemplate has correct byte layout', () => {
const msg: Sv2TdpNewTemplate = {
templateId: 1n,
futureTemplate: false,
version: 0x20000000,
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.from([0xab]),
coinbaseTxInputSequence: 0xffffffff,
coinbaseTxValueRemaining: 100n,
coinbaseTxOutputsCount: 0,
coinbaseTxOutputs: Buffer.alloc(0),
coinbaseTxLocktime: 0,
merklePath: [],
};
const buf = serializeTdpNewTemplate(msg);
// U64(8) + BOOL(1) + U32(4) + U32(4) + B0_255(1+1) + U32(4) + U64(8) + U32(4) + B0_64K(2+0) + U32(4) + SEQ0_255(1+0)
// = 8 + 1 + 4 + 4 + 2 + 4 + 8 + 4 + 2 + 4 + 1 = 42
expect(buf.length).toBe(42);
expect(buf.readBigUInt64LE(0)).toBe(1n);
expect(buf[8]).toBe(0); // futureTemplate = false
});
});
-196
View File
@@ -1,196 +0,0 @@
// ── SV2 Template Distribution Protocol (TDP) Messages ──────────────
// Interfaces + serialize/deserialize for all TDP message types.
import { BufferReader, BufferWriter } from './sv2-binary-codec';
// ── CoinbaseOutputConstraints (0x70) ────────────────────────────────
export interface Sv2TdpCoinbaseOutputConstraints {
coinbaseOutputMaxAdditionalSize: number; // U32
coinbaseOutputMaxAdditionalSigops: number; // U16
}
export function serializeTdpCoinbaseOutputConstraints(msg: Sv2TdpCoinbaseOutputConstraints): Buffer {
const w = new BufferWriter();
w.writeU32(msg.coinbaseOutputMaxAdditionalSize);
w.writeU16(msg.coinbaseOutputMaxAdditionalSigops);
return w.toBuffer();
}
export function deserializeTdpCoinbaseOutputConstraints(reader: BufferReader): Sv2TdpCoinbaseOutputConstraints {
return {
coinbaseOutputMaxAdditionalSize: reader.readU32(),
coinbaseOutputMaxAdditionalSigops: reader.readU16(),
};
}
// ── NewTemplate (0x71) ─────────────────────────────────────────────
export interface Sv2TdpNewTemplate {
templateId: bigint; // U64
futureTemplate: boolean; // BOOL
version: number; // U32
coinbaseTxVersion: number; // U32
coinbasePrefix: Buffer; // B0_255
coinbaseTxInputSequence: number; // U32
coinbaseTxValueRemaining: bigint; // U64
coinbaseTxOutputsCount: number; // U32
coinbaseTxOutputs: Buffer; // B0_64K (serialized outputs)
coinbaseTxLocktime: number; // U32
merklePath: Buffer[]; // SEQ0_255<U256>
}
export function serializeTdpNewTemplate(msg: Sv2TdpNewTemplate): Buffer {
const w = new BufferWriter();
w.writeU64(msg.templateId);
w.writeBool(msg.futureTemplate);
w.writeU32(msg.version);
w.writeU32(msg.coinbaseTxVersion);
w.writeB0_255(msg.coinbasePrefix);
w.writeU32(msg.coinbaseTxInputSequence);
w.writeU64(msg.coinbaseTxValueRemaining);
w.writeU32(msg.coinbaseTxOutputsCount);
w.writeB0_64K(msg.coinbaseTxOutputs);
w.writeU32(msg.coinbaseTxLocktime);
w.writeSeq0_255(msg.merklePath, (writer, hash) => writer.writeU256(hash));
return w.toBuffer();
}
export function deserializeTdpNewTemplate(reader: BufferReader): Sv2TdpNewTemplate {
return {
templateId: reader.readU64(),
futureTemplate: reader.readBool(),
version: reader.readU32(),
coinbaseTxVersion: reader.readU32(),
coinbasePrefix: reader.readB0_255(),
coinbaseTxInputSequence: reader.readU32(),
coinbaseTxValueRemaining: reader.readU64(),
coinbaseTxOutputsCount: reader.readU32(),
coinbaseTxOutputs: reader.readB0_64K(),
coinbaseTxLocktime: reader.readU32(),
merklePath: reader.readSeq0_255((r) => r.readU256()),
};
}
// ── SetNewPrevHash (0x72) ──────────────────────────────────────────
export interface Sv2TdpSetNewPrevHash {
templateId: bigint; // U64
prevHash: Buffer; // U256
headerTimestamp: number; // U32
nBits: number; // U32
target: Buffer; // U256
}
export function serializeTdpSetNewPrevHash(msg: Sv2TdpSetNewPrevHash): Buffer {
const w = new BufferWriter();
w.writeU64(msg.templateId);
w.writeU256(msg.prevHash);
w.writeU32(msg.headerTimestamp);
w.writeU32(msg.nBits);
w.writeU256(msg.target);
return w.toBuffer();
}
export function deserializeTdpSetNewPrevHash(reader: BufferReader): Sv2TdpSetNewPrevHash {
return {
templateId: reader.readU64(),
prevHash: reader.readU256(),
headerTimestamp: reader.readU32(),
nBits: reader.readU32(),
target: reader.readU256(),
};
}
// ── RequestTransactionData (0x73) ──────────────────────────────────
export interface Sv2TdpRequestTransactionData {
templateId: bigint; // U64
}
export function serializeTdpRequestTransactionData(msg: Sv2TdpRequestTransactionData): Buffer {
const w = new BufferWriter();
w.writeU64(msg.templateId);
return w.toBuffer();
}
export function deserializeTdpRequestTransactionData(reader: BufferReader): Sv2TdpRequestTransactionData {
return {
templateId: reader.readU64(),
};
}
// ── RequestTransactionData.Success (0x74) ──────────────────────────
export interface Sv2TdpRequestTransactionDataSuccess {
templateId: bigint; // U64
excessData: Buffer; // B0_64K
transactionList: Buffer[]; // SEQ0_64K<B0_16M>
}
export function serializeTdpRequestTransactionDataSuccess(msg: Sv2TdpRequestTransactionDataSuccess): Buffer {
const w = new BufferWriter();
w.writeU64(msg.templateId);
w.writeB0_64K(msg.excessData);
w.writeSeq0_64K(msg.transactionList, (writer, tx) => writer.writeB0_16M(tx));
return w.toBuffer();
}
export function deserializeTdpRequestTransactionDataSuccess(reader: BufferReader): Sv2TdpRequestTransactionDataSuccess {
return {
templateId: reader.readU64(),
excessData: reader.readB0_64K(),
transactionList: reader.readSeq0_64K((r) => r.readB0_16M()),
};
}
// ── RequestTransactionData.Error (0x75) ────────────────────────────
export interface Sv2TdpRequestTransactionDataError {
templateId: bigint; // U64
errorCode: string; // STR0_255
}
export function serializeTdpRequestTransactionDataError(msg: Sv2TdpRequestTransactionDataError): Buffer {
const w = new BufferWriter();
w.writeU64(msg.templateId);
w.writeStr0_255(msg.errorCode);
return w.toBuffer();
}
export function deserializeTdpRequestTransactionDataError(reader: BufferReader): Sv2TdpRequestTransactionDataError {
return {
templateId: reader.readU64(),
errorCode: reader.readStr0_255(),
};
}
// ── SubmitSolution (0x76) ──────────────────────────────────────────
export interface Sv2TdpSubmitSolution {
templateId: bigint; // U64
version: number; // U32
headerTimestamp: number; // U32
headerNonce: number; // U32
coinbaseTx: Buffer; // B0_64K
}
export function serializeTdpSubmitSolution(msg: Sv2TdpSubmitSolution): Buffer {
const w = new BufferWriter();
w.writeU64(msg.templateId);
w.writeU32(msg.version);
w.writeU32(msg.headerTimestamp);
w.writeU32(msg.headerNonce);
w.writeB0_64K(msg.coinbaseTx);
return w.toBuffer();
}
export function deserializeTdpSubmitSolution(reader: BufferReader): Sv2TdpSubmitSolution {
return {
templateId: reader.readU64(),
version: reader.readU32(),
headerTimestamp: reader.readU32(),
headerNonce: reader.readU32(),
coinbaseTx: reader.readB0_64K(),
};
}
-16
View File
@@ -1,16 +0,0 @@
import { AppDataSource } from '../data-source';
async function main() {
await AppDataSource.initialize();
try {
const migrations = await AppDataSource.runMigrations();
console.log(`Ran ${migrations.length} database migrations`);
} finally {
await AppDataSource.destroy();
}
}
main().catch(error => {
console.error('Database migration failed:', error);
process.exit(1);
});
-68
View File
@@ -1,68 +0,0 @@
import { spawn } from 'child_process';
import * as net from 'net';
async function main() {
await waitForTcp(process.env.DB_HOST, parseInt(process.env.DB_PORT ?? '5432', 10), 'TimescaleDB');
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://redis:6379');
await waitForTcp(redisUrl.hostname, parseInt(redisUrl.port || '6379', 10), 'Redis');
await run('node', ['dist/scripts/run-migrations.js']);
if (process.env.PM2_ENABLED === 'false') {
await run('node', ['dist/main.js'], true);
return;
}
await run('./node_modules/.bin/pm2-runtime', ['ecosystem.config.js'], true);
}
function waitForTcp(host: string, port: number, label: string): Promise<void> {
const deadline = Date.now() + parseInt(process.env.STARTUP_WAIT_TIMEOUT_MS ?? '60000', 10);
return new Promise((resolve, reject) => {
const attempt = () => {
const socket = net.createConnection({ host, port });
socket.once('connect', () => {
socket.destroy();
console.log(`${label} is reachable at ${host}:${port}`);
resolve();
});
socket.once('error', () => {
socket.destroy();
if (Date.now() > deadline) {
reject(new Error(`${label} was not reachable at ${host}:${port}`));
return;
}
setTimeout(attempt, 1000);
});
};
attempt();
});
}
function run(command: string, args: string[], inherit = false): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: inherit ? 'inherit' : 'pipe',
env: process.env,
});
if (!inherit) {
child.stdout?.on('data', data => process.stdout.write(data));
child.stderr?.on('data', data => process.stderr.write(data));
}
child.once('exit', code => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${command} ${args.join(' ')} exited with ${code}`));
});
});
}
main().catch(error => {
console.error(error);
process.exit(1);
});
+29 -56
View File
@@ -1,93 +1,66 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { UserAgentReportService } from '../ORM/_views/user-agent-report/user-agent-report.service';
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
import { ClientService } from '../ORM/client/client.service';
import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service';
import { ShareAccountingService } from '../ORM/share-accounting/share-accounting.service';
@Injectable()
export class AppService implements OnModuleInit {
private refreshingLiveUserAgentReport = false;
private refreshingPoolSummary = false;
constructor(
private readonly clientStatisticsService: ClientStatisticsService,
private readonly clientService: ClientService,
private readonly dataSource: DataSource,
private readonly rpcBlockService: RpcBlockService,
private readonly userAgentReportService: UserAgentReportService,
private readonly shareAccountingService: ShareAccountingService
) {
}
async onModuleInit() {
if (process.env.MASTER == 'true') {
// if (process.env.NODE_APP_INSTANCE == '0') {
// await this.dataSource.query(`VACUUM;`);
// }
//https://phiresky.github.io/blog/2020/sqlite-performance-tuning/
// //500 MB DB cache
// await this.dataSource.query(`PRAGMA cache_size = -500000;`);
//Normal is still completely corruption safe in WAL mode, and means only WAL checkpoints have to wait for FSYNC.
await this.dataSource.query(`PRAGMA synchronous = off;`);
// //6Gb
// await this.dataSource.query(`PRAGMA mmap_size = 6000000000;`);
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
setInterval(async () => {
await this.deleteOldClients();
await this.deleteOldStatistics();
}, 1000 * 60 * 60);
setInterval(async () => {
console.log('Killing dead clients');
await this.clientService.killDeadClients();
}, 1000 * 60 * 5);
setInterval(async () => {
console.log('Deleting Old Blocks');
await this.rpcBlockService.deleteOldBlocks();
}, 1000 * 60 * 60 * 24);
setInterval(async () => {
await this.refreshLiveUserAgentReport();
}, 1000 * 30);
setTimeout(async () => {
await this.refreshLiveUserAgentReport();
await this.refreshPoolSummary();
}, 1000 * 15);
setInterval(async () => {
await this.refreshPoolSummary();
}, 1000 * 60 * 5);
setInterval(async () => {
console.log('Refreshing user agent report view')
await this.userAgentReportService.refreshReport();
console.log('Finished Refreshing user agent report view')
}, 1000 * 60 * 5);
}
}
private async deleteOldClients() {
console.log('Deleting old clients');
private async deleteOldStatistics() {
console.log('Deleting statistics');
const deletedStatistics = await this.clientStatisticsService.deleteOldStatistics();
console.log(`Deleted ${deletedStatistics.affected} old statistics`);
const deletedClients = await this.clientService.deleteOldClients();
console.log(`Deleted ${deletedClients.affected} old clients`);
}
private async refreshLiveUserAgentReport() {
if (this.refreshingLiveUserAgentReport) {
return;
}
this.refreshingLiveUserAgentReport = true;
try {
await this.userAgentReportService.refreshLiveReport();
} catch (error) {
console.error(`Failed refreshing live user agent report: ${error.message}`);
} finally {
this.refreshingLiveUserAgentReport = false;
}
}
private async refreshPoolSummary() {
if (this.refreshingPoolSummary) {
return;
}
this.refreshingPoolSummary = true;
try {
await this.shareAccountingService.refreshPoolSummary();
} catch (error) {
console.error(`Failed refreshing pool accounting summary: ${error.message}`);
} finally {
this.refreshingPoolSummary = false;
}
}
}
}
+81 -97
View File
@@ -1,42 +1,45 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
import { asyncScheduler, BehaviorSubject, delay, filter, from, interval, scheduled, shareReplay, startWith, Subject, switchMap } from 'rxjs';
import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service';
import * as fs from 'node:fs';
import { BehaviorSubject, filter, shareReplay } from 'rxjs';
import * as zmq from 'zeromq';
import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service';
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
import { RedisMessagingService } from './redis-messaging.service';
@Injectable()
export class BitcoinRpcService implements OnModuleInit {
private blockHeight = 0;
private client: AxiosInstance;
private _newBlockTemplate$: BehaviorSubject<IBlockTemplate> = new BehaviorSubject(undefined);
private resetTemplateInterval$ = new Subject<void>();
private rpcRequestId = 0;
public miningInfo: IMiningInfo;
public newBlockTemplate$ = this._newBlockTemplate$.pipe(filter(block => block != null), shareReplay({ refCount: true, bufferSize: 1 }));
private _newBlock$: BehaviorSubject<IMiningInfo> = new BehaviorSubject(undefined);
public newBlock$ = this._newBlock$.pipe(filter(block => block != null), shareReplay({ refCount: true, bufferSize: 1 }));
constructor(
private readonly configService: ConfigService,
private rpcBlockService: RpcBlockService,
private readonly redisMessagingService: RedisMessagingService
private rpcBlockService: RpcBlockService
) {
}
async onModuleInit() {
const url = this.configService.get('BITCOIN_RPC_URL');
const user = this.configService.get('BITCOIN_RPC_USER');
const pass = this.configService.get('BITCOIN_RPC_PASSWORD');
let user = this.configService.get('BITCOIN_RPC_USER');
let pass = this.configService.get('BITCOIN_RPC_PASSWORD');
const port = parseInt(this.configService.get('BITCOIN_RPC_PORT'));
const timeout = parseInt(this.configService.get('BITCOIN_RPC_TIMEOUT'));
const cookiefile = this.configService.get('BITCOIN_RPC_COOKIEFILE');
if (cookiefile != undefined && cookiefile != '') {
const cookie = fs.readFileSync(cookiefile).toString().trim().split(':');
user = cookie[0];
pass = cookie[1];
}
const baseURL = this.buildRpcUrl(url, port);
this.client = axios.create({
baseURL,
@@ -47,115 +50,101 @@ export class BitcoinRpcService implements OnModuleInit {
}
});
console.log(`MASTER? ${process.env.MASTER}`)
if (process.env.MASTER != 'true') {
await this.loadLatestMiningInfoForReplayProcess();
if (process.env.API_ONLY != 'true') {
await this.loadLatestTemplateForWorker();
}
await this.redisMessagingService.subscribeMiningInfoUpdates(async (miningInfo: IMiningInfo) => {
this.miningInfo = miningInfo;
if (process.env.API_ONLY != 'true') {
await this.loadTemplateForWorker(miningInfo.blocks);
}
});
if (process.env.API_ONLY == 'true') {
console.log('API-only process using Redis mining info replay');
}
return;
} else {
this.callRpc('getrpcinfo').then((res) => {
console.log('Bitcoin RPC connected');
}, () => {
console.error('Could not reach RPC host');
});
this.callRpc('getrpcinfo').then(() => {
console.log('Bitcoin RPC connected');
}, () => {
console.error('Could not reach RPC host');
});
this.miningInfo = await this.getMiningInfo();
if (this.configService.get('BITCOIN_ZMQ_HOST')) {
console.log('Using ZMQ');
const sock = new zmq.Subscriber;
sock.connectTimeout = 1000;
sock.events.on('connect', () => {
console.log('ZMQ Connected');
});
sock.events.on('connect:retry', () => {
console.error('ZMQ Unable to connect, Retrying');
console.log('ZMQ Unable to connect, Retrying');
});
sock.connect(this.configService.get('BITCOIN_ZMQ_HOST'));
sock.subscribe('rawblock');
// Don't await this, otherwise it will block the rest of the program
this.listenForNewBlocks(sock);
await this.pollMiningInfo();
// Between new blocks we want refresh jobs with the latest transactions
this.resetTemplateInterval$.pipe(
startWith(null),
switchMap(() =>interval(60000))
).subscribe(async () =>{
await this.getAndBroadcastLatestTemplate();
});
}
}
private async loadLatestMiningInfoForReplayProcess() {
const latestMiningInfo = await this.redisMessagingService.getLatestMiningInfo();
if (latestMiningInfo != null) {
this.miningInfo = latestMiningInfo;
} else {
setInterval(this.pollMiningInfo.bind(this), 500);
}
}
private async listenForNewBlocks(sock: zmq.Subscriber) {
for await (const [topic, msg] of sock) {
console.log("New Block");
this.miningInfo = await this.getMiningInfo();
await this.getAndBroadcastLatestTemplate();
//Reset the block update interval
this.resetTemplateInterval$.next();
await this.pollMiningInfo();
}
}
public async getAndBroadcastLatestTemplate() {
const blockTemplate = await this.loadBlockTemplate(this.miningInfo.blocks);
this._newBlockTemplate$.next(blockTemplate);
await this.redisMessagingService.setLatestMiningInfo(this.miningInfo);
await this.redisMessagingService.setBlockTemplate(this.miningInfo.blocks, blockTemplate);
await this.redisMessagingService.publishMiningInfoUpdate(this.miningInfo);
}
private async loadLatestTemplateForWorker() {
const latestMiningInfo = await this.redisMessagingService.getLatestMiningInfo();
if (latestMiningInfo != null) {
this.miningInfo = latestMiningInfo;
await this.loadTemplateForWorker(latestMiningInfo.blocks);
return;
}
const latestBlockTemplate = await this.redisMessagingService.getLatestBlockTemplate();
if (latestBlockTemplate != null) {
this._newBlockTemplate$.next(latestBlockTemplate);
public async pollMiningInfo() {
const miningInfo = await this.getMiningInfo();
if (miningInfo != null && miningInfo.blocks > this.blockHeight) {
console.log("block height change");
this._newBlock$.next(miningInfo);
this.blockHeight = miningInfo.blocks;
}
}
private async loadTemplateForWorker(blockHeight: number) {
const redisBlockTemplate = await this.redisMessagingService.getBlockTemplate(blockHeight);
if (redisBlockTemplate != null) {
this._newBlockTemplate$.next(redisBlockTemplate);
return;
}
private async waitForBlock(blockHeight: number): Promise<IBlockTemplate> {
while (true) {
await new Promise(r => setTimeout(r, 100));
const savedBlockTemplate = await this.rpcBlockService.getSavedBlockTemplate(blockHeight);
if (savedBlockTemplate?.data != null) {
this._newBlockTemplate$.next(JSON.parse(savedBlockTemplate.data));
const block = await this.rpcBlockService.getBlock(blockHeight);
if (block != null && block.data != null) {
console.log(`promise loop resolved, block height ${blockHeight}`);
return Promise.resolve(JSON.parse(block.data));
}
console.log(`promise loop, block height ${blockHeight}`);
}
}
public async getBlockTemplate(blockHeight: number): Promise<IBlockTemplate> {
let result: IBlockTemplate;
try {
const block = await this.rpcBlockService.getBlock(blockHeight);
const completeBlock = block?.data != null;
// If the block has already been loaded, and the same instance is fetching the template again, we just need to refresh it.
if (completeBlock && block.lockedBy == process.env.NODE_APP_INSTANCE) {
result = await this.loadBlockTemplate(blockHeight);
}
else if (completeBlock) {
return Promise.resolve(JSON.parse(block.data));
} else if (!completeBlock) {
if (process.env.NODE_APP_INSTANCE != null) {
// There is a unique constraint on the block height so if another process tries to lock, it'll throw
try {
await this.rpcBlockService.lockBlock(blockHeight, process.env.NODE_APP_INSTANCE);
} catch (e) {
result = await this.waitForBlock(blockHeight);
}
}
result = await this.loadBlockTemplate(blockHeight);
} else {
//wait for block
result = await this.waitForBlock(blockHeight);
}
} catch (e) {
console.error('Error getblocktemplate:', e.message);
throw new Error('Error getblocktemplate');
}
console.log(`getblocktemplate tx count: ${result.transactions.length}`);
return result;
}
private async loadBlockTemplate(blockHeight: number) {
console.log(`Master fetching block template ${blockHeight}`);
let blockTemplate: IBlockTemplate;
while (blockTemplate == null) {
blockTemplate = await this.callRpc<IBlockTemplate>('getblocktemplate', [
@@ -167,13 +156,8 @@ export class BitcoinRpcService implements OnModuleInit {
]);
}
try {
console.log(`Saving block ${blockHeight}`);
await this.rpcBlockService.saveBlock(blockHeight, JSON.stringify(blockTemplate));
console.log('block saved');
} catch (e) {
console.error('Error saving block', e);
}
await this.rpcBlockService.saveBlock(blockHeight, JSON.stringify(blockTemplate));
return blockTemplate;
}
+5 -5
View File
@@ -34,7 +34,7 @@ export class DiscordService implements OnModuleInit {
constructor(private readonly configService: ConfigService) {
if (process.env.MASTER == 'true') {
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
this.token = this.configService.get('DISCORD_BOT_TOKEN');
this.clientId = this.configService.get('DISCORD_BOT_CLIENTID');
this.guildId = this.configService.get('DISCORD_BOT_GUILD_ID');
@@ -61,7 +61,7 @@ export class DiscordService implements OnModuleInit {
async onModuleInit(): Promise<void> {
if (process.env.MASTER == 'true') {
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
if (this.bot == null) {
return;
}
@@ -93,7 +93,7 @@ export class DiscordService implements OnModuleInit {
}
private async registerCommands() {
if (process.env.MASTER == 'true') {
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
const rest = new REST().setToken(this.token);
try {
console.log(`Started refreshing ${commands.length} application (/) commands.`);
@@ -113,7 +113,7 @@ export class DiscordService implements OnModuleInit {
}
public async notifyRestarted() {
if (process.env.MASTER == 'true') {
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
if (this.bot == null) {
return;
}
@@ -125,7 +125,7 @@ export class DiscordService implements OnModuleInit {
}
public async notifySubscribersBlockFound(height: number, block: Block, message: string) {
if (process.env.MASTER == 'true') {
if (process.env.NODE_APP_INSTANCE == null || process.env.NODE_APP_INSTANCE == '0') {
if (this.bot == null) {
return;
}
+31
View File
@@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { ExternalPoolShare } from '../models/ExternalPoolShare';
@Injectable()
export class ExternalSharesService {
private readonly shareApiUrl: string;
private readonly shareApiKey: string;
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService
) {
this.shareApiUrl = this.configService.get('SHARE_SUBMISSION_URL') || 'https://web.public-pool.io';
this.shareApiKey = this.configService.get('SHARE_SUBMISSION_API_KEY');
}
public submitShare(share: ExternalPoolShare): void {
this.httpService.post(`${this.shareApiUrl}/api/share`, share, {
headers: {
'x-api-key': this.shareApiKey
}
}).subscribe({
next: () =>{
console.log('External share accepted');
},
error: (error) => console.error('Failed to submit share to API:', error.message)
});
}
}
+1 -5
View File
@@ -14,10 +14,6 @@ export class NotificationService implements OnModuleInit {
) { }
async onModuleInit(): Promise<void> {
if (process.env.MASTER !== 'true') {
return;
}
await this.discordService.notifyRestarted();
}
@@ -25,4 +21,4 @@ export class NotificationService implements OnModuleInit {
await this.discordService.notifySubscribersBlockFound(height, block, message);
await this.telegramService.notifySubscribersBlockFound(address, height, block, message);
}
}
}

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