mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 09:05:06 -07:00
Compare commits
38
Commits
deaf3e56bd
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96a9202c11 | ||
|
|
95565eeec4 | ||
|
|
b971e9ce4c | ||
|
|
e1b88207d3 | ||
|
|
e7f7cb52af | ||
|
|
6cf1c225a4 | ||
|
|
fd775965e5 | ||
|
|
554a8916a7 | ||
|
|
fda3f21b71 | ||
|
|
0201703e35 | ||
|
|
ddb7a89615 | ||
|
|
f9e49a8955 | ||
|
|
2912dd5071 | ||
|
|
4282233d2f | ||
|
|
8ce057b3b5 | ||
|
|
7bb436bf1f | ||
|
|
0917911eb0 | ||
|
|
de2b41a3c1 | ||
|
|
1cd5bcc97d | ||
|
|
277aa10144 | ||
|
|
f08103bd8c | ||
|
|
811e7972f9 | ||
|
|
c654a706ae | ||
|
|
e0239d2805 | ||
|
|
fc96b9ea45 | ||
|
|
ad1f32cc2c | ||
|
|
917d312211 | ||
|
|
76e2426c30 | ||
|
|
098fe2867a | ||
|
|
6e3902b732 | ||
|
|
8c62817f73 | ||
|
|
9f723782ab | ||
|
|
2e296c00d5 | ||
|
|
437b0c9bc4 | ||
|
|
deed775d55 | ||
|
|
ed537658ac | ||
|
|
29e0d884e3 | ||
|
|
e35bacc648 |
+9
-45
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,3 @@ rpcuser=bitcoin
|
||||
|
||||
# set pruning
|
||||
prune=550
|
||||
|
||||
# notify pool master about new blocks
|
||||
zmqpubrawblock=tcp://0.0.0.0:3000
|
||||
|
||||
@@ -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]
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 +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 +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
|
||||
|
||||
@@ -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
|
||||
Generated
+74
-9866
File diff suppressed because it is too large
Load Diff
+2
-21
@@ -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)`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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({});
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ export class RpcBlockEntity {
|
||||
@PrimaryColumn()
|
||||
blockHeight: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
lockedBy?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
data?: string;
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user