mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 09:05:06 -07:00
Compare commits
38
Commits
a33c530268
..
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
-85
@@ -10,55 +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
|
||||
|
||||
# Docker json-file log rotation. Applies when using the compose files.
|
||||
DOCKER_LOG_MAX_SIZE=100m
|
||||
DOCKER_LOG_MAX_FILES=5
|
||||
|
||||
# Plain TCP Stratum ports accept both SV1 JSON-RPC and SV2 Noise/binary traffic.
|
||||
STRATUM_PORTS=3333,3332,3331,3330
|
||||
STRATUM_WORKERS=2
|
||||
STRATUM_WORKER_MAX_MEMORY_RESTART=4096M
|
||||
STRATUM_MIN_DIFFICULTY=1
|
||||
STRATUM_SOCKET_TIMEOUT_MS=3600000
|
||||
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS=60000
|
||||
|
||||
# Optional additional SV2-only mining ports. Usually unnecessary when STRATUM_PORTS are exposed.
|
||||
#STRATUM_V2_PORTS=
|
||||
#SV2_START_DIFFICULTY=100000
|
||||
#SV2_TARGET_SHARES_PER_MINUTE=2
|
||||
#SV2_DIFFICULTY_CHECK_INTERVAL_MS=60000
|
||||
#STRATUM_V2_SOCKET_TIMEOUT_MS=3600000
|
||||
#SV2_AUTHORITY_PRIVKEY=
|
||||
#SV2_JDP_PORTS=
|
||||
#SV2_JDP_POOL_PAYOUT_ADDRESS=
|
||||
#SV2_JDP_TOKEN_TTL_MS=600000
|
||||
#SV2_TDP_PORTS=
|
||||
#SV2_TDP_POOL_PAYOUT_ADDRESS=
|
||||
|
||||
# Optional DATUM Prime-compatible listener. DATUM_POOL_PAYOUT_ADDRESS is the
|
||||
# required pool coinbase output DATUM gateways must include in custom templates.
|
||||
#DATUM_PORTS=
|
||||
#DATUM_POOL_PAYOUT_ADDRESS=
|
||||
#DATUM_SHARE_DIFFICULTY=1
|
||||
# 32-byte hex seed for the DATUM server identity key. DATUM clients must be
|
||||
# configured with the matching logged public key.
|
||||
#DATUM_IDENTITY_SEED=
|
||||
|
||||
STRATUM_SECURE=true
|
||||
SECURE_STRATUM_PORTS=4333,4332,4331,4330
|
||||
STRATUM_PORT=3333
|
||||
STRATUM_MAX_CONNECTIONS_PER_LISTENER=10000
|
||||
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS=10000
|
||||
|
||||
#optional telegram bot
|
||||
#TELEGRAM_BOT_TOKEN=
|
||||
@@ -74,42 +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
|
||||
SHARE_ROLLUP_ENABLED=true
|
||||
SHARE_ROLLUP_INTERVAL_MS=60000
|
||||
SHARE_ROLLUP_SAFETY_LAG_SECONDS=30
|
||||
SHARE_ROLLUP_MAX_SHARES_PER_BATCH=5000000
|
||||
|
||||
# payout snapshot accounting
|
||||
PAYOUT_SNAPSHOT_ENABLED=true
|
||||
PAYOUT_METHOD=pplns
|
||||
PAYOUT_WINDOW_FACTOR=4
|
||||
# solo preserves current one-address coinbase payouts. Set to snapshot to pay
|
||||
# the latest payout snapshot directly in coinbase outputs.
|
||||
PAYOUT_COINBASE_MODE=solo
|
||||
PAYOUT_MAX_COINBASE_OUTPUTS=10
|
||||
PAYOUT_MIN_OUTPUT_SATS=546
|
||||
PAYOUT_FEE_ADDRESS=
|
||||
PAYOUT_FEE_PERCENT=0
|
||||
PAYOUT_COINBASE_WEIGHT_BUDGET=50000
|
||||
|
||||
#redis
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
PRODUCTION=false
|
||||
# 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"
|
||||
|
||||
+2
-6
@@ -13,14 +13,10 @@ lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
._*
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
/.baseline-*.json
|
||||
/.test-artifacts
|
||||
/.profiles
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
@@ -42,7 +38,7 @@ settings.json
|
||||
|
||||
#DB
|
||||
**.sqlite**
|
||||
/full-setup/data/
|
||||
./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,99 +0,0 @@
|
||||
x-log-limits: &log-limits
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: ${DOCKER_LOG_MAX_SIZE:-100m}
|
||||
max-file: "${DOCKER_LOG_MAX_FILES:-5}"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
container_name: public-pool-redis
|
||||
restart: unless-stopped
|
||||
logging: *log-limits
|
||||
ports:
|
||||
- "127.0.0.1:${REDIS_PORT:-6379}:6379/tcp"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
public-pool:
|
||||
container_name: public-pool
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
logging: *log-limits
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "0.0.0.0:3333:3333/tcp"
|
||||
- "0.0.0.0:3332:3332/tcp"
|
||||
- "0.0.0.0:3331:3331/tcp"
|
||||
- "0.0.0.0:3330:3330/tcp"
|
||||
- "0.0.0.0:4333:4333/tcp"
|
||||
- "0.0.0.0:4332:4332/tcp"
|
||||
- "0.0.0.0:4331:4331/tcp"
|
||||
- "0.0.0.0:4330:4330/tcp"
|
||||
- "${API_BIND_HOST:-127.0.0.1}:${API_PUBLIC_PORT:-3334}:3334/tcp"
|
||||
volumes:
|
||||
- "./.env:/public-pool/.env:ro"
|
||||
- "./secrets:/public-pool/secrets:ro"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DB_HOST: ${DB_HOST:?set DB_HOST to your external TimescaleDB/Postgres host}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_USERNAME: ${DB_USERNAME:-public_pool}
|
||||
DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD for your external database}
|
||||
DB_DATABASE: ${DB_DATABASE:-public_pool}
|
||||
DB_POOL_SIZE: ${DB_POOL_SIZE:-10}
|
||||
DB_SSL: ${DB_SSL:-false}
|
||||
DB_SSL_REJECT_UNAUTHORIZED: ${DB_SSL_REJECT_UNAUTHORIZED:-true}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
API_PORT: ${API_PORT:-3334}
|
||||
API_SECURE: ${API_SECURE:-false}
|
||||
API_WORKERS: ${API_WORKERS:-4}
|
||||
PM2_ENABLED: ${PM2_ENABLED:-true}
|
||||
STRATUM_WORKERS: ${STRATUM_WORKERS:-2}
|
||||
STRATUM_PORTS: ${STRATUM_PORTS:-3333,3332,3331,3330}
|
||||
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
|
||||
STRATUM_SOCKET_TIMEOUT_MS: ${STRATUM_SOCKET_TIMEOUT_MS:-3600000}
|
||||
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS: ${STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS:-60000}
|
||||
STRATUM_SECURE: ${STRATUM_SECURE:-true}
|
||||
SECURE_STRATUM_PORTS: ${SECURE_STRATUM_PORTS:-4333,4332,4331,4330}
|
||||
STRATUM_MAX_CONNECTIONS_PER_LISTENER: ${STRATUM_MAX_CONNECTIONS_PER_LISTENER:-10000}
|
||||
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS: ${STRATUM_TLS_HANDSHAKE_TIMEOUT_MS:-10000}
|
||||
STRATUM_BACKPRESSURE_ENABLED: "${STRATUM_BACKPRESSURE_ENABLED:-false}"
|
||||
STRATUM_V2_PORTS: ${STRATUM_V2_PORTS:-}
|
||||
SHARE_ACCOUNTING_BATCH_SIZE: ${SHARE_ACCOUNTING_BATCH_SIZE:-500}
|
||||
SHARE_ACCOUNTING_FLUSH_INTERVAL_MS: ${SHARE_ACCOUNTING_FLUSH_INTERVAL_MS:-25}
|
||||
SHARE_ACCOUNTING_MAX_QUEUE_SIZE: ${SHARE_ACCOUNTING_MAX_QUEUE_SIZE:-50000}
|
||||
SHARE_ACCOUNTING_SUMMARY_CACHE_MS: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MS:-2500}
|
||||
SHARE_ACCOUNTING_SUMMARY_CACHE_MAX: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MAX:-10000}
|
||||
SHARE_ROLLUP_ENABLED: ${SHARE_ROLLUP_ENABLED:-true}
|
||||
SHARE_ROLLUP_INTERVAL_MS: ${SHARE_ROLLUP_INTERVAL_MS:-60000}
|
||||
SHARE_ROLLUP_SAFETY_LAG_SECONDS: ${SHARE_ROLLUP_SAFETY_LAG_SECONDS:-30}
|
||||
SHARE_ROLLUP_MAX_SHARES_PER_BATCH: ${SHARE_ROLLUP_MAX_SHARES_PER_BATCH:-5000000}
|
||||
PAYOUT_SNAPSHOT_ENABLED: ${PAYOUT_SNAPSHOT_ENABLED:-true}
|
||||
PAYOUT_METHOD: ${PAYOUT_METHOD:-pplns}
|
||||
PAYOUT_WINDOW_FACTOR: ${PAYOUT_WINDOW_FACTOR:-4}
|
||||
PAYOUT_COINBASE_MODE: ${PAYOUT_COINBASE_MODE:-solo}
|
||||
PAYOUT_MAX_COINBASE_OUTPUTS: ${PAYOUT_MAX_COINBASE_OUTPUTS:-10}
|
||||
PAYOUT_MIN_OUTPUT_SATS: ${PAYOUT_MIN_OUTPUT_SATS:-546}
|
||||
PAYOUT_FEE_ADDRESS: ${PAYOUT_FEE_ADDRESS:-}
|
||||
PAYOUT_FEE_PERCENT: ${PAYOUT_FEE_PERCENT:-0}
|
||||
PAYOUT_COINBASE_WEIGHT_BUDGET: ${PAYOUT_COINBASE_WEIGHT_BUDGET:-50000}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"const http=require('http'); const https=require('https'); const secure=process.env.API_SECURE==='true'; const client=secure?https:http; const req=client.get({hostname:'127.0.0.1',port:process.env.API_PORT||3334,path:'/api/network',rejectUnauthorized:false},res=>process.exit(res.statusCode<500?0:1)); req.on('error',()=>process.exit(1)); req.setTimeout(5000,()=>{req.destroy(); process.exit(1);});\""]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
@@ -1,65 +0,0 @@
|
||||
name: public-pool-test
|
||||
|
||||
x-log-limits: &log-limits
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: ${DOCKER_LOG_MAX_SIZE:-100m}
|
||||
max-file: "${DOCKER_LOG_MAX_FILES:-5}"
|
||||
|
||||
services:
|
||||
timescaledb:
|
||||
image: timescale/timescaledb:latest-pg17
|
||||
logging: *log-limits
|
||||
environment:
|
||||
POSTGRES_DB: public_pool_test
|
||||
POSTGRES_USER: public_pool
|
||||
POSTGRES_PASSWORD: public_pool
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- max_wal_size=16GB
|
||||
- -c
|
||||
- min_wal_size=2GB
|
||||
- -c
|
||||
- checkpoint_timeout=15min
|
||||
- -c
|
||||
- checkpoint_completion_target=0.9
|
||||
- -c
|
||||
- wal_compression=on
|
||||
ports:
|
||||
- "127.0.0.1:15432:5432/tcp"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U public_pool -d public_pool_test"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
logging: *log-limits
|
||||
ports:
|
||||
- "127.0.0.1:16379:6379/tcp"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
integration-tests:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.test
|
||||
logging: *log-limits
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: integration
|
||||
DB_HOST: timescaledb
|
||||
DB_PORT: 5432
|
||||
DB_USERNAME: public_pool
|
||||
DB_PASSWORD: public_pool
|
||||
DB_DATABASE: public_pool_test
|
||||
REDIS_URL: redis://redis:6379
|
||||
+5
-96
@@ -1,111 +1,20 @@
|
||||
x-log-limits: &log-limits
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: ${DOCKER_LOG_MAX_SIZE:-100m}
|
||||
max-file: "${DOCKER_LOG_MAX_FILES:-5}"
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
timescaledb:
|
||||
image: timescale/timescaledb:latest-pg17
|
||||
container_name: public-pool-timescaledb
|
||||
restart: unless-stopped
|
||||
logging: *log-limits
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_DATABASE:-public_pool}
|
||||
POSTGRES_USER: ${DB_USERNAME:-public_pool}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-public_pool}
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- max_wal_size=16GB
|
||||
- -c
|
||||
- min_wal_size=2GB
|
||||
- -c
|
||||
- checkpoint_timeout=15min
|
||||
- -c
|
||||
- checkpoint_completion_target=0.9
|
||||
- -c
|
||||
- wal_compression=on
|
||||
ports:
|
||||
- "127.0.0.1:${DB_PORT:-5432}:5432/tcp"
|
||||
volumes:
|
||||
- timescaledb-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-public_pool} -d ${DB_DATABASE:-public_pool}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
container_name: public-pool-redis
|
||||
restart: unless-stopped
|
||||
logging: *log-limits
|
||||
ports:
|
||||
- "127.0.0.1:${REDIS_PORT:-6379}:6379/tcp"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
public-pool:
|
||||
container_name: public-pool
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
logging: *log-limits
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "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_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
|
||||
STRATUM_SOCKET_TIMEOUT_MS: ${STRATUM_SOCKET_TIMEOUT_MS:-3600000}
|
||||
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS: ${STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS:-60000}
|
||||
STRATUM_SECURE: ${STRATUM_SECURE:-true}
|
||||
SECURE_STRATUM_PORTS: ${SECURE_STRATUM_PORTS:-4333,4332,4331,4330}
|
||||
STRATUM_MAX_CONNECTIONS_PER_LISTENER: ${STRATUM_MAX_CONNECTIONS_PER_LISTENER:-10000}
|
||||
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS: ${STRATUM_TLS_HANDSHAKE_TIMEOUT_MS:-10000}
|
||||
STRATUM_V2_PORTS: ${STRATUM_V2_PORTS:-}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"const http=require('http'); const https=require('https'); const secure=process.env.API_SECURE==='true'; const client=secure?https:http; const req=client.get({hostname:'127.0.0.1',port:process.env.API_PORT||3334,path:'/api/network',rejectUnauthorized:false},res=>process.exit(res.statusCode<500?0:1)); req.on('error',()=>process.exit(1)); req.setTimeout(5000,()=>{req.destroy(); process.exit(1);});\""]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
- NODE_ENV=production
|
||||
|
||||
volumes:
|
||||
timescaledb-data:
|
||||
redis-data:
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
const dockerLogConfig = {
|
||||
out_file: '/dev/stdout',
|
||||
error_file: '/dev/stderr',
|
||||
merge_logs: true,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
// API instance
|
||||
{
|
||||
...dockerLogConfig,
|
||||
name: 'api',
|
||||
script: './dist/main.js',
|
||||
instances: parseInt(process.env.API_WORKERS || '4', 10),
|
||||
exec_mode: 'cluster',
|
||||
env: {
|
||||
MASTER: 'false',
|
||||
API_ONLY: 'true',
|
||||
API_ENABLED: 'true',
|
||||
NODE_CLUSTER_SCHED_POLICY: 'none',
|
||||
},
|
||||
time: true
|
||||
},
|
||||
// Master instance
|
||||
{
|
||||
...dockerLogConfig,
|
||||
name: 'master',
|
||||
script: './dist/main.js',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
env: {
|
||||
MASTER: 'true',
|
||||
API_ENABLED: 'false',
|
||||
NODE_CLUSTER_SCHED_POLICY: 'none',
|
||||
},
|
||||
time: true
|
||||
},
|
||||
// Worker instances
|
||||
{
|
||||
...dockerLogConfig,
|
||||
name: 'workers',
|
||||
script: './dist/main.js',
|
||||
instances: parseInt(process.env.STRATUM_WORKERS || '2', 10),
|
||||
exec_mode: "cluster",
|
||||
max_memory_restart: process.env.STRATUM_WORKER_MAX_MEMORY_RESTART || '4096M',
|
||||
env: {
|
||||
MASTER: 'false',
|
||||
API_ENABLED: 'false',
|
||||
NODE_CLUSTER_SCHED_POLICY: 'none',
|
||||
},
|
||||
time: true
|
||||
},
|
||||
],
|
||||
};
|
||||
+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,8 +1,4 @@
|
||||
x-log-limits: &log-limits
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: ${DOCKER_LOG_MAX_SIZE:-100m}
|
||||
max-file: "${DOCKER_LOG_MAX_FILES:-5}"
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
bitcoin:
|
||||
@@ -12,7 +8,6 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
logging: *log-limits
|
||||
networks:
|
||||
- bitcoin
|
||||
ports:
|
||||
@@ -25,52 +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
|
||||
logging: *log-limits
|
||||
networks:
|
||||
- bitcoin
|
||||
environment:
|
||||
POSTGRES_DB: public_pool_mainnet
|
||||
POSTGRES_USER: public_pool
|
||||
POSTGRES_PASSWORD: public_pool
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- max_wal_size=16GB
|
||||
- -c
|
||||
- min_wal_size=2GB
|
||||
- -c
|
||||
- checkpoint_timeout=15min
|
||||
- -c
|
||||
- checkpoint_completion_target=0.9
|
||||
- -c
|
||||
- wal_compression=on
|
||||
volumes:
|
||||
- "./data/mainnet/timescaledb:/var/lib/postgresql/data"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U public_pool -d public_pool_mainnet"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
container_name: public-pool-mainnet-redis
|
||||
restart: unless-stopped
|
||||
logging: *log-limits
|
||||
networks:
|
||||
- bitcoin
|
||||
volumes:
|
||||
- "./data/mainnet/redis:/data"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
public-pool:
|
||||
container_name: public-pool
|
||||
build:
|
||||
@@ -78,54 +27,16 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
logging: *log-limits
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- bitcoin
|
||||
ports:
|
||||
- "0.0.0.0:3333:3333/tcp"
|
||||
- "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}
|
||||
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
|
||||
STRATUM_SOCKET_TIMEOUT_MS: ${STRATUM_SOCKET_TIMEOUT_MS:-3600000}
|
||||
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS: ${STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS:-60000}
|
||||
SHARE_ROLLUP_ENABLED: ${SHARE_ROLLUP_ENABLED:-true}
|
||||
SHARE_ROLLUP_INTERVAL_MS: ${SHARE_ROLLUP_INTERVAL_MS:-60000}
|
||||
SHARE_ROLLUP_SAFETY_LAG_SECONDS: ${SHARE_ROLLUP_SAFETY_LAG_SECONDS:-30}
|
||||
SHARE_ROLLUP_MAX_SHARES_PER_BATCH: ${SHARE_ROLLUP_MAX_SHARES_PER_BATCH:-5000000}
|
||||
PAYOUT_SNAPSHOT_ENABLED: ${PAYOUT_SNAPSHOT_ENABLED:-true}
|
||||
PAYOUT_METHOD: ${PAYOUT_METHOD:-pplns}
|
||||
PAYOUT_WINDOW_FACTOR: ${PAYOUT_WINDOW_FACTOR:-4}
|
||||
PAYOUT_COINBASE_MODE: ${PAYOUT_COINBASE_MODE:-solo}
|
||||
PAYOUT_MAX_COINBASE_OUTPUTS: ${PAYOUT_MAX_COINBASE_OUTPUTS:-10}
|
||||
PAYOUT_MIN_OUTPUT_SATS: ${PAYOUT_MIN_OUTPUT_SATS:-546}
|
||||
PAYOUT_FEE_ADDRESS: ${PAYOUT_FEE_ADDRESS:-}
|
||||
PAYOUT_FEE_PERCENT: ${PAYOUT_FEE_PERCENT:-0}
|
||||
PAYOUT_COINBASE_WEIGHT_BUDGET: ${PAYOUT_COINBASE_WEIGHT_BUDGET:-50000}
|
||||
- NODE_ENV=production
|
||||
|
||||
networks:
|
||||
bitcoin:
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
x-log-limits: &log-limits
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: ${DOCKER_LOG_MAX_SIZE:-100m}
|
||||
max-file: "${DOCKER_LOG_MAX_FILES:-5}"
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
bitcoin-regtest:
|
||||
@@ -12,7 +8,6 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
logging: *log-limits
|
||||
networks:
|
||||
- bitcoin-regtest
|
||||
ports:
|
||||
@@ -27,52 +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
|
||||
logging: *log-limits
|
||||
networks:
|
||||
- bitcoin-regtest
|
||||
environment:
|
||||
POSTGRES_DB: public_pool_regtest
|
||||
POSTGRES_USER: public_pool
|
||||
POSTGRES_PASSWORD: public_pool
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- max_wal_size=16GB
|
||||
- -c
|
||||
- min_wal_size=2GB
|
||||
- -c
|
||||
- checkpoint_timeout=15min
|
||||
- -c
|
||||
- checkpoint_completion_target=0.9
|
||||
- -c
|
||||
- wal_compression=on
|
||||
volumes:
|
||||
- "./data/regtest/timescaledb:/var/lib/postgresql/data"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U public_pool -d public_pool_regtest"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis-regtest:
|
||||
image: redis:8-alpine
|
||||
container_name: public-pool-regtest-redis
|
||||
restart: unless-stopped
|
||||
logging: *log-limits
|
||||
networks:
|
||||
- bitcoin-regtest
|
||||
volumes:
|
||||
- "./data/regtest/redis:/data"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
public-pool-regtest:
|
||||
container_name: public-pool-regtest
|
||||
build:
|
||||
@@ -80,36 +29,16 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
logging: *log-limits
|
||||
depends_on:
|
||||
timescaledb-regtest:
|
||||
condition: service_healthy
|
||||
redis-regtest:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- bitcoin-regtest
|
||||
ports:
|
||||
- "0.0.0.0:23333:23333/tcp"
|
||||
- "0.0.0.0:23332:23332/tcp"
|
||||
- "0.0.0.0:23331:23331/tcp"
|
||||
- "0.0.0.0:23330:23330/tcp"
|
||||
- "127.0.0.1:23334:23334/tcp"
|
||||
volumes:
|
||||
- "./data/regtest/public-pool:/public-pool/DB"
|
||||
- "./public-pool-regtest.env:/public-pool/.env:ro"
|
||||
- "../secrets:/public-pool/secrets:ro"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DB_HOST: timescaledb-regtest
|
||||
DB_PORT: 5432
|
||||
DB_USERNAME: public_pool
|
||||
DB_PASSWORD: public_pool
|
||||
DB_DATABASE: public_pool_regtest
|
||||
REDIS_URL: redis://redis-regtest:6379
|
||||
PM2_ENABLED: "true"
|
||||
STRATUM_WORKERS: ${STRATUM_WORKERS:-2}
|
||||
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
|
||||
STRATUM_SOCKET_TIMEOUT_MS: ${STRATUM_SOCKET_TIMEOUT_MS:-3600000}
|
||||
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS: ${STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS:-60000}
|
||||
- NODE_ENV=production
|
||||
|
||||
networks:
|
||||
bitcoin-regtest:
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
x-log-limits: &log-limits
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: ${DOCKER_LOG_MAX_SIZE:-100m}
|
||||
max-file: "${DOCKER_LOG_MAX_FILES:-5}"
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
bitcoin-testnet:
|
||||
@@ -12,7 +8,6 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
logging: *log-limits
|
||||
networks:
|
||||
- bitcoin-testnet
|
||||
ports:
|
||||
@@ -26,52 +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
|
||||
logging: *log-limits
|
||||
networks:
|
||||
- bitcoin-testnet
|
||||
environment:
|
||||
POSTGRES_DB: public_pool_testnet
|
||||
POSTGRES_USER: public_pool
|
||||
POSTGRES_PASSWORD: public_pool
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- max_wal_size=16GB
|
||||
- -c
|
||||
- min_wal_size=2GB
|
||||
- -c
|
||||
- checkpoint_timeout=15min
|
||||
- -c
|
||||
- checkpoint_completion_target=0.9
|
||||
- -c
|
||||
- wal_compression=on
|
||||
volumes:
|
||||
- "./data/testnet/timescaledb:/var/lib/postgresql/data"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U public_pool -d public_pool_testnet"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis-testnet:
|
||||
image: redis:8-alpine
|
||||
container_name: public-pool-testnet-redis
|
||||
restart: unless-stopped
|
||||
logging: *log-limits
|
||||
networks:
|
||||
- bitcoin-testnet
|
||||
volumes:
|
||||
- "./data/testnet/redis:/data"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
public-pool-testnet:
|
||||
container_name: public-pool-testnet
|
||||
build:
|
||||
@@ -79,36 +28,16 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
logging: *log-limits
|
||||
depends_on:
|
||||
timescaledb-testnet:
|
||||
condition: service_healthy
|
||||
redis-testnet:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- bitcoin-testnet
|
||||
ports:
|
||||
- "0.0.0.0:13333:13333/tcp"
|
||||
- "0.0.0.0:13332:13332/tcp"
|
||||
- "0.0.0.0:13331:13331/tcp"
|
||||
- "0.0.0.0:13330:13330/tcp"
|
||||
- "127.0.0.1:13334:13334/tcp"
|
||||
volumes:
|
||||
- "./data/testnet/public-pool:/public-pool/DB"
|
||||
- "./public-pool-testnet.env:/public-pool/.env:ro"
|
||||
- "../secrets:/public-pool/secrets:ro"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DB_HOST: timescaledb-testnet
|
||||
DB_PORT: 5432
|
||||
DB_USERNAME: public_pool
|
||||
DB_PASSWORD: public_pool
|
||||
DB_DATABASE: public_pool_testnet
|
||||
REDIS_URL: redis://redis-testnet:6379
|
||||
PM2_ENABLED: "true"
|
||||
STRATUM_WORKERS: ${STRATUM_WORKERS:-2}
|
||||
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
|
||||
STRATUM_SOCKET_TIMEOUT_MS: ${STRATUM_SOCKET_TIMEOUT_MS:-3600000}
|
||||
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS: ${STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS:-60000}
|
||||
- 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
-9928
File diff suppressed because it is too large
Load Diff
+2
-23
@@ -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,12 +40,8 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.4",
|
||||
"discord.js": "^14.26.4",
|
||||
"dotenv": "^16.6.1",
|
||||
"libsodium-wrappers-sumo": "^0.8.4",
|
||||
"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",
|
||||
@@ -65,7 +55,6 @@
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/express": "^4.17.25",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/libsodium-wrappers-sumo": "^0.7.8",
|
||||
"@types/node": "^18.19.130",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
@@ -98,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,74 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AcceptedShareProtocolMetadata1781130600000 implements MigrationInterface {
|
||||
public name = 'AcceptedShareProtocolMetadata1781130600000';
|
||||
public transaction = false;
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await this.decompressAcceptedShareChunks(queryRunner);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'accepted_share_entity'
|
||||
AND column_name = 'protocol'
|
||||
AND COALESCE(character_maximum_length, 0) < 16
|
||||
) THEN
|
||||
ALTER TABLE "accepted_share_entity" ALTER COLUMN "protocol" TYPE varchar(16);
|
||||
END IF;
|
||||
END
|
||||
$$
|
||||
`);
|
||||
await queryRunner.query(`ALTER TABLE "accepted_share_entity" ADD COLUMN IF NOT EXISTS "workSource" varchar(16) NOT NULL DEFAULT 'pool_template'`);
|
||||
await queryRunner.query(`ALTER TABLE "accepted_share_entity" ADD COLUMN IF NOT EXISTS "workProtocol" varchar(16) NOT NULL DEFAULT 'pool'`);
|
||||
await this.restoreAcceptedShareCompression(queryRunner);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_protocol_work"
|
||||
ON "accepted_share_entity" ("protocol", "workSource", "workProtocol", "acceptedAt" DESC)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await this.decompressAcceptedShareChunks(queryRunner);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_protocol_work"`);
|
||||
await queryRunner.query(`ALTER TABLE "accepted_share_entity" DROP COLUMN IF EXISTS "workProtocol"`);
|
||||
await queryRunner.query(`ALTER TABLE "accepted_share_entity" DROP COLUMN IF EXISTS "workSource"`);
|
||||
await queryRunner.query(`ALTER TABLE "accepted_share_entity" ALTER COLUMN "protocol" TYPE varchar(8)`);
|
||||
await this.restoreAcceptedShareCompression(queryRunner);
|
||||
}
|
||||
|
||||
private async decompressAcceptedShareChunks(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`SELECT remove_compression_policy('accepted_share_entity', if_exists => TRUE)`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE
|
||||
chunk_name regclass;
|
||||
BEGIN
|
||||
FOR chunk_name IN SELECT show_chunks('accepted_share_entity')
|
||||
LOOP
|
||||
PERFORM decompress_chunk(chunk_name, if_compressed => TRUE);
|
||||
END LOOP;
|
||||
END
|
||||
$$
|
||||
`);
|
||||
}
|
||||
|
||||
private async restoreAcceptedShareCompression(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "accepted_share_entity" SET (
|
||||
timescaledb.compress,
|
||||
timescaledb.compress_orderby = '"acceptedAt" DESC',
|
||||
timescaledb.compress_segmentby = '"address","clientName"'
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
SELECT add_compression_policy(
|
||||
'accepted_share_entity',
|
||||
INTERVAL '24 hours',
|
||||
if_not_exists => TRUE
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AcceptedShareRetentionCompression1780966200000 implements MigrationInterface {
|
||||
public name = 'AcceptedShareRetentionCompression1780966200000';
|
||||
public transaction = false;
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "accepted_share_entity" SET (
|
||||
timescaledb.compress,
|
||||
timescaledb.compress_orderby = '"acceptedAt" DESC',
|
||||
timescaledb.compress_segmentby = '"address","clientName"'
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`SELECT remove_compression_policy('accepted_share_entity', if_exists => TRUE)`);
|
||||
await queryRunner.query(`
|
||||
SELECT add_compression_policy(
|
||||
'accepted_share_entity',
|
||||
INTERVAL '24 hours',
|
||||
if_not_exists => TRUE
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`SELECT remove_retention_policy('accepted_share_entity', if_exists => TRUE)`);
|
||||
await queryRunner.query(`
|
||||
SELECT add_retention_policy(
|
||||
'accepted_share_entity',
|
||||
INTERVAL '7 days',
|
||||
if_not_exists => TRUE
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`SELECT remove_compression_policy('accepted_share_entity', if_exists => TRUE)`);
|
||||
await queryRunner.query(`
|
||||
SELECT add_compression_policy(
|
||||
'accepted_share_entity',
|
||||
INTERVAL '1 day',
|
||||
if_not_exists => TRUE
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`SELECT remove_retention_policy('accepted_share_entity', if_exists => TRUE)`);
|
||||
await queryRunner.query(`
|
||||
SELECT add_retention_policy(
|
||||
'accepted_share_entity',
|
||||
INTERVAL '30 days',
|
||||
if_not_exists => TRUE
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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,27 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class BlocksSubmissionMetadata1781220000000 implements MigrationInterface {
|
||||
public name = 'BlocksSubmissionMetadata1781220000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "blocks_entity" ADD COLUMN IF NOT EXISTS "blockHash" varchar`);
|
||||
await queryRunner.query(`ALTER TABLE "blocks_entity" ADD COLUMN IF NOT EXISTS "blockSubmissionResult" varchar`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_blocks_successful_found"
|
||||
ON "blocks_entity" ("height" DESC, "createdAt" DESC)
|
||||
WHERE "blockSubmissionResult" IS NULL OR "blockSubmissionResult" = 'SUCCESS!'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_blocks_block_hash"
|
||||
ON "blocks_entity" ("blockHash")
|
||||
WHERE "blockHash" IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "UQ_blocks_block_hash"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_blocks_successful_found"`);
|
||||
await queryRunner.query(`ALTER TABLE "blocks_entity" DROP COLUMN IF EXISTS "blockSubmissionResult"`);
|
||||
await queryRunner.query(`ALTER TABLE "blocks_entity" DROP COLUMN IF EXISTS "blockHash"`);
|
||||
}
|
||||
}
|
||||
@@ -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,43 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CurrentRoundWorkRollup1780899000000 implements MigrationInterface {
|
||||
public name = 'CurrentRoundWorkRollup1780899000000';
|
||||
public transaction = false;
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_block_10m"
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket(INTERVAL '10 minutes', "acceptedAt") AS "bucket",
|
||||
"blockHeight",
|
||||
SUM("creditedDifficulty") AS "shares",
|
||||
COUNT(*) AS "acceptedCount",
|
||||
MAX("networkDifficulty") AS "networkDifficulty",
|
||||
MAX("submissionDifficulty") AS "bestSubmissionDifficulty"
|
||||
FROM "accepted_share_entity"
|
||||
GROUP BY "bucket", "blockHeight"
|
||||
WITH NO DATA
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
SELECT add_continuous_aggregate_policy(
|
||||
'accepted_share_block_10m',
|
||||
start_offset => INTERVAL '180 days',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute',
|
||||
if_not_exists => TRUE
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_accepted_share_block_10m_height_bucket"
|
||||
ON "accepted_share_block_10m" ("blockHeight" DESC, "bucket" DESC)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_accepted_share_block_10m_height_bucket"`);
|
||||
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "accepted_share_block_10m"`);
|
||||
}
|
||||
}
|
||||
@@ -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,217 +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,
|
||||
"blockHash" varchar,
|
||||
"blockSubmissionResult" varchar,
|
||||
"deletedAt" timestamptz,
|
||||
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
||||
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "rpc_block_entity" (
|
||||
"blockHeight" integer PRIMARY KEY,
|
||||
"data" varchar
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "telegram_subscriptions_entity" (
|
||||
"id" bigserial PRIMARY KEY,
|
||||
"address" varchar(62) NOT NULL,
|
||||
"telegramChatId" integer NOT NULL,
|
||||
"deletedAt" timestamptz,
|
||||
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
||||
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_telegram_subscriptions_address" ON "telegram_subscriptions_entity" ("address")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS "user_agent_report_view" AS
|
||||
SELECT
|
||||
"userAgent",
|
||||
COUNT("userAgent") AS "count",
|
||||
MAX("bestDifficulty") AS "bestDifficulty",
|
||||
SUM("hashRate") AS "totalHashRate"
|
||||
FROM "client_entity"
|
||||
WHERE "deletedAt" IS NULL
|
||||
GROUP BY "userAgent"
|
||||
ORDER BY "totalHashRate" DESC
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_10m"
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket(INTERVAL '10 minutes', "acceptedAt") AS "bucket",
|
||||
"address",
|
||||
"clientName",
|
||||
"sessionId",
|
||||
"clientId",
|
||||
SUM("creditedDifficulty") AS "shares",
|
||||
COUNT(*) AS "acceptedCount",
|
||||
ROUND(((SUM("creditedDifficulty") * 4294967296) / 600)) AS "hashRate"
|
||||
FROM "accepted_share_entity"
|
||||
GROUP BY "bucket", "address", "clientName", "sessionId", "clientId"
|
||||
WITH NO DATA
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_1h"
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket(INTERVAL '1 hour', "acceptedAt") AS "bucket",
|
||||
"address",
|
||||
"clientName",
|
||||
SUM("creditedDifficulty") AS "shares",
|
||||
COUNT(*) AS "acceptedCount"
|
||||
FROM "accepted_share_entity"
|
||||
GROUP BY "bucket", "address", "clientName"
|
||||
WITH NO DATA
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_1d"
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket(INTERVAL '1 day', "acceptedAt") AS "bucket",
|
||||
"address",
|
||||
SUM("creditedDifficulty") AS "shares",
|
||||
COUNT(*) AS "acceptedCount"
|
||||
FROM "accepted_share_entity"
|
||||
GROUP BY "bucket", "address"
|
||||
WITH NO DATA
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
SELECT add_continuous_aggregate_policy(
|
||||
'accepted_share_10m',
|
||||
start_offset => INTERVAL '2 days',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute',
|
||||
if_not_exists => TRUE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
SELECT add_continuous_aggregate_policy(
|
||||
'accepted_share_1h',
|
||||
start_offset => INTERVAL '30 days',
|
||||
end_offset => INTERVAL '10 minutes',
|
||||
schedule_interval => INTERVAL '10 minutes',
|
||||
if_not_exists => TRUE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
SELECT add_continuous_aggregate_policy(
|
||||
'accepted_share_1d',
|
||||
start_offset => INTERVAL '365 days',
|
||||
end_offset => INTERVAL '1 hour',
|
||||
schedule_interval => INTERVAL '1 hour',
|
||||
if_not_exists => TRUE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`SELECT add_retention_policy('accepted_share_entity', INTERVAL '90 days', if_not_exists => TRUE)`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "accepted_share_1d"`);
|
||||
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "accepted_share_1h"`);
|
||||
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "accepted_share_10m"`);
|
||||
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "user_agent_report_view"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "accepted_share_entity" CASCADE`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "telegram_subscriptions_entity"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "rpc_block_entity"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "blocks_entity"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "client_entity"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "address_settings_entity"`);
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class PayoutSnapshots1780969800000 implements MigrationInterface {
|
||||
public name = 'PayoutSnapshots1780969800000';
|
||||
public transaction = false;
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "payout_snapshot" (
|
||||
"id" bigserial PRIMARY KEY,
|
||||
"method" varchar(32) NOT NULL,
|
||||
"status" varchar(16) NOT NULL DEFAULT 'finalized',
|
||||
"blockHeight" bigint NOT NULL,
|
||||
"coinbaseValueSats" bigint NOT NULL,
|
||||
"networkDifficulty" numeric NOT NULL,
|
||||
"windowTargetDifficulty" numeric NOT NULL DEFAULT 0,
|
||||
"windowFactor" numeric NOT NULL DEFAULT 4,
|
||||
"feeAddress" varchar(62),
|
||||
"feeSats" bigint NOT NULL DEFAULT 0,
|
||||
"minPayoutSats" integer NOT NULL DEFAULT 546,
|
||||
"coinbaseWeightBudget" integer NOT NULL DEFAULT 50000,
|
||||
"startBatchId" bigint NOT NULL REFERENCES "share_rollup_batch" ("id"),
|
||||
"endBatchId" bigint NOT NULL REFERENCES "share_rollup_batch" ("id"),
|
||||
"windowStartShareIndex" bigint NOT NULL,
|
||||
"windowEndShareIndex" bigint NOT NULL,
|
||||
"totalCreditedDifficulty" numeric NOT NULL,
|
||||
"totalAcceptedShareCount" bigint NOT NULL,
|
||||
"eligibleAddressCount" integer NOT NULL,
|
||||
"includedOutputCount" integer NOT NULL,
|
||||
"distributedSats" bigint NOT NULL,
|
||||
"unallocatedRemainderSats" bigint NOT NULL,
|
||||
"createdAt" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_payout_snapshot_latest"
|
||||
ON "payout_snapshot" ("status", "createdAt" DESC, "id" DESC)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_payout_snapshot_window"
|
||||
ON "payout_snapshot" ("windowEndShareIndex" DESC)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_payout_snapshot_template_window"
|
||||
ON "payout_snapshot" ("method", "blockHeight", "coinbaseValueSats", "windowEndShareIndex")
|
||||
WHERE "status" = 'finalized'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "payout_snapshot_entry" (
|
||||
"id" bigserial PRIMARY KEY,
|
||||
"snapshotId" bigint NOT NULL REFERENCES "payout_snapshot" ("id") ON DELETE CASCADE,
|
||||
"address" varchar(62) NOT NULL,
|
||||
"creditedDifficulty" numeric NOT NULL,
|
||||
"acceptedShareCount" bigint NOT NULL,
|
||||
"payoutWeight" numeric NOT NULL,
|
||||
"grossPayoutSats" bigint NOT NULL,
|
||||
"payoutSats" bigint NOT NULL,
|
||||
"balanceBeforeSats" bigint NOT NULL DEFAULT 0,
|
||||
"balanceAfterSats" bigint NOT NULL DEFAULT 0,
|
||||
"includedInCoinbase" boolean NOT NULL DEFAULT true,
|
||||
"rowType" varchar(16) NOT NULL DEFAULT 'coinbase',
|
||||
"rank" integer NOT NULL,
|
||||
"createdAt" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_payout_snapshot_entry_snapshot_address"
|
||||
ON "payout_snapshot_entry" ("snapshotId", "address")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_payout_snapshot_entry_snapshot_rank"
|
||||
ON "payout_snapshot_entry" ("snapshotId", "rank")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_payout_snapshot_entry_address"
|
||||
ON "payout_snapshot_entry" ("address")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "payout_balance" (
|
||||
"address" varchar(62) PRIMARY KEY,
|
||||
"balanceSats" bigint NOT NULL DEFAULT 0,
|
||||
"totalPaidSats" bigint NOT NULL DEFAULT 0,
|
||||
"lastAcceptedShareAt" timestamptz,
|
||||
"updatedAt" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_payout_balance_nonzero"
|
||||
ON "payout_balance" ("balanceSats")
|
||||
WHERE "balanceSats" != 0
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "payout_history" (
|
||||
"id" bigserial PRIMARY KEY,
|
||||
"blockHeight" bigint NOT NULL,
|
||||
"payoutSnapshotId" bigint NOT NULL REFERENCES "payout_snapshot" ("id") ON DELETE CASCADE,
|
||||
"address" varchar(62) NOT NULL,
|
||||
"paidSats" bigint NOT NULL DEFAULT 0,
|
||||
"percent" numeric NOT NULL DEFAULT 0,
|
||||
"balanceBeforeSats" bigint NOT NULL DEFAULT 0,
|
||||
"balanceAfterSats" bigint NOT NULL DEFAULT 0,
|
||||
"rowType" varchar(16) NOT NULL DEFAULT 'coinbase',
|
||||
"createdAt" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_payout_history_block_address"
|
||||
ON "payout_history" ("blockHeight", "address")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_payout_history_address_created"
|
||||
ON "payout_history" ("address", "createdAt" DESC)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "blocks_entity" ADD COLUMN IF NOT EXISTS "payoutSnapshotId" bigint`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_blocks_payout_snapshot"
|
||||
ON "blocks_entity" ("payoutSnapshotId")
|
||||
WHERE "payoutSnapshotId" IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_blocks_payout_snapshot"`);
|
||||
await queryRunner.query(`ALTER TABLE "blocks_entity" DROP COLUMN IF EXISTS "payoutSnapshotId"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_history_address_created"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "UQ_payout_history_block_address"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "payout_history"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_balance_nonzero"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "payout_balance"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_snapshot_entry_address"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_snapshot_entry_snapshot_rank"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_snapshot_entry_snapshot_address"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "payout_snapshot_entry"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_snapshot_template_window"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_snapshot_window"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_payout_snapshot_latest"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "payout_snapshot"`);
|
||||
}
|
||||
}
|
||||
@@ -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,14 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class RemoveAcceptedShareRetention1780959000000 implements MigrationInterface {
|
||||
public name = 'RemoveAcceptedShareRetention1780959000000';
|
||||
public transaction = false;
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`SELECT remove_retention_policy('accepted_share_entity', if_exists => TRUE)`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`SELECT add_retention_policy('accepted_share_entity', INTERVAL '30 days', if_not_exists => TRUE)`);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ShareRollupBatches1780962600000 implements MigrationInterface {
|
||||
public name = 'ShareRollupBatches1780962600000';
|
||||
public transaction = false;
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "share_rollup_batch" (
|
||||
"id" bigserial PRIMARY KEY,
|
||||
"startShareIndex" bigint NOT NULL,
|
||||
"endShareIndex" bigint NOT NULL,
|
||||
"startAcceptedAt" timestamptz NOT NULL,
|
||||
"endAcceptedAt" timestamptz NOT NULL,
|
||||
"acceptedShareCount" bigint NOT NULL,
|
||||
"creditedDifficulty" numeric NOT NULL,
|
||||
"status" varchar(16) NOT NULL DEFAULT 'finalized',
|
||||
"createdAt" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"finalizedAt" timestamptz,
|
||||
CONSTRAINT "CHK_share_rollup_batch_index_order"
|
||||
CHECK ("endShareIndex" >= "startShareIndex")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_share_rollup_batch_range"
|
||||
ON "share_rollup_batch" ("startShareIndex", "endShareIndex")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_share_rollup_batch_finalized_end"
|
||||
ON "share_rollup_batch" ("endShareIndex" DESC)
|
||||
WHERE "status" = 'finalized'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "share_rollup_batch_summary" (
|
||||
"batchId" bigint NOT NULL REFERENCES "share_rollup_batch" ("id") ON DELETE CASCADE,
|
||||
"address" varchar(62) NOT NULL,
|
||||
"clientName" varchar NOT NULL,
|
||||
"protocol" varchar(8) NOT NULL,
|
||||
"blockHeight" integer NOT NULL,
|
||||
"creditedDifficulty" numeric NOT NULL,
|
||||
"acceptedShareCount" bigint NOT NULL,
|
||||
"bestSubmissionDifficulty" numeric NOT NULL,
|
||||
"firstShareAt" timestamptz NOT NULL,
|
||||
"lastShareAt" timestamptz NOT NULL,
|
||||
PRIMARY KEY ("batchId", "address", "clientName", "protocol", "blockHeight")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_share_rollup_summary_address_batch"
|
||||
ON "share_rollup_batch_summary" ("address", "clientName", "blockHeight", "batchId")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_share_rollup_summary_address_batch"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "share_rollup_batch_summary"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_share_rollup_batch_finalized_end"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_share_rollup_batch_range"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "share_rollup_batch"`);
|
||||
}
|
||||
}
|
||||
@@ -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,111 +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';
|
||||
|
||||
@Injectable()
|
||||
export class UserAgentReportService {
|
||||
private readonly liveReportCacheKey = 'presence:user-agent-report';
|
||||
private liveRefreshPromise: Promise<UserAgentReportView[]> | null = null;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(UserAgentReportView)
|
||||
private userAgentReport: Repository<UserAgentReportView>,
|
||||
@InjectRepository(ClientEntity)
|
||||
private clientRepository: Repository<ClientEntity>,
|
||||
private redisMessagingService: RedisMessagingService,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
public async getReport() {
|
||||
const cachedReport = await this.redisMessagingService
|
||||
.getJsonCache<UserAgentReportView[]>(this.liveReportCacheKey)
|
||||
.catch(error => {
|
||||
console.error(`Live user-agent report cache read failed: ${error.message}`);
|
||||
return null;
|
||||
});
|
||||
if (cachedReport != null) {
|
||||
return cachedReport;
|
||||
}
|
||||
|
||||
if (process.env.API_ONLY == 'true') {
|
||||
return await this.userAgentReport.find();
|
||||
}
|
||||
|
||||
return await this.refreshLiveReport();
|
||||
}
|
||||
|
||||
public async refreshLiveReport() {
|
||||
if (this.liveRefreshPromise != null) {
|
||||
return this.liveRefreshPromise;
|
||||
}
|
||||
|
||||
this.liveRefreshPromise = this.buildLiveReport()
|
||||
.finally(() => {
|
||||
this.liveRefreshPromise = null;
|
||||
});
|
||||
|
||||
return this.liveRefreshPromise;
|
||||
}
|
||||
|
||||
private async buildLiveReport() {
|
||||
const presences = await this.redisMessagingService.getAllClientPresence();
|
||||
const rows = new Map<string, {
|
||||
userAgent: string;
|
||||
count: number;
|
||||
bestDifficulty: number;
|
||||
totalHashRate: number;
|
||||
}>();
|
||||
|
||||
presences.forEach(presence => {
|
||||
const userAgent = presence.userAgent == null || presence.userAgent.length === 0
|
||||
? 'Other'
|
||||
: presence.userAgent;
|
||||
const row = rows.get(userAgent) ?? {
|
||||
userAgent,
|
||||
count: 0,
|
||||
bestDifficulty: 0,
|
||||
totalHashRate: 0,
|
||||
};
|
||||
row.count++;
|
||||
row.bestDifficulty = Math.max(
|
||||
row.bestDifficulty,
|
||||
Number(presence.bestDifficulty ?? 0),
|
||||
);
|
||||
row.totalHashRate += Number(presence.hashRate ?? 0);
|
||||
rows.set(userAgent, row);
|
||||
});
|
||||
|
||||
const report = [...rows.values()]
|
||||
.sort((left, right) => right.totalHashRate - left.totalHashRate)
|
||||
.map(row => ({
|
||||
userAgent: row.userAgent,
|
||||
count: row.count.toString(),
|
||||
bestDifficulty: row.bestDifficulty,
|
||||
totalHashRate: row.totalHashRate.toString(),
|
||||
}));
|
||||
|
||||
await this.redisMessagingService
|
||||
.setJsonCache(this.liveReportCacheKey, report, 60 * 1000)
|
||||
.catch(error => {
|
||||
console.error(`Live user-agent report cache write failed: ${error.message}`);
|
||||
});
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
public async refreshReport() {
|
||||
try {
|
||||
await this.userAgentReport.query(`REFRESH MATERIALIZED VIEW user_agent_report_view`);
|
||||
return await this.refreshLiveReport();
|
||||
} catch (e) {
|
||||
|
||||
console.log(e)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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,77 +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: 16, type: 'varchar' })
|
||||
protocol: 'sv1' | 'sv1_tls' | 'sv2' | 'sv2_jdp' | 'datum';
|
||||
|
||||
@Column({ length: 16, type: 'varchar', default: 'pool_template' })
|
||||
workSource: 'pool_template' | 'miner_template';
|
||||
|
||||
@Column({ length: 16, type: 'varchar', default: 'pool' })
|
||||
workProtocol: 'pool' | 'sv2_jdp' | 'datum';
|
||||
|
||||
@Column({ length: 62, type: 'varchar' })
|
||||
address: string;
|
||||
|
||||
@Column()
|
||||
clientName: string;
|
||||
|
||||
@Column({ length: 8, type: 'varchar' })
|
||||
sessionId: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
clientId: string;
|
||||
|
||||
@Column()
|
||||
jobId: string;
|
||||
|
||||
@Column()
|
||||
jobTemplateId: string;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
blockHeight: number;
|
||||
|
||||
@Column({ type: 'decimal' })
|
||||
creditedDifficulty: number;
|
||||
|
||||
@Column({ type: 'decimal' })
|
||||
submissionDifficulty: number;
|
||||
|
||||
@Column({ type: 'decimal' })
|
||||
networkDifficulty: number;
|
||||
|
||||
@Column()
|
||||
nonce: string;
|
||||
|
||||
@Column()
|
||||
ntime: string;
|
||||
|
||||
@Column()
|
||||
version: string;
|
||||
|
||||
@Column()
|
||||
extraNonce2: string;
|
||||
|
||||
@Column({ default: false })
|
||||
isBlockCandidate: boolean;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
blockSubmissionResult?: string;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -11,14 +11,14 @@ export class AddressSettingsEntity extends TrackedEntity {
|
||||
@Column({ default: 0 })
|
||||
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,13 +23,4 @@ export class BlocksEntity extends TrackedEntity {
|
||||
@Column()
|
||||
blockData: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
blockHash?: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
blockSubmissionResult?: string;
|
||||
|
||||
@Column({ type: 'bigint', nullable: true })
|
||||
payoutSnapshotId?: string;
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { BlocksService } from './blocks.service';
|
||||
|
||||
describe('BlocksService', () => {
|
||||
it('computes block hash metadata when saving found blocks', async () => {
|
||||
const repository = createRepository();
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
const service = new BlocksService({} as any, repository as any);
|
||||
const blockHex = createBlockHex();
|
||||
const blockHash = bitcoinjs.Block.fromHex(blockHex).getId();
|
||||
|
||||
await service.save({
|
||||
height: 123,
|
||||
minerAddress: 'tb1qexample',
|
||||
worker: 'worker',
|
||||
sessionId: 'abcd1234',
|
||||
blockData: blockHex,
|
||||
blockSubmissionResult: 'SUCCESS!',
|
||||
});
|
||||
|
||||
expect(repository.save).toHaveBeenCalledWith(expect.objectContaining({
|
||||
blockHash,
|
||||
blockSubmissionResult: 'SUCCESS!',
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not downgrade an existing successful block on duplicate rejected submissions', async () => {
|
||||
const repository = createRepository();
|
||||
repository.findOne.mockResolvedValue({
|
||||
id: 9,
|
||||
blockSubmissionResult: 'SUCCESS!',
|
||||
payoutSnapshotId: '7',
|
||||
});
|
||||
const service = new BlocksService({} as any, repository as any);
|
||||
|
||||
await service.save({
|
||||
blockData: createBlockHex(),
|
||||
blockSubmissionResult: 'duplicate',
|
||||
payoutSnapshotId: '8',
|
||||
});
|
||||
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
expect(repository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns one public found-block row per block hash or legacy height', async () => {
|
||||
const repository = createRepository();
|
||||
repository.find.mockResolvedValue([
|
||||
{ height: 124, minerAddress: 'addr2', worker: 'b', sessionId: '2222', blockHash: 'hash-b' },
|
||||
{ height: 124, minerAddress: 'addr2', worker: 'b-dup', sessionId: '3333', blockHash: 'hash-b' },
|
||||
{ height: 123, minerAddress: 'addr1', worker: 'a', sessionId: '1111', blockHash: null },
|
||||
{ height: 123, minerAddress: 'addr1', worker: 'a-dup', sessionId: '4444', blockHash: null },
|
||||
]);
|
||||
const service = new BlocksService({} as any, repository as any);
|
||||
|
||||
await expect(service.getFoundBlocks()).resolves.toEqual([
|
||||
{ height: 124, minerAddress: 'addr2', worker: 'b', sessionId: '2222', blockHash: 'hash-b' },
|
||||
{ height: 123, minerAddress: 'addr1', worker: 'a', sessionId: '1111', blockHash: null },
|
||||
]);
|
||||
});
|
||||
|
||||
function createRepository() {
|
||||
return {
|
||||
save: jest.fn().mockResolvedValue(undefined),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function createBlockHex(): string {
|
||||
const tx = new bitcoinjs.Transaction();
|
||||
tx.version = 1;
|
||||
tx.addInput(Buffer.alloc(32), 0xffffffff, 0xffffffff, Buffer.from([0x01]));
|
||||
tx.addOutput(Buffer.from('6a', 'hex'), 0);
|
||||
|
||||
const block = new bitcoinjs.Block();
|
||||
block.version = 1;
|
||||
block.prevHash = Buffer.alloc(32);
|
||||
block.timestamp = 1;
|
||||
block.bits = 0x207fffff;
|
||||
block.nonce = 1;
|
||||
block.transactions = [tx];
|
||||
block.merkleRoot = bitcoinjs.Block.calculateMerkleRoot(block.transactions, false);
|
||||
return block.toHex(false);
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { DataSource, IsNull, Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
|
||||
import { BlocksEntity } from './blocks.entity';
|
||||
|
||||
@@ -20,106 +19,31 @@ export class BlocksService {
|
||||
|
||||
|
||||
public async save(block: Partial<BlocksEntity>) {
|
||||
const blockHash = block.blockHash ?? this.getBlockHash(block.blockData);
|
||||
const existing = blockHash == null
|
||||
? null
|
||||
: await this.blocksRepository.findOne({
|
||||
select: { id: true, blockSubmissionResult: true, payoutSnapshotId: true },
|
||||
where: { blockHash },
|
||||
});
|
||||
if (existing != null) {
|
||||
if (this.isSuccessfulBlockSubmission(block.blockSubmissionResult)) {
|
||||
await this.blocksRepository.update(existing.id, {
|
||||
blockSubmissionResult: 'SUCCESS!',
|
||||
payoutSnapshotId: block.payoutSnapshotId ?? existing.payoutSnapshotId,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.blocksRepository.save({
|
||||
...block,
|
||||
blockHash,
|
||||
});
|
||||
await this.blocksRepository.save(block);
|
||||
}
|
||||
|
||||
public async getFoundBlocks() {
|
||||
const rows = await this.blocksRepository.find({
|
||||
return await this.blocksRepository.find({
|
||||
select: {
|
||||
height: true,
|
||||
minerAddress: true,
|
||||
worker: true,
|
||||
sessionId: true,
|
||||
blockHash: true,
|
||||
blockSubmissionResult: true,
|
||||
createdAt: true,
|
||||
},
|
||||
where: [
|
||||
{ blockSubmissionResult: 'SUCCESS!' },
|
||||
{ blockSubmissionResult: IsNull() },
|
||||
],
|
||||
order: {
|
||||
height: 'DESC',
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
sessionId: true
|
||||
}
|
||||
});
|
||||
return this.uniqueFoundBlocks(rows);
|
||||
}
|
||||
|
||||
public async getFoundBlocksByAddress(address: string) {
|
||||
const rows = await this.blocksRepository.find({
|
||||
return await this.blocksRepository.find({
|
||||
select: {
|
||||
height: true,
|
||||
minerAddress: true,
|
||||
worker: true,
|
||||
sessionId: true,
|
||||
blockHash: true,
|
||||
blockSubmissionResult: true,
|
||||
createdAt: true,
|
||||
sessionId: true
|
||||
},
|
||||
where: [
|
||||
{ minerAddress: address, blockSubmissionResult: 'SUCCESS!' },
|
||||
{ minerAddress: address, blockSubmissionResult: IsNull() },
|
||||
],
|
||||
order: {
|
||||
height: 'DESC',
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
});
|
||||
return this.uniqueFoundBlocks(rows);
|
||||
}
|
||||
|
||||
private uniqueFoundBlocks(rows: Partial<BlocksEntity>[]): Partial<BlocksEntity>[] {
|
||||
const seenBlocks = new Set<string>();
|
||||
const seenHeights = new Set<number>();
|
||||
return rows.filter(row => {
|
||||
if (row.height != null && seenHeights.has(row.height)) {
|
||||
return false;
|
||||
where: {
|
||||
minerAddress: address
|
||||
}
|
||||
const key = row.blockHash || `height:${row.height}`;
|
||||
if (seenBlocks.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seenBlocks.add(key);
|
||||
if (row.height != null) {
|
||||
seenHeights.add(row.height);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private getBlockHash(blockData?: string): string | undefined {
|
||||
if (blockData == null || blockData.length < 160) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return bitcoinjs.Block.fromHex(blockData).getId();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private isSuccessfulBlockSubmission(result?: string | null): boolean {
|
||||
return result == null || result === 'SUCCESS!';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,104 +1,257 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
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 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]);
|
||||
|
||||
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 this.dataSource.query(query, params);
|
||||
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({});
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Column, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
const bigintTransformer = {
|
||||
from: (value: string | number | null): number => Number(value ?? 0),
|
||||
to: (value: number): number => Math.trunc(value ?? 0),
|
||||
};
|
||||
|
||||
@Entity({ name: 'payout_balance' })
|
||||
export class PayoutBalanceEntity {
|
||||
@PrimaryColumn({ length: 62, type: 'varchar' })
|
||||
address: string;
|
||||
|
||||
@Column({ type: 'bigint', default: 0, transformer: bigintTransformer })
|
||||
balanceSats: number;
|
||||
|
||||
@Column({ type: 'bigint', default: 0, transformer: bigintTransformer })
|
||||
totalPaidSats: number;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
lastAcceptedShareAt: Date | null;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { buildPayoutDistribution } from './payout-distribution';
|
||||
|
||||
const ADDRESS_A = 'bc1qs29kyaqqc0fkvj897ke9e5xa9utljjey0y5jjn';
|
||||
const ADDRESS_B = 'bc1q99n3pu025yyu0jlywpmwzalyhm36tg5u37w20d';
|
||||
const ADDRESS_C = 'bc1q7yx4d3xspf4xvssmsm4prf4mg2slkv8jtkc5l6';
|
||||
|
||||
describe('buildPayoutDistribution', () => {
|
||||
it('should conserve the full coinbase reward across outputs', () => {
|
||||
const result = buildPayoutDistribution({
|
||||
addressWork: [
|
||||
{ address: ADDRESS_A, creditedDifficulty: 2, acceptedShareCount: 2 },
|
||||
{ address: ADDRESS_B, creditedDifficulty: 1, acceptedShareCount: 1 },
|
||||
],
|
||||
balances: [],
|
||||
coinbaseValueSats: 1000,
|
||||
feePercent: 0,
|
||||
minOutputSats: 546,
|
||||
coinbaseWeightBudget: 50_000,
|
||||
});
|
||||
|
||||
expect(result.payoutOutputs.map(entry => ({
|
||||
address: entry.address,
|
||||
payoutSats: entry.payoutSats,
|
||||
balanceAfterSats: entry.balanceAfterSats,
|
||||
}))).toEqual([
|
||||
{ address: ADDRESS_A, payoutSats: 1000, balanceAfterSats: -334 },
|
||||
]);
|
||||
expect(result.entries.find(entry => entry.address === ADDRESS_B))
|
||||
.toEqual(expect.objectContaining({ includedInCoinbase: false, balanceAfterSats: 333 }));
|
||||
expect(result.distributedSats).toBe(1000);
|
||||
expect(result.unallocatedRemainderSats).toBe(0);
|
||||
});
|
||||
|
||||
it('should carry sub-dust work forward as signed credit', () => {
|
||||
const result = buildPayoutDistribution({
|
||||
addressWork: [
|
||||
{ address: ADDRESS_A, creditedDifficulty: 999, acceptedShareCount: 999 },
|
||||
{ address: ADDRESS_B, creditedDifficulty: 1, acceptedShareCount: 1 },
|
||||
],
|
||||
balances: [],
|
||||
coinbaseValueSats: 1000,
|
||||
feePercent: 0,
|
||||
minOutputSats: 546,
|
||||
coinbaseWeightBudget: 50_000,
|
||||
});
|
||||
|
||||
const pending = result.entries.find(entry => entry.address === ADDRESS_B);
|
||||
const paid = result.entries.find(entry => entry.address === ADDRESS_A);
|
||||
|
||||
expect(pending).toEqual(expect.objectContaining({
|
||||
includedInCoinbase: false,
|
||||
grossPayoutSats: 1,
|
||||
payoutSats: 0,
|
||||
balanceAfterSats: 1,
|
||||
}));
|
||||
expect(paid).toEqual(expect.objectContaining({
|
||||
includedInCoinbase: true,
|
||||
payoutSats: 1000,
|
||||
balanceAfterSats: -1,
|
||||
}));
|
||||
expect(result.distributedSats).toBe(1000);
|
||||
});
|
||||
|
||||
it('should trim outputs that do not fit the coinbase weight budget', () => {
|
||||
const result = buildPayoutDistribution({
|
||||
addressWork: [
|
||||
{ address: ADDRESS_A, creditedDifficulty: 10, acceptedShareCount: 1 },
|
||||
{ address: ADDRESS_B, creditedDifficulty: 9, acceptedShareCount: 1 },
|
||||
{ address: ADDRESS_C, creditedDifficulty: 8, acceptedShareCount: 1 },
|
||||
],
|
||||
balances: [],
|
||||
coinbaseValueSats: 3000,
|
||||
feePercent: 0,
|
||||
minOutputSats: 546,
|
||||
coinbaseWeightBudget: 850,
|
||||
});
|
||||
|
||||
expect(result.payoutOutputs).toHaveLength(1);
|
||||
expect(result.payoutOutputs[0].address).toBe(ADDRESS_A);
|
||||
expect(result.entries.find(entry => entry.address === ADDRESS_B))
|
||||
.toEqual(expect.objectContaining({ includedInCoinbase: false, balanceAfterSats: 1000 }));
|
||||
expect(result.entries.find(entry => entry.address === ADDRESS_C))
|
||||
.toEqual(expect.objectContaining({ includedInCoinbase: false, balanceAfterSats: 888 }));
|
||||
});
|
||||
|
||||
it('should apply positive and negative carried balances to the next distribution', () => {
|
||||
const result = buildPayoutDistribution({
|
||||
addressWork: [
|
||||
{ address: ADDRESS_A, creditedDifficulty: 10, acceptedShareCount: 1 },
|
||||
{ address: ADDRESS_B, creditedDifficulty: 10, acceptedShareCount: 1 },
|
||||
],
|
||||
balances: [
|
||||
{ address: ADDRESS_A, balanceSats: 100 },
|
||||
{ address: ADDRESS_B, balanceSats: -100 },
|
||||
],
|
||||
coinbaseValueSats: 2000,
|
||||
feePercent: 0,
|
||||
minOutputSats: 546,
|
||||
coinbaseWeightBudget: 50_000,
|
||||
});
|
||||
|
||||
expect(result.payoutOutputs.map(entry => ({
|
||||
address: entry.address,
|
||||
payoutSats: entry.payoutSats,
|
||||
balanceAfterSats: entry.balanceAfterSats,
|
||||
}))).toEqual([
|
||||
{ address: ADDRESS_A, payoutSats: 1100, balanceAfterSats: 0 },
|
||||
{ address: ADDRESS_B, payoutSats: 900, balanceAfterSats: 0 },
|
||||
]);
|
||||
expect(result.unallocatedRemainderSats).toBe(0);
|
||||
});
|
||||
|
||||
it('should reserve a configured fee output before miner distribution', () => {
|
||||
const result = buildPayoutDistribution({
|
||||
addressWork: [
|
||||
{ address: ADDRESS_A, creditedDifficulty: 1, acceptedShareCount: 1 },
|
||||
],
|
||||
balances: [],
|
||||
coinbaseValueSats: 100000,
|
||||
feeAddress: ADDRESS_C,
|
||||
feePercent: 2,
|
||||
minOutputSats: 546,
|
||||
coinbaseWeightBudget: 50_000,
|
||||
});
|
||||
|
||||
expect(result.payoutOutputs[0]).toEqual(expect.objectContaining({
|
||||
address: ADDRESS_C,
|
||||
payoutSats: 2000,
|
||||
}));
|
||||
expect(result.payoutOutputs[1]).toEqual(expect.objectContaining({
|
||||
address: ADDRESS_A,
|
||||
payoutSats: 98000,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -1,276 +0,0 @@
|
||||
import { AddressType, getAddressInfo } from 'bitcoin-address-validation';
|
||||
|
||||
export interface PayoutAddressWork {
|
||||
address: string;
|
||||
creditedDifficulty: number;
|
||||
acceptedShareCount: number;
|
||||
}
|
||||
|
||||
export interface PayoutBalanceInput {
|
||||
address: string;
|
||||
balanceSats: number;
|
||||
}
|
||||
|
||||
export interface PayoutDistributionEntry {
|
||||
address: string;
|
||||
creditedDifficulty: number;
|
||||
acceptedShareCount: number;
|
||||
payoutWeight: number;
|
||||
grossPayoutSats: number;
|
||||
payoutSats: number;
|
||||
balanceBeforeSats: number;
|
||||
balanceAfterSats: number;
|
||||
includedInCoinbase: boolean;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export interface PayoutDistributionResult {
|
||||
entries: PayoutDistributionEntry[];
|
||||
payoutOutputs: PayoutDistributionEntry[];
|
||||
consideredAddressCount: number;
|
||||
distributedSats: number;
|
||||
unallocatedRemainderSats: number;
|
||||
feeSats: number;
|
||||
}
|
||||
|
||||
export interface PayoutDistributionInput {
|
||||
addressWork: PayoutAddressWork[];
|
||||
balances: PayoutBalanceInput[];
|
||||
coinbaseValueSats: number;
|
||||
feeAddress?: string;
|
||||
feePercent: number;
|
||||
minOutputSats: number;
|
||||
coinbaseWeightBudget: number;
|
||||
}
|
||||
|
||||
const DEFAULT_COINBASE_WEIGHT_BUDGET = 50_000;
|
||||
const DUST_LIMIT_SATS = 546;
|
||||
const COINBASE_BASE_WEIGHT = 328;
|
||||
const COINBASE_WITNESS_COMMITMENT_WEIGHT = 188;
|
||||
const COINBASE_OUTPUT_WEIGHT = 172;
|
||||
const BUDGET_SAFETY_MARGIN_WEIGHT = 200;
|
||||
|
||||
const OUTPUT_WEIGHT_BY_TYPE: Record<string, number> = {
|
||||
[AddressType.p2wpkh]: 124,
|
||||
[AddressType.p2sh]: 128,
|
||||
[AddressType.p2pkh]: 136,
|
||||
[AddressType.p2wsh]: 172,
|
||||
[AddressType.p2tr]: 172,
|
||||
};
|
||||
|
||||
const outputWeightCache = new Map<string, number>();
|
||||
|
||||
interface ComputedAddress {
|
||||
address: string;
|
||||
creditedDifficulty: number;
|
||||
acceptedShareCount: number;
|
||||
payoutWeight: number;
|
||||
rawFairSats: number;
|
||||
balanceBeforeSats: number;
|
||||
targetSats: number;
|
||||
payoutSats: number;
|
||||
balanceAfterSats: number;
|
||||
includedInCoinbase: boolean;
|
||||
}
|
||||
|
||||
export function buildPayoutDistribution(input: PayoutDistributionInput): PayoutDistributionResult {
|
||||
const coinbaseValueSats = Math.max(0, Math.floor(input.coinbaseValueSats));
|
||||
const minOutputSats = Math.max(DUST_LIMIT_SATS, Math.floor(input.minOutputSats));
|
||||
const coinbaseWeightBudget = input.coinbaseWeightBudget > 0
|
||||
? input.coinbaseWeightBudget
|
||||
: DEFAULT_COINBASE_WEIGHT_BUDGET;
|
||||
const feePercent = Number.isFinite(input.feePercent) && input.feePercent > 0
|
||||
? input.feePercent
|
||||
: 0;
|
||||
const feeAddress = input.feeAddress?.trim() ?? '';
|
||||
const wantedFeeSats = Math.floor((feePercent / 100) * coinbaseValueSats);
|
||||
const feeSats = feeAddress.length > 0 && wantedFeeSats >= minOutputSats ? wantedFeeSats : 0;
|
||||
const minerRewardSats = coinbaseValueSats - feeSats;
|
||||
|
||||
const workByAddress = new Map<string, { creditedDifficulty: number; acceptedShareCount: number }>();
|
||||
for (const work of input.addressWork) {
|
||||
if (!work.address || !Number.isFinite(work.creditedDifficulty) || work.creditedDifficulty <= 0) {
|
||||
continue;
|
||||
}
|
||||
const existing = workByAddress.get(work.address) ?? { creditedDifficulty: 0, acceptedShareCount: 0 };
|
||||
existing.creditedDifficulty += work.creditedDifficulty;
|
||||
existing.acceptedShareCount += work.acceptedShareCount;
|
||||
workByAddress.set(work.address, existing);
|
||||
}
|
||||
|
||||
const balanceByAddress = new Map<string, number>();
|
||||
for (const balance of input.balances) {
|
||||
if (balance.address && Number.isFinite(balance.balanceSats) && balance.balanceSats !== 0) {
|
||||
balanceByAddress.set(balance.address, Math.trunc(balance.balanceSats));
|
||||
}
|
||||
}
|
||||
|
||||
let totalCreditedDifficulty = 0;
|
||||
for (const work of workByAddress.values()) {
|
||||
totalCreditedDifficulty += work.creditedDifficulty;
|
||||
}
|
||||
if (coinbaseValueSats <= 0 || totalCreditedDifficulty <= 0) {
|
||||
return {
|
||||
entries: [],
|
||||
payoutOutputs: [],
|
||||
consideredAddressCount: 0,
|
||||
distributedSats: 0,
|
||||
unallocatedRemainderSats: coinbaseValueSats,
|
||||
feeSats,
|
||||
};
|
||||
}
|
||||
|
||||
const computations = new Map<string, ComputedAddress>();
|
||||
const considered = new Set<string>([...workByAddress.keys(), ...balanceByAddress.keys()]);
|
||||
|
||||
for (const address of considered) {
|
||||
const work = workByAddress.get(address);
|
||||
const creditedDifficulty = work?.creditedDifficulty ?? 0;
|
||||
const acceptedShareCount = work?.acceptedShareCount ?? 0;
|
||||
const payoutWeight = creditedDifficulty > 0 ? creditedDifficulty / totalCreditedDifficulty : 0;
|
||||
const rawFairSats = Math.floor(payoutWeight * minerRewardSats);
|
||||
const balanceBeforeSats = balanceByAddress.get(address) ?? 0;
|
||||
const targetSats = rawFairSats + balanceBeforeSats;
|
||||
computations.set(address, {
|
||||
address,
|
||||
creditedDifficulty,
|
||||
acceptedShareCount,
|
||||
payoutWeight,
|
||||
rawFairSats,
|
||||
balanceBeforeSats,
|
||||
targetSats,
|
||||
payoutSats: 0,
|
||||
balanceAfterSats: targetSats,
|
||||
includedInCoinbase: false,
|
||||
});
|
||||
}
|
||||
|
||||
const feeWeight = feeSats > 0 ? outputWeightForAddress(feeAddress) : 0;
|
||||
let usedWeight = COINBASE_BASE_WEIGHT + COINBASE_WITNESS_COMMITMENT_WEIGHT + feeWeight;
|
||||
const effectiveBudget = coinbaseWeightBudget - BUDGET_SAFETY_MARGIN_WEIGHT;
|
||||
const eligible = [...computations.values()]
|
||||
.filter(row => row.targetSats >= minOutputSats)
|
||||
.sort((a, b) => b.targetSats - a.targetSats || a.address.localeCompare(b.address));
|
||||
|
||||
for (const row of eligible) {
|
||||
const outputWeight = outputWeightForAddress(row.address);
|
||||
if (usedWeight + outputWeight > effectiveBudget) {
|
||||
continue;
|
||||
}
|
||||
row.includedInCoinbase = true;
|
||||
row.payoutSats = row.targetSats;
|
||||
row.balanceAfterSats = 0;
|
||||
usedWeight += outputWeight;
|
||||
}
|
||||
|
||||
let distributedToMiners = 0;
|
||||
for (const row of computations.values()) {
|
||||
distributedToMiners += row.payoutSats;
|
||||
}
|
||||
|
||||
const remainder = minerRewardSats - distributedToMiners;
|
||||
if (remainder > 0) {
|
||||
const keptActive = [...computations.values()]
|
||||
.filter(row => row.includedInCoinbase && row.creditedDifficulty > 0)
|
||||
.sort((a, b) => b.creditedDifficulty - a.creditedDifficulty || a.address.localeCompare(b.address));
|
||||
if (keptActive.length > 0) {
|
||||
let assigned = 0;
|
||||
for (const row of keptActive) {
|
||||
const bonus = Math.floor((remainder * row.creditedDifficulty) / totalCreditedDifficulty);
|
||||
row.payoutSats += bonus;
|
||||
row.balanceAfterSats -= bonus;
|
||||
assigned += bonus;
|
||||
}
|
||||
const residual = remainder - assigned;
|
||||
if (residual > 0) {
|
||||
keptActive[0].payoutSats += residual;
|
||||
keptActive[0].balanceAfterSats -= residual;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const feeEntry = feeSats > 0
|
||||
? {
|
||||
address: feeAddress,
|
||||
creditedDifficulty: 0,
|
||||
acceptedShareCount: 0,
|
||||
payoutWeight: 0,
|
||||
grossPayoutSats: feeSats,
|
||||
payoutSats: feeSats,
|
||||
balanceBeforeSats: 0,
|
||||
balanceAfterSats: 0,
|
||||
includedInCoinbase: true,
|
||||
rank: 1,
|
||||
}
|
||||
: null;
|
||||
|
||||
const minerEntries = [...computations.values()]
|
||||
.filter(row => row.includedInCoinbase || row.balanceBeforeSats !== row.balanceAfterSats || row.balanceAfterSats !== 0)
|
||||
.sort((a, b) => {
|
||||
if (a.includedInCoinbase !== b.includedInCoinbase) {
|
||||
return a.includedInCoinbase ? -1 : 1;
|
||||
}
|
||||
return b.payoutSats - a.payoutSats || b.targetSats - a.targetSats || a.address.localeCompare(b.address);
|
||||
})
|
||||
.map(row => ({
|
||||
address: row.address,
|
||||
creditedDifficulty: row.creditedDifficulty,
|
||||
acceptedShareCount: row.acceptedShareCount,
|
||||
payoutWeight: row.payoutWeight,
|
||||
grossPayoutSats: row.rawFairSats,
|
||||
payoutSats: row.payoutSats,
|
||||
balanceBeforeSats: row.balanceBeforeSats,
|
||||
balanceAfterSats: row.balanceAfterSats,
|
||||
includedInCoinbase: row.includedInCoinbase,
|
||||
rank: 0,
|
||||
}));
|
||||
|
||||
const mergedEntries = feeEntry == null
|
||||
? minerEntries
|
||||
: mergeFeeEntry(feeEntry, minerEntries);
|
||||
const entries = mergedEntries
|
||||
.map((entry, index) => ({ ...entry, rank: index + 1 }));
|
||||
const payoutOutputs = entries.filter(entry => entry.includedInCoinbase);
|
||||
const distributedSats = payoutOutputs.reduce((sum, entry) => sum + entry.payoutSats, 0);
|
||||
|
||||
return {
|
||||
entries,
|
||||
payoutOutputs,
|
||||
consideredAddressCount: considered.size,
|
||||
distributedSats,
|
||||
unallocatedRemainderSats: coinbaseValueSats - distributedSats,
|
||||
feeSats,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeFeeEntry(
|
||||
feeEntry: PayoutDistributionEntry,
|
||||
minerEntries: Omit<PayoutDistributionEntry, 'rank'>[],
|
||||
): Omit<PayoutDistributionEntry, 'rank'>[] {
|
||||
const existing = minerEntries.find(entry => entry.address === feeEntry.address && entry.includedInCoinbase);
|
||||
if (existing == null) {
|
||||
return [feeEntry, ...minerEntries];
|
||||
}
|
||||
existing.payoutSats += feeEntry.payoutSats;
|
||||
existing.grossPayoutSats += feeEntry.grossPayoutSats;
|
||||
return minerEntries;
|
||||
}
|
||||
|
||||
export function outputWeightForAddress(address: string): number {
|
||||
if (!address) {
|
||||
return COINBASE_OUTPUT_WEIGHT;
|
||||
}
|
||||
const cached = outputWeightCache.get(address);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
let weight = COINBASE_OUTPUT_WEIGHT;
|
||||
try {
|
||||
const info = getAddressInfo(address);
|
||||
weight = OUTPUT_WEIGHT_BY_TYPE[info.type] ?? COINBASE_OUTPUT_WEIGHT;
|
||||
} catch {
|
||||
weight = COINBASE_OUTPUT_WEIGHT;
|
||||
}
|
||||
outputWeightCache.set(address, weight);
|
||||
return weight;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
const bigintTransformer = {
|
||||
from: (value: string | number | null): number => Number(value ?? 0),
|
||||
to: (value: number): number => Math.trunc(value ?? 0),
|
||||
};
|
||||
|
||||
@Entity({ name: 'payout_history' })
|
||||
@Index('UQ_payout_history_block_address', ['blockHeight', 'address'], { unique: true })
|
||||
@Index('IDX_payout_history_address_created', ['address', 'createdAt'])
|
||||
export class PayoutHistoryEntity {
|
||||
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
blockHeight: number;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
payoutSnapshotId: string;
|
||||
|
||||
@Column({ length: 62, type: 'varchar' })
|
||||
address: string;
|
||||
|
||||
@Column({ type: 'bigint', default: 0, transformer: bigintTransformer })
|
||||
paidSats: number;
|
||||
|
||||
@Column({ type: 'decimal', default: 0 })
|
||||
percent: number;
|
||||
|
||||
@Column({ type: 'bigint', default: 0, transformer: bigintTransformer })
|
||||
balanceBeforeSats: number;
|
||||
|
||||
@Column({ type: 'bigint', default: 0, transformer: bigintTransformer })
|
||||
balanceAfterSats: number;
|
||||
|
||||
@Column({ length: 16, type: 'varchar', default: 'coinbase' })
|
||||
rowType: 'coinbase' | 'pending';
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
import { PayoutSnapshotEntity } from './payout-snapshot.entity';
|
||||
|
||||
@Entity({ name: 'payout_snapshot_entry' })
|
||||
@Index('IDX_payout_snapshot_entry_snapshot_rank', ['snapshotId', 'rank'])
|
||||
@Index('IDX_payout_snapshot_entry_address', ['address'])
|
||||
export class PayoutSnapshotEntryEntity {
|
||||
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
snapshotId: string;
|
||||
|
||||
@ManyToOne(() => PayoutSnapshotEntity, snapshot => snapshot.entries, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'snapshotId' })
|
||||
snapshot: PayoutSnapshotEntity;
|
||||
|
||||
@Column({ length: 62, type: 'varchar' })
|
||||
address: string;
|
||||
|
||||
@Column({ type: 'decimal' })
|
||||
creditedDifficulty: number;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
acceptedShareCount: string;
|
||||
|
||||
@Column({ type: 'decimal' })
|
||||
payoutWeight: number;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
grossPayoutSats: string;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
payoutSats: string;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
balanceBeforeSats: string;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
balanceAfterSats: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
includedInCoinbase: boolean;
|
||||
|
||||
@Column({ length: 16, type: 'varchar', default: 'coinbase' })
|
||||
rowType: 'coinbase' | 'pending';
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
rank: number;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { Column, Entity, Index, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
import { PayoutSnapshotEntryEntity } from './payout-snapshot-entry.entity';
|
||||
|
||||
@Entity({ name: 'payout_snapshot' })
|
||||
@Index('IDX_payout_snapshot_latest', ['status', 'createdAt'])
|
||||
@Index('IDX_payout_snapshot_window', ['windowEndShareIndex'])
|
||||
export class PayoutSnapshotEntity {
|
||||
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||
id: string;
|
||||
|
||||
@Column({ length: 32, type: 'varchar' })
|
||||
method: string;
|
||||
|
||||
@Column({ length: 16, type: 'varchar', default: 'finalized' })
|
||||
status: 'finalized';
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
blockHeight: number;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
coinbaseValueSats: string;
|
||||
|
||||
@Column({ type: 'decimal' })
|
||||
networkDifficulty: number;
|
||||
|
||||
@Column({ type: 'decimal', default: 0 })
|
||||
windowTargetDifficulty: number;
|
||||
|
||||
@Column({ type: 'decimal', default: 4 })
|
||||
windowFactor: number;
|
||||
|
||||
@Column({ length: 62, type: 'varchar', nullable: true })
|
||||
feeAddress?: string | null;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
feeSats: string;
|
||||
|
||||
@Column({ type: 'integer', default: 546 })
|
||||
minPayoutSats: number;
|
||||
|
||||
@Column({ type: 'integer', default: 50000 })
|
||||
coinbaseWeightBudget: number;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
startBatchId: string;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
endBatchId: string;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
windowStartShareIndex: string;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
windowEndShareIndex: string;
|
||||
|
||||
@Column({ type: 'decimal' })
|
||||
totalCreditedDifficulty: number;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
totalAcceptedShareCount: string;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
eligibleAddressCount: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
includedOutputCount: number;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
distributedSats: string;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
unallocatedRemainderSats: string;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt: Date;
|
||||
|
||||
@OneToMany(() => PayoutSnapshotEntryEntity, entry => entry.snapshot)
|
||||
entries: PayoutSnapshotEntryEntity[];
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { PayoutBalanceEntity } from './payout-balance.entity';
|
||||
import { PayoutHistoryEntity } from './payout-history.entity';
|
||||
import { PayoutSnapshotEntryEntity } from './payout-snapshot-entry.entity';
|
||||
import { PayoutSnapshotEntity } from './payout-snapshot.entity';
|
||||
import { PayoutSnapshotService } from './payout-snapshot.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([
|
||||
PayoutBalanceEntity,
|
||||
PayoutHistoryEntity,
|
||||
PayoutSnapshotEntity,
|
||||
PayoutSnapshotEntryEntity,
|
||||
])],
|
||||
providers: [PayoutSnapshotService],
|
||||
exports: [TypeOrmModule, PayoutSnapshotService],
|
||||
})
|
||||
export class PayoutSnapshotModule { }
|
||||
@@ -1,213 +0,0 @@
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
|
||||
import { PayoutSnapshotService } from './payout-snapshot.service';
|
||||
|
||||
const ADDRESS_A = 'bc1qs29kyaqqc0fkvj897ke9e5xa9utljjey0y5jjn';
|
||||
const ADDRESS_B = 'bc1q99n3pu025yyu0jlywpmwzalyhm36tg5u37w20d';
|
||||
|
||||
describe('PayoutSnapshotService', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
let manager: { query: jest.Mock };
|
||||
let dataSource: { transaction: jest.Mock; query: jest.Mock };
|
||||
let service: PayoutSnapshotService;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
process.env.PAYOUT_SNAPSHOT_ENABLED = 'true';
|
||||
process.env.PAYOUT_METHOD = 'pplns';
|
||||
process.env.PAYOUT_WINDOW_FACTOR = '4';
|
||||
process.env.PAYOUT_MAX_COINBASE_OUTPUTS = '10';
|
||||
process.env.PAYOUT_MIN_OUTPUT_SATS = '546';
|
||||
process.env.PAYOUT_FEE_PERCENT = '0';
|
||||
process.env.PAYOUT_FEE_ADDRESS = '';
|
||||
process.env.PAYOUT_COINBASE_WEIGHT_BUDGET = '50000';
|
||||
manager = { query: jest.fn() };
|
||||
dataSource = {
|
||||
transaction: jest.fn(async callback => callback(manager as unknown as EntityManager)),
|
||||
query: jest.fn(),
|
||||
};
|
||||
service = new PayoutSnapshotService(dataSource as unknown as DataSource);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should create a PPLNS payout snapshot from finalized rollup batches', async () => {
|
||||
manager.query
|
||||
.mockResolvedValueOnce([{
|
||||
startBatchId: '10',
|
||||
endBatchId: '12',
|
||||
windowStartShareIndex: '1000',
|
||||
windowEndShareIndex: '2000',
|
||||
totalCreditedDifficulty: '100',
|
||||
totalAcceptedShareCount: '5',
|
||||
}])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{ address: ADDRESS_A, creditedDifficulty: 60, acceptedShareCount: 3 },
|
||||
{ address: ADDRESS_B, creditedDifficulty: 40, acceptedShareCount: 2 },
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ id: '55' }])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{
|
||||
id: '55',
|
||||
method: 'pplns',
|
||||
blockHeight: 900000,
|
||||
coinbaseValueSats: '1000',
|
||||
windowStartShareIndex: '1000',
|
||||
windowEndShareIndex: '2000',
|
||||
totalCreditedDifficulty: 100,
|
||||
totalAcceptedShareCount: '5',
|
||||
eligibleAddressCount: 2,
|
||||
includedOutputCount: 1,
|
||||
distributedSats: '1000',
|
||||
unallocatedRemainderSats: '0',
|
||||
}])
|
||||
.mockResolvedValueOnce([
|
||||
{ address: ADDRESS_A, payoutSats: '1000' },
|
||||
]);
|
||||
|
||||
const snapshot = await service.createSnapshotForTemplate({
|
||||
blockHeight: 900000,
|
||||
coinbaseValueSats: 1000,
|
||||
networkDifficulty: 25,
|
||||
});
|
||||
|
||||
expect(snapshot).toEqual(expect.objectContaining({
|
||||
id: '55',
|
||||
method: 'pplns',
|
||||
blockHeight: 900000,
|
||||
totalCreditedDifficulty: 100,
|
||||
totalAcceptedShareCount: 5,
|
||||
distributedSats: '1000',
|
||||
unallocatedRemainderSats: '0',
|
||||
}));
|
||||
expect(snapshot.payoutOutputs).toEqual([
|
||||
{ address: ADDRESS_A, amountSats: 1000, percent: 100 },
|
||||
]);
|
||||
expect(manager.query).toHaveBeenCalledTimes(9);
|
||||
expect(manager.query.mock.calls[0][1]).toEqual([100]);
|
||||
});
|
||||
|
||||
it('should not create a snapshot when there is no finalized rollup work', async () => {
|
||||
manager.query.mockResolvedValueOnce([{
|
||||
startBatchId: null,
|
||||
endBatchId: null,
|
||||
windowStartShareIndex: null,
|
||||
windowEndShareIndex: null,
|
||||
totalCreditedDifficulty: '0',
|
||||
totalAcceptedShareCount: '0',
|
||||
}]);
|
||||
|
||||
await expect(service.createSnapshotForTemplate({
|
||||
blockHeight: 900000,
|
||||
coinbaseValueSats: 1000,
|
||||
networkDifficulty: 25,
|
||||
})).resolves.toBeNull();
|
||||
expect(manager.query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should reuse an existing snapshot for the same template and rollup window', async () => {
|
||||
manager.query
|
||||
.mockResolvedValueOnce([{
|
||||
startBatchId: '10',
|
||||
endBatchId: '12',
|
||||
windowStartShareIndex: '1000',
|
||||
windowEndShareIndex: '2000',
|
||||
totalCreditedDifficulty: '100',
|
||||
totalAcceptedShareCount: '5',
|
||||
}])
|
||||
.mockResolvedValueOnce([{ id: '55' }])
|
||||
.mockResolvedValueOnce([{
|
||||
id: '55',
|
||||
method: 'pplns',
|
||||
blockHeight: 900000,
|
||||
coinbaseValueSats: '1000',
|
||||
windowStartShareIndex: '1000',
|
||||
windowEndShareIndex: '2000',
|
||||
totalCreditedDifficulty: 100,
|
||||
totalAcceptedShareCount: '5',
|
||||
eligibleAddressCount: 2,
|
||||
includedOutputCount: 1,
|
||||
distributedSats: '1000',
|
||||
unallocatedRemainderSats: '0',
|
||||
}])
|
||||
.mockResolvedValueOnce([
|
||||
{ address: ADDRESS_A, payoutSats: '1000' },
|
||||
]);
|
||||
|
||||
const snapshot = await service.createSnapshotForTemplate({
|
||||
blockHeight: 900000,
|
||||
coinbaseValueSats: 1000,
|
||||
networkDifficulty: 25,
|
||||
});
|
||||
|
||||
expect(snapshot.id).toBe('55');
|
||||
expect(snapshot.payoutOutputs).toEqual([
|
||||
{ address: ADDRESS_A, amountSats: 1000, percent: 100 },
|
||||
]);
|
||||
expect(manager.query).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('should finalize snapshot balances and payout history for a found block', async () => {
|
||||
manager.query
|
||||
.mockResolvedValueOnce([{ id: '55', coinbaseValueSats: '1000' }])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
address: ADDRESS_A,
|
||||
payoutSats: '1000',
|
||||
balanceBeforeSats: '0',
|
||||
balanceAfterSats: '-400',
|
||||
creditedDifficulty: 60,
|
||||
includedInCoinbase: true,
|
||||
rowType: 'coinbase',
|
||||
},
|
||||
{
|
||||
address: ADDRESS_B,
|
||||
payoutSats: '0',
|
||||
balanceBeforeSats: '0',
|
||||
balanceAfterSats: '400',
|
||||
creditedDifficulty: 40,
|
||||
includedInCoinbase: false,
|
||||
rowType: 'pending',
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.finalizeSnapshotForBlock({
|
||||
payoutSnapshotId: '55',
|
||||
blockHeight: 900000,
|
||||
blockSubmissionResult: 'SUCCESS!',
|
||||
})).resolves.toEqual({
|
||||
finalized: true,
|
||||
payoutSnapshotId: '55',
|
||||
historyRows: 2,
|
||||
balanceRows: 2,
|
||||
});
|
||||
expect(manager.query).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
|
||||
it('should skip finalization when a block was already processed', async () => {
|
||||
manager.query
|
||||
.mockResolvedValueOnce([{ id: '55', coinbaseValueSats: '1000' }])
|
||||
.mockResolvedValueOnce([{ id: '99' }]);
|
||||
|
||||
await expect(service.finalizeSnapshotForBlock({
|
||||
payoutSnapshotId: '55',
|
||||
blockHeight: 900000,
|
||||
blockSubmissionResult: 'SUCCESS!',
|
||||
})).resolves.toEqual({
|
||||
finalized: false,
|
||||
reason: 'already-finalized',
|
||||
payoutSnapshotId: '55',
|
||||
});
|
||||
expect(manager.query).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,617 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
|
||||
import type { AddressObject } from '../../models/MiningJob';
|
||||
import {
|
||||
buildPayoutDistribution,
|
||||
PayoutAddressWork,
|
||||
PayoutBalanceInput,
|
||||
PayoutDistributionEntry,
|
||||
} from './payout-distribution';
|
||||
|
||||
export interface PayoutSnapshotForTemplate {
|
||||
id: string;
|
||||
method: string;
|
||||
blockHeight: number;
|
||||
coinbaseValueSats: string;
|
||||
windowStartShareIndex: string;
|
||||
windowEndShareIndex: string;
|
||||
totalCreditedDifficulty: number;
|
||||
totalAcceptedShareCount: number;
|
||||
eligibleAddressCount: number;
|
||||
includedOutputCount: number;
|
||||
distributedSats: string;
|
||||
unallocatedRemainderSats: string;
|
||||
payoutOutputs: AddressObject[];
|
||||
}
|
||||
|
||||
export interface PayoutFinalizationResult {
|
||||
finalized: boolean;
|
||||
reason?: 'disabled' | 'missing-snapshot' | 'already-finalized' | 'duplicate';
|
||||
payoutSnapshotId?: string;
|
||||
historyRows?: number;
|
||||
balanceRows?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_COINBASE_OUTPUTS = 10;
|
||||
const DEFAULT_MIN_OUTPUT_SATS = 546;
|
||||
const DEFAULT_PAYOUT_METHOD = 'pplns';
|
||||
const DEFAULT_PAYOUT_WINDOW_FACTOR = 4;
|
||||
const DEFAULT_COINBASE_WEIGHT_BUDGET = 50_000;
|
||||
|
||||
@Injectable()
|
||||
export class PayoutSnapshotService {
|
||||
private readonly snapshotsEnabled = this.readBoolean('PAYOUT_SNAPSHOT_ENABLED', true);
|
||||
private readonly method = process.env.PAYOUT_METHOD ?? DEFAULT_PAYOUT_METHOD;
|
||||
private readonly windowFactor = this.readPositiveNumber('PAYOUT_WINDOW_FACTOR', DEFAULT_PAYOUT_WINDOW_FACTOR);
|
||||
private readonly maxCoinbaseOutputs = this.readPositiveInt('PAYOUT_MAX_COINBASE_OUTPUTS', DEFAULT_MAX_COINBASE_OUTPUTS);
|
||||
private readonly minOutputSats = this.readNonNegativeInt('PAYOUT_MIN_OUTPUT_SATS', DEFAULT_MIN_OUTPUT_SATS);
|
||||
private readonly feeAddress = process.env.PAYOUT_FEE_ADDRESS?.trim() ?? '';
|
||||
private readonly feePercent = this.readNonNegativeNumber('PAYOUT_FEE_PERCENT', 0);
|
||||
private readonly coinbaseWeightBudget = this.readPositiveInt('PAYOUT_COINBASE_WEIGHT_BUDGET', DEFAULT_COINBASE_WEIGHT_BUDGET);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
) { }
|
||||
|
||||
public async createSnapshotForTemplate(input: {
|
||||
blockHeight: number;
|
||||
coinbaseValueSats: number;
|
||||
networkDifficulty: number;
|
||||
}): Promise<PayoutSnapshotForTemplate | null> {
|
||||
if (!this.snapshotsEnabled || input.coinbaseValueSats <= 0 || input.networkDifficulty <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async manager => {
|
||||
const windowTargetDifficulty = input.networkDifficulty * this.windowFactor;
|
||||
const window = await this.getPplnsWindow(manager, windowTargetDifficulty);
|
||||
if (window == null || this.toNumber(window.totalCreditedDifficulty) <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = await this.getExistingSnapshot(manager, {
|
||||
blockHeight: input.blockHeight,
|
||||
coinbaseValueSats: input.coinbaseValueSats,
|
||||
windowEndShareIndex: window.windowEndShareIndex,
|
||||
});
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const addressWork = await this.getAddressWork(manager, window);
|
||||
const balances = await this.getOpenBalances(manager);
|
||||
if (addressWork.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const distribution = buildPayoutDistribution({
|
||||
addressWork,
|
||||
balances,
|
||||
coinbaseValueSats: input.coinbaseValueSats,
|
||||
feeAddress: this.feeAddress,
|
||||
feePercent: this.feePercent,
|
||||
minOutputSats: this.minOutputSats,
|
||||
coinbaseWeightBudget: this.coinbaseWeightBudget,
|
||||
});
|
||||
const entries = this.limitCoinbaseOutputs(distribution.entries);
|
||||
if (entries.every(entry => !entry.includedInCoinbase)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const includedOutputCount = entries.filter(entry => entry.includedInCoinbase).length;
|
||||
const distributedSats = entries
|
||||
.filter(entry => entry.includedInCoinbase)
|
||||
.reduce((sum, entry) => sum + entry.payoutSats, 0);
|
||||
const unallocatedRemainderSats = input.coinbaseValueSats - distributedSats;
|
||||
const [snapshotRow] = await manager.query(`
|
||||
INSERT INTO "payout_snapshot" (
|
||||
"method",
|
||||
"status",
|
||||
"blockHeight",
|
||||
"coinbaseValueSats",
|
||||
"networkDifficulty",
|
||||
"windowTargetDifficulty",
|
||||
"windowFactor",
|
||||
"feeAddress",
|
||||
"feeSats",
|
||||
"minPayoutSats",
|
||||
"coinbaseWeightBudget",
|
||||
"startBatchId",
|
||||
"endBatchId",
|
||||
"windowStartShareIndex",
|
||||
"windowEndShareIndex",
|
||||
"totalCreditedDifficulty",
|
||||
"totalAcceptedShareCount",
|
||||
"eligibleAddressCount",
|
||||
"includedOutputCount",
|
||||
"distributedSats",
|
||||
"unallocatedRemainderSats"
|
||||
) VALUES (
|
||||
$1,
|
||||
'finalized',
|
||||
$2::bigint,
|
||||
$3::bigint,
|
||||
$4::numeric,
|
||||
$5::numeric,
|
||||
$6::numeric,
|
||||
NULLIF($7, ''),
|
||||
$8::bigint,
|
||||
$9::int,
|
||||
$10::int,
|
||||
$11::bigint,
|
||||
$12::bigint,
|
||||
$13::bigint,
|
||||
$14::bigint,
|
||||
$15::numeric,
|
||||
$16::bigint,
|
||||
$17::int,
|
||||
$18::int,
|
||||
$19::bigint,
|
||||
$20::bigint
|
||||
)
|
||||
RETURNING "id"::text AS "id"
|
||||
`, [
|
||||
this.method,
|
||||
input.blockHeight,
|
||||
input.coinbaseValueSats.toString(),
|
||||
input.networkDifficulty,
|
||||
windowTargetDifficulty,
|
||||
this.windowFactor,
|
||||
this.feeAddress,
|
||||
distribution.feeSats.toString(),
|
||||
this.minOutputSats,
|
||||
this.coinbaseWeightBudget,
|
||||
window.startBatchId,
|
||||
window.endBatchId,
|
||||
window.windowStartShareIndex,
|
||||
window.windowEndShareIndex,
|
||||
window.totalCreditedDifficulty,
|
||||
window.totalAcceptedShareCount,
|
||||
distribution.consideredAddressCount,
|
||||
includedOutputCount,
|
||||
distributedSats.toString(),
|
||||
unallocatedRemainderSats.toString(),
|
||||
]);
|
||||
|
||||
for (const entry of entries) {
|
||||
await this.insertSnapshotEntry(manager, snapshotRow.id, entry);
|
||||
}
|
||||
|
||||
return this.getSnapshotById(manager, snapshotRow.id);
|
||||
});
|
||||
}
|
||||
|
||||
public async finalizeSnapshotForBlock(input: {
|
||||
payoutSnapshotId?: string | null;
|
||||
blockHeight: number;
|
||||
blockSubmissionResult?: string | null;
|
||||
}): Promise<PayoutFinalizationResult> {
|
||||
if (!this.snapshotsEnabled) {
|
||||
return { finalized: false, reason: 'disabled' };
|
||||
}
|
||||
if (!this.isSuccessfulBlockSubmission(input.blockSubmissionResult)) {
|
||||
return { finalized: false, reason: 'missing-snapshot' };
|
||||
}
|
||||
if (input.payoutSnapshotId == null) {
|
||||
return { finalized: false, reason: 'missing-snapshot' };
|
||||
}
|
||||
|
||||
return this.dataSource.transaction<PayoutFinalizationResult>(async manager => {
|
||||
const [snapshot] = await manager.query(`
|
||||
SELECT "id"::text AS "id", "coinbaseValueSats"::text AS "coinbaseValueSats"
|
||||
FROM "payout_snapshot"
|
||||
WHERE "id" = $1::bigint
|
||||
LIMIT 1
|
||||
`, [input.payoutSnapshotId]);
|
||||
if (snapshot == null) {
|
||||
return { finalized: false, reason: 'missing-snapshot' };
|
||||
}
|
||||
|
||||
const [existing] = await manager.query(`
|
||||
SELECT "id"::text AS "id"
|
||||
FROM "payout_history"
|
||||
WHERE "blockHeight" = $1::bigint
|
||||
LIMIT 1
|
||||
`, [input.blockHeight]);
|
||||
if (existing != null) {
|
||||
return {
|
||||
finalized: false,
|
||||
reason: 'already-finalized',
|
||||
payoutSnapshotId: input.payoutSnapshotId,
|
||||
};
|
||||
}
|
||||
|
||||
const entries = await manager.query(`
|
||||
SELECT
|
||||
"address",
|
||||
"payoutSats"::text AS "payoutSats",
|
||||
"balanceBeforeSats"::text AS "balanceBeforeSats",
|
||||
"balanceAfterSats"::text AS "balanceAfterSats",
|
||||
"creditedDifficulty"::float AS "creditedDifficulty",
|
||||
"includedInCoinbase",
|
||||
"rowType"
|
||||
FROM "payout_snapshot_entry"
|
||||
WHERE "snapshotId" = $1::bigint
|
||||
ORDER BY "rank"
|
||||
`, [input.payoutSnapshotId]);
|
||||
if (entries.length === 0) {
|
||||
return { finalized: false, reason: 'missing-snapshot' };
|
||||
}
|
||||
|
||||
let balanceRows = 0;
|
||||
let historyRows = 0;
|
||||
const coinbaseValue = this.toNumber(snapshot.coinbaseValueSats);
|
||||
for (const entry of entries) {
|
||||
const payoutSats = this.toNumber(entry.payoutSats);
|
||||
const balanceBeforeSats = this.toNumber(entry.balanceBeforeSats);
|
||||
const balanceAfterSats = this.toNumber(entry.balanceAfterSats);
|
||||
const isCoinbase = entry.includedInCoinbase === true;
|
||||
const wasActive = this.toNumber(entry.creditedDifficulty) > 0;
|
||||
|
||||
await manager.query(`
|
||||
INSERT INTO "payout_balance" (
|
||||
"address",
|
||||
"balanceSats",
|
||||
"totalPaidSats",
|
||||
"lastAcceptedShareAt",
|
||||
"updatedAt"
|
||||
) VALUES (
|
||||
$1,
|
||||
$2::bigint,
|
||||
$3::bigint,
|
||||
CASE WHEN $4::boolean THEN NOW() ELSE NULL END,
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT ("address") DO UPDATE SET
|
||||
"balanceSats" = EXCLUDED."balanceSats",
|
||||
"totalPaidSats" = "payout_balance"."totalPaidSats" + EXCLUDED."totalPaidSats",
|
||||
"lastAcceptedShareAt" = CASE
|
||||
WHEN $4::boolean THEN NOW()
|
||||
ELSE "payout_balance"."lastAcceptedShareAt"
|
||||
END,
|
||||
"updatedAt" = NOW()
|
||||
`, [
|
||||
entry.address,
|
||||
balanceAfterSats.toString(),
|
||||
isCoinbase ? payoutSats.toString() : '0',
|
||||
wasActive,
|
||||
]);
|
||||
balanceRows++;
|
||||
|
||||
await manager.query(`
|
||||
INSERT INTO "payout_history" (
|
||||
"blockHeight",
|
||||
"payoutSnapshotId",
|
||||
"address",
|
||||
"paidSats",
|
||||
"percent",
|
||||
"balanceBeforeSats",
|
||||
"balanceAfterSats",
|
||||
"rowType"
|
||||
) VALUES (
|
||||
$1::bigint,
|
||||
$2::bigint,
|
||||
$3,
|
||||
$4::bigint,
|
||||
$5::numeric,
|
||||
$6::bigint,
|
||||
$7::bigint,
|
||||
$8
|
||||
)
|
||||
ON CONFLICT ("blockHeight", "address") DO NOTHING
|
||||
`, [
|
||||
input.blockHeight,
|
||||
input.payoutSnapshotId,
|
||||
entry.address,
|
||||
payoutSats.toString(),
|
||||
coinbaseValue > 0 && isCoinbase ? (payoutSats / coinbaseValue) * 100 : 0,
|
||||
balanceBeforeSats.toString(),
|
||||
balanceAfterSats.toString(),
|
||||
isCoinbase ? 'coinbase' : 'pending',
|
||||
]);
|
||||
historyRows++;
|
||||
}
|
||||
|
||||
return {
|
||||
finalized: true,
|
||||
payoutSnapshotId: input.payoutSnapshotId,
|
||||
historyRows,
|
||||
balanceRows,
|
||||
};
|
||||
}).catch(error => {
|
||||
if (error?.code === '23505') {
|
||||
return {
|
||||
finalized: false,
|
||||
reason: 'duplicate',
|
||||
payoutSnapshotId: input.payoutSnapshotId ?? undefined,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
public async getLatestSnapshot(): Promise<PayoutSnapshotForTemplate | null> {
|
||||
const [snapshot] = await this.dataSource.query(`
|
||||
SELECT "id"::text AS "id"
|
||||
FROM "payout_snapshot"
|
||||
WHERE "status" = 'finalized'
|
||||
ORDER BY "createdAt" DESC, "id" DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
if (snapshot?.id == null) {
|
||||
return null;
|
||||
}
|
||||
return this.dataSource.transaction(manager => this.getSnapshotById(manager, snapshot.id));
|
||||
}
|
||||
|
||||
private async getPplnsWindow(manager: EntityManager, windowTargetDifficulty: number): Promise<{
|
||||
startBatchId: string;
|
||||
endBatchId: string;
|
||||
windowStartShareIndex: string;
|
||||
windowEndShareIndex: string;
|
||||
totalCreditedDifficulty: string;
|
||||
totalAcceptedShareCount: string;
|
||||
} | null> {
|
||||
const [window] = await manager.query(`
|
||||
WITH ordered AS MATERIALIZED (
|
||||
SELECT
|
||||
"id",
|
||||
"startShareIndex",
|
||||
"endShareIndex",
|
||||
"acceptedShareCount",
|
||||
"creditedDifficulty",
|
||||
COALESCE(
|
||||
SUM("creditedDifficulty") OVER (
|
||||
ORDER BY "endShareIndex" DESC
|
||||
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
||||
),
|
||||
0
|
||||
) AS "difficultyAfter"
|
||||
FROM "share_rollup_batch"
|
||||
WHERE "status" = 'finalized'
|
||||
),
|
||||
selected AS MATERIALIZED (
|
||||
SELECT *
|
||||
FROM ordered
|
||||
WHERE "difficultyAfter" < $1::numeric
|
||||
)
|
||||
SELECT
|
||||
MIN("id")::text AS "startBatchId",
|
||||
MAX("id")::text AS "endBatchId",
|
||||
MIN("startShareIndex")::text AS "windowStartShareIndex",
|
||||
MAX("endShareIndex")::text AS "windowEndShareIndex",
|
||||
COALESCE(SUM("creditedDifficulty"), 0)::numeric AS "totalCreditedDifficulty",
|
||||
COALESCE(SUM("acceptedShareCount"), 0)::bigint AS "totalAcceptedShareCount"
|
||||
FROM selected
|
||||
`, [windowTargetDifficulty]);
|
||||
|
||||
if (window?.startBatchId == null || window?.windowEndShareIndex == null) {
|
||||
return null;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
private async getAddressWork(
|
||||
manager: EntityManager,
|
||||
window: { windowStartShareIndex: string; windowEndShareIndex: string },
|
||||
): Promise<PayoutAddressWork[]> {
|
||||
const rows = await manager.query(`
|
||||
SELECT
|
||||
s."address",
|
||||
COALESCE(SUM(s."creditedDifficulty"), 0)::float AS "creditedDifficulty",
|
||||
COALESCE(SUM(s."acceptedShareCount"), 0)::int AS "acceptedShareCount"
|
||||
FROM "share_rollup_batch_summary" s
|
||||
JOIN "share_rollup_batch" b ON b."id" = s."batchId"
|
||||
WHERE b."status" = 'finalized'
|
||||
AND b."endShareIndex" >= $1::bigint
|
||||
AND b."endShareIndex" <= $2::bigint
|
||||
GROUP BY s."address"
|
||||
HAVING COALESCE(SUM(s."creditedDifficulty"), 0) > 0
|
||||
`, [
|
||||
window.windowStartShareIndex,
|
||||
window.windowEndShareIndex,
|
||||
]);
|
||||
return rows.map(row => ({
|
||||
address: row.address,
|
||||
creditedDifficulty: this.toNumber(row.creditedDifficulty),
|
||||
acceptedShareCount: this.toNumber(row.acceptedShareCount),
|
||||
}));
|
||||
}
|
||||
|
||||
private async getOpenBalances(manager: EntityManager): Promise<PayoutBalanceInput[]> {
|
||||
const rows = await manager.query(`
|
||||
SELECT "address", "balanceSats"::text AS "balanceSats"
|
||||
FROM "payout_balance"
|
||||
WHERE "balanceSats" != 0
|
||||
`);
|
||||
return rows.map(row => ({
|
||||
address: row.address,
|
||||
balanceSats: this.toNumber(row.balanceSats),
|
||||
}));
|
||||
}
|
||||
|
||||
private async getExistingSnapshot(
|
||||
manager: EntityManager,
|
||||
input: { blockHeight: number; coinbaseValueSats: number; windowEndShareIndex: string },
|
||||
): Promise<PayoutSnapshotForTemplate | null> {
|
||||
const [snapshot] = await manager.query(`
|
||||
SELECT "id"::text AS "id"
|
||||
FROM "payout_snapshot"
|
||||
WHERE "method" = $1
|
||||
AND "status" = 'finalized'
|
||||
AND "blockHeight" = $2::bigint
|
||||
AND "coinbaseValueSats" = $3::bigint
|
||||
AND "windowEndShareIndex" = $4::bigint
|
||||
ORDER BY "id" DESC
|
||||
LIMIT 1
|
||||
`, [
|
||||
this.method,
|
||||
input.blockHeight,
|
||||
input.coinbaseValueSats.toString(),
|
||||
input.windowEndShareIndex,
|
||||
]);
|
||||
|
||||
if (snapshot?.id == null) {
|
||||
return null;
|
||||
}
|
||||
return this.getSnapshotById(manager, snapshot.id);
|
||||
}
|
||||
|
||||
private async getSnapshotById(manager: EntityManager, id: string): Promise<PayoutSnapshotForTemplate | null> {
|
||||
const [snapshot] = await manager.query(`
|
||||
SELECT
|
||||
"id"::text AS "id",
|
||||
"method",
|
||||
"blockHeight"::int AS "blockHeight",
|
||||
"coinbaseValueSats"::text AS "coinbaseValueSats",
|
||||
"windowStartShareIndex"::text AS "windowStartShareIndex",
|
||||
"windowEndShareIndex"::text AS "windowEndShareIndex",
|
||||
"totalCreditedDifficulty"::float AS "totalCreditedDifficulty",
|
||||
"totalAcceptedShareCount"::bigint AS "totalAcceptedShareCount",
|
||||
"eligibleAddressCount",
|
||||
"includedOutputCount",
|
||||
"distributedSats"::text AS "distributedSats",
|
||||
"unallocatedRemainderSats"::text AS "unallocatedRemainderSats"
|
||||
FROM "payout_snapshot"
|
||||
WHERE "id" = $1::bigint
|
||||
`, [id]);
|
||||
if (snapshot == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = await manager.query(`
|
||||
SELECT
|
||||
"address",
|
||||
"payoutSats"::text AS "payoutSats"
|
||||
FROM "payout_snapshot_entry"
|
||||
WHERE "snapshotId" = $1::bigint
|
||||
AND "includedInCoinbase" = true
|
||||
ORDER BY "rank"
|
||||
`, [id]);
|
||||
const coinbaseValue = Number(snapshot.coinbaseValueSats);
|
||||
|
||||
return {
|
||||
id: snapshot.id,
|
||||
method: snapshot.method,
|
||||
blockHeight: this.toNumber(snapshot.blockHeight),
|
||||
coinbaseValueSats: snapshot.coinbaseValueSats,
|
||||
windowStartShareIndex: snapshot.windowStartShareIndex,
|
||||
windowEndShareIndex: snapshot.windowEndShareIndex,
|
||||
totalCreditedDifficulty: this.toNumber(snapshot.totalCreditedDifficulty),
|
||||
totalAcceptedShareCount: this.toNumber(snapshot.totalAcceptedShareCount),
|
||||
eligibleAddressCount: this.toNumber(snapshot.eligibleAddressCount),
|
||||
includedOutputCount: this.toNumber(snapshot.includedOutputCount),
|
||||
distributedSats: snapshot.distributedSats,
|
||||
unallocatedRemainderSats: snapshot.unallocatedRemainderSats,
|
||||
payoutOutputs: entries.map(entry => ({
|
||||
address: entry.address,
|
||||
amountSats: this.toNumber(entry.payoutSats),
|
||||
percent: coinbaseValue > 0
|
||||
? (this.toNumber(entry.payoutSats) / coinbaseValue) * 100
|
||||
: 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async insertSnapshotEntry(manager: EntityManager, snapshotId: string, entry: PayoutDistributionEntry): Promise<void> {
|
||||
await manager.query(`
|
||||
INSERT INTO "payout_snapshot_entry" (
|
||||
"snapshotId",
|
||||
"address",
|
||||
"creditedDifficulty",
|
||||
"acceptedShareCount",
|
||||
"payoutWeight",
|
||||
"grossPayoutSats",
|
||||
"payoutSats",
|
||||
"balanceBeforeSats",
|
||||
"balanceAfterSats",
|
||||
"includedInCoinbase",
|
||||
"rowType",
|
||||
"rank"
|
||||
) VALUES (
|
||||
$1::bigint,
|
||||
$2,
|
||||
$3::numeric,
|
||||
$4::bigint,
|
||||
$5::numeric,
|
||||
$6::bigint,
|
||||
$7::bigint,
|
||||
$8::bigint,
|
||||
$9::bigint,
|
||||
$10::boolean,
|
||||
$11,
|
||||
$12::int
|
||||
)
|
||||
`, [
|
||||
snapshotId,
|
||||
entry.address,
|
||||
entry.creditedDifficulty,
|
||||
entry.acceptedShareCount,
|
||||
entry.payoutWeight,
|
||||
entry.grossPayoutSats,
|
||||
entry.payoutSats,
|
||||
entry.balanceBeforeSats,
|
||||
entry.balanceAfterSats,
|
||||
entry.includedInCoinbase,
|
||||
entry.includedInCoinbase ? 'coinbase' : 'pending',
|
||||
entry.rank,
|
||||
]);
|
||||
}
|
||||
|
||||
private limitCoinbaseOutputs(entries: PayoutDistributionEntry[]): PayoutDistributionEntry[] {
|
||||
let included = 0;
|
||||
return entries.map(entry => {
|
||||
if (!entry.includedInCoinbase) {
|
||||
return entry;
|
||||
}
|
||||
included++;
|
||||
if (included <= this.maxCoinbaseOutputs) {
|
||||
return entry;
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
payoutSats: 0,
|
||||
balanceAfterSats: entry.balanceAfterSats + entry.payoutSats,
|
||||
includedInCoinbase: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private isSuccessfulBlockSubmission(result?: string | null): boolean {
|
||||
return result == null || result === 'SUCCESS!';
|
||||
}
|
||||
|
||||
private toNumber(value: unknown): number {
|
||||
const parsed = Number(value ?? 0);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
private readPositiveInt(name: string, defaultValue: number): number {
|
||||
const value = Number(process.env[name]);
|
||||
return Number.isInteger(value) && value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private readNonNegativeInt(name: string, defaultValue: number): number {
|
||||
const value = Number(process.env[name]);
|
||||
return Number.isInteger(value) && value >= 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private readPositiveNumber(name: string, defaultValue: number): number {
|
||||
const value = Number(process.env[name]);
|
||||
return Number.isFinite(value) && value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private readNonNegativeNumber(name: string, defaultValue: number): number {
|
||||
const value = Number(process.env[name]);
|
||||
return Number.isFinite(value) && value >= 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private readBoolean(name: string, defaultValue: boolean): boolean {
|
||||
const value = process.env[name]?.toLowerCase();
|
||||
if (value == null || value.length === 0) {
|
||||
return defaultValue;
|
||||
}
|
||||
return value === 'true' || value === '1' || value === 'yes';
|
||||
}
|
||||
}
|
||||
@@ -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,434 +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 accept TLS SV1 protocol labels for accounting', async () => {
|
||||
const repository = {
|
||||
create: jest.fn(value => value),
|
||||
insert: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '1';
|
||||
const service = new ShareAccountingService(repository as any);
|
||||
|
||||
await expect(service.recordAcceptedShare({
|
||||
...buildRecord('sv1-tls'),
|
||||
protocol: 'sv1_tls',
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
protocol: 'sv1_tls',
|
||||
}));
|
||||
|
||||
expect(repository.insert).toHaveBeenCalledWith([expect.objectContaining({
|
||||
protocol: 'sv1_tls',
|
||||
})]);
|
||||
});
|
||||
|
||||
it('should batch accepted share inserts until the batch size is reached', async () => {
|
||||
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '2';
|
||||
process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '1000';
|
||||
|
||||
const repository = {
|
||||
create: jest.fn(value => value),
|
||||
insert: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
const service = new ShareAccountingService(repository as any);
|
||||
|
||||
const first = service.recordAcceptedShare(buildRecord('1'));
|
||||
expect(repository.insert).not.toHaveBeenCalled();
|
||||
|
||||
const second = service.recordAcceptedShare(buildRecord('2'));
|
||||
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
|
||||
|
||||
expect(repository.insert).toHaveBeenCalledTimes(1);
|
||||
expect(repository.insert).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ jobId: '1' }),
|
||||
expect.objectContaining({ jobId: '2' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should flush partial batches on the flush interval', async () => {
|
||||
jest.useFakeTimers();
|
||||
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '10';
|
||||
process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '25';
|
||||
|
||||
const repository = {
|
||||
create: jest.fn(value => value),
|
||||
insert: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
const service = new ShareAccountingService(repository as any);
|
||||
|
||||
const pending = service.recordAcceptedShare(buildRecord('interval'));
|
||||
expect(repository.insert).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(25);
|
||||
await expect(pending).resolves.toEqual(expect.objectContaining({ jobId: 'interval' }));
|
||||
expect(repository.insert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should reject accepted shares when the accounting queue is full', async () => {
|
||||
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '10';
|
||||
process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '1000';
|
||||
process.env.SHARE_ACCOUNTING_MAX_QUEUE_SIZE = '1';
|
||||
|
||||
const repository = {
|
||||
create: jest.fn(value => value),
|
||||
insert: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
const service = new ShareAccountingService(repository as any);
|
||||
|
||||
const queued = service.recordAcceptedShare(buildRecord('queued'));
|
||||
|
||||
await expect(service.recordAcceptedShare(buildRecord('overflow')))
|
||||
.rejects
|
||||
.toThrow('Share accounting queue is full');
|
||||
await service.flushPendingShares();
|
||||
await expect(queued).resolves.toEqual(expect.objectContaining({ jobId: 'queued' }));
|
||||
});
|
||||
|
||||
it('should return numeric accounting summaries from the share rollup', async () => {
|
||||
const repository = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce([{
|
||||
totalAcceptedShares: '3',
|
||||
totalCreditedDifficulty: '96',
|
||||
acceptedSharesLast10Minutes: '2',
|
||||
creditedDifficultyLast10Minutes: '64',
|
||||
acceptedSharesLastHour: '3',
|
||||
creditedDifficultyLastHour: '96',
|
||||
acceptedSharesLastDay: '3',
|
||||
creditedDifficultyLastDay: '96',
|
||||
hashRateLast10Minutes: '458129844.9',
|
||||
hashRateLastHour: '114532461.2',
|
||||
latestShareAt: new Date('2026-06-07T12:10:00Z'),
|
||||
}]),
|
||||
};
|
||||
const service = new ShareAccountingService(repository as any);
|
||||
|
||||
await expect(service.getAddressSummary('bc1qtest')).resolves.toEqual({
|
||||
totalAcceptedShares: 3,
|
||||
totalCreditedDifficulty: 96,
|
||||
acceptedSharesLast10Minutes: 2,
|
||||
creditedDifficultyLast10Minutes: 64,
|
||||
acceptedSharesLastHour: 3,
|
||||
creditedDifficultyLastHour: 96,
|
||||
acceptedSharesLastDay: 3,
|
||||
creditedDifficultyLastDay: 96,
|
||||
hashRateLast10Minutes: 458129844.9,
|
||||
hashRateLastHour: 114532461.2,
|
||||
bestSubmissionDifficulty: 0,
|
||||
bestSubmissionDifficultyAt: null,
|
||||
workSinceLastBlock: 0,
|
||||
currentRoundAcceptedShares: 0,
|
||||
currentRoundNetworkDifficulty: 0,
|
||||
networkDifficultyPercent: 0,
|
||||
blockCandidateCount: 0,
|
||||
latestShareAt: '2026-06-07T12:10:00.000Z',
|
||||
protocolBreakdown: [],
|
||||
});
|
||||
expect(repository.query).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining('"accepted_share_10m"'),
|
||||
['bc1qtest'],
|
||||
);
|
||||
expect(repository.query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should serve API-only address accounting from the share rollup instead of empty summaries', async () => {
|
||||
process.env.API_ONLY = 'true';
|
||||
const repository = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce([{
|
||||
totalAcceptedShares: '12',
|
||||
totalCreditedDifficulty: '384',
|
||||
acceptedSharesLast10Minutes: '4',
|
||||
creditedDifficultyLast10Minutes: '128',
|
||||
acceptedSharesLastHour: '10',
|
||||
creditedDifficultyLastHour: '320',
|
||||
acceptedSharesLastDay: '12',
|
||||
creditedDifficultyLastDay: '384',
|
||||
hashRateLast10Minutes: '916259689.8',
|
||||
hashRateLastHour: '381774870.2',
|
||||
latestShareAt: new Date('2026-06-07T12:30:00Z'),
|
||||
}]),
|
||||
};
|
||||
const service = new ShareAccountingService(repository as any);
|
||||
|
||||
await expect(service.getAddressSummary('bc1qapi')).resolves.toEqual(expect.objectContaining({
|
||||
totalAcceptedShares: 12,
|
||||
totalCreditedDifficulty: 384,
|
||||
acceptedSharesLast10Minutes: 4,
|
||||
creditedDifficultyLast10Minutes: 128,
|
||||
hashRateLast10Minutes: 916259689.8,
|
||||
latestShareAt: '2026-06-07T12:30:00.000Z',
|
||||
}));
|
||||
expect(repository.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM "accepted_share_10m"'),
|
||||
['bc1qapi'],
|
||||
);
|
||||
expect(repository.query).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM "accepted_share_entity"'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use Redis cache for share accounting summaries across API workers', async () => {
|
||||
process.env.SHARE_ACCOUNTING_SUMMARY_CACHE_MS = '0';
|
||||
const repository = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce([{
|
||||
totalAcceptedShares: '1',
|
||||
totalCreditedDifficulty: '32',
|
||||
acceptedSharesLast10Minutes: '1',
|
||||
creditedDifficultyLast10Minutes: '32',
|
||||
acceptedSharesLastHour: '1',
|
||||
creditedDifficultyLastHour: '32',
|
||||
acceptedSharesLastDay: '1',
|
||||
creditedDifficultyLastDay: '32',
|
||||
hashRateLast10Minutes: '1',
|
||||
hashRateLastHour: '1',
|
||||
latestShareAt: null,
|
||||
}]),
|
||||
};
|
||||
const redis = {
|
||||
getJsonCache: jest.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
...new ShareAccountingService(repository as any).emptySummary(),
|
||||
totalAcceptedShares: 1,
|
||||
}),
|
||||
setJsonCache: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new ShareAccountingService(repository as any, redis as any);
|
||||
|
||||
await service.getAddressSummary('bc1qcached');
|
||||
await expect(service.getAddressSummary('bc1qcached')).resolves.toEqual(expect.objectContaining({
|
||||
totalAcceptedShares: 1,
|
||||
}));
|
||||
|
||||
expect(repository.query).toHaveBeenCalledTimes(1);
|
||||
expect(redis.setJsonCache).toHaveBeenCalledWith(
|
||||
expect.stringContaining('accounting:summary:'),
|
||||
expect.objectContaining({ totalAcceptedShares: 1 }),
|
||||
30000,
|
||||
);
|
||||
});
|
||||
|
||||
it('should refresh pool summaries from completed rollup buckets and current round rollups', async () => {
|
||||
const redis = {
|
||||
getJsonCache: jest.fn().mockResolvedValue(null),
|
||||
setJsonCache: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const repository = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce([{
|
||||
totalAcceptedShares: '3',
|
||||
totalCreditedDifficulty: '96',
|
||||
acceptedSharesLast10Minutes: '2',
|
||||
creditedDifficultyLast10Minutes: '64',
|
||||
acceptedSharesLastHour: '3',
|
||||
creditedDifficultyLastHour: '96',
|
||||
acceptedSharesLastDay: '3',
|
||||
creditedDifficultyLastDay: '96',
|
||||
hashRateLast10Minutes: '458129844.9',
|
||||
hashRateLastHour: '114532461.2',
|
||||
latestShareAt: new Date('2026-06-07T12:10:00Z'),
|
||||
}])
|
||||
.mockResolvedValueOnce([{
|
||||
bestSubmissionDifficulty: '4096',
|
||||
bestSubmissionDifficultyAt: new Date('2026-06-07T12:10:00Z'),
|
||||
currentRoundAcceptedShares: '11',
|
||||
workSinceLastBlock: '352',
|
||||
currentRoundNetworkDifficulty: '1000',
|
||||
}]),
|
||||
};
|
||||
const service = new ShareAccountingService(repository as any, redis as any);
|
||||
|
||||
await expect(service.refreshPoolSummary()).resolves.toEqual(expect.objectContaining({
|
||||
acceptedSharesLast10Minutes: 2,
|
||||
creditedDifficultyLast10Minutes: 64,
|
||||
hashRateLast10Minutes: 458129844.9,
|
||||
bestSubmissionDifficulty: 4096,
|
||||
bestSubmissionDifficultyAt: '2026-06-07T12:10:00.000Z',
|
||||
workSinceLastBlock: 352,
|
||||
currentRoundAcceptedShares: 11,
|
||||
currentRoundNetworkDifficulty: 1000,
|
||||
networkDifficultyPercent: 35.2,
|
||||
latestShareAt: '2026-06-07T12:10:00.000Z',
|
||||
}));
|
||||
expect(repository.query).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining('FROM "accepted_share_block_10m"'),
|
||||
);
|
||||
expect(repository.query).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM "accepted_share_entity"'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cache accounting summaries briefly to protect hot dashboard endpoints', async () => {
|
||||
process.env.SHARE_ACCOUNTING_SUMMARY_CACHE_MS = '1000';
|
||||
const repository = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce([{
|
||||
totalAcceptedShares: '1',
|
||||
totalCreditedDifficulty: '32',
|
||||
acceptedSharesLast10Minutes: '1',
|
||||
creditedDifficultyLast10Minutes: '32',
|
||||
acceptedSharesLastHour: '1',
|
||||
creditedDifficultyLastHour: '32',
|
||||
acceptedSharesLastDay: '1',
|
||||
creditedDifficultyLastDay: '32',
|
||||
hashRateLast10Minutes: '1',
|
||||
hashRateLastHour: '1',
|
||||
latestShareAt: null,
|
||||
}]),
|
||||
};
|
||||
const service = new ShareAccountingService(repository as any);
|
||||
|
||||
await service.getPoolSummary();
|
||||
await service.getPoolSummary();
|
||||
|
||||
expect(repository.query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should finalize accepted shares into an append-only share rollup batch', async () => {
|
||||
const manager = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce([{ locked: true }])
|
||||
.mockResolvedValueOnce([{ lastProcessedShareIndex: '10' }])
|
||||
.mockResolvedValueOnce([{ startShareIndex: '11', endShareIndex: '20', acceptedShareCount: 10 }])
|
||||
.mockResolvedValueOnce([{
|
||||
startAcceptedAt: new Date('2026-06-07T12:00:00Z'),
|
||||
endAcceptedAt: new Date('2026-06-07T12:00:30Z'),
|
||||
acceptedShareCount: '10',
|
||||
creditedDifficulty: '2048',
|
||||
}])
|
||||
.mockResolvedValueOnce([{ batchId: '7' }])
|
||||
.mockResolvedValueOnce([]),
|
||||
};
|
||||
const repository = {
|
||||
manager: {
|
||||
transaction: jest.fn(callback => callback(manager)),
|
||||
},
|
||||
};
|
||||
const service = new ShareAccountingService(repository as any);
|
||||
|
||||
await expect(service.processPendingShareRollupBatch()).resolves.toEqual({
|
||||
processed: true,
|
||||
batchId: '7',
|
||||
startShareIndex: '11',
|
||||
endShareIndex: '20',
|
||||
acceptedShareCount: 10,
|
||||
creditedDifficulty: 2048,
|
||||
});
|
||||
|
||||
expect(repository.manager.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(manager.query).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining('pg_try_advisory_xact_lock'),
|
||||
['1780962600'],
|
||||
);
|
||||
expect(manager.query).toHaveBeenNthCalledWith(
|
||||
6,
|
||||
expect.stringContaining('INSERT INTO "share_rollup_batch_summary"'),
|
||||
['7', '11', '20'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip share rollup work when another process holds the advisory lock', async () => {
|
||||
const manager = {
|
||||
query: jest.fn().mockResolvedValueOnce([{ locked: false }]),
|
||||
};
|
||||
const repository = {
|
||||
manager: {
|
||||
transaction: jest.fn(callback => callback(manager)),
|
||||
},
|
||||
};
|
||||
const service = new ShareAccountingService(repository as any);
|
||||
|
||||
await expect(service.processPendingShareRollupBatch()).resolves.toEqual({
|
||||
processed: false,
|
||||
reason: 'locked',
|
||||
});
|
||||
|
||||
expect(manager.query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
function buildRecord(jobId: string) {
|
||||
return {
|
||||
protocol: 'sv1' as const,
|
||||
acceptedAt: new Date('2026-06-07T12:00:00Z'),
|
||||
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
||||
clientName: 'worker',
|
||||
sessionId: '57a6f098',
|
||||
clientId: '6df7beea-921a-46cb-b7dc-e8075f916232',
|
||||
jobId,
|
||||
jobTemplateId: 'template',
|
||||
blockHeight: 900000,
|
||||
creditedDifficulty: 1024,
|
||||
submissionDifficulty: 2048,
|
||||
networkDifficulty: 100000,
|
||||
nonce: 123,
|
||||
ntime: 456,
|
||||
version: 536870912,
|
||||
extraNonce2: 'c708000000000000',
|
||||
isBlockCandidate: false,
|
||||
blockSubmissionResult: null,
|
||||
};
|
||||
}
|
||||
@@ -1,770 +0,0 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AcceptedShareEntity } from '../accepted-share/accepted-share.entity';
|
||||
import { RedisMessagingService } from '../../services/redis-messaging.service';
|
||||
|
||||
export interface AcceptedShareRecord {
|
||||
protocol: 'sv1' | 'sv1_tls' | 'sv2' | 'sv2_jdp' | 'datum';
|
||||
workSource?: 'pool_template' | 'miner_template';
|
||||
workProtocol?: 'pool' | 'sv2_jdp' | 'datum';
|
||||
acceptedAt?: Date;
|
||||
address: string;
|
||||
clientName: string;
|
||||
sessionId: string;
|
||||
clientId: string;
|
||||
jobId: string;
|
||||
jobTemplateId: string;
|
||||
blockHeight: number;
|
||||
creditedDifficulty: number;
|
||||
submissionDifficulty: number;
|
||||
networkDifficulty: number;
|
||||
nonce: string | number;
|
||||
ntime: string | number;
|
||||
version: string | number;
|
||||
extraNonce2: string;
|
||||
isBlockCandidate: boolean;
|
||||
blockSubmissionResult?: string | null;
|
||||
}
|
||||
|
||||
export interface ShareAccountingSummary {
|
||||
totalAcceptedShares: number;
|
||||
totalCreditedDifficulty: number;
|
||||
acceptedSharesLast10Minutes: number;
|
||||
creditedDifficultyLast10Minutes: number;
|
||||
acceptedSharesLastHour: number;
|
||||
creditedDifficultyLastHour: number;
|
||||
acceptedSharesLastDay: number;
|
||||
creditedDifficultyLastDay: number;
|
||||
hashRateLast10Minutes: number;
|
||||
hashRateLastHour: number;
|
||||
bestSubmissionDifficulty: number;
|
||||
bestSubmissionDifficultyAt: string | null;
|
||||
workSinceLastBlock: number;
|
||||
currentRoundAcceptedShares: number;
|
||||
currentRoundNetworkDifficulty: number;
|
||||
networkDifficultyPercent: number;
|
||||
blockCandidateCount: number;
|
||||
latestShareAt: string | null;
|
||||
protocolBreakdown: {
|
||||
protocol: string;
|
||||
acceptedShares: number;
|
||||
creditedDifficulty: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface SessionShareSummary {
|
||||
clientId: string;
|
||||
latestShareAt: string | null;
|
||||
hashRateLast10Minutes: number;
|
||||
bestSubmissionDifficulty: number;
|
||||
}
|
||||
|
||||
export interface ShareRollupBatchResult {
|
||||
processed: boolean;
|
||||
reason?: 'disabled' | 'locked' | 'no-shares';
|
||||
batchId?: string;
|
||||
startShareIndex?: string;
|
||||
endShareIndex?: string;
|
||||
acceptedShareCount?: number;
|
||||
creditedDifficulty?: number;
|
||||
}
|
||||
|
||||
interface AccountingFilter {
|
||||
address?: string;
|
||||
clientName?: string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
const HASHES_PER_DIFFICULTY = 4294967296;
|
||||
const ROLLUP_BUCKET_SECONDS = 600;
|
||||
const DEFAULT_BATCH_SIZE = 500;
|
||||
const DEFAULT_FLUSH_INTERVAL_MS = 25;
|
||||
const DEFAULT_MAX_QUEUE_SIZE = 50000;
|
||||
const DEFAULT_SUMMARY_CACHE_MS = 2500;
|
||||
const DEFAULT_SUMMARY_CACHE_MAX = 10000;
|
||||
const DEFAULT_REDIS_SUMMARY_CACHE_MS = 30000;
|
||||
const DEFAULT_ROLLUP_INTERVAL_MS = 60000;
|
||||
const DEFAULT_ROLLUP_SAFETY_LAG_SECONDS = 10;
|
||||
const DEFAULT_ROLLUP_MAX_SHARES_PER_BATCH = 5000000;
|
||||
const SHARE_ROLLUP_ADVISORY_LOCK = '1780962600';
|
||||
|
||||
@Injectable()
|
||||
export class ShareAccountingService implements OnModuleInit, OnModuleDestroy {
|
||||
private pendingShares: PendingShare[] = [];
|
||||
private flushTimer: NodeJS.Timeout | null = null;
|
||||
private activeFlush: Promise<void> | null = null;
|
||||
private rollupTimer: NodeJS.Timeout | null = null;
|
||||
private activeRollup: Promise<void> | null = null;
|
||||
private summaryCache = new Map<string, SummaryCacheEntry>();
|
||||
private readonly poolSummaryCacheKey = 'accounting:pool-summary';
|
||||
private readonly batchSize = this.readPositiveInt('SHARE_ACCOUNTING_BATCH_SIZE', DEFAULT_BATCH_SIZE);
|
||||
private readonly flushIntervalMs = this.readPositiveInt('SHARE_ACCOUNTING_FLUSH_INTERVAL_MS', DEFAULT_FLUSH_INTERVAL_MS);
|
||||
private readonly maxQueueSize = this.readPositiveInt('SHARE_ACCOUNTING_MAX_QUEUE_SIZE', DEFAULT_MAX_QUEUE_SIZE);
|
||||
private readonly summaryCacheMs = this.readNonNegativeInt('SHARE_ACCOUNTING_SUMMARY_CACHE_MS', DEFAULT_SUMMARY_CACHE_MS);
|
||||
private readonly summaryCacheMax = this.readPositiveInt('SHARE_ACCOUNTING_SUMMARY_CACHE_MAX', DEFAULT_SUMMARY_CACHE_MAX);
|
||||
private readonly redisSummaryCacheMs = this.readNonNegativeInt('SHARE_ACCOUNTING_REDIS_SUMMARY_CACHE_MS', DEFAULT_REDIS_SUMMARY_CACHE_MS);
|
||||
private readonly shareRollupEnabled = this.readBoolean('SHARE_ROLLUP_ENABLED', true);
|
||||
private readonly shareRollupIntervalMs = this.readPositiveInt('SHARE_ROLLUP_INTERVAL_MS', DEFAULT_ROLLUP_INTERVAL_MS);
|
||||
private readonly shareRollupSafetyLagSeconds = this.readNonNegativeInt('SHARE_ROLLUP_SAFETY_LAG_SECONDS', DEFAULT_ROLLUP_SAFETY_LAG_SECONDS);
|
||||
private readonly shareRollupMaxSharesPerBatch = this.readPositiveInt('SHARE_ROLLUP_MAX_SHARES_PER_BATCH', DEFAULT_ROLLUP_MAX_SHARES_PER_BATCH);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AcceptedShareEntity)
|
||||
private readonly acceptedShareRepository: Repository<AcceptedShareEntity>,
|
||||
private readonly redisMessagingService?: RedisMessagingService,
|
||||
) { }
|
||||
|
||||
public onModuleInit(): void {
|
||||
this.startShareRollupTimer();
|
||||
}
|
||||
|
||||
public async recordAcceptedShare(record: AcceptedShareRecord): Promise<AcceptedShareEntity> {
|
||||
const acceptedShare = this.acceptedShareRepository.create({
|
||||
...record,
|
||||
workSource: record.workSource ?? 'pool_template',
|
||||
workProtocol: record.workProtocol ?? 'pool',
|
||||
acceptedAt: record.acceptedAt ?? new Date(),
|
||||
nonce: record.nonce.toString(),
|
||||
ntime: record.ntime.toString(),
|
||||
version: record.version.toString(),
|
||||
blockSubmissionResult: record.blockSubmissionResult == null
|
||||
? null
|
||||
: record.blockSubmissionResult.toString(),
|
||||
});
|
||||
|
||||
if (this.pendingShares.length >= this.maxQueueSize) {
|
||||
throw new Error(`Share accounting queue is full (${this.maxQueueSize})`);
|
||||
}
|
||||
|
||||
return await new Promise<AcceptedShareEntity>((resolve, reject) => {
|
||||
this.pendingShares.push({ entity: acceptedShare, resolve, reject });
|
||||
|
||||
if (this.pendingShares.length >= this.batchSize) {
|
||||
this.startFlush();
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleFlush();
|
||||
});
|
||||
}
|
||||
|
||||
public async flushPendingShares(): Promise<void> {
|
||||
while (this.pendingShares.length > 0 || this.activeFlush != null) {
|
||||
if (this.activeFlush != null) {
|
||||
await this.activeFlush;
|
||||
continue;
|
||||
}
|
||||
|
||||
this.startFlush();
|
||||
}
|
||||
}
|
||||
|
||||
public async onModuleDestroy(): Promise<void> {
|
||||
if (this.rollupTimer != null) {
|
||||
clearInterval(this.rollupTimer);
|
||||
this.rollupTimer = null;
|
||||
}
|
||||
if (this.activeRollup != null) {
|
||||
await this.activeRollup;
|
||||
}
|
||||
await this.flushPendingShares();
|
||||
}
|
||||
|
||||
public async processPendingShareRollupBatch(): Promise<ShareRollupBatchResult> {
|
||||
if (!this.shareRollupEnabled) {
|
||||
return { processed: false, reason: 'disabled' };
|
||||
}
|
||||
|
||||
return this.acceptedShareRepository.manager.transaction(async manager => {
|
||||
const [lockRow] = await manager.query(`
|
||||
SELECT pg_try_advisory_xact_lock($1::bigint) AS "locked"
|
||||
`, [SHARE_ROLLUP_ADVISORY_LOCK]);
|
||||
|
||||
if (lockRow?.locked !== true) {
|
||||
return { processed: false, reason: 'locked' };
|
||||
}
|
||||
|
||||
const [lastRow] = await manager.query(`
|
||||
SELECT COALESCE(MAX("endShareIndex"), 0)::text AS "lastProcessedShareIndex"
|
||||
FROM "share_rollup_batch"
|
||||
WHERE "status" = 'finalized'
|
||||
`);
|
||||
const lastProcessedShareIndex = lastRow?.lastProcessedShareIndex ?? '0';
|
||||
|
||||
const [rangeRow] = await manager.query(`
|
||||
WITH last_state AS MATERIALIZED (
|
||||
SELECT $1::bigint AS "lastProcessedShareIndex"
|
||||
),
|
||||
first_unstable AS MATERIALIZED (
|
||||
SELECT MIN("shareIndex") AS "firstUnstableShareIndex"
|
||||
FROM "accepted_share_entity", last_state
|
||||
WHERE "shareIndex" > last_state."lastProcessedShareIndex"
|
||||
AND "acceptedAt" > NOW() - ($2::int * INTERVAL '1 second')
|
||||
),
|
||||
stable_bound AS MATERIALIZED (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN (SELECT "firstUnstableShareIndex" FROM first_unstable) IS NULL THEN (
|
||||
SELECT MAX("shareIndex")
|
||||
FROM "accepted_share_entity", last_state
|
||||
WHERE "shareIndex" > last_state."lastProcessedShareIndex"
|
||||
)
|
||||
ELSE (SELECT "firstUnstableShareIndex" FROM first_unstable) - 1
|
||||
END AS "maxStableShareIndex"
|
||||
),
|
||||
bounded AS MATERIALIZED (
|
||||
SELECT "shareIndex"
|
||||
FROM "accepted_share_entity", last_state, stable_bound
|
||||
WHERE "shareIndex" > last_state."lastProcessedShareIndex"
|
||||
AND "shareIndex" <= stable_bound."maxStableShareIndex"
|
||||
ORDER BY "shareIndex" ASC
|
||||
LIMIT $3::int
|
||||
)
|
||||
SELECT
|
||||
MIN("shareIndex")::text AS "startShareIndex",
|
||||
MAX("shareIndex")::text AS "endShareIndex",
|
||||
COUNT(*)::int AS "acceptedShareCount"
|
||||
FROM bounded
|
||||
`, [
|
||||
lastProcessedShareIndex,
|
||||
this.shareRollupSafetyLagSeconds,
|
||||
this.shareRollupMaxSharesPerBatch,
|
||||
]);
|
||||
|
||||
if (rangeRow?.startShareIndex == null || rangeRow?.endShareIndex == null || this.toNumber(rangeRow.acceptedShareCount) === 0) {
|
||||
return { processed: false, reason: 'no-shares' };
|
||||
}
|
||||
|
||||
const [statsRow] = await manager.query(`
|
||||
SELECT
|
||||
MIN("acceptedAt") AS "startAcceptedAt",
|
||||
MAX("acceptedAt") AS "endAcceptedAt",
|
||||
COUNT(*)::bigint AS "acceptedShareCount",
|
||||
COALESCE(SUM("creditedDifficulty"), 0)::numeric AS "creditedDifficulty"
|
||||
FROM "accepted_share_entity"
|
||||
WHERE "shareIndex" >= $1::bigint
|
||||
AND "shareIndex" <= $2::bigint
|
||||
`, [rangeRow.startShareIndex, rangeRow.endShareIndex]);
|
||||
|
||||
if (statsRow?.startAcceptedAt == null || this.toNumber(statsRow.acceptedShareCount) === 0) {
|
||||
return { processed: false, reason: 'no-shares' };
|
||||
}
|
||||
|
||||
const [batchRow] = await manager.query(`
|
||||
INSERT INTO "share_rollup_batch" (
|
||||
"startShareIndex",
|
||||
"endShareIndex",
|
||||
"startAcceptedAt",
|
||||
"endAcceptedAt",
|
||||
"acceptedShareCount",
|
||||
"creditedDifficulty",
|
||||
"status",
|
||||
"finalizedAt"
|
||||
) VALUES (
|
||||
$1::bigint,
|
||||
$2::bigint,
|
||||
$3::timestamptz,
|
||||
$4::timestamptz,
|
||||
$5::bigint,
|
||||
$6::numeric,
|
||||
'finalized',
|
||||
NOW()
|
||||
)
|
||||
RETURNING "id"::text AS "batchId"
|
||||
`, [
|
||||
rangeRow.startShareIndex,
|
||||
rangeRow.endShareIndex,
|
||||
statsRow.startAcceptedAt,
|
||||
statsRow.endAcceptedAt,
|
||||
statsRow.acceptedShareCount,
|
||||
statsRow.creditedDifficulty,
|
||||
]);
|
||||
|
||||
await manager.query(`
|
||||
INSERT INTO "share_rollup_batch_summary" (
|
||||
"batchId",
|
||||
"address",
|
||||
"clientName",
|
||||
"protocol",
|
||||
"blockHeight",
|
||||
"creditedDifficulty",
|
||||
"acceptedShareCount",
|
||||
"bestSubmissionDifficulty",
|
||||
"firstShareAt",
|
||||
"lastShareAt"
|
||||
)
|
||||
SELECT
|
||||
$1::bigint AS "batchId",
|
||||
"address",
|
||||
"clientName",
|
||||
"protocol",
|
||||
"blockHeight",
|
||||
SUM("creditedDifficulty") AS "creditedDifficulty",
|
||||
COUNT(*)::bigint AS "acceptedShareCount",
|
||||
MAX("submissionDifficulty") AS "bestSubmissionDifficulty",
|
||||
MIN("acceptedAt") AS "firstShareAt",
|
||||
MAX("acceptedAt") AS "lastShareAt"
|
||||
FROM "accepted_share_entity"
|
||||
WHERE "shareIndex" >= $2::bigint
|
||||
AND "shareIndex" <= $3::bigint
|
||||
GROUP BY "address", "clientName", "protocol", "blockHeight"
|
||||
`, [
|
||||
batchRow.batchId,
|
||||
rangeRow.startShareIndex,
|
||||
rangeRow.endShareIndex,
|
||||
]);
|
||||
|
||||
return {
|
||||
processed: true,
|
||||
batchId: batchRow.batchId,
|
||||
startShareIndex: rangeRow.startShareIndex,
|
||||
endShareIndex: rangeRow.endShareIndex,
|
||||
acceptedShareCount: this.toNumber(statsRow.acceptedShareCount),
|
||||
creditedDifficulty: this.toNumber(statsRow.creditedDifficulty),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async getPoolSummary(): Promise<ShareAccountingSummary> {
|
||||
const cached = await this.redisMessagingService
|
||||
?.getJsonCache<ShareAccountingSummary>(this.poolSummaryCacheKey)
|
||||
.catch(error => {
|
||||
console.error(`Pool accounting summary cache read failed: ${error.message}`);
|
||||
return null;
|
||||
});
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (process.env.API_ONLY === 'true') {
|
||||
return this.emptySummary();
|
||||
}
|
||||
|
||||
return this.getSummary({});
|
||||
}
|
||||
|
||||
public async refreshPoolSummary(): Promise<ShareAccountingSummary> {
|
||||
const summary = await this.withPoolRollupOverlay(await this.getSummary({}));
|
||||
await this.redisMessagingService
|
||||
?.setJsonCache(this.poolSummaryCacheKey, summary, 10 * 60 * 1000)
|
||||
.catch(error => {
|
||||
console.error(`Pool accounting summary cache write failed: ${error.message}`);
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
|
||||
private async withPoolRollupOverlay(summary: ShareAccountingSummary): Promise<ShareAccountingSummary> {
|
||||
if (process.env.API_ONLY === 'true') {
|
||||
return summary;
|
||||
}
|
||||
|
||||
const [currentRoundRow] = await this.acceptedShareRepository.query(`
|
||||
WITH latest_found_block AS (
|
||||
SELECT COALESCE(MAX("height"), 0) AS "height"
|
||||
FROM "blocks_entity"
|
||||
),
|
||||
filtered_rows AS (
|
||||
SELECT "accepted_share_block_10m".*
|
||||
FROM "accepted_share_block_10m", latest_found_block
|
||||
WHERE "blockHeight" > latest_found_block."height"
|
||||
),
|
||||
best_share AS (
|
||||
SELECT
|
||||
"bestSubmissionDifficulty",
|
||||
"bucket"
|
||||
FROM filtered_rows
|
||||
ORDER BY "bestSubmissionDifficulty" DESC, "bucket" DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT
|
||||
COALESCE(SUM("acceptedCount"), 0)::int AS "currentRoundAcceptedShares",
|
||||
COALESCE(SUM("shares"), 0)::float AS "workSinceLastBlock",
|
||||
COALESCE(MAX("networkDifficulty"), 0)::float AS "currentRoundNetworkDifficulty",
|
||||
COALESCE((SELECT "bestSubmissionDifficulty" FROM best_share), 0)::float AS "bestSubmissionDifficulty",
|
||||
(SELECT "bucket" FROM best_share) AS "bestSubmissionDifficultyAt"
|
||||
FROM filtered_rows
|
||||
`);
|
||||
const currentRoundNetworkDifficulty = this.toNumber(currentRoundRow?.currentRoundNetworkDifficulty);
|
||||
const workSinceLastBlock = this.toNumber(currentRoundRow?.workSinceLastBlock);
|
||||
|
||||
return {
|
||||
...summary,
|
||||
bestSubmissionDifficulty: this.toNumber(currentRoundRow?.bestSubmissionDifficulty),
|
||||
bestSubmissionDifficultyAt: currentRoundRow?.bestSubmissionDifficultyAt == null
|
||||
? null
|
||||
: new Date(currentRoundRow.bestSubmissionDifficultyAt).toISOString(),
|
||||
workSinceLastBlock,
|
||||
currentRoundAcceptedShares: this.toNumber(currentRoundRow?.currentRoundAcceptedShares),
|
||||
currentRoundNetworkDifficulty,
|
||||
networkDifficultyPercent: currentRoundNetworkDifficulty > 0
|
||||
? this.roundPercent((workSinceLastBlock / currentRoundNetworkDifficulty) * 100)
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
public emptySummary(): ShareAccountingSummary {
|
||||
return {
|
||||
totalAcceptedShares: 0,
|
||||
totalCreditedDifficulty: 0,
|
||||
acceptedSharesLast10Minutes: 0,
|
||||
creditedDifficultyLast10Minutes: 0,
|
||||
acceptedSharesLastHour: 0,
|
||||
creditedDifficultyLastHour: 0,
|
||||
acceptedSharesLastDay: 0,
|
||||
creditedDifficultyLastDay: 0,
|
||||
hashRateLast10Minutes: 0,
|
||||
hashRateLastHour: 0,
|
||||
bestSubmissionDifficulty: 0,
|
||||
bestSubmissionDifficultyAt: null,
|
||||
workSinceLastBlock: 0,
|
||||
currentRoundAcceptedShares: 0,
|
||||
currentRoundNetworkDifficulty: 0,
|
||||
networkDifficultyPercent: 0,
|
||||
blockCandidateCount: 0,
|
||||
latestShareAt: null,
|
||||
protocolBreakdown: [],
|
||||
};
|
||||
}
|
||||
|
||||
public async getAddressSummary(address: string): Promise<ShareAccountingSummary> {
|
||||
return this.getSummary({ address });
|
||||
}
|
||||
|
||||
public async getWorkerGroupSummary(address: string, clientName: string): Promise<ShareAccountingSummary> {
|
||||
return this.getSummary({ address, clientName });
|
||||
}
|
||||
|
||||
public async getSessionSummary(clientId: string): Promise<ShareAccountingSummary> {
|
||||
return this.getSummary({ clientId });
|
||||
}
|
||||
|
||||
public async getSessionSummaries(clientIds: string[]): Promise<Map<string, SessionShareSummary>> {
|
||||
const uniqueClientIds = [...new Set(clientIds.filter(clientId => clientId != null))];
|
||||
const summaries = new Map<string, SessionShareSummary>();
|
||||
if (uniqueClientIds.length === 0 || process.env.API_ONLY === 'true') {
|
||||
return summaries;
|
||||
}
|
||||
|
||||
const rows = await this.acceptedShareRepository.query(`
|
||||
WITH clock AS MATERIALIZED (
|
||||
SELECT
|
||||
time_bucket(INTERVAL '10 minutes', NOW()) AS "currentBucket"
|
||||
),
|
||||
latest_bucket AS MATERIALIZED (
|
||||
SELECT "bucket"
|
||||
FROM "accepted_share_10m", clock
|
||||
WHERE "bucket" < clock."currentBucket"
|
||||
ORDER BY "bucket" DESC
|
||||
LIMIT 1
|
||||
),
|
||||
bounds AS MATERIALIZED (
|
||||
SELECT
|
||||
"currentBucket",
|
||||
COALESCE(
|
||||
(SELECT "bucket" FROM latest_bucket),
|
||||
"currentBucket" - INTERVAL '10 minutes'
|
||||
) AS "latestCompletedBucket"
|
||||
FROM clock
|
||||
),
|
||||
filtered_rows AS (
|
||||
SELECT "accepted_share_10m".*, bounds."latestCompletedBucket"
|
||||
FROM "accepted_share_10m", bounds
|
||||
WHERE "clientId" = ANY($1::uuid[])
|
||||
)
|
||||
SELECT
|
||||
"clientId",
|
||||
MAX("bucket") AS "latestShareAt",
|
||||
COALESCE((SUM("shares") FILTER (WHERE "bucket" = "latestCompletedBucket") * ${HASHES_PER_DIFFICULTY}) / ${ROLLUP_BUCKET_SECONDS}, 0)::float AS "hashRateLast10Minutes"
|
||||
FROM filtered_rows
|
||||
GROUP BY "clientId"
|
||||
`, [uniqueClientIds]);
|
||||
|
||||
rows.forEach(row => {
|
||||
summaries.set(row.clientId, {
|
||||
clientId: row.clientId,
|
||||
latestShareAt: row.latestShareAt == null
|
||||
? null
|
||||
: new Date(row.latestShareAt).toISOString(),
|
||||
hashRateLast10Minutes: this.toNumber(row.hashRateLast10Minutes),
|
||||
bestSubmissionDifficulty: 0,
|
||||
});
|
||||
});
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private async getSummary(filter: AccountingFilter): Promise<ShareAccountingSummary> {
|
||||
const cacheKey = this.getSummaryCacheKey(filter);
|
||||
const cached = this.summaryCache.get(cacheKey);
|
||||
const now = Date.now();
|
||||
|
||||
if (cached != null && cached.expiresAt > now) {
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
const value = this.loadCachedSummary(filter, cacheKey).catch(error => {
|
||||
this.summaryCache.delete(cacheKey);
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (this.summaryCacheMs > 0) {
|
||||
this.summaryCache.set(cacheKey, {
|
||||
expiresAt: now + this.summaryCacheMs,
|
||||
value,
|
||||
});
|
||||
this.trimSummaryCache();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private async loadCachedSummary(filter: AccountingFilter, cacheKey: string): Promise<ShareAccountingSummary> {
|
||||
const redisCacheKey = `accounting:summary:${cacheKey}`;
|
||||
const cached = await this.redisMessagingService
|
||||
?.getJsonCache<ShareAccountingSummary>(redisCacheKey)
|
||||
.catch(error => {
|
||||
console.error(`Share accounting summary cache read failed: ${error.message}`);
|
||||
return null;
|
||||
});
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const summary = await this.loadSummary(filter);
|
||||
|
||||
await this.redisMessagingService
|
||||
?.setJsonCache(redisCacheKey, summary, this.redisSummaryCacheMs)
|
||||
.catch(error => {
|
||||
console.error(`Share accounting summary cache write failed: ${error.message}`);
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
private async loadSummary(filter: AccountingFilter): Promise<ShareAccountingSummary> {
|
||||
const { whereSql, params } = this.buildWhereClause(filter);
|
||||
const [summary] = await this.acceptedShareRepository.query(`
|
||||
WITH clock AS MATERIALIZED (
|
||||
SELECT
|
||||
time_bucket(INTERVAL '10 minutes', NOW()) AS "currentBucket"
|
||||
),
|
||||
latest_bucket AS MATERIALIZED (
|
||||
SELECT "bucket"
|
||||
FROM "accepted_share_10m", clock
|
||||
WHERE "bucket" < clock."currentBucket"
|
||||
ORDER BY "bucket" DESC
|
||||
LIMIT 1
|
||||
),
|
||||
bounds AS MATERIALIZED (
|
||||
SELECT
|
||||
"currentBucket",
|
||||
COALESCE(
|
||||
(SELECT "bucket" FROM latest_bucket),
|
||||
"currentBucket" - INTERVAL '10 minutes'
|
||||
) AS "latestCompletedBucket"
|
||||
FROM clock
|
||||
),
|
||||
filtered_rows AS (
|
||||
SELECT "accepted_share_10m".*, bounds."currentBucket", bounds."latestCompletedBucket"
|
||||
FROM "accepted_share_10m", bounds
|
||||
${whereSql.length > 0
|
||||
? `${whereSql} AND "accepted_share_10m"."bucket" <= bounds."latestCompletedBucket"`
|
||||
: `WHERE "accepted_share_10m"."bucket" <= bounds."latestCompletedBucket"`}
|
||||
)
|
||||
SELECT
|
||||
COALESCE(SUM("acceptedCount"), 0)::int AS "totalAcceptedShares",
|
||||
COALESCE(SUM("shares"), 0)::float AS "totalCreditedDifficulty",
|
||||
COALESCE(SUM("acceptedCount") FILTER (WHERE "bucket" = "latestCompletedBucket"), 0)::int AS "acceptedSharesLast10Minutes",
|
||||
COALESCE(SUM("shares") FILTER (WHERE "bucket" = "latestCompletedBucket"), 0)::float AS "creditedDifficultyLast10Minutes",
|
||||
COALESCE(SUM("acceptedCount") FILTER (WHERE "bucket" > "latestCompletedBucket" - INTERVAL '1 hour' AND "bucket" <= "latestCompletedBucket"), 0)::int AS "acceptedSharesLastHour",
|
||||
COALESCE(SUM("shares") FILTER (WHERE "bucket" > "latestCompletedBucket" - INTERVAL '1 hour' AND "bucket" <= "latestCompletedBucket"), 0)::float AS "creditedDifficultyLastHour",
|
||||
COALESCE(SUM("acceptedCount") FILTER (WHERE "bucket" > "latestCompletedBucket" - INTERVAL '1 day' AND "bucket" <= "latestCompletedBucket"), 0)::int AS "acceptedSharesLastDay",
|
||||
COALESCE(SUM("shares") FILTER (WHERE "bucket" > "latestCompletedBucket" - INTERVAL '1 day' AND "bucket" <= "latestCompletedBucket"), 0)::float AS "creditedDifficultyLastDay",
|
||||
COALESCE((SUM("shares") FILTER (WHERE "bucket" = "latestCompletedBucket") * ${HASHES_PER_DIFFICULTY}) / ${ROLLUP_BUCKET_SECONDS}, 0)::float AS "hashRateLast10Minutes",
|
||||
COALESCE((SUM("shares") FILTER (WHERE "bucket" > "latestCompletedBucket" - INTERVAL '1 hour' AND "bucket" <= "latestCompletedBucket") * ${HASHES_PER_DIFFICULTY}) / 3600, 0)::float AS "hashRateLastHour",
|
||||
MAX("bucket") AS "latestShareAt"
|
||||
FROM filtered_rows
|
||||
`, params);
|
||||
|
||||
return {
|
||||
totalAcceptedShares: this.toNumber(summary?.totalAcceptedShares),
|
||||
totalCreditedDifficulty: this.toNumber(summary?.totalCreditedDifficulty),
|
||||
acceptedSharesLast10Minutes: this.toNumber(summary?.acceptedSharesLast10Minutes),
|
||||
creditedDifficultyLast10Minutes: this.toNumber(summary?.creditedDifficultyLast10Minutes),
|
||||
acceptedSharesLastHour: this.toNumber(summary?.acceptedSharesLastHour),
|
||||
creditedDifficultyLastHour: this.toNumber(summary?.creditedDifficultyLastHour),
|
||||
acceptedSharesLastDay: this.toNumber(summary?.acceptedSharesLastDay),
|
||||
creditedDifficultyLastDay: this.toNumber(summary?.creditedDifficultyLastDay),
|
||||
hashRateLast10Minutes: this.toNumber(summary?.hashRateLast10Minutes),
|
||||
hashRateLastHour: this.toNumber(summary?.hashRateLastHour),
|
||||
bestSubmissionDifficulty: 0,
|
||||
bestSubmissionDifficultyAt: null,
|
||||
workSinceLastBlock: 0,
|
||||
currentRoundAcceptedShares: 0,
|
||||
currentRoundNetworkDifficulty: 0,
|
||||
networkDifficultyPercent: 0,
|
||||
blockCandidateCount: 0,
|
||||
latestShareAt: summary?.latestShareAt == null
|
||||
? null
|
||||
: new Date(summary.latestShareAt).toISOString(),
|
||||
protocolBreakdown: [],
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleFlush(): void {
|
||||
if (this.flushTimer != null || this.activeFlush != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
this.startFlush();
|
||||
}, this.flushIntervalMs);
|
||||
this.flushTimer.unref?.();
|
||||
}
|
||||
|
||||
private startFlush(): void {
|
||||
if (this.activeFlush != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.flushTimer != null) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
|
||||
const batch = this.pendingShares.splice(0, this.batchSize);
|
||||
if (batch.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeFlush = this.flushBatch(batch).finally(() => {
|
||||
this.activeFlush = null;
|
||||
|
||||
if (this.pendingShares.length >= this.batchSize) {
|
||||
this.startFlush();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pendingShares.length > 0) {
|
||||
this.scheduleFlush();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async flushBatch(batch: PendingShare[]): Promise<void> {
|
||||
try {
|
||||
await this.acceptedShareRepository.insert(batch.map(item => item.entity));
|
||||
batch.forEach(item => item.resolve(item.entity));
|
||||
} catch (error) {
|
||||
batch.forEach(item => item.reject(error));
|
||||
}
|
||||
}
|
||||
|
||||
private startShareRollupTimer(): void {
|
||||
if (!this.shareRollupEnabled || process.env.MASTER !== 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rollupTimer = setInterval(() => {
|
||||
if (this.activeRollup != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeRollup = this.processPendingShareRollupBatch()
|
||||
.then(result => {
|
||||
if (result.processed) {
|
||||
console.log(`Share rollup batch ${result.batchId} finalized: indexes ${result.startShareIndex}-${result.endShareIndex}, shares ${result.acceptedShareCount}, difficulty ${result.creditedDifficulty}`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Share rollup batch failed: ${error.message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
this.activeRollup = null;
|
||||
});
|
||||
}, this.shareRollupIntervalMs);
|
||||
this.rollupTimer.unref?.();
|
||||
}
|
||||
|
||||
private buildWhereClause(filter: AccountingFilter): { whereSql: string; params: string[] } {
|
||||
const where: string[] = [];
|
||||
const params: string[] = [];
|
||||
|
||||
if (filter.address != null) {
|
||||
params.push(filter.address);
|
||||
where.push(`"address" = $${params.length}`);
|
||||
}
|
||||
if (filter.clientName != null) {
|
||||
params.push(filter.clientName);
|
||||
where.push(`"clientName" = $${params.length}`);
|
||||
}
|
||||
if (filter.clientId != null) {
|
||||
params.push(filter.clientId);
|
||||
where.push(`"clientId" = $${params.length}`);
|
||||
}
|
||||
|
||||
return {
|
||||
whereSql: where.length > 0 ? `WHERE ${where.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
private toNumber(value: unknown): number {
|
||||
const parsed = Number(value ?? 0);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
private roundPercent(value: number): number {
|
||||
return Math.round(value * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
private getSummaryCacheKey(filter: AccountingFilter): string {
|
||||
return JSON.stringify({
|
||||
address: filter.address ?? null,
|
||||
clientName: filter.clientName ?? null,
|
||||
clientId: filter.clientId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
private trimSummaryCache(): void {
|
||||
while (this.summaryCache.size > this.summaryCacheMax) {
|
||||
const firstKey = this.summaryCache.keys().next().value;
|
||||
if (firstKey == null) {
|
||||
return;
|
||||
}
|
||||
this.summaryCache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
private readPositiveInt(name: string, defaultValue: number): number {
|
||||
const value = Number(process.env[name]);
|
||||
return Number.isInteger(value) && value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private readNonNegativeInt(name: string, defaultValue: number): number {
|
||||
const value = Number(process.env[name]);
|
||||
return Number.isInteger(value) && value >= 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private readBoolean(name: string, defaultValue: boolean): boolean {
|
||||
const value = process.env[name]?.toLowerCase();
|
||||
if (value == null || value.length === 0) {
|
||||
return defaultValue;
|
||||
}
|
||||
return value === 'true' || value === '1' || value === 'yes';
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingShare {
|
||||
entity: AcceptedShareEntity;
|
||||
resolve: (entity: AcceptedShareEntity) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
interface SummaryCacheEntry {
|
||||
expiresAt: number;
|
||||
value: Promise<ShareAccountingSummary>;
|
||||
}
|
||||
@@ -1,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
|
||||
}
|
||||
}
|
||||
+22
-188
@@ -3,22 +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';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
|
||||
private uptime = new Date();
|
||||
private siteInfoRefreshPromise: Promise<SiteInfoResponse> | null = null;
|
||||
|
||||
constructor(
|
||||
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
||||
@@ -27,139 +21,53 @@ 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 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) {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const staleResult = await this.getCached<SiteInfoResponse>(STALE_CACHE_KEY, 60 * 60 * 1000);
|
||||
if (staleResult != null) {
|
||||
void this.refreshSiteInfo(staleResult);
|
||||
return staleResult;
|
||||
}
|
||||
|
||||
return await this.refreshSiteInfo(null);
|
||||
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', this.blocksService.getFoundBlocks(), staleInfo?.blockData ?? []),
|
||||
withInfoTimeout('high scores', this.addressSettingsService.getHighScores(), staleInfo?.highScores ?? []),
|
||||
withInfoTimeout('SV2 authority', this.stratumV2Service.getPoolAuthorityPublicKey(), {
|
||||
publicKey: staleInfo?.sv2?.poolAuthorityPublicKey ?? '',
|
||||
configured: staleInfo?.sv2?.authorityKeyConfigured ?? false
|
||||
}),
|
||||
withInfoTimeout<UserAgentReportView[]>('user agent report', this.userAgentReportService.getReport(), staleInfo?.userAgents ?? []),
|
||||
]);
|
||||
|
||||
const other: {
|
||||
count: number,
|
||||
bestDifficulty: number,
|
||||
totalHashRate: number;
|
||||
} = {
|
||||
count: 0,
|
||||
bestDifficulty: 0,
|
||||
totalHashRate: 0
|
||||
};
|
||||
const userAgents: UserAgentReportView[] = userAgentReport.reduce((pre, cur, idx, arr) => {
|
||||
// If less than 10Th/s and less than 100 devices, add to 'other'
|
||||
if (parseInt(cur.totalHashRate) < 10000000000000 && parseInt(cur.count) < 200) {
|
||||
other.totalHashRate += parseFloat(cur.totalHashRate);
|
||||
other.count += parseInt(cur.count);
|
||||
if (other.bestDifficulty < cur.bestDifficulty) {
|
||||
other.bestDifficulty = cur.bestDifficulty;
|
||||
}
|
||||
} else {
|
||||
pre.push(cur);
|
||||
}
|
||||
return pre;
|
||||
}, []);
|
||||
|
||||
if (other.count > 0) {
|
||||
userAgents.push({ userAgent: 'Other', count: other.count.toString(), bestDifficulty: other.bestDifficulty, totalHashRate: other.totalHashRate.toString() })
|
||||
}
|
||||
|
||||
const data: SiteInfoResponse = {
|
||||
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 CACHE_KEY = 'SITE_ACCOUNTING';
|
||||
const cachedResult = await this.getCached(CACHE_KEY, 15 * 1000);
|
||||
|
||||
if (cachedResult != null) {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const data = await this.shareAccountingService.getPoolSummary();
|
||||
|
||||
//15 sec
|
||||
await this.setCached(CACHE_KEY, data, 15 * 1000);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@Get('pool')
|
||||
public async pool() {
|
||||
|
||||
const CACHE_KEY = 'POOL_INFO';
|
||||
const cachedResult = await this.getCached(CACHE_KEY, 15 * 1000);
|
||||
const cachedResult = await this.cacheManager.get(CACHE_KEY);
|
||||
|
||||
if (cachedResult != null) {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const userAgents = await this.userAgentReportService.getReport();
|
||||
|
||||
const userAgents = await this.clientService.getUserAgents();
|
||||
const totalHashRate = userAgents.reduce((acc, userAgent) => acc + parseFloat(userAgent.totalHashRate), 0);
|
||||
const totalMiners = userAgents.reduce((acc, userAgent) => acc + parseFloat(userAgent.count), 0);
|
||||
const blockHeight = this.bitcoinRpcService.miningInfo.blocks;
|
||||
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 = {
|
||||
@@ -170,21 +78,24 @@ 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);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@Get('network')
|
||||
public async network() {
|
||||
return this.bitcoinRpcService.miningInfo ?? {};
|
||||
const miningInfo = await firstValueFrom(this.bitcoinRpcService.newBlock$);
|
||||
return miningInfo;
|
||||
}
|
||||
|
||||
@Get('info/chart')
|
||||
public async infoChart() {
|
||||
|
||||
|
||||
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) {
|
||||
return cachedResult;
|
||||
@@ -193,88 +104,11 @@ export class AppController {
|
||||
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);
|
||||
|
||||
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
-42
@@ -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,34 +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 { PayoutSnapshotModule } from './ORM/payout-snapshot/payout-snapshot.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 { CustomWorkService } from './services/custom-work.service';
|
||||
import { DatumService } from './services/datum.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 { Sv2JobDeclarationRegistryService } from './services/sv2-job-declaration-registry.service';
|
||||
import { Sv2JobDeclarationService } from './services/sv2-job-declaration.service';
|
||||
import { Sv2TemplateDistributionService } from './services/sv2-template-distribution.service';
|
||||
import { StratumV2Service } from './services/stratum-v2.service';
|
||||
import { TelegramService } from './services/telegram.service';
|
||||
import { TemplateProviderService } from './services/template-provider.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,
|
||||
@@ -45,40 +35,32 @@ const ORMModules = [
|
||||
TelegramSubscriptionsModule,
|
||||
BlocksModule,
|
||||
RpcBlocksModule,
|
||||
UserAgentReportModule,
|
||||
ShareAccountingModule,
|
||||
PayoutSnapshotModule
|
||||
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,
|
||||
@@ -89,15 +71,9 @@ const ORMModules = [
|
||||
NotificationService,
|
||||
BitcoinAddressValidator,
|
||||
StratumV1JobsService,
|
||||
StratumV2Service,
|
||||
TemplateProviderService,
|
||||
Sv2JobDeclarationRegistryService,
|
||||
Sv2JobDeclarationService,
|
||||
Sv2TemplateDistributionService,
|
||||
CustomWorkService,
|
||||
DatumService,
|
||||
BTCPayService,
|
||||
BraiinsService
|
||||
BraiinsService,
|
||||
ExternalSharesService,
|
||||
],
|
||||
})
|
||||
export class AppModule {
|
||||
|
||||
@@ -1,103 +1,37 @@
|
||||
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;
|
||||
let addressSettingsService: { getSettings: jest.Mock };
|
||||
let shareAccountingService: {
|
||||
getAddressSummary: jest.Mock;
|
||||
getWorkerGroupSummary: jest.Mock;
|
||||
getSessionSummary: jest.Mock;
|
||||
getSessionSummaries: jest.Mock;
|
||||
};
|
||||
let redisMessagingService: { getClientPresenceByAddress: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
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);
|
||||
addressSettingsService = module.get(AddressSettingsService);
|
||||
shareAccountingService = module.get(ShareAccountingService);
|
||||
redisMessagingService = module.get(RedisMessagingService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('should expose the existing address best difficulty in accounting when rollup best is empty', async () => {
|
||||
redisMessagingService.getClientPresenceByAddress.mockResolvedValue([
|
||||
{
|
||||
clientId: '92f5302f-5e32-487e-af67-f56fd78b13c7',
|
||||
sessionId: 'abcd1234',
|
||||
clientName: 'worker',
|
||||
bestDifficulty: 64,
|
||||
hashRate: 1024,
|
||||
startTime: '2026-06-08T12:00:00.000Z',
|
||||
lastSeen: '2026-06-08T12:10:00.000Z',
|
||||
},
|
||||
]);
|
||||
addressSettingsService.getSettings.mockResolvedValue({ bestDifficulty: 4096 });
|
||||
shareAccountingService.getSessionSummaries.mockResolvedValue(new Map());
|
||||
shareAccountingService.getAddressSummary.mockResolvedValue({
|
||||
totalAcceptedShares: 10,
|
||||
totalCreditedDifficulty: 100,
|
||||
bestSubmissionDifficulty: 0,
|
||||
});
|
||||
|
||||
await expect(controller.getClientInfo('bc1qtest')).resolves.toEqual(expect.objectContaining({
|
||||
bestDifficulty: 4096,
|
||||
accounting: expect.objectContaining({
|
||||
bestSubmissionDifficulty: 4096,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +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';
|
||||
|
||||
|
||||
@Controller('client')
|
||||
@@ -13,63 +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 workers = await this.redisMessagingService.getClientPresenceByAddress(address);
|
||||
const sessionSummaries = await this.shareAccountingService.getSessionSummaries(workers.map(worker => worker.clientId));
|
||||
|
||||
const addressSettings = process.env.API_ONLY === 'true'
|
||||
? null
|
||||
: await this.addressSettingsService.getSettings(address, false);
|
||||
const bestDifficulty = addressSettings?.bestDifficulty ?? workers.reduce((best, worker) => {
|
||||
return Math.max(best, Number(worker.bestDifficulty ?? 0));
|
||||
}, 0);
|
||||
const accounting = this.withBestSubmissionDifficulty(
|
||||
await this.shareAccountingService.getAddressSummary(address),
|
||||
bestDifficulty,
|
||||
);
|
||||
const workers = await this.clientService.getByAddress(address);
|
||||
|
||||
const response = {
|
||||
bestDifficulty,
|
||||
const addressSettings = await this.addressSettingsService.getSettings(address, false);
|
||||
|
||||
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
|
||||
};
|
||||
})
|
||||
)
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@Get(':address/chart')
|
||||
async getClientInfoChart(@Param('address') address: string) {
|
||||
return await this.clientStatisticsService.getChartDataForAddress(address);
|
||||
const chartData = await this.clientStatisticsService.getChartDataForAddress(address);
|
||||
return chartData;
|
||||
}
|
||||
|
||||
@Get(':address/:workerName')
|
||||
async getWorkerGroupInfo(@Param('address') address: string, @Param('workerName') workerName: string) {
|
||||
const addressWorkers = await this.redisMessagingService.getClientPresenceByAddress(address);
|
||||
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) {
|
||||
@@ -79,69 +59,30 @@ export class ClientController {
|
||||
}, 0);
|
||||
|
||||
const chartData = await this.clientStatisticsService.getChartDataForGroup(address, workerName);
|
||||
const accounting = this.withBestSubmissionDifficulty(
|
||||
await this.shareAccountingService.getWorkerGroupSummary(address, workerName),
|
||||
bestDifficulty,
|
||||
);
|
||||
const response = {
|
||||
return {
|
||||
|
||||
name: workerName,
|
||||
bestDifficulty: Math.floor(bestDifficulty),
|
||||
accounting,
|
||||
chartData: chartData,
|
||||
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@Get(':address/:workerName/:sessionId')
|
||||
async getWorkerInfo(@Param('address') address: string, @Param('workerName') workerName: string, @Param('sessionId') sessionId: string) {
|
||||
const addressWorkers = await this.redisMessagingService.getClientPresenceByAddress(address);
|
||||
const presenceWorker = addressWorkers
|
||||
.find(worker => worker.clientName === workerName && worker.sessionId === sessionId);
|
||||
const worker = presenceWorker == null
|
||||
? await this.clientService.getBySessionId(address, workerName, sessionId)
|
||||
: {
|
||||
id: presenceWorker.clientId,
|
||||
sessionId: presenceWorker.sessionId,
|
||||
clientName: presenceWorker.clientName,
|
||||
bestDifficulty: presenceWorker.bestDifficulty,
|
||||
startTime: presenceWorker.startTime,
|
||||
};
|
||||
|
||||
const worker = await this.clientService.getBySessionId(address, workerName, sessionId);
|
||||
if (worker == null) {
|
||||
return new NotFoundException();
|
||||
}
|
||||
const chartData = await this.clientStatisticsService.getChartDataForSession(worker.id);
|
||||
const accounting = this.withBestSubmissionDifficulty(
|
||||
await this.shareAccountingService.getSessionSummary(worker.id),
|
||||
worker.bestDifficulty,
|
||||
);
|
||||
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
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private withBestSubmissionDifficulty<T extends { bestSubmissionDifficulty?: number }>(
|
||||
accounting: T,
|
||||
fallbackBestDifficulty: unknown,
|
||||
): T {
|
||||
const existing = Number(accounting?.bestSubmissionDifficulty ?? 0);
|
||||
const fallback = Number(fallbackBestDifficulty ?? 0);
|
||||
|
||||
if (!Number.isFinite(fallback) || fallback <= existing) {
|
||||
return accounting;
|
||||
}
|
||||
|
||||
return {
|
||||
...accounting,
|
||||
bestSubmissionDifficulty: fallback,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,77 +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 { CurrentRoundWorkRollup1780899000000 } from './ORM/_migrations/CurrentRoundWorkRollup1780899000000';
|
||||
import { ShareRollupBatches1780962600000 } from './ORM/_migrations/ShareRollupBatches1780962600000';
|
||||
import { AcceptedShareRetentionCompression1780966200000 } from './ORM/_migrations/AcceptedShareRetentionCompression1780966200000';
|
||||
import { PayoutSnapshots1780969800000 } from './ORM/_migrations/PayoutSnapshots1780969800000';
|
||||
import { AcceptedShareProtocolMetadata1781130600000 } from './ORM/_migrations/AcceptedShareProtocolMetadata1781130600000';
|
||||
import { BlocksSubmissionMetadata1781220000000 } from './ORM/_migrations/BlocksSubmissionMetadata1781220000000';
|
||||
import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
|
||||
import { AcceptedShareEntity } from './ORM/accepted-share/accepted-share.entity';
|
||||
import { AddressSettingsEntity } from './ORM/address-settings/address-settings.entity';
|
||||
import { BlocksEntity } from './ORM/blocks/blocks.entity';
|
||||
import { ClientEntity } from './ORM/client/client.entity';
|
||||
import { PayoutBalanceEntity } from './ORM/payout-snapshot/payout-balance.entity';
|
||||
import { PayoutHistoryEntity } from './ORM/payout-snapshot/payout-history.entity';
|
||||
import { PayoutSnapshotEntity } from './ORM/payout-snapshot/payout-snapshot.entity';
|
||||
import { PayoutSnapshotEntryEntity } from './ORM/payout-snapshot/payout-snapshot-entry.entity';
|
||||
import { RpcBlockEntity } from './ORM/rpc-block/rpc-block.entity';
|
||||
import { TelegramSubscriptionsEntity } from './ORM/telegram-subscriptions/telegram-subscriptions.entity';
|
||||
|
||||
export const databaseEntities = [
|
||||
ClientEntity,
|
||||
AddressSettingsEntity,
|
||||
BlocksEntity,
|
||||
RpcBlockEntity,
|
||||
TelegramSubscriptionsEntity,
|
||||
UserAgentReportView,
|
||||
AcceptedShareEntity,
|
||||
PayoutBalanceEntity,
|
||||
PayoutHistoryEntity,
|
||||
PayoutSnapshotEntity,
|
||||
PayoutSnapshotEntryEntity,
|
||||
];
|
||||
|
||||
export const databaseMigrations = [
|
||||
InitialTimescaleSchema1780859300000,
|
||||
ActiveOnlyUserAgentReport1780860200000,
|
||||
TimescaleOperationalHardening1780861200000,
|
||||
AcceptedShareIndex1780862400000,
|
||||
AcceptedShareRollupIndexes1780865400000,
|
||||
PoolAccountingDashboardIndexes1780867200000,
|
||||
CurrentRoundBestShareIndex1780897600000,
|
||||
CurrentRoundWorkRollup1780899000000,
|
||||
ShareRollupBatches1780962600000,
|
||||
AcceptedShareRetentionCompression1780966200000,
|
||||
PayoutSnapshots1780969800000,
|
||||
AcceptedShareProtocolMetadata1781130600000,
|
||||
BlocksSubmissionMetadata1781220000000,
|
||||
];
|
||||
|
||||
export function createDatabaseOptions(env: NodeJS.ProcessEnv): TypeOrmModuleOptions & DataSourceOptions {
|
||||
return {
|
||||
type: 'postgres',
|
||||
host: env.DB_HOST,
|
||||
port: parseInt(env.DB_PORT ?? '5432', 10),
|
||||
username: env.DB_USERNAME,
|
||||
password: env.DB_PASSWORD,
|
||||
database: env.DB_DATABASE,
|
||||
entities: databaseEntities,
|
||||
synchronize: false,
|
||||
logging: env.DB_LOGGING === 'true',
|
||||
poolSize: parseInt(env.DB_POOL_SIZE ?? '10', 10),
|
||||
ssl: env.DB_SSL === 'true'
|
||||
? { rejectUnauthorized: env.DB_SSL_REJECT_UNAUTHORIZED !== 'false' }
|
||||
: undefined,
|
||||
migrations: databaseMigrations,
|
||||
migrationsTransactionMode: 'none',
|
||||
};
|
||||
}
|
||||
+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;
|
||||
}
|
||||
+273
-125
@@ -1,147 +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
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should split coinbase around 12 bytes of extranonce space', () => {
|
||||
const notify = JSON.parse(job.response(jobTemplate));
|
||||
const coinbasePart1 = notify.params[2];
|
||||
const coinbasePart2 = notify.params[3];
|
||||
const extraNonce1 = '57a6f098';
|
||||
const extraNonce2 = 'c708000000000000';
|
||||
const coinbase = bitcoinjs.Transaction.fromHex(`${coinbasePart1}${extraNonce1}${extraNonce2}${coinbasePart2}`);
|
||||
|
||||
expect(Buffer.byteLength(extraNonce1 + extraNonce2, 'hex')).toBe(12);
|
||||
expect(coinbase.ins[0].script.toString('hex')).toContain(`${extraNonce1}${extraNonce2}`);
|
||||
expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose coinbase prefix and suffix buffers for SV2 extended jobs', () => {
|
||||
const notify = JSON.parse(job.response(jobTemplate));
|
||||
const extraNonce1 = '57a6f098';
|
||||
const extraNonce2 = 'c708000000000000';
|
||||
const fromNotify = Buffer.from(`${notify.params[2]}${extraNonce1}${extraNonce2}${notify.params[3]}`, 'hex');
|
||||
const fromBuffers = Buffer.concat([
|
||||
job.getCoinbasePrefixBuffer(),
|
||||
Buffer.from(`${extraNonce1}${extraNonce2}`, 'hex'),
|
||||
job.getCoinbaseSuffixBuffer(),
|
||||
]);
|
||||
|
||||
expect(fromBuffers).toEqual(fromNotify);
|
||||
expect(bitcoinjs.Transaction.fromBuffer(fromBuffers).ins[0].script.toString('hex'))
|
||||
.toContain(`${extraNonce1}${extraNonce2}`);
|
||||
});
|
||||
|
||||
it('should support exact satoshi payout outputs', () => {
|
||||
const firstAmount = Math.floor(jobTemplate.blockData.coinbasevalue / 3);
|
||||
const secondAmount = jobTemplate.blockData.coinbasevalue - firstAmount;
|
||||
const exactPayoutJob = new MiningJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
'2',
|
||||
[
|
||||
{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', amountSats: firstAmount },
|
||||
{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', amountSats: secondAmount },
|
||||
beforeAll(async () => {
|
||||
moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: jest.fn(() => null)
|
||||
}
|
||||
}
|
||||
],
|
||||
jobTemplate
|
||||
);
|
||||
const coinbase = exactPayoutJob.cloneCoinbaseTransaction();
|
||||
|
||||
expect(coinbase.outs[0].value).toBe(firstAmount);
|
||||
expect(coinbase.outs[1].value).toBe(secondAmount);
|
||||
expect(coinbase.outs[2].value).toBe(0);
|
||||
}).compile();
|
||||
configService = moduleRef.get<ConfigService>(ConfigService);
|
||||
});
|
||||
|
||||
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);
|
||||
describe('constructor', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
const updatedBlock = job.copyAndUpdateBlock(
|
||||
jobTemplate,
|
||||
parseInt('00002000', 16),
|
||||
parseInt('ed460d91', 16),
|
||||
extraNonce1,
|
||||
extraNonce2,
|
||||
timestamp
|
||||
);
|
||||
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 = [];
|
||||
|
||||
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 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 leave block version unchanged without a version mask', () => {
|
||||
const updatedBlock = job.copyAndUpdateBlock(
|
||||
jobTemplate,
|
||||
0,
|
||||
parseInt('ed460d91', 16),
|
||||
'57a6f098',
|
||||
'c708000000000000',
|
||||
parseInt(MockRecording1.TIME, 16)
|
||||
);
|
||||
describe('block updates', () => {
|
||||
let job: MiningJob;
|
||||
|
||||
expect(updatedBlock.version).toBe(jobTemplate.block.version);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
|
||||
configService.get = jest.fn(() => null);
|
||||
|
||||
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 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);
|
||||
|
||||
const updatedBlock = job.copyAndUpdateBlock(
|
||||
jobTemplate,
|
||||
versionMask,
|
||||
nonce,
|
||||
extraNonce1,
|
||||
extraNonce2,
|
||||
timestamp
|
||||
);
|
||||
const fastHeader = job.buildHeaderBuffer(
|
||||
jobTemplate,
|
||||
versionMask,
|
||||
nonce,
|
||||
extraNonce1,
|
||||
extraNonce2,
|
||||
timestamp
|
||||
);
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
expect(fastHeader.equals(updatedBlock.toBuffer(true))).toBe(true);
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should split coinbase around 12 bytes of extranonce space', () => {
|
||||
const notify = JSON.parse(job.response(jobTemplate));
|
||||
const coinbasePart1 = notify.params[2];
|
||||
const coinbasePart2 = notify.params[3];
|
||||
const extraNonce1 = '57a6f098';
|
||||
const extraNonce2 = 'c708000000000000';
|
||||
const coinbase = bitcoinjs.Transaction.fromHex(`${coinbasePart1}${extraNonce1}${extraNonce2}${coinbasePart2}`);
|
||||
|
||||
expect(Buffer.byteLength(extraNonce1 + extraNonce2, 'hex')).toBe(12);
|
||||
expect(coinbase.ins[0].script.toString('hex')).toContain(`${extraNonce1}${extraNonce2}`);
|
||||
expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should update block nonce, timestamp, version mask, and coinbase script', () => {
|
||||
const extraNonce1 = '57a6f098';
|
||||
const extraNonce2 = 'c708000000000000';
|
||||
const timestamp = parseInt(MockRecording1.TIME, 16);
|
||||
const originalMerkleRoot = Buffer.from(jobTemplate.block.merkleRoot);
|
||||
|
||||
const updatedBlock = job.copyAndUpdateBlock(
|
||||
jobTemplate,
|
||||
parseInt('00002000', 16),
|
||||
parseInt('ed460d91', 16),
|
||||
extraNonce1,
|
||||
extraNonce2,
|
||||
timestamp
|
||||
);
|
||||
|
||||
expect(updatedBlock.nonce).toBe(parseInt('ed460d91', 16));
|
||||
expect(updatedBlock.timestamp).toBe(timestamp);
|
||||
expect(updatedBlock.version).toBe(jobTemplate.block.version ^ parseInt('00002000', 16));
|
||||
expect(updatedBlock.transactions[0].ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
|
||||
expect(updatedBlock.merkleRoot.equals(originalMerkleRoot)).toBe(false);
|
||||
});
|
||||
|
||||
it('should leave block version unchanged without a version mask', () => {
|
||||
const updatedBlock = job.copyAndUpdateBlock(
|
||||
jobTemplate,
|
||||
0,
|
||||
parseInt('ed460d91', 16),
|
||||
'57a6f098',
|
||||
'c708000000000000',
|
||||
parseInt(MockRecording1.TIME, 16)
|
||||
);
|
||||
|
||||
expect(updatedBlock.version).toBe(jobTemplate.block.version);
|
||||
});
|
||||
|
||||
it('should build the same header as the full block update path', () => {
|
||||
const versionMask = parseInt('00002000', 16);
|
||||
const nonce = parseInt('ed460d91', 16);
|
||||
const extraNonce1 = '57a6f098';
|
||||
const extraNonce2 = 'c708000000000000';
|
||||
const timestamp = parseInt(MockRecording1.TIME, 16);
|
||||
|
||||
const updatedBlock = job.copyAndUpdateBlock(
|
||||
jobTemplate,
|
||||
versionMask,
|
||||
nonce,
|
||||
extraNonce1,
|
||||
extraNonce2,
|
||||
timestamp
|
||||
);
|
||||
const fastHeader = job.buildHeaderBuffer(
|
||||
jobTemplate,
|
||||
versionMask,
|
||||
nonce,
|
||||
extraNonce1,
|
||||
extraNonce2,
|
||||
timestamp
|
||||
);
|
||||
|
||||
expect(fastHeader.equals(updatedBlock.toBuffer(true))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+28
-22
@@ -2,16 +2,16 @@ import { AddressType, getAddressInfo } from 'bitcoin-address-validation';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
|
||||
import { IJobTemplate } from '../services/stratum-v1-jobs.service';
|
||||
import { hash256 } from '../utils/hash.utils';
|
||||
import { eResponseMethod } from './enums/eResponseMethod';
|
||||
import { IMiningNotify } from './stratum-messages/IMiningNotify';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants';
|
||||
|
||||
|
||||
export interface AddressObject {
|
||||
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;
|
||||
amountSats?: number;
|
||||
percent: number;
|
||||
}
|
||||
export class MiningJob {
|
||||
|
||||
@@ -27,6 +27,7 @@ export class MiningJob {
|
||||
public creation: number;
|
||||
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private network: bitcoinjs.networks.Network,
|
||||
public jobId: string,
|
||||
payoutInformation: AddressObject[],
|
||||
@@ -47,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
|
||||
@@ -56,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');
|
||||
@@ -84,21 +100,13 @@ 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,
|
||||
Buffer.from(`${extraNonce}${extraNonce2}`, 'hex'),
|
||||
this.coinbasePart2Buffer,
|
||||
]);
|
||||
const coinbaseHash = hash256(coinbaseBuffer);
|
||||
const coinbaseHash = bitcoinjs.crypto.hash256(coinbaseBuffer);
|
||||
const merkleRoot = this.calculateMerkleRootHash(coinbaseHash, this.merkleBranchBuffers);
|
||||
|
||||
let version = jobTemplate.block.version;
|
||||
@@ -156,7 +164,7 @@ export class MiningJob {
|
||||
|
||||
for (let i = 0; i < merkleBranches.length; i++) {
|
||||
bothMerkles.set(merkleBranches[i], 32);
|
||||
newRoot = hash256(bothMerkles);
|
||||
newRoot = bitcoinjs.crypto.hash256(bothMerkles);
|
||||
bothMerkles.set(newRoot);
|
||||
}
|
||||
|
||||
@@ -178,9 +186,7 @@ export class MiningJob {
|
||||
let rewardBalance = reward;
|
||||
|
||||
addresses.forEach(recipientAddress => {
|
||||
const amount = recipientAddress.amountSats == null
|
||||
? Math.floor(((recipientAddress.percent ?? 0) / 100) * reward)
|
||||
: recipientAddress.amountSats;
|
||||
const amount = Math.floor((recipientAddress.percent / 100) * reward);
|
||||
rewardBalance -= amount;
|
||||
coinbaseTransaction.addOutput(this.getPaymentScript(recipientAddress.address), amount);
|
||||
})
|
||||
|
||||
+110
-161
@@ -1,16 +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 { DifficultyUtils } from '../utils/difficulty.utils';
|
||||
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';
|
||||
@@ -36,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;
|
||||
|
||||
@@ -51,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 () => {
|
||||
|
||||
@@ -61,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);
|
||||
@@ -118,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)
|
||||
};
|
||||
|
||||
|
||||
@@ -143,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;
|
||||
@@ -169,36 +200,6 @@ describe('StratumV1Client', () => {
|
||||
expect(socket.on).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clean up socket state only once when destroyed repeatedly', async () => {
|
||||
const unsubscribe = jest.fn();
|
||||
const timer = setInterval(() => undefined, 1000);
|
||||
const removeListenerSpy = jest.spyOn(socket, 'removeListener');
|
||||
|
||||
(client as any).clientEntity = {
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
address: 'tb1qcleanup',
|
||||
};
|
||||
(client as any).stratumSubscription = { unsubscribe };
|
||||
(client as any).backgroundWork = [timer];
|
||||
(client as any).miningSubmissionHashes.add('submitted-share');
|
||||
(client as any).buffer = 'partial-message';
|
||||
|
||||
await Promise.all([client.destroy(), client.destroy()]);
|
||||
|
||||
expect(redisMessagingService.removeClientPresence).toHaveBeenCalledTimes(1);
|
||||
expect(redisMessagingService.removeClientPresence).toHaveBeenCalledWith(
|
||||
'00000000-0000-4000-8000-000000000001',
|
||||
'tb1qcleanup',
|
||||
);
|
||||
expect(clientService.delete).toHaveBeenCalledTimes(1);
|
||||
expect(clientService.delete).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000001');
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(removeListenerSpy).toHaveBeenCalledWith('data', expect.any(Function));
|
||||
expect((client as any).backgroundWork).toEqual([]);
|
||||
expect((client as any).miningSubmissionHashes.size).toBe(0);
|
||||
expect((client as any).buffer).toBe('');
|
||||
});
|
||||
|
||||
it('should close socket on invalid JSON', () => {
|
||||
emitMessage('INVALID');
|
||||
jest.spyOn(socket, 'destroy');
|
||||
@@ -217,17 +218,6 @@ describe('StratumV1Client', () => {
|
||||
|
||||
});
|
||||
|
||||
it('should disable application idle timeout after Stratum initialization', async () => {
|
||||
const setTimeoutSpy = jest.spyOn(socket, 'setTimeout').mockImplementation(() => socket);
|
||||
jest.spyOn(client as any, 'write').mockImplementation(() => Promise.resolve(true));
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('should block non-compliant user agents on subscribe without allocating a session', async () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string) => {
|
||||
switch (key) {
|
||||
@@ -280,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`));
|
||||
@@ -324,26 +316,6 @@ describe('StratumV1Client', () => {
|
||||
expect(socket.write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[512]}\n`, expect.any(Function));
|
||||
});
|
||||
|
||||
it('should clamp suggested difficulty to the configured minimum', async () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string) => {
|
||||
switch (key) {
|
||||
case 'STRATUM_MIN_DIFFICULTY':
|
||||
return '1';
|
||||
case 'DEV_FEE_ADDRESS':
|
||||
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
|
||||
case 'NETWORK':
|
||||
return 'testnet';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
jest.spyOn(socket, 'write').mockImplementation((data) => true);
|
||||
|
||||
emitMessage(`{"id":4,"method":"mining.suggest_difficulty","params":[0]}`);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
expect(socket.write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[1]}\n`, expect.any(Function));
|
||||
});
|
||||
|
||||
it('should set difficulty', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
|
||||
@@ -364,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);
|
||||
@@ -393,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);
|
||||
@@ -405,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,
|
||||
}));
|
||||
|
||||
|
||||
});
|
||||
@@ -435,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));
|
||||
|
||||
@@ -472,9 +439,9 @@ describe('StratumV1Client', () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
|
||||
submissionDifficulty: 1024,
|
||||
submissionHash: 'share',
|
||||
hashBuffer: DifficultyUtils.difficultyToTarget(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 });
|
||||
@@ -485,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));
|
||||
|
||||
@@ -497,27 +466,6 @@ describe('StratumV1Client', () => {
|
||||
expect(getSettingsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject shares by exact target even when reported difficulty is huge', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
|
||||
submissionDifficulty: Number.MAX_SAFE_INTEGER,
|
||||
submissionHash: 'too-easy',
|
||||
hashBuffer: Buffer.alloc(32, 0xff),
|
||||
});
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1024]}`);
|
||||
emitMessage(MockRecording1.MINING_AUTHORIZE);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBMIT);
|
||||
jest.useRealTimers();
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[23,"Difficulty too low",""]}\n`);
|
||||
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject duplicate submissions', async () => {
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
|
||||
@@ -618,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);
|
||||
@@ -646,13 +596,12 @@ describe('StratumV1Client', () => {
|
||||
});
|
||||
|
||||
it('should submit and persist found blocks', async () => {
|
||||
(bitcoinRpcService.SUBMIT_BLOCK as jest.Mock).mockResolvedValue('SUCCESS!');
|
||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
|
||||
submissionDifficulty: Number.MAX_SAFE_INTEGER,
|
||||
submissionHash: 'block-share',
|
||||
hashBuffer: Buffer.alloc(32),
|
||||
submissionHash: 'block-share'
|
||||
});
|
||||
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
|
||||
jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined);
|
||||
|
||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||
|
||||
+104
-219
@@ -9,20 +9,16 @@ 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 { PayoutSnapshotService } from '../ORM/payout-snapshot/payout-snapshot.service';
|
||||
import { ShareAccountingService } from '../ORM/share-accounting/share-accounting.service';
|
||||
import { BitcoinRpcService } from '../services/bitcoin-rpc.service';
|
||||
import { NotificationService } from '../services/notification.service';
|
||||
import { RedisMessagingService } from '../services/redis-messaging.service';
|
||||
import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service';
|
||||
import { DifficultyUtils } from '../utils/difficulty.utils';
|
||||
import { hash256 } from '../utils/hash.utils';
|
||||
import { eRequestMethod } from './enums/eRequestMethod';
|
||||
import { eResponseMethod } from './enums/eResponseMethod';
|
||||
import { eStratumErrorCode } from './enums/eStratumErrorCode';
|
||||
import { AddressObject, MiningJob } from './MiningJob';
|
||||
import { MiningJob } from './MiningJob';
|
||||
import { AuthorizationMessage } from './stratum-messages/AuthorizationMessage';
|
||||
import { ConfigurationMessage } from './stratum-messages/ConfigurationMessage';
|
||||
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
|
||||
@@ -31,38 +27,35 @@ 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;
|
||||
const VALIDATION_ERROR_LOG_INTERVAL_MS = 60 * 1000;
|
||||
const DEFAULT_MIN_DIFFICULTY = 0.001;
|
||||
|
||||
export class StratumV1Client {
|
||||
private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>();
|
||||
private static validationErrorLogState = new Map<string, { nextLogAt: number, suppressed: number, sample: string }>();
|
||||
|
||||
public clientSubscription: SubscriptionMessage;
|
||||
private clientSubscription: SubscriptionMessage;
|
||||
private clientConfiguration: ConfigurationMessage;
|
||||
private clientAuthorization: AuthorizationMessage;
|
||||
private clientSuggestedDifficulty: SuggestDifficulty;
|
||||
private stratumSubscription: Subscription;
|
||||
private backgroundWork: NodeJS.Timeout[] = [];
|
||||
private readonly socketDataHandler: (data: Buffer) => void;
|
||||
private destroyPromise: Promise<void> | null = null;
|
||||
|
||||
private statistics: StratumV1ClientStatistics;
|
||||
private stratumInitialized = false;
|
||||
private usedSuggestedDifficulty = false;
|
||||
private sessionDifficulty: number = 100000;
|
||||
private sessionDifficultyTarget: Buffer = DifficultyUtils.difficultyToTarget(this.sessionDifficulty);
|
||||
|
||||
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;
|
||||
@@ -75,77 +68,50 @@ 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 payoutSnapshotService?: PayoutSnapshotService,
|
||||
private readonly accountingProtocol: 'sv1' | 'sv1_tls' = 'sv1',
|
||||
private readonly externalSharesService: ExternalSharesService
|
||||
) {
|
||||
|
||||
this.socketDataHandler = (data: Buffer) => {
|
||||
void this.handleSocketData(data);
|
||||
};
|
||||
this.socket.on('data', this.socketDataHandler);
|
||||
this.socket.on('data', (data: Buffer) => {
|
||||
this.buffer += data.toString();
|
||||
let lines = this.buffer.split('\n');
|
||||
this.buffer = lines.pop() || ''; // Save the last part of the data (incomplete line) to the buffer
|
||||
|
||||
(async () => {
|
||||
for (const m of lines.filter(l => l.length > 0)) {
|
||||
if (this.connectionClosed || this.socket.destroyed || this.socket.writableEnded) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await this.handleMessage(m);
|
||||
} catch (e) {
|
||||
await this.socket.end();
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
if (this.destroyPromise != null) {
|
||||
return this.destroyPromise;
|
||||
public async destroy() {
|
||||
|
||||
if (this.extraNonceAndSessionId) {
|
||||
await this.clientService.delete(this.extraNonceAndSessionId);
|
||||
}
|
||||
|
||||
this.destroyPromise = this.destroyInternal();
|
||||
return this.destroyPromise;
|
||||
}
|
||||
|
||||
private async destroyInternal(): Promise<void> {
|
||||
this.connectionClosed = true;
|
||||
this.socket.removeListener('data', this.socketDataHandler);
|
||||
this.buffer = '';
|
||||
|
||||
if (this.stratumSubscription != null) {
|
||||
this.stratumSubscription.unsubscribe();
|
||||
this.stratumSubscription = null;
|
||||
}
|
||||
|
||||
for (const work of this.backgroundWork) {
|
||||
this.backgroundWork.forEach(work => {
|
||||
clearInterval(work);
|
||||
}
|
||||
this.backgroundWork = [];
|
||||
this.miningSubmissionHashes.clear();
|
||||
|
||||
if (this.clientEntity?.id) {
|
||||
const clientId = this.clientEntity.id;
|
||||
const address = this.clientEntity.address;
|
||||
await this.redisMessagingService?.removeClientPresence(clientId, address);
|
||||
await this.clientService.delete(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSocketData(data: Buffer): Promise<void> {
|
||||
if (this.connectionClosed || this.socket.destroyed || this.socket.writableEnded) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.buffer += data.toString();
|
||||
const lines = this.buffer.split('\n');
|
||||
this.buffer = lines.pop() || ''; // Save the last part of the data (incomplete line) to the buffer
|
||||
|
||||
for (const m of lines.filter(l => l.length > 0)) {
|
||||
if (this.connectionClosed || this.socket.destroyed || this.socket.writableEnded) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await this.handleMessage(m);
|
||||
} catch (e) {
|
||||
await this.socket.end();
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getRandomHexString() {
|
||||
@@ -192,9 +158,9 @@ export class StratumV1Client {
|
||||
|
||||
if (this.sessionStart == null) {
|
||||
this.sessionStart = new Date();
|
||||
this.statistics = new StratumV1ClientStatistics(this.getMinimumDifficulty());
|
||||
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;
|
||||
@@ -241,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,
|
||||
@@ -274,19 +240,19 @@ export class StratumV1Client {
|
||||
this.clientAuthorization = authorizationMessage;
|
||||
if (this.clientSuggestedDifficulty == null && this.clientAuthorization.startingDiff != null && this.clientAuthorization.startingDiff > this.sessionDifficulty) {
|
||||
this.sessionDifficulty = this.clientAuthorization.startingDiff;
|
||||
this.sessionDifficultyTarget = DifficultyUtils.difficultyToTarget(this.sessionDifficulty);
|
||||
}
|
||||
const success = await this.write(JSON.stringify(this.clientAuthorization.response()) + '\n');
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.error('Authorization validation error');
|
||||
const err = new StratumErrorMessage(
|
||||
authorizationMessage.id,
|
||||
eStratumErrorCode.OtherUnknown,
|
||||
'Authorization validation error',
|
||||
errors).response();
|
||||
//console.log(err);
|
||||
console.error(err);
|
||||
const success = await this.write(err);
|
||||
if (!success) {
|
||||
return;
|
||||
@@ -315,8 +281,7 @@ export class StratumV1Client {
|
||||
if (errors.length === 0) {
|
||||
|
||||
this.clientSuggestedDifficulty = suggestDifficultyMessage;
|
||||
this.sessionDifficulty = this.clampDifficulty(suggestDifficultyMessage.suggestedDifficulty);
|
||||
this.sessionDifficultyTarget = DifficultyUtils.difficultyToTarget(this.sessionDifficulty);
|
||||
this.sessionDifficulty = suggestDifficultyMessage.suggestedDifficulty;
|
||||
const success = await this.write(JSON.stringify(this.clientSuggestedDifficulty.response(this.sessionDifficulty)) + '\n');
|
||||
if (!success) {
|
||||
return;
|
||||
@@ -340,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;
|
||||
}
|
||||
@@ -397,14 +362,13 @@ export class StratumV1Client {
|
||||
&& this.clientAuthorization != null
|
||||
&& this.stratumInitialized == false) {
|
||||
|
||||
this.initStratum();
|
||||
await this.initStratum();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private async initStratum() {
|
||||
this.stratumInitialized = true;
|
||||
this.socket.setTimeout(0);
|
||||
|
||||
if (this.isBlockedUserAgent(this.clientSubscription.userAgent)) {
|
||||
this.logBlockedUserAgent(this.clientSubscription.userAgent);
|
||||
@@ -412,6 +376,12 @@ export class StratumV1Client {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (this.clientSubscription.userAgent) {
|
||||
case 'cpuminer': {
|
||||
this.sessionDifficulty = 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.clientSuggestedDifficulty == null) {
|
||||
//console.log(`Setting difficulty to ${this.sessionDifficulty}`)
|
||||
const setDifficulty = JSON.stringify(new SuggestDifficulty().response(this.sessionDifficulty));
|
||||
@@ -439,37 +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 = this.getPayoutInformation(jobTemplate, this.clientAuthorization.address);
|
||||
// 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;
|
||||
@@ -485,6 +447,7 @@ export class StratumV1Client {
|
||||
}
|
||||
|
||||
const job = new MiningJob(
|
||||
this.configService,
|
||||
network,
|
||||
this.stratumV1JobsService.getNextId(),
|
||||
payoutInformation,
|
||||
@@ -507,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,
|
||||
@@ -521,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);
|
||||
@@ -567,7 +507,6 @@ export class StratumV1Client {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const jobTemplate = this.stratumV1JobsService.getJobTemplateById(job.jobTemplateId);
|
||||
|
||||
if (jobTemplate == null) {
|
||||
@@ -616,23 +555,18 @@ export class StratumV1Client {
|
||||
submission.extraNonce2,
|
||||
timestamp
|
||||
);
|
||||
const { submissionDifficulty, hashBuffer } = this.calculateDifficulty(header);
|
||||
const { submissionDifficulty } = this.calculateDifficulty(header);
|
||||
|
||||
//console.log(`DIFF: ${submissionDifficulty} of ${this.sessionDifficulty} from ${this.clientAuthorization.worker + '.' + this.extraNonceAndSessionId}`);
|
||||
|
||||
|
||||
if (DifficultyUtils.meetsTarget(hashBuffer, this.sessionDifficultyTarget)) {
|
||||
if (submissionDifficulty >= this.sessionDifficulty) {
|
||||
const success = await this.write(JSON.stringify(submission.response()) + '\n');
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let blockSubmissionResult: string = null;
|
||||
const isBlockCandidate = DifficultyUtils.meetsTarget(
|
||||
hashBuffer,
|
||||
DifficultyUtils.difficultyToTarget(jobTemplate.blockData.networkDifficulty),
|
||||
);
|
||||
if (isBlockCandidate) {
|
||||
if (submissionDifficulty >= jobTemplate.blockData.networkDifficulty) {
|
||||
console.log('!!! BLOCK FOUND !!!');
|
||||
const updatedJobBlock = job.copyAndUpdateBlock(
|
||||
jobTemplate,
|
||||
@@ -643,69 +577,54 @@ 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,
|
||||
worker: this.clientAuthorization.worker,
|
||||
sessionId: this.extraNonceAndSessionId,
|
||||
blockData: blockHex,
|
||||
blockSubmissionResult,
|
||||
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId ?? null,
|
||||
});
|
||||
await this.payoutSnapshotService?.finalizeSnapshotForBlock({
|
||||
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId,
|
||||
blockHeight: jobTemplate.blockData.height,
|
||||
blockSubmissionResult,
|
||||
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 (this.isSuccessfulBlockSubmission(blockSubmissionResult)) {
|
||||
if (result == null) {
|
||||
await this.addressSettingsService.resetBestDifficultyAndShares();
|
||||
}
|
||||
}
|
||||
await this.ensureClientEntity();
|
||||
try {
|
||||
await this.shareAccountingService?.recordAcceptedShare({
|
||||
protocol: this.accountingProtocol,
|
||||
address: this.clientAuthorization.address,
|
||||
clientName: this.clientAuthorization.worker,
|
||||
sessionId: this.extraNonceAndSessionId,
|
||||
clientId: this.clientEntity.id,
|
||||
jobId: job.jobId,
|
||||
jobTemplateId: job.jobTemplateId,
|
||||
blockHeight: jobTemplate.blockData.height,
|
||||
creditedDifficulty: this.sessionDifficulty,
|
||||
submissionDifficulty,
|
||||
networkDifficulty: jobTemplate.blockData.networkDifficulty,
|
||||
nonce: submission.nonce,
|
||||
ntime: submission.ntime,
|
||||
version: Number.isFinite(versionMask)
|
||||
? (jobTemplate.block.version ^ versionMask).toString(16)
|
||||
: jobTemplate.block.version.toString(16),
|
||||
extraNonce2: submission.extraNonce2,
|
||||
isBlockCandidate,
|
||||
blockSubmissionResult,
|
||||
});
|
||||
await this.statistics.addShares(this.clientEntity, this.sessionDifficulty);
|
||||
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(
|
||||
@@ -727,7 +646,7 @@ export class StratumV1Client {
|
||||
}
|
||||
|
||||
private async checkDifficulty() {
|
||||
const targetDiff = this.clampDifficulty(this.statistics.getSuggestedDifficulty(this.sessionDifficulty));
|
||||
const targetDiff = this.statistics.getSuggestedDifficulty(this.sessionDifficulty);
|
||||
if (targetDiff == null) {
|
||||
return;
|
||||
}
|
||||
@@ -735,7 +654,6 @@ export class StratumV1Client {
|
||||
if (targetDiff != this.sessionDifficulty) {
|
||||
//console.log(`Adjusting ${this.extraNonceAndSessionId} difficulty from ${this.sessionDifficulty} to ${targetDiff}`);
|
||||
this.sessionDifficulty = targetDiff;
|
||||
this.sessionDifficultyTarget = DifficultyUtils.difficultyToTarget(this.sessionDifficulty);
|
||||
|
||||
const data = JSON.stringify({
|
||||
id: null,
|
||||
@@ -766,13 +684,13 @@ export class StratumV1Client {
|
||||
}
|
||||
}
|
||||
|
||||
private calculateDifficulty(header: Buffer): { submissionDifficulty: number, submissionHash: string, hashBuffer: Buffer } {
|
||||
private calculateDifficulty(header: Buffer): { submissionDifficulty: number, submissionHash: string } {
|
||||
|
||||
const hashResult = hash256(header);
|
||||
const hashResult = bitcoinjs.crypto.hash256(header);
|
||||
|
||||
const target = this.le256todouble(hashResult);
|
||||
const submissionDifficulty = target === 0 ? Number.POSITIVE_INFINITY : TRUE_DIFF_ONE / target;
|
||||
return { submissionDifficulty, submissionHash: hashResult.toString('hex'), hashBuffer: hashResult };
|
||||
return { submissionDifficulty, submissionHash: hashResult.toString('hex') };
|
||||
}
|
||||
|
||||
|
||||
@@ -864,39 +782,6 @@ export class StratumV1Client {
|
||||
return ` sample=${values.join(',')}`;
|
||||
}
|
||||
|
||||
private clampDifficulty(difficulty: number | null): number | null {
|
||||
if (difficulty == null || !Number.isFinite(difficulty)) {
|
||||
return null;
|
||||
}
|
||||
const configuredMinimum = this.getConfiguredMinimumDifficulty();
|
||||
return configuredMinimum == null ? difficulty : Math.max(difficulty, configuredMinimum);
|
||||
}
|
||||
|
||||
private getMinimumDifficulty(): number {
|
||||
return this.getConfiguredMinimumDifficulty() ?? DEFAULT_MIN_DIFFICULTY;
|
||||
}
|
||||
|
||||
private getConfiguredMinimumDifficulty(): number | null {
|
||||
const configured = parseFloat(
|
||||
this.configService.get<string>('STRATUM_MIN_DIFFICULTY')
|
||||
?? process.env.STRATUM_MIN_DIFFICULTY
|
||||
?? '',
|
||||
);
|
||||
return Number.isFinite(configured) && configured > 0 ? configured : null;
|
||||
}
|
||||
|
||||
private getPayoutInformation(jobTemplate: IJobTemplate, fallbackAddress: string): AddressObject[] {
|
||||
if (this.configService.get('PAYOUT_COINBASE_MODE') === 'snapshot' && jobTemplate.blockData.payoutOutputs?.length > 0) {
|
||||
return jobTemplate.blockData.payoutOutputs;
|
||||
}
|
||||
|
||||
return [{ address: fallbackAddress, percent: 100 }];
|
||||
}
|
||||
|
||||
private isSuccessfulBlockSubmission(result?: string | null): boolean {
|
||||
return result == null || result === 'SUCCESS!';
|
||||
}
|
||||
|
||||
private closeSocket() {
|
||||
this.connectionClosed = true;
|
||||
if (!this.socket.destroyed) {
|
||||
@@ -920,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();
|
||||
@@ -934,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,13 +105,6 @@ describe('StratumV1ClientStatistics', () => {
|
||||
await statistics.addShares(client, 64);
|
||||
}
|
||||
|
||||
expect(statistics.getSuggestedDifficulty(128)).toBe(16);
|
||||
});
|
||||
|
||||
it('should not suggest a difficulty below the configured minimum', () => {
|
||||
statistics = new StratumV1ClientStatistics(1);
|
||||
jest.setSystemTime(new Date('2026-05-06T12:06:00Z'));
|
||||
|
||||
expect(statistics.getSuggestedDifficulty(1)).toBe(1);
|
||||
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 DEFAULT_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 readonly minDifficulty = DEFAULT_MIN_DIFF) {
|
||||
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)
|
||||
@@ -67,8 +148,8 @@ export class StratumV1ClientStatistics {
|
||||
if (val === 0) {
|
||||
return null;
|
||||
}
|
||||
if (val < this.minDifficulty) {
|
||||
return this.minDifficulty;
|
||||
if (val < MIN_DIFF) {
|
||||
return MIN_DIFF;
|
||||
}
|
||||
let x = val | (val >> 1);
|
||||
x = x | (x >> 2);
|
||||
@@ -77,8 +158,8 @@ export class StratumV1ClientStatistics {
|
||||
x = x | (x >> 16);
|
||||
x = x | (x >> 32);
|
||||
const res = x - (x >> 1);
|
||||
if (res == 0 && val * 100 < this.minDifficulty) {
|
||||
return this.minDifficulty;
|
||||
if (res == 0 && val * 100 < MIN_DIFF) {
|
||||
return MIN_DIFF;
|
||||
}
|
||||
if (res == 0) {
|
||||
return this.nearestPowerOfTwo(val * 100) / 100;
|
||||
@@ -86,4 +167,4 @@ export class StratumV1ClientStatistics {
|
||||
return res;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { Socket } from 'net';
|
||||
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
||||
|
||||
import { MockRecording1 } from '../../test/models/MockRecording1';
|
||||
import { ClientEntity } from '../ORM/client/client.entity';
|
||||
import { CustomWorkService } from '../services/custom-work.service';
|
||||
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
|
||||
import { BufferReader } from './sv2/sv2-binary-codec';
|
||||
import { SV2_CHANNEL_MSG_FLAG, Sv2MiningSetupFlags, Sv2MsgType, Sv2Protocol } from './sv2/sv2-constants';
|
||||
import {
|
||||
deserializeOpenExtendedMiningChannelSuccess,
|
||||
deserializeNewExtendedMiningJob,
|
||||
serializeOpenExtendedMiningChannel,
|
||||
serializeSubmitSharesExtended,
|
||||
} from './sv2/sv2-extended-messages';
|
||||
import { deserializeSetNewPrevHash, deserializeSubmitSharesError, serializeSetupConnection } from './sv2/sv2-messages';
|
||||
import {
|
||||
deserializeSetCustomMiningJobError,
|
||||
deserializeSetCustomMiningJobSuccess,
|
||||
serializeSetCustomMiningJob,
|
||||
} from './sv2/sv2-jdp-messages';
|
||||
import { MiningJob } from './MiningJob';
|
||||
import { StratumV2Client } from './StratumV2Client';
|
||||
|
||||
describe('StratumV2Client extended channels', () => {
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
let consoleErrorSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
|
||||
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('opens an extended channel and sends initial extended work', async () => {
|
||||
const { client, sentFrames } = await createClient();
|
||||
const maxTarget = Buffer.alloc(32, 0xff);
|
||||
|
||||
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
|
||||
requestId: 7,
|
||||
userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker',
|
||||
nominalHashRate: 0,
|
||||
maxTarget,
|
||||
minExtranonceSize: 8,
|
||||
}));
|
||||
|
||||
const successFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.OPEN_EXTENDED_MINING_CHANNEL_SUCCESS);
|
||||
const jobFrames = sentFrames.filter(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB);
|
||||
const jobFrame = jobFrames[jobFrames.length - 1];
|
||||
const prevHashFrames = sentFrames.filter(frame => frame.msgType === Sv2MsgType.SET_NEW_PREV_HASH);
|
||||
const prevHashFrame = prevHashFrames[prevHashFrames.length - 1];
|
||||
|
||||
expect(successFrame?.extensionType).toBe(0);
|
||||
const success = deserializeOpenExtendedMiningChannelSuccess(new BufferReader(successFrame.payload));
|
||||
expect(success.requestId).toBe(7);
|
||||
expect(success.channelId).toBe(1);
|
||||
expect(success.extranonceSize).toBe(10);
|
||||
expect(success.extranoncePrefix).toEqual(Buffer.from('00000001', 'hex'));
|
||||
|
||||
expect(jobFrame?.extensionType).toBe(SV2_CHANNEL_MSG_FLAG);
|
||||
const job = deserializeNewExtendedMiningJob(new BufferReader(jobFrame.payload));
|
||||
expect(job.channelId).toBe(1);
|
||||
expect(job.minNtime).toBeNull();
|
||||
expect(job.coinbasePrefix.length).toBeGreaterThan(0);
|
||||
expect(job.coinbaseSuffix.length).toBeGreaterThan(0);
|
||||
expect(job.merklePath.length).toBeGreaterThan(0);
|
||||
|
||||
expect(prevHashFrame?.extensionType).toBe(SV2_CHANNEL_MSG_FLAG);
|
||||
const prevHash = deserializeSetNewPrevHash(new BufferReader(prevHashFrame.payload));
|
||||
expect(prevHash.channelId).toBe(1);
|
||||
expect(prevHash.jobId).toBe(job.jobId);
|
||||
});
|
||||
|
||||
it('rejects extended shares with the wrong negotiated extranonce size', async () => {
|
||||
const { client, sentFrames } = await createClient();
|
||||
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
|
||||
requestId: 1,
|
||||
userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker',
|
||||
nominalHashRate: 0,
|
||||
maxTarget: Buffer.alloc(32, 0xff),
|
||||
minExtranonceSize: 8,
|
||||
}));
|
||||
const jobFrames = sentFrames.filter(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB);
|
||||
const jobFrame = jobFrames[jobFrames.length - 1];
|
||||
const job = deserializeNewExtendedMiningJob(new BufferReader(jobFrame.payload));
|
||||
sentFrames.length = 0;
|
||||
|
||||
await (client as any).handleSubmitSharesExtended(serializeSubmitSharesExtended({
|
||||
channelId: 1,
|
||||
sequenceNumber: 42,
|
||||
jobId: job.jobId,
|
||||
nonce: 0,
|
||||
ntime: parseInt(MockRecording1.TIME, 16),
|
||||
version: job.version,
|
||||
extranonce: Buffer.alloc(7),
|
||||
}));
|
||||
|
||||
const errorFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.SUBMIT_SHARES_ERROR);
|
||||
expect(errorFrame?.extensionType).toBe(SV2_CHANNEL_MSG_FLAG);
|
||||
const error = deserializeSubmitSharesError(new BufferReader(errorFrame.payload));
|
||||
expect(error.channelId).toBe(1);
|
||||
expect(error.sequenceNumber).toBe(42);
|
||||
expect(error.errorCode).toBe('invalid-extranonce-size');
|
||||
});
|
||||
|
||||
it('accepts SV2 work-selection setup and SetCustomMiningJob without sending pool work', async () => {
|
||||
const { client, sentFrames } = await createClient();
|
||||
await (client as any).handleSetupConnection(serializeSetupConnection({
|
||||
protocol: Sv2Protocol.MINING,
|
||||
minVersion: 2,
|
||||
maxVersion: 2,
|
||||
flags: Sv2MiningSetupFlags.REQUIRES_WORK_SELECTION | Sv2MiningSetupFlags.REQUIRES_VERSION_ROLLING,
|
||||
endpoint_host: 'localhost',
|
||||
endpoint_port: 3333,
|
||||
vendor: 'jd-client',
|
||||
hardwareVersion: '',
|
||||
firmwareVersion: '',
|
||||
deviceId: '',
|
||||
}));
|
||||
|
||||
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
|
||||
requestId: 1,
|
||||
userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker',
|
||||
nominalHashRate: 0,
|
||||
maxTarget: Buffer.alloc(32, 0xff),
|
||||
minExtranonceSize: 8,
|
||||
}));
|
||||
expect(sentFrames.some(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB)).toBe(false);
|
||||
|
||||
sentFrames.length = 0;
|
||||
await (client as any).handleSetCustomMiningJob(serializeSetCustomMiningJob({
|
||||
channelId: 1,
|
||||
requestId: 99,
|
||||
token: Buffer.from('aa', 'hex'),
|
||||
version: MockRecording1.BLOCK_TEMPLATE.version,
|
||||
prevHash: Buffer.alloc(32, 1),
|
||||
minNtime: parseInt(MockRecording1.TIME, 16),
|
||||
nBits: parseInt(MockRecording1.BLOCK_TEMPLATE.bits, 16),
|
||||
coinbaseTxVersion: 2,
|
||||
coinbasePrefix: Buffer.from('51', 'hex'),
|
||||
coinbaseTxInputNSequence: 0xffffffff,
|
||||
coinbaseTxOutputs: Buffer.from('010000000000000000016a', 'hex'),
|
||||
coinbaseTxLocktime: 0,
|
||||
merklePath: [],
|
||||
}));
|
||||
|
||||
const successFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.SET_CUSTOM_MINING_JOB_SUCCESS);
|
||||
const success = deserializeSetCustomMiningJobSuccess(new BufferReader(successFrame.payload));
|
||||
expect(success.channelId).toBe(1);
|
||||
expect(success.requestId).toBe(99);
|
||||
expect((client as any).channels.get(1).extendedJobs.get(success.jobId).workProtocol).toBe('sv2_jdp');
|
||||
});
|
||||
|
||||
it('rejects SetCustomMiningJob when work selection was not negotiated', async () => {
|
||||
const { client, sentFrames } = await createClient();
|
||||
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
|
||||
requestId: 1,
|
||||
userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker',
|
||||
nominalHashRate: 0,
|
||||
maxTarget: Buffer.alloc(32, 0xff),
|
||||
minExtranonceSize: 8,
|
||||
}));
|
||||
|
||||
sentFrames.length = 0;
|
||||
await (client as any).handleSetCustomMiningJob(serializeSetCustomMiningJob({
|
||||
channelId: 1,
|
||||
requestId: 100,
|
||||
token: Buffer.from('aa', 'hex'),
|
||||
version: MockRecording1.BLOCK_TEMPLATE.version,
|
||||
prevHash: Buffer.alloc(32, 1),
|
||||
minNtime: parseInt(MockRecording1.TIME, 16),
|
||||
nBits: parseInt(MockRecording1.BLOCK_TEMPLATE.bits, 16),
|
||||
coinbaseTxVersion: 2,
|
||||
coinbasePrefix: Buffer.from('51', 'hex'),
|
||||
coinbaseTxInputNSequence: 0xffffffff,
|
||||
coinbaseTxOutputs: Buffer.from('010000000000000000016a', 'hex'),
|
||||
coinbaseTxLocktime: 0,
|
||||
merklePath: [],
|
||||
}));
|
||||
|
||||
const errorFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR);
|
||||
const error = deserializeSetCustomMiningJobError(new BufferReader(errorFrame.payload));
|
||||
expect(error.channelId).toBe(1);
|
||||
expect(error.requestId).toBe(100);
|
||||
expect(error.errorCode).toBe('work-selection-not-negotiated');
|
||||
expect((client as any).channels.get(1).extendedJobs.has(100)).toBe(false);
|
||||
});
|
||||
|
||||
it('records accepted SV2 shares before presence updates', async () => {
|
||||
const { client, shareAccountingService, redisMessagingService, jobTemplate } = await createClient();
|
||||
(client as any).address = 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
|
||||
(client as any).workerName = 'worker';
|
||||
(client as any).sessionId = 'sv2-session';
|
||||
(client as any).userAgent = 'test/sv2';
|
||||
|
||||
await (client as any).recordAcceptedShare(
|
||||
2048,
|
||||
1024,
|
||||
jobTemplate,
|
||||
null,
|
||||
{
|
||||
jobId: '1',
|
||||
nonce: 123,
|
||||
ntime: parseInt(MockRecording1.TIME, 16),
|
||||
version: jobTemplate.block.version,
|
||||
extraNonce2: 'c708000000000000',
|
||||
},
|
||||
);
|
||||
|
||||
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
|
||||
protocol: 'sv2',
|
||||
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
||||
clientName: 'worker',
|
||||
sessionId: 'sv2-session',
|
||||
jobId: '1',
|
||||
creditedDifficulty: 1024,
|
||||
submissionDifficulty: 2048,
|
||||
nonce: 123,
|
||||
extraNonce2: 'c708000000000000',
|
||||
}));
|
||||
const accountingCallOrder = shareAccountingService.recordAcceptedShare.mock.invocationCallOrder[0];
|
||||
const postAccountingPresenceUpdates = redisMessagingService.setClientPresence.mock.invocationCallOrder
|
||||
.filter(callOrder => callOrder > accountingCallOrder);
|
||||
expect(postAccountingPresenceUpdates.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not submit a block when only the reported SV2 difficulty is huge', async () => {
|
||||
const { client, shareAccountingService, bitcoinRpcService, blocksService, jobTemplate } = await createClient();
|
||||
(client as any).address = 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
|
||||
(client as any).workerName = 'worker';
|
||||
(client as any).sessionId = 'sv2-session';
|
||||
(client as any).userAgent = 'test/sv2';
|
||||
|
||||
const job = new MiningJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
'1',
|
||||
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
|
||||
jobTemplate,
|
||||
);
|
||||
|
||||
await (client as any).handleAcceptedShare(
|
||||
{
|
||||
nonce: 123,
|
||||
ntime: parseInt(MockRecording1.TIME, 16),
|
||||
version: jobTemplate.block.version,
|
||||
},
|
||||
{ extranoncePrefix: Buffer.from(MockRecording1.EXTRA_NONCE, 'hex') },
|
||||
job,
|
||||
jobTemplate,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
1024,
|
||||
Buffer.alloc(32, 0xff),
|
||||
);
|
||||
|
||||
expect(bitcoinRpcService.SUBMIT_BLOCK).not.toHaveBeenCalled();
|
||||
expect(blocksService.save).not.toHaveBeenCalled();
|
||||
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
|
||||
submissionDifficulty: Number.MAX_SAFE_INTEGER,
|
||||
isBlockCandidate: false,
|
||||
}));
|
||||
});
|
||||
|
||||
it('submits a block when the SV2 hash target exactly meets network target', async () => {
|
||||
const { client, bitcoinRpcService, blocksService, notificationService, addressSettingsService, jobTemplate } = await createClient();
|
||||
bitcoinRpcService.SUBMIT_BLOCK.mockResolvedValue('SUCCESS!');
|
||||
(client as any).address = 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
|
||||
(client as any).workerName = 'worker';
|
||||
(client as any).sessionId = 'sv2-session';
|
||||
(client as any).userAgent = 'test/sv2';
|
||||
|
||||
const job = new MiningJob(
|
||||
bitcoinjs.networks.testnet,
|
||||
'1',
|
||||
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
|
||||
jobTemplate,
|
||||
);
|
||||
|
||||
await (client as any).handleAcceptedShare(
|
||||
{
|
||||
nonce: 123,
|
||||
ntime: parseInt(MockRecording1.TIME, 16),
|
||||
version: jobTemplate.block.version,
|
||||
},
|
||||
{ extranoncePrefix: Buffer.from(MockRecording1.EXTRA_NONCE, 'hex') },
|
||||
job,
|
||||
jobTemplate,
|
||||
1,
|
||||
1024,
|
||||
Buffer.alloc(32),
|
||||
);
|
||||
|
||||
expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledWith(expect.any(String));
|
||||
expect(blocksService.save).toHaveBeenCalledWith(expect.objectContaining({
|
||||
height: MockRecording1.BLOCK_TEMPLATE.height,
|
||||
minerAddress: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
||||
worker: 'worker',
|
||||
sessionId: 'sv2-session',
|
||||
blockData: expect.any(String),
|
||||
}));
|
||||
expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalled();
|
||||
expect(addressSettingsService.resetBestDifficultyAndShares).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
async function createClient(): Promise<{
|
||||
client: StratumV2Client;
|
||||
sentFrames: any[];
|
||||
bitcoinRpcService: { SUBMIT_BLOCK: jest.Mock };
|
||||
blocksService: { save: jest.Mock };
|
||||
notificationService: { notifySubscribersBlockFound: jest.Mock };
|
||||
addressSettingsService: { resetBestDifficultyAndShares: jest.Mock; updateBestDifficultyIfHigher: jest.Mock };
|
||||
shareAccountingService: { recordAcceptedShare: jest.Mock };
|
||||
redisMessagingService: { setClientPresence: jest.Mock; removeClientPresence: jest.Mock };
|
||||
jobTemplate: any;
|
||||
}> {
|
||||
const blockTemplate$ = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
|
||||
const bitcoinRpcService = {
|
||||
newBlockTemplate$: blockTemplate$.asObservable(),
|
||||
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height },
|
||||
SUBMIT_BLOCK: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
const stratumV1JobsService = new StratumV1JobsService(bitcoinRpcService as any);
|
||||
const jobTemplate = await firstValueFrom(stratumV1JobsService.newMiningJob$);
|
||||
|
||||
const mockSocket: any = {
|
||||
destroyed: false,
|
||||
writableEnded: false,
|
||||
on: jest.fn().mockReturnThis(),
|
||||
write: jest.fn((data, callback) => {
|
||||
callback?.();
|
||||
return true;
|
||||
}),
|
||||
destroy: jest.fn(function destroy() {
|
||||
mockSocket.destroyed = true;
|
||||
return mockSocket;
|
||||
}),
|
||||
};
|
||||
const socket = mockSocket as Socket;
|
||||
|
||||
const clientEntity = {
|
||||
id: 1,
|
||||
sessionId: 'sv2-session',
|
||||
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
||||
clientName: 'worker',
|
||||
userAgent: 'test/sv2',
|
||||
startTime: new Date(),
|
||||
bestDifficulty: 0,
|
||||
} as unknown as ClientEntity;
|
||||
|
||||
let nextChannelId = 1;
|
||||
const sentFrames: any[] = [];
|
||||
const clientService = {
|
||||
insert: jest.fn().mockResolvedValue(clientEntity),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const shareAccountingService = {
|
||||
recordAcceptedShare: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const notificationService = {
|
||||
notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const blocksService = {
|
||||
save: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const redisMessagingService = {
|
||||
setClientPresence: jest.fn().mockResolvedValue(undefined),
|
||||
removeClientPresence: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const addressSettingsService = {
|
||||
resetBestDifficultyAndShares: jest.fn().mockResolvedValue(undefined),
|
||||
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const client = new StratumV2Client(
|
||||
socket,
|
||||
Buffer.alloc(0),
|
||||
{
|
||||
getNoiseConfig: () => ({
|
||||
staticKeypair: {
|
||||
privateKey: Buffer.alloc(32, 1),
|
||||
publicKey: Buffer.alloc(64, 2),
|
||||
},
|
||||
certificateMessage: {
|
||||
version: 0,
|
||||
validFrom: 0,
|
||||
notValidAfter: 1,
|
||||
signature: Buffer.alloc(64),
|
||||
},
|
||||
}),
|
||||
getNextChannelId: () => nextChannelId++,
|
||||
generateExtranoncePrefix: () => Buffer.from('00000002', 'hex'),
|
||||
allocateExtendedExtranoncePrefix: jest.fn(() => Buffer.from('00000001', 'hex')),
|
||||
releaseExtendedExtranoncePrefix: jest.fn(),
|
||||
getExtendedMinerExtranonceSize: () => 10,
|
||||
getExtendedTotalExtranonceSize: () => 14,
|
||||
} as any,
|
||||
stratumV1JobsService,
|
||||
bitcoinRpcService as any,
|
||||
clientService as any,
|
||||
notificationService as any,
|
||||
blocksService as any,
|
||||
{
|
||||
get: jest.fn((key: string) => {
|
||||
switch (key) {
|
||||
case 'NETWORK':
|
||||
return 'testnet';
|
||||
case 'SV2_START_DIFFICULTY':
|
||||
return '1';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
} as unknown as ConfigService,
|
||||
addressSettingsService as any,
|
||||
new CustomWorkService(),
|
||||
{
|
||||
hasKnownToken: jest.fn().mockReturnValue(true),
|
||||
getDeclaredJob: jest.fn().mockReturnValue({
|
||||
validationMode: 'full_template',
|
||||
job: { version: MockRecording1.BLOCK_TEMPLATE.version },
|
||||
}),
|
||||
} as any,
|
||||
shareAccountingService as any,
|
||||
redisMessagingService as any,
|
||||
);
|
||||
(client as any).sendFrame = jest.fn((msgType: number, payload: Buffer, extensionType = 0) => {
|
||||
sentFrames.push({ msgType, payload, extensionType });
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
return {
|
||||
client,
|
||||
sentFrames,
|
||||
bitcoinRpcService,
|
||||
blocksService,
|
||||
notificationService,
|
||||
addressSettingsService,
|
||||
shareAccountingService,
|
||||
redisMessagingService,
|
||||
jobTemplate,
|
||||
};
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,13 +7,6 @@ export interface IBlockTemplateTx {
|
||||
sigops: number, // (numeric) total SigOps cost, as counted for purposes of block limits; if key is not present, sigop cost is unknown and clients MUST NOT assume it is zero
|
||||
weight: number // (numeric) total transaction weight, as counted for purposes of block limits
|
||||
}
|
||||
|
||||
export interface IBlockTemplatePayoutOutput {
|
||||
address: string;
|
||||
percent?: number;
|
||||
amountSats?: number;
|
||||
}
|
||||
|
||||
export interface IBlockTemplate {
|
||||
// (json object)
|
||||
version: number; // (numeric) The preferred block version
|
||||
@@ -42,7 +35,5 @@ export interface IBlockTemplate {
|
||||
height: number, // (numeric) The height of the next block
|
||||
default_witness_commitment: string // (string, optional) a valid witness commitment for the unmodified block template
|
||||
capabilities: string[]
|
||||
payoutSnapshotId?: string;
|
||||
payoutOutputs?: IBlockTemplatePayoutOutput[];
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import {
|
||||
DATUM_INITIAL_HEADER_KEY,
|
||||
DATUM_EXTRANONCE_SIZE,
|
||||
DatumFrameReader,
|
||||
DatumMiningCommand,
|
||||
DatumProtocolCommand,
|
||||
DatumShareResponseStatus,
|
||||
datumHeaderXorFeedback,
|
||||
decodeDatumHeader,
|
||||
deserializeDatumCoinbaserFetch,
|
||||
deserializeDatumJobValidationResponse,
|
||||
deserializeDatumMiningCommand,
|
||||
deserializeDatumPowSubmit,
|
||||
encodeDatumFrame,
|
||||
encodeDatumHeader,
|
||||
serializeDatumJobValidationFullTransactionBlobRequest,
|
||||
serializeDatumJobValidationFullTransactionsRequest,
|
||||
serializeDatumJobValidationShortTxIdsRequest,
|
||||
serializeDatumCoinbaserFetchResponse,
|
||||
serializeDatumMiningCommand,
|
||||
serializeDatumShareResponse,
|
||||
} from './datum-codec';
|
||||
|
||||
describe('datum-codec', () => {
|
||||
it('round-trips DATUM packed headers with XOR key', () => {
|
||||
const encoded = encodeDatumHeader({
|
||||
cmdLen: 1234,
|
||||
reserved: 0,
|
||||
isSigned: true,
|
||||
isEncryptedPubkey: false,
|
||||
isEncryptedChannel: true,
|
||||
protoCmd: DatumProtocolCommand.MINING,
|
||||
}, DATUM_INITIAL_HEADER_KEY);
|
||||
|
||||
expect(decodeDatumHeader(encoded, DATUM_INITIAL_HEADER_KEY)).toEqual({
|
||||
cmdLen: 1234,
|
||||
reserved: 0,
|
||||
isSigned: true,
|
||||
isEncryptedPubkey: false,
|
||||
isEncryptedChannel: true,
|
||||
protoCmd: DatumProtocolCommand.MINING,
|
||||
});
|
||||
});
|
||||
|
||||
it('matches the DATUM header feedback function for stable inputs', () => {
|
||||
expect(datumHeaderXorFeedback(0).toString(16)).toBe('74a55cf6');
|
||||
expect(datumHeaderXorFeedback(0xdc871829).toString(16)).toBe('88e1697d');
|
||||
});
|
||||
|
||||
it('splits framed DATUM payloads', () => {
|
||||
const first = encodeDatumFrame({
|
||||
header: {
|
||||
cmdLen: 0,
|
||||
reserved: 0,
|
||||
isSigned: false,
|
||||
isEncryptedPubkey: false,
|
||||
isEncryptedChannel: true,
|
||||
protoCmd: DatumProtocolCommand.MINING,
|
||||
},
|
||||
payload: serializeDatumMiningCommand(DatumMiningCommand.TEMPLATE_REFRESH),
|
||||
});
|
||||
const second = encodeDatumFrame({
|
||||
header: {
|
||||
cmdLen: 0,
|
||||
reserved: 0,
|
||||
isSigned: false,
|
||||
isEncryptedPubkey: false,
|
||||
isEncryptedChannel: true,
|
||||
protoCmd: DatumProtocolCommand.MINING,
|
||||
},
|
||||
payload: serializeDatumMiningCommand(DatumMiningCommand.FETCH_COINBASER, Buffer.alloc(8)),
|
||||
}, datumHeaderXorFeedback(DATUM_INITIAL_HEADER_KEY));
|
||||
|
||||
const frames = new DatumFrameReader().feed(Buffer.concat([first, second]));
|
||||
expect(frames).toHaveLength(2);
|
||||
expect(deserializeDatumMiningCommand(frames[0].payload).command).toBe(DatumMiningCommand.TEMPLATE_REFRESH);
|
||||
expect(deserializeDatumMiningCommand(frames[1].payload).command).toBe(DatumMiningCommand.FETCH_COINBASER);
|
||||
});
|
||||
|
||||
it('serializes coinbaser fetch responses', () => {
|
||||
const response = serializeDatumCoinbaserFetchResponse(50n, [
|
||||
{ value: 50n, scriptPubKey: Buffer.from('6a', 'hex') },
|
||||
]);
|
||||
const mining = deserializeDatumMiningCommand(response);
|
||||
expect(mining.command).toBe(DatumMiningCommand.FETCH_COINBASER_RESPONSE);
|
||||
expect(mining.body.readBigUInt64LE(0)).toBe(50n);
|
||||
expect(mining.body.readUInt32LE(8)).toBe(11);
|
||||
});
|
||||
|
||||
it('deserializes coinbaser fetch requests', () => {
|
||||
const payload = Buffer.alloc(8);
|
||||
payload.writeBigUInt64LE(123n, 0);
|
||||
expect(deserializeDatumCoinbaserFetch(payload)).toEqual({ rewardValue: 123n });
|
||||
});
|
||||
|
||||
it('deserializes fixed DATUM POW submissions', () => {
|
||||
const username = Buffer.from('bc1ptest.worker\0', 'utf8');
|
||||
const extranonce = Buffer.from('000102030405060708090a0b', 'hex');
|
||||
const payload = Buffer.alloc(17 + DATUM_EXTRANONCE_SIZE);
|
||||
payload[0] = 7;
|
||||
payload[1] = 1;
|
||||
payload[2] = 0x01;
|
||||
payload[3] = 4;
|
||||
payload.writeUInt32LE(100, 4);
|
||||
payload.writeUInt32LE(200, 8);
|
||||
payload.writeUInt32LE(0x20000000, 12);
|
||||
payload[16] = DATUM_EXTRANONCE_SIZE;
|
||||
extranonce.copy(payload, 17);
|
||||
const parsed = deserializeDatumPowSubmit(Buffer.concat([
|
||||
payload,
|
||||
username,
|
||||
Buffer.alloc(4),
|
||||
Buffer.from([0xfe]),
|
||||
]));
|
||||
|
||||
expect(parsed.jobId).toBe(7);
|
||||
expect(parsed.coinbaseId).toBe(1);
|
||||
expect(parsed.isBlock).toBe(true);
|
||||
expect(parsed.extranonce).toEqual(extranonce);
|
||||
expect(parsed.username).toBe('bc1ptest.worker');
|
||||
});
|
||||
|
||||
it('deserializes cached DATUM POW context sections', () => {
|
||||
const username = Buffer.from('bc1ptest.worker\0', 'utf8');
|
||||
const extranonce = Buffer.from('000102030405060708090a0b', 'hex');
|
||||
const base = Buffer.alloc(17 + DATUM_EXTRANONCE_SIZE);
|
||||
base[0] = 3;
|
||||
base[1] = 2;
|
||||
base[2] = 0;
|
||||
base[3] = 0x12;
|
||||
base.writeUInt32LE(100, 4);
|
||||
base.writeUInt32LE(200, 8);
|
||||
base.writeUInt32LE(0x20000000, 12);
|
||||
base[16] = DATUM_EXTRANONCE_SIZE;
|
||||
extranonce.copy(base, 17);
|
||||
|
||||
const templateSection = Buffer.concat([
|
||||
Buffer.from([0x01]),
|
||||
Buffer.alloc(32, 0xaa),
|
||||
Buffer.from([0x09, 0x00]),
|
||||
Buffer.from('ffff7f20', 'hex'),
|
||||
Buffer.from([7]),
|
||||
Buffer.from('64000000', 'hex'),
|
||||
Buffer.from('0100000000000000', 'hex'),
|
||||
Buffer.from('02000000', 'hex'),
|
||||
Buffer.from('03000000', 'hex'),
|
||||
Buffer.from('04000000', 'hex'),
|
||||
Buffer.from('05000000', 'hex'),
|
||||
Buffer.from([1]),
|
||||
Buffer.alloc(32, 0xbb),
|
||||
]);
|
||||
const coinbaseSection = Buffer.concat([
|
||||
Buffer.from([0x02, 2]),
|
||||
Buffer.from([0x02, 0x00]),
|
||||
Buffer.from([0x03, 0x00]),
|
||||
Buffer.from('abcd', 'hex'),
|
||||
Buffer.from('010203', 'hex'),
|
||||
]);
|
||||
|
||||
const parsed = deserializeDatumPowSubmit(Buffer.concat([
|
||||
base,
|
||||
username,
|
||||
Buffer.alloc(4),
|
||||
templateSection,
|
||||
coinbaseSection,
|
||||
Buffer.from([0xfe]),
|
||||
]));
|
||||
|
||||
expect(parsed.jobId).toBe(3);
|
||||
expect(parsed.coinbaseId).toBe(2);
|
||||
expect(parsed.targetByte).toBe(0x12);
|
||||
expect(parsed.prevBlockHash?.equals(Buffer.alloc(32, 0xaa))).toBe(true);
|
||||
expect(parsed.targetByteIndex).toBe(9);
|
||||
expect(parsed.nBits?.toString('hex')).toBe('ffff7f20');
|
||||
expect(parsed.height).toBe(100);
|
||||
expect(parsed.merkleBranches?.[0].equals(Buffer.alloc(32, 0xbb))).toBe(true);
|
||||
expect(parsed.coinbasePairs.get(2)?.coinb1.toString('hex')).toBe('abcd');
|
||||
expect(parsed.coinbasePairs.get(2)?.coinb2.toString('hex')).toBe('010203');
|
||||
});
|
||||
|
||||
it('rejects DATUM POW submissions with non-standard extranonce sizes', () => {
|
||||
const payload = Buffer.alloc(18);
|
||||
payload[16] = 1;
|
||||
payload[17] = 0xaa;
|
||||
|
||||
expect(() => deserializeDatumPowSubmit(payload)).toThrow('Unsupported DATUM extranonce size 1');
|
||||
});
|
||||
|
||||
it('serializes share responses', () => {
|
||||
const response = serializeDatumShareResponse({
|
||||
status: DatumShareResponseStatus.ACCEPTED,
|
||||
reasonCode: 0,
|
||||
nonce: 123,
|
||||
targetByte: 4,
|
||||
jobId: 7,
|
||||
});
|
||||
expect(response.toString('hex')).toBe('8f5000007b0000000407');
|
||||
});
|
||||
|
||||
it('serializes DATUM job validation requests', () => {
|
||||
expect(serializeDatumJobValidationShortTxIdsRequest(7).toString('hex')).toBe('501007');
|
||||
expect(serializeDatumJobValidationFullTransactionBlobRequest(7).toString('hex')).toBe('501207');
|
||||
expect(serializeDatumJobValidationFullTransactionsRequest(7, [1, 258]).toString('hex')).toBe('501107020001000201');
|
||||
});
|
||||
|
||||
it('deserializes DATUM short transaction ID validation responses', () => {
|
||||
const payload = Buffer.concat([
|
||||
Buffer.from([0x90, 7, 0x01]),
|
||||
Buffer.from('0200', 'hex'),
|
||||
Buffer.from('010203040506', 'hex'),
|
||||
Buffer.from('111213141516', 'hex'),
|
||||
Buffer.alloc(32, 0xaa),
|
||||
Buffer.from([0xfe, 0x00, 0x00]),
|
||||
]);
|
||||
|
||||
const parsed = deserializeDatumJobValidationResponse(payload);
|
||||
expect(parsed.kind).toBe('short-txids');
|
||||
expect(parsed.jobId).toBe(7);
|
||||
expect(parsed.status).toBe(1);
|
||||
expect(parsed.transactionCount).toBe(2);
|
||||
if (parsed.kind !== 'short-txids') {
|
||||
throw new Error(`Unexpected DATUM validation response kind ${parsed.kind}`);
|
||||
}
|
||||
expect(parsed.shortTxIds.map(id => id.toString('hex'))).toEqual(['010203040506', '111213141516']);
|
||||
expect(parsed.crosscheck?.equals(Buffer.alloc(32, 0xaa))).toBe(true);
|
||||
});
|
||||
|
||||
it('deserializes DATUM full transaction blob validation responses', () => {
|
||||
const tx = Buffer.from(
|
||||
'0100000001'
|
||||
+ '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
+ 'ffffffff00ffffffff'
|
||||
+ '01000000000000000000'
|
||||
+ '00000000',
|
||||
'hex',
|
||||
);
|
||||
const txSize = Buffer.alloc(3);
|
||||
txSize.writeUIntLE(tx.length, 0, 3);
|
||||
const payload = Buffer.concat([
|
||||
Buffer.from([0x92, 9, 0x01]),
|
||||
Buffer.from('0100', 'hex'),
|
||||
txSize,
|
||||
tx,
|
||||
Buffer.from([0xfe]),
|
||||
]);
|
||||
|
||||
const parsed = deserializeDatumJobValidationResponse(payload);
|
||||
expect(parsed.kind).toBe('full-transaction-blob');
|
||||
expect(parsed.jobId).toBe(9);
|
||||
expect(parsed.status).toBe(1);
|
||||
expect(parsed.transactionCount).toBe(1);
|
||||
if (parsed.kind !== 'full-transaction-blob') {
|
||||
throw new Error(`Unexpected DATUM validation response kind ${parsed.kind}`);
|
||||
}
|
||||
expect(parsed.transactions[0]).toEqual(tx);
|
||||
});
|
||||
});
|
||||
@@ -1,506 +0,0 @@
|
||||
import { BufferReader, BufferWriter } from '../sv2/sv2-binary-codec';
|
||||
|
||||
export const DATUM_PROTOCOL_VERSION = 'v0.4.1-beta';
|
||||
export const DATUM_INITIAL_HEADER_KEY = 0xdc871829;
|
||||
export const DATUM_MAX_PAYLOAD_SIZE = 4194304;
|
||||
export const DATUM_MAX_JOBS = 8;
|
||||
export const DATUM_EXTRANONCE_SIZE = 12;
|
||||
|
||||
export enum DatumProtocolCommand {
|
||||
PING = 0x01,
|
||||
HANDSHAKE_INIT = 0x01,
|
||||
HANDSHAKE_RESPONSE = 0x02,
|
||||
MINING = 0x05,
|
||||
}
|
||||
|
||||
export enum DatumMiningCommand {
|
||||
FETCH_COINBASER = 0x10,
|
||||
FETCH_COINBASER_RESPONSE = 0x11,
|
||||
SUBMIT_POW = 0x27,
|
||||
JOB_VALIDATION = 0x50,
|
||||
SHARE_RESPONSE = 0x8f,
|
||||
CLIENT_CONFIGURE = 0x99,
|
||||
TEMPLATE_REFRESH = 0xf9,
|
||||
}
|
||||
|
||||
export enum DatumJobValidationCommand {
|
||||
REQUEST_SHORT_TX_IDS = 0x10,
|
||||
REQUEST_FULL_TRANSACTIONS = 0x11,
|
||||
REQUEST_FULL_TRANSACTION_BLOB = 0x12,
|
||||
SHORT_TX_IDS_RESPONSE = 0x90,
|
||||
FULL_TRANSACTIONS_RESPONSE = 0x91,
|
||||
FULL_TRANSACTION_BLOB_RESPONSE = 0x92,
|
||||
}
|
||||
|
||||
export enum DatumJobValidationStatus {
|
||||
SUCCESS = 0x01,
|
||||
}
|
||||
|
||||
export enum DatumShareResponseStatus {
|
||||
ACCEPTED = 0x50,
|
||||
ACCEPTED_TENTATIVE = 0x55,
|
||||
REJECTED = 0x66,
|
||||
}
|
||||
|
||||
export enum DatumRejectReason {
|
||||
BAD_JOB_ID = 10,
|
||||
BAD_COINBASE_ID = 11,
|
||||
BAD_EXTRANONCE_SIZE = 12,
|
||||
BAD_TARGET = 13,
|
||||
BAD_USERNAME = 14,
|
||||
BAD_COINBASER_ID = 15,
|
||||
BAD_MERKLE_COUNT = 16,
|
||||
BAD_COINBASE_TOO_LARGE = 17,
|
||||
COINBASE_MISSING = 18,
|
||||
TARGET_MISMATCH = 19,
|
||||
HIGH_HASH = 21,
|
||||
BAD_NTIME = 23,
|
||||
BAD_VERSION = 24,
|
||||
STALE_BLOCK = 25,
|
||||
DUPLICATE_WORK = 29,
|
||||
OTHER = 30,
|
||||
}
|
||||
|
||||
export interface DatumHeader {
|
||||
cmdLen: number;
|
||||
reserved: number;
|
||||
isSigned: boolean;
|
||||
isEncryptedPubkey: boolean;
|
||||
isEncryptedChannel: boolean;
|
||||
protoCmd: number;
|
||||
}
|
||||
|
||||
export interface DatumFrame {
|
||||
header: DatumHeader;
|
||||
payload: Buffer;
|
||||
}
|
||||
|
||||
export interface DatumCoinbaserFetch {
|
||||
rewardValue: bigint;
|
||||
}
|
||||
|
||||
export interface DatumPayoutOutput {
|
||||
value: bigint;
|
||||
scriptPubKey: Buffer;
|
||||
}
|
||||
|
||||
export interface DatumPowSubmit {
|
||||
jobId: number;
|
||||
coinbaseId: number;
|
||||
isBlock: boolean;
|
||||
subsidyOnly: boolean;
|
||||
quickDiff: boolean;
|
||||
targetByte: number;
|
||||
ntime: number;
|
||||
nonce: number;
|
||||
version: number;
|
||||
extranonce: Buffer;
|
||||
username: string;
|
||||
reserved: Buffer;
|
||||
prevBlockHash?: Buffer;
|
||||
targetByteIndex?: number;
|
||||
nBits?: Buffer;
|
||||
coinbaserId?: number;
|
||||
height?: number;
|
||||
coinbaseValue?: bigint;
|
||||
transactionCount?: number;
|
||||
totalWeight?: number;
|
||||
totalSize?: number;
|
||||
totalSigops?: number;
|
||||
merkleBranches?: Buffer[];
|
||||
coinbasePairs: Map<number, { coinb1: Buffer; coinb2: Buffer }>;
|
||||
subsidyOnlyCoinbase?: { coinb1: Buffer; coinb2: Buffer };
|
||||
}
|
||||
|
||||
export interface DatumShortTxIdsResponse {
|
||||
kind: 'short-txids';
|
||||
jobId: number;
|
||||
status: number;
|
||||
transactionCount: number;
|
||||
shortTxIds: Buffer[];
|
||||
crosscheck: Buffer | null;
|
||||
}
|
||||
|
||||
export interface DatumFullTransactionsResponse {
|
||||
kind: 'full-transactions';
|
||||
jobId: number;
|
||||
status: number;
|
||||
transactionCount: number;
|
||||
transactions: Buffer[];
|
||||
}
|
||||
|
||||
export interface DatumFullTransactionBlobResponse {
|
||||
kind: 'full-transaction-blob';
|
||||
jobId: number;
|
||||
status: number;
|
||||
transactionCount: number;
|
||||
transactions: Buffer[];
|
||||
}
|
||||
|
||||
export type DatumJobValidationResponse =
|
||||
| DatumShortTxIdsResponse
|
||||
| DatumFullTransactionsResponse
|
||||
| DatumFullTransactionBlobResponse;
|
||||
|
||||
export function encodeDatumHeader(header: DatumHeader, xorKey = 0): Buffer {
|
||||
if (header.cmdLen < 0 || header.cmdLen > DATUM_MAX_PAYLOAD_SIZE - 1) {
|
||||
throw new RangeError(`DATUM payload length ${header.cmdLen} exceeds protocol limit`);
|
||||
}
|
||||
|
||||
let raw = header.cmdLen & 0x3fffff;
|
||||
raw |= (header.reserved & 0x03) << 22;
|
||||
raw |= (header.isSigned ? 1 : 0) << 24;
|
||||
raw |= (header.isEncryptedPubkey ? 1 : 0) << 25;
|
||||
raw |= (header.isEncryptedChannel ? 1 : 0) << 26;
|
||||
raw |= (header.protoCmd & 0x1f) << 27;
|
||||
|
||||
const out = Buffer.alloc(4);
|
||||
out.writeUInt32LE((raw ^ xorKey) >>> 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decodeDatumHeader(input: Buffer, xorKey = 0): DatumHeader {
|
||||
if (input.length < 4) {
|
||||
throw new RangeError('DATUM header requires 4 bytes');
|
||||
}
|
||||
const raw = (input.readUInt32LE(0) ^ xorKey) >>> 0;
|
||||
return {
|
||||
cmdLen: raw & 0x3fffff,
|
||||
reserved: (raw >>> 22) & 0x03,
|
||||
isSigned: ((raw >>> 24) & 0x01) !== 0,
|
||||
isEncryptedPubkey: ((raw >>> 25) & 0x01) !== 0,
|
||||
isEncryptedChannel: ((raw >>> 26) & 0x01) !== 0,
|
||||
protoCmd: (raw >>> 27) & 0x1f,
|
||||
};
|
||||
}
|
||||
|
||||
export function datumHeaderXorFeedback(input: number): number {
|
||||
let h = 0xb10cfeed >>> 0;
|
||||
let k = input >>> 0;
|
||||
k = Math.imul(k, 0xcc9e2d51) >>> 0;
|
||||
k = (((k << 15) >>> 0) | (k >>> 17)) >>> 0;
|
||||
k = Math.imul(k, 0x1b873593) >>> 0;
|
||||
h = (h ^ k) >>> 0;
|
||||
h = (((h << 13) >>> 0) | (h >>> 19)) >>> 0;
|
||||
h = (Math.imul(h, 5) + 0xe6546b64) >>> 0;
|
||||
h = (h ^ 4) >>> 0;
|
||||
h = (h ^ (h >>> 16)) >>> 0;
|
||||
h = Math.imul(h, 0x85ebca6b) >>> 0;
|
||||
h = (h ^ (h >>> 13)) >>> 0;
|
||||
h = Math.imul(h, 0xc2b2ae35) >>> 0;
|
||||
h = (h ^ (h >>> 16)) >>> 0;
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
export class DatumFrameReader {
|
||||
private buffer = Buffer.alloc(0);
|
||||
|
||||
constructor(private headerXorKey = DATUM_INITIAL_HEADER_KEY) {}
|
||||
|
||||
public setHeaderXorKey(headerXorKey: number): void {
|
||||
this.headerXorKey = headerXorKey >>> 0;
|
||||
}
|
||||
|
||||
public feed(data: Buffer): DatumFrame[] {
|
||||
this.buffer = Buffer.concat([this.buffer, data]);
|
||||
const frames: DatumFrame[] = [];
|
||||
|
||||
while (this.buffer.length >= 4) {
|
||||
const header = decodeDatumHeader(this.buffer.subarray(0, 4), this.headerXorKey);
|
||||
if (header.cmdLen > DATUM_MAX_PAYLOAD_SIZE) {
|
||||
throw new RangeError(`DATUM payload length ${header.cmdLen} exceeds protocol limit`);
|
||||
}
|
||||
if (this.buffer.length < 4 + header.cmdLen) {
|
||||
break;
|
||||
}
|
||||
|
||||
frames.push({
|
||||
header,
|
||||
payload: Buffer.from(this.buffer.subarray(4, 4 + header.cmdLen)),
|
||||
});
|
||||
this.buffer = this.buffer.subarray(4 + header.cmdLen);
|
||||
if (header.isEncryptedChannel) {
|
||||
this.headerXorKey = datumHeaderXorFeedback(this.headerXorKey);
|
||||
}
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeDatumFrame(frame: DatumFrame, xorKey = DATUM_INITIAL_HEADER_KEY): Buffer {
|
||||
return Buffer.concat([
|
||||
encodeDatumHeader({ ...frame.header, cmdLen: frame.payload.length }, xorKey),
|
||||
frame.payload,
|
||||
]);
|
||||
}
|
||||
|
||||
export function deserializeDatumMiningCommand(payload: Buffer): { command: DatumMiningCommand; body: Buffer } {
|
||||
if (payload.length < 1) {
|
||||
throw new RangeError('DATUM mining command requires a sub-command byte');
|
||||
}
|
||||
return {
|
||||
command: payload[0],
|
||||
body: Buffer.from(payload.subarray(1)),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeDatumMiningCommand(command: DatumMiningCommand, body = Buffer.alloc(0)): Buffer {
|
||||
return Buffer.concat([Buffer.from([command]), body]);
|
||||
}
|
||||
|
||||
export function serializeDatumJobValidationShortTxIdsRequest(jobId: number): Buffer {
|
||||
return serializeDatumMiningCommand(DatumMiningCommand.JOB_VALIDATION, Buffer.from([
|
||||
DatumJobValidationCommand.REQUEST_SHORT_TX_IDS,
|
||||
jobId & 0xff,
|
||||
]));
|
||||
}
|
||||
|
||||
export function serializeDatumJobValidationFullTransactionBlobRequest(jobId: number): Buffer {
|
||||
return serializeDatumMiningCommand(DatumMiningCommand.JOB_VALIDATION, Buffer.from([
|
||||
DatumJobValidationCommand.REQUEST_FULL_TRANSACTION_BLOB,
|
||||
jobId & 0xff,
|
||||
]));
|
||||
}
|
||||
|
||||
export function serializeDatumJobValidationFullTransactionsRequest(jobId: number, transactionIndexes: number[]): Buffer {
|
||||
if (transactionIndexes.length > 0xffff) {
|
||||
throw new RangeError('DATUM requested transaction index count must fit in u16');
|
||||
}
|
||||
const w = new BufferWriter();
|
||||
w.writeU8(DatumJobValidationCommand.REQUEST_FULL_TRANSACTIONS);
|
||||
w.writeU8(jobId);
|
||||
w.writeU16(transactionIndexes.length);
|
||||
for (const index of transactionIndexes) {
|
||||
if (!Number.isInteger(index) || index < 0 || index > 0xffff) {
|
||||
throw new RangeError(`Invalid DATUM transaction index ${index}`);
|
||||
}
|
||||
w.writeU16(index);
|
||||
}
|
||||
return serializeDatumMiningCommand(DatumMiningCommand.JOB_VALIDATION, Buffer.from(w.toBuffer()));
|
||||
}
|
||||
|
||||
export function deserializeDatumJobValidationResponse(payload: Buffer): DatumJobValidationResponse {
|
||||
const reader = new BufferReader(payload);
|
||||
const responseCommand = reader.readU8();
|
||||
const jobId = reader.readU8();
|
||||
const status = reader.readU8();
|
||||
|
||||
if (responseCommand === DatumJobValidationCommand.SHORT_TX_IDS_RESPONSE) {
|
||||
if (status !== DatumJobValidationStatus.SUCCESS) {
|
||||
return {
|
||||
kind: 'short-txids',
|
||||
jobId,
|
||||
status,
|
||||
transactionCount: 0,
|
||||
shortTxIds: [],
|
||||
crosscheck: null,
|
||||
};
|
||||
}
|
||||
const transactionCount = reader.readU16();
|
||||
const shortTxIds: Buffer[] = [];
|
||||
for (let i = 0; i < transactionCount; i++) {
|
||||
shortTxIds.push(reader.readBytes(6));
|
||||
}
|
||||
const crosscheck = reader.remaining >= 32 ? reader.readBytes(32) : null;
|
||||
consumeDatumTerminatorAndPadding(reader);
|
||||
return {
|
||||
kind: 'short-txids',
|
||||
jobId,
|
||||
status,
|
||||
transactionCount,
|
||||
shortTxIds,
|
||||
crosscheck,
|
||||
};
|
||||
}
|
||||
|
||||
if (responseCommand === DatumJobValidationCommand.FULL_TRANSACTIONS_RESPONSE) {
|
||||
return deserializeDatumTransactionListResponse('full-transactions', reader, jobId, status);
|
||||
}
|
||||
|
||||
if (responseCommand === DatumJobValidationCommand.FULL_TRANSACTION_BLOB_RESPONSE) {
|
||||
return deserializeDatumTransactionListResponse('full-transaction-blob', reader, jobId, status);
|
||||
}
|
||||
|
||||
throw new RangeError(`Unsupported DATUM job validation response 0x${responseCommand.toString(16)}`);
|
||||
}
|
||||
|
||||
export function deserializeDatumCoinbaserFetch(payload: Buffer): DatumCoinbaserFetch {
|
||||
if (payload.length < 8) {
|
||||
throw new RangeError('DATUM coinbaser fetch requires reward value');
|
||||
}
|
||||
return { rewardValue: payload.readBigUInt64LE(0) };
|
||||
}
|
||||
|
||||
export function serializeDatumCoinbaserFetchResponse(rewardValue: bigint, outputs: DatumPayoutOutput[]): Buffer {
|
||||
const w = new BufferWriter();
|
||||
w.writeU64(rewardValue);
|
||||
const outputsBuffer = serializeDatumPayoutOutputs(outputs);
|
||||
w.writeU32(outputsBuffer.length);
|
||||
w.writeBytes(outputsBuffer);
|
||||
return serializeDatumMiningCommand(DatumMiningCommand.FETCH_COINBASER_RESPONSE, Buffer.from(w.toBuffer()));
|
||||
}
|
||||
|
||||
export function serializeDatumPayoutOutputs(outputs: DatumPayoutOutput[]): Buffer {
|
||||
if (outputs.length > 255) {
|
||||
throw new RangeError('DATUM payout output count must fit in one byte');
|
||||
}
|
||||
const w = new BufferWriter();
|
||||
w.writeU8(outputs.length);
|
||||
for (const output of outputs) {
|
||||
if (output.scriptPubKey.length > 255) {
|
||||
throw new RangeError('DATUM scriptPubKey length must fit in one byte');
|
||||
}
|
||||
w.writeU64(output.value);
|
||||
w.writeU8(output.scriptPubKey.length);
|
||||
w.writeBytes(output.scriptPubKey);
|
||||
}
|
||||
return w.toBuffer();
|
||||
}
|
||||
|
||||
export function deserializeDatumPowSubmit(payload: Buffer): DatumPowSubmit {
|
||||
const reader = new BufferReader(payload);
|
||||
const jobId = reader.readU8();
|
||||
const coinbaseId = reader.readU8();
|
||||
const flags = reader.readU8();
|
||||
const targetByte = reader.readU8();
|
||||
const ntime = reader.readU32();
|
||||
const nonce = reader.readU32();
|
||||
const version = reader.readU32();
|
||||
const extranonceSize = reader.readU8();
|
||||
if (extranonceSize !== DATUM_EXTRANONCE_SIZE) {
|
||||
throw new RangeError(`Unsupported DATUM extranonce size ${extranonceSize}`);
|
||||
}
|
||||
const extranonce = reader.readBytes(extranonceSize);
|
||||
const username = readNullTerminatedString(reader);
|
||||
const reserved = reader.readBytes(Math.min(4, reader.remaining));
|
||||
const coinbasePairs = new Map<number, { coinb1: Buffer; coinb2: Buffer }>();
|
||||
const result: DatumPowSubmit = {
|
||||
jobId,
|
||||
coinbaseId,
|
||||
isBlock: (flags & 0x01) !== 0,
|
||||
subsidyOnly: (flags & 0x02) !== 0,
|
||||
quickDiff: (flags & 0x04) !== 0,
|
||||
targetByte,
|
||||
ntime,
|
||||
nonce,
|
||||
version,
|
||||
extranonce,
|
||||
username,
|
||||
reserved,
|
||||
coinbasePairs,
|
||||
};
|
||||
|
||||
while (reader.remaining > 0) {
|
||||
const section = reader.readU8();
|
||||
if (section === 0xfe) {
|
||||
break;
|
||||
}
|
||||
if (section === 0x01) {
|
||||
result.prevBlockHash = reader.readBytes(32);
|
||||
result.targetByteIndex = reader.readU16();
|
||||
result.nBits = reader.readBytes(4);
|
||||
result.coinbaserId = reader.readU8();
|
||||
result.height = reader.readU32();
|
||||
result.coinbaseValue = reader.readU64();
|
||||
result.transactionCount = reader.readU32();
|
||||
result.totalWeight = reader.readU32();
|
||||
result.totalSize = reader.readU32();
|
||||
result.totalSigops = reader.readU32();
|
||||
const merkleBranchCount = reader.readU8();
|
||||
result.merkleBranches = [];
|
||||
for (let i = 0; i < merkleBranchCount; i++) {
|
||||
result.merkleBranches.push(reader.readBytes(32));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (section === 0x02) {
|
||||
const coinbaseType = reader.readU8();
|
||||
const coinb1Len = reader.readU16();
|
||||
const coinb2Len = reader.readU16();
|
||||
const coinbase = {
|
||||
coinb1: reader.readBytes(coinb1Len),
|
||||
coinb2: reader.readBytes(coinb2Len),
|
||||
};
|
||||
if (coinbaseType === 255 || coinbaseId === 255) {
|
||||
result.subsidyOnlyCoinbase = coinbase;
|
||||
} else {
|
||||
coinbasePairs.set(coinbaseType, coinbase);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw new RangeError(`Unsupported DATUM POW section 0x${section.toString(16)}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function serializeDatumShareResponse(input: {
|
||||
status: DatumShareResponseStatus;
|
||||
reasonCode: number;
|
||||
nonce: number;
|
||||
targetByte: number;
|
||||
jobId: number;
|
||||
}): Buffer {
|
||||
const w = new BufferWriter();
|
||||
w.writeU8(input.status);
|
||||
w.writeU16(input.reasonCode);
|
||||
w.writeU32(input.nonce);
|
||||
w.writeU8(input.targetByte);
|
||||
w.writeU8(input.jobId);
|
||||
return serializeDatumMiningCommand(DatumMiningCommand.SHARE_RESPONSE, Buffer.from(w.toBuffer()));
|
||||
}
|
||||
|
||||
function deserializeDatumTransactionListResponse(
|
||||
kind: 'full-transactions' | 'full-transaction-blob',
|
||||
reader: BufferReader,
|
||||
jobId: number,
|
||||
status: number,
|
||||
): DatumFullTransactionsResponse | DatumFullTransactionBlobResponse {
|
||||
if (status !== DatumJobValidationStatus.SUCCESS) {
|
||||
return {
|
||||
kind,
|
||||
jobId,
|
||||
status,
|
||||
transactionCount: 0,
|
||||
transactions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const transactionCount = reader.readU16();
|
||||
const transactions: Buffer[] = [];
|
||||
for (let i = 0; i < transactionCount; i++) {
|
||||
const transactionSize = reader.readU24();
|
||||
transactions.push(reader.readBytes(transactionSize));
|
||||
}
|
||||
consumeDatumTerminatorAndPadding(reader);
|
||||
return {
|
||||
kind,
|
||||
jobId,
|
||||
status,
|
||||
transactionCount,
|
||||
transactions,
|
||||
};
|
||||
}
|
||||
|
||||
function consumeDatumTerminatorAndPadding(reader: BufferReader): void {
|
||||
if (reader.remaining <= 0) {
|
||||
return;
|
||||
}
|
||||
const terminator = reader.readU8();
|
||||
if (terminator !== 0xfe) {
|
||||
throw new RangeError(`DATUM job validation response missing terminator; got 0x${terminator.toString(16)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readNullTerminatedString(reader: BufferReader): string {
|
||||
const bytes: number[] = [];
|
||||
while (reader.remaining > 0) {
|
||||
const byte = reader.readU8();
|
||||
if (byte === 0) {
|
||||
break;
|
||||
}
|
||||
bytes.push(byte);
|
||||
}
|
||||
return Buffer.from(bytes).toString('utf8');
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import * as sodium from 'libsodium-wrappers-sumo';
|
||||
import {
|
||||
DATUM_INITIAL_HEADER_KEY,
|
||||
DatumProtocolCommand,
|
||||
datumHeaderXorFeedback,
|
||||
decodeDatumHeader,
|
||||
encodeDatumFrame,
|
||||
} from './datum-codec';
|
||||
import { DatumCryptoSession } from './datum-crypto';
|
||||
|
||||
describe('DatumCryptoSession', () => {
|
||||
it('derives stable DATUM public keys from a configured seed', async () => {
|
||||
await sodium.ready;
|
||||
const seed = Buffer.from('000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', 'hex');
|
||||
|
||||
const first = DatumCryptoSession.generateKeyPairFromSeed(seed);
|
||||
const second = DatumCryptoSession.generateKeyPairFromSeed(seed);
|
||||
|
||||
expect(Buffer.concat([first.edPublicKey, first.xPublicKey]).toString('hex'))
|
||||
.toBe(Buffer.concat([second.edPublicKey, second.xPublicKey]).toString('hex'));
|
||||
expect(first.edSecretKey).toEqual(second.edSecretKey);
|
||||
});
|
||||
|
||||
it('opens signed sealed handshakes and builds encrypted responses', async () => {
|
||||
await sodium.ready;
|
||||
const serverIdentity = DatumCryptoSession.generateKeyPair();
|
||||
const serverSessionKeys = DatumCryptoSession.generateKeyPair();
|
||||
const clientIdentity = DatumCryptoSession.generateKeyPair();
|
||||
const clientSessionKeys = DatumCryptoSession.generateKeyPair();
|
||||
const server = await DatumCryptoSession.create(serverIdentity, serverSessionKeys);
|
||||
const nextHeaderKey = 0x12345678;
|
||||
|
||||
const signedBody = Buffer.concat([
|
||||
clientIdentity.edPublicKey,
|
||||
clientIdentity.xPublicKey,
|
||||
clientSessionKeys.edPublicKey,
|
||||
clientSessionKeys.xPublicKey,
|
||||
Buffer.from('datum-test/0\0', 'utf8'),
|
||||
Buffer.from([0xfe]),
|
||||
uint32(nextHeaderKey),
|
||||
Buffer.from([1, 2, 3]),
|
||||
]);
|
||||
const signature = Buffer.from(sodium.crypto_sign_detached(signedBody, clientIdentity.edSecretKey));
|
||||
const encrypted = Buffer.from(sodium.crypto_box_seal(
|
||||
Buffer.concat([signedBody, signature]),
|
||||
serverIdentity.xPublicKey,
|
||||
));
|
||||
const frame = encodeDatumFrame({
|
||||
header: {
|
||||
cmdLen: encrypted.length,
|
||||
reserved: 0,
|
||||
isSigned: true,
|
||||
isEncryptedPubkey: true,
|
||||
isEncryptedChannel: false,
|
||||
protoCmd: DatumProtocolCommand.HANDSHAKE_INIT,
|
||||
},
|
||||
payload: encrypted,
|
||||
}, DATUM_INITIAL_HEADER_KEY);
|
||||
const header = decodeDatumHeader(frame.subarray(0, 4), DATUM_INITIAL_HEADER_KEY);
|
||||
|
||||
const hello = server.openHandshake(frame.subarray(4, 4 + header.cmdLen));
|
||||
expect(hello.userAgent).toBe('datum-test/0');
|
||||
expect(hello.nextHeaderKey).toBe(nextHeaderKey);
|
||||
expect(hello.sessionXPublicKey).toEqual(clientSessionKeys.xPublicKey);
|
||||
|
||||
const response = server.buildHandshakeResponse(hello, 'motd');
|
||||
const responseHeader = decodeDatumHeader(response.subarray(0, 4), datumHeaderXorFeedback((~nextHeaderKey) >>> 0));
|
||||
expect(responseHeader.protoCmd).toBe(DatumProtocolCommand.HANDSHAKE_RESPONSE);
|
||||
expect(responseHeader.isSigned).toBe(true);
|
||||
expect(responseHeader.isEncryptedPubkey).toBe(true);
|
||||
const opened = Buffer.from(sodium.crypto_box_seal_open(
|
||||
response.subarray(4),
|
||||
clientSessionKeys.xPublicKey,
|
||||
clientSessionKeys.xSecretKey,
|
||||
));
|
||||
expect(opened.subarray(0, 32)).toEqual(clientIdentity.edPublicKey);
|
||||
expect(opened.subarray(128, 160)).toEqual(serverSessionKeys.edPublicKey);
|
||||
|
||||
const ping = server.encryptChannelFrame(DatumProtocolCommand.PING, Buffer.alloc(0));
|
||||
const pingHeader = decodeDatumHeader(
|
||||
ping.subarray(0, 4),
|
||||
datumHeaderXorFeedback(datumHeaderXorFeedback((~nextHeaderKey) >>> 0)),
|
||||
);
|
||||
expect(pingHeader.protoCmd).toBe(DatumProtocolCommand.PING);
|
||||
expect(pingHeader.isEncryptedChannel).toBe(true);
|
||||
expect(pingHeader.cmdLen).toBe(sodium.crypto_box_MACBYTES);
|
||||
});
|
||||
});
|
||||
|
||||
function uint32(value: number): Buffer {
|
||||
const result = Buffer.alloc(4);
|
||||
result.writeUInt32LE(value >>> 0, 0);
|
||||
return result;
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as sodium from 'libsodium-wrappers-sumo';
|
||||
import {
|
||||
DATUM_INITIAL_HEADER_KEY,
|
||||
DatumFrame,
|
||||
DatumProtocolCommand,
|
||||
decodeDatumHeader,
|
||||
encodeDatumFrame,
|
||||
datumHeaderXorFeedback,
|
||||
} from './datum-codec';
|
||||
|
||||
export interface DatumKeyPair {
|
||||
edPublicKey: Buffer;
|
||||
edSecretKey: Buffer;
|
||||
xPublicKey: Buffer;
|
||||
xSecretKey: Buffer;
|
||||
}
|
||||
|
||||
export interface DatumHello {
|
||||
identityEdPublicKey: Buffer;
|
||||
identityXPublicKey: Buffer;
|
||||
sessionEdPublicKey: Buffer;
|
||||
sessionXPublicKey: Buffer;
|
||||
userAgent: string;
|
||||
nextHeaderKey: number;
|
||||
}
|
||||
|
||||
export class DatumCryptoSession {
|
||||
private initialized = false;
|
||||
private sharedSecret: Uint8Array | null = null;
|
||||
private receiveNonce: Buffer | null = null;
|
||||
private sendNonce: Buffer | null = null;
|
||||
private receiveHeaderKey = DATUM_INITIAL_HEADER_KEY;
|
||||
private sendHeaderKey = DATUM_INITIAL_HEADER_KEY;
|
||||
private clientIdentityXPublicKey: Buffer | null = null;
|
||||
|
||||
private constructor(
|
||||
private readonly identityKeys: DatumKeyPair,
|
||||
private readonly sessionKeys: DatumKeyPair,
|
||||
) {}
|
||||
|
||||
public static async create(identityKeys?: DatumKeyPair, sessionKeys?: DatumKeyPair): Promise<DatumCryptoSession> {
|
||||
await sodium.ready;
|
||||
return new DatumCryptoSession(
|
||||
identityKeys ?? DatumCryptoSession.generateKeyPair(),
|
||||
sessionKeys ?? DatumCryptoSession.generateKeyPair(),
|
||||
);
|
||||
}
|
||||
|
||||
public get publicKeyHex(): string {
|
||||
return Buffer.concat([this.identityKeys.edPublicKey, this.identityKeys.xPublicKey]).toString('hex');
|
||||
}
|
||||
|
||||
public get currentReceiveHeaderKey(): number {
|
||||
return this.receiveHeaderKey;
|
||||
}
|
||||
|
||||
public get currentSendHeaderKey(): number {
|
||||
return this.sendHeaderKey;
|
||||
}
|
||||
|
||||
public openHandshake(framePayload: Buffer): DatumHello {
|
||||
const opened = Buffer.from(sodium.crypto_box_seal_open(
|
||||
framePayload,
|
||||
this.identityKeys.xPublicKey,
|
||||
this.identityKeys.xSecretKey,
|
||||
));
|
||||
if (opened.length < 32 * 4 + 1 + 4 + sodium.crypto_sign_BYTES) {
|
||||
throw new Error('DATUM handshake payload is too short');
|
||||
}
|
||||
|
||||
const signature = opened.subarray(opened.length - sodium.crypto_sign_BYTES);
|
||||
const signedPayload = opened.subarray(0, opened.length - sodium.crypto_sign_BYTES);
|
||||
const clientIdentityEdPublicKey = signedPayload.subarray(0, 32);
|
||||
if (!sodium.crypto_sign_verify_detached(signature, signedPayload, clientIdentityEdPublicKey)) {
|
||||
throw new Error('DATUM handshake signature verification failed');
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
const hello: DatumHello = {
|
||||
identityEdPublicKey: Buffer.from(signedPayload.subarray(offset, offset += 32)),
|
||||
identityXPublicKey: Buffer.from(signedPayload.subarray(offset, offset += 32)),
|
||||
sessionEdPublicKey: Buffer.from(signedPayload.subarray(offset, offset += 32)),
|
||||
sessionXPublicKey: Buffer.from(signedPayload.subarray(offset, offset += 32)),
|
||||
userAgent: '',
|
||||
nextHeaderKey: 0,
|
||||
};
|
||||
|
||||
const userAgentStart = offset;
|
||||
while (offset < signedPayload.length && signedPayload[offset] !== 0) {
|
||||
offset++;
|
||||
}
|
||||
hello.userAgent = signedPayload.subarray(userAgentStart, offset).toString('utf8');
|
||||
offset++;
|
||||
|
||||
if (signedPayload[offset] !== 0xfe) {
|
||||
throw new Error('DATUM handshake missing key delimiter');
|
||||
}
|
||||
offset++;
|
||||
hello.nextHeaderKey = signedPayload.readUInt32LE(offset);
|
||||
|
||||
this.clientIdentityXPublicKey = hello.identityXPublicKey;
|
||||
this.sharedSecret = sodium.crypto_box_beforenm(hello.sessionXPublicKey, this.sessionKeys.xSecretKey);
|
||||
this.receiveHeaderKey = datumHeaderXorFeedback(hello.nextHeaderKey);
|
||||
this.sendHeaderKey = datumHeaderXorFeedback((~hello.nextHeaderKey) >>> 0);
|
||||
this.initializeNonces(hello.nextHeaderKey, hello.sessionEdPublicKey);
|
||||
this.initialized = true;
|
||||
|
||||
return hello;
|
||||
}
|
||||
|
||||
public buildHandshakeResponse(hello: DatumHello, motd: string): Buffer {
|
||||
if (this.clientIdentityXPublicKey == null) {
|
||||
throw new Error('Cannot build DATUM handshake response before opening client hello');
|
||||
}
|
||||
|
||||
const body = Buffer.concat([
|
||||
hello.identityEdPublicKey,
|
||||
hello.identityXPublicKey,
|
||||
hello.sessionEdPublicKey,
|
||||
hello.sessionXPublicKey,
|
||||
this.sessionKeys.edPublicKey,
|
||||
this.sessionKeys.xPublicKey,
|
||||
Buffer.from(`${motd}\0`, 'utf8'),
|
||||
]);
|
||||
const signature = Buffer.from(sodium.crypto_sign_detached(body, this.identityKeys.edSecretKey));
|
||||
const encrypted = Buffer.from(sodium.crypto_box_seal(
|
||||
Buffer.concat([body, signature]),
|
||||
hello.sessionXPublicKey,
|
||||
));
|
||||
|
||||
const response = encodeDatumFrame({
|
||||
header: {
|
||||
cmdLen: encrypted.length,
|
||||
reserved: 0,
|
||||
isSigned: true,
|
||||
isEncryptedPubkey: true,
|
||||
isEncryptedChannel: false,
|
||||
protoCmd: DatumProtocolCommand.HANDSHAKE_RESPONSE,
|
||||
},
|
||||
payload: encrypted,
|
||||
}, this.sendHeaderKey);
|
||||
this.sendHeaderKey = datumHeaderXorFeedback(this.sendHeaderKey);
|
||||
return response;
|
||||
}
|
||||
|
||||
public decryptChannelPayload(payload: Buffer): Buffer {
|
||||
if (!this.initialized || this.sharedSecret == null || this.receiveNonce == null) {
|
||||
throw new Error('DATUM channel is not initialized');
|
||||
}
|
||||
const opened = sodium.crypto_box_open_easy_afternm(payload, this.receiveNonce, this.sharedSecret);
|
||||
this.incrementNonce(this.receiveNonce);
|
||||
this.receiveHeaderKey = datumHeaderXorFeedback(this.receiveHeaderKey);
|
||||
return Buffer.from(opened);
|
||||
}
|
||||
|
||||
public encryptChannelFrame(protoCmd: DatumProtocolCommand, payload: Buffer, signed = false): Buffer {
|
||||
if (!this.initialized || this.sharedSecret == null || this.sendNonce == null) {
|
||||
throw new Error('DATUM channel is not initialized');
|
||||
}
|
||||
const signedPayload = signed
|
||||
? Buffer.concat([payload, Buffer.from(sodium.crypto_sign_detached(payload, this.sessionKeys.edSecretKey))])
|
||||
: payload;
|
||||
const encrypted = Buffer.from(sodium.crypto_box_easy_afternm(signedPayload, this.sendNonce, this.sharedSecret));
|
||||
const frame = encodeDatumFrame({
|
||||
header: {
|
||||
cmdLen: encrypted.length,
|
||||
reserved: 0,
|
||||
isSigned: signed,
|
||||
isEncryptedPubkey: false,
|
||||
isEncryptedChannel: true,
|
||||
protoCmd,
|
||||
},
|
||||
payload: encrypted,
|
||||
}, this.sendHeaderKey);
|
||||
this.incrementNonce(this.sendNonce);
|
||||
this.sendHeaderKey = datumHeaderXorFeedback(this.sendHeaderKey);
|
||||
return frame;
|
||||
}
|
||||
|
||||
public decodeHeader(headerBytes: Buffer): ReturnType<typeof decodeDatumHeader> {
|
||||
return decodeDatumHeader(headerBytes, this.receiveHeaderKey);
|
||||
}
|
||||
|
||||
private initializeNonces(nextHeaderKey: number, clientSessionEdPublicKey: Buffer): void {
|
||||
const receiver = Buffer.alloc(sodium.crypto_box_NONCEBYTES);
|
||||
let key = ((nextHeaderKey - 42) ^ clientSessionEdPublicKey.readUInt32LE(7)) >>> 0;
|
||||
for (let i = 0; i < receiver.length; i += 4) {
|
||||
receiver.writeUInt32LE(datumHeaderXorFeedback((key - 42) >>> 0), i);
|
||||
key = (~receiver.readUInt32LE(i)) >>> 0;
|
||||
}
|
||||
|
||||
const sender = Buffer.alloc(receiver.length);
|
||||
for (let i = 0; i < receiver.length; i += 4) {
|
||||
sender.writeUInt32LE((receiver.readUInt32LE(i) ^ 0x57575757) >>> 0, i);
|
||||
}
|
||||
|
||||
this.sendNonce = receiver;
|
||||
this.receiveNonce = sender;
|
||||
}
|
||||
|
||||
private incrementNonce(nonce: Buffer): void {
|
||||
for (let offset = 0; offset < nonce.length; offset += 4) {
|
||||
const next = (nonce.readUInt32LE(offset) + 1) >>> 0;
|
||||
nonce.writeUInt32LE(next, offset);
|
||||
if (next !== 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static generateKeyPair(): DatumKeyPair {
|
||||
const seed = crypto.randomBytes(32);
|
||||
return DatumCryptoSession.generateKeyPairFromSeed(seed);
|
||||
}
|
||||
|
||||
public static generateKeyPairFromSeed(seed: Buffer): DatumKeyPair {
|
||||
if (seed.length !== 32) {
|
||||
throw new RangeError(`DATUM key seed must be 32 bytes, got ${seed.length}`);
|
||||
}
|
||||
const ed = sodium.crypto_sign_seed_keypair(seed);
|
||||
const xPublicKey = sodium.crypto_sign_ed25519_pk_to_curve25519(ed.publicKey);
|
||||
const xSecretKey = sodium.crypto_sign_ed25519_sk_to_curve25519(ed.privateKey);
|
||||
return {
|
||||
edPublicKey: Buffer.from(ed.publicKey),
|
||||
edSecretKey: Buffer.from(ed.privateKey),
|
||||
xPublicKey: Buffer.from(xPublicKey),
|
||||
xSecretKey: Buffer.from(xSecretKey),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDatumPlainFrame(protoCmd: DatumProtocolCommand, payload: Buffer): DatumFrame {
|
||||
return {
|
||||
header: {
|
||||
cmdLen: payload.length,
|
||||
reserved: 0,
|
||||
isSigned: false,
|
||||
isEncryptedPubkey: false,
|
||||
isEncryptedChannel: false,
|
||||
protoCmd,
|
||||
},
|
||||
payload,
|
||||
};
|
||||
}
|
||||
@@ -25,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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,6 @@ describe('MiningSubmitMessage', () => {
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should hash submissions deterministically', () => {
|
||||
expect(message.hash()).toBe('t2bFzhZ6mketRxa5nOoKrNxG3RkFIZuOwY1WewFtv9k=');
|
||||
});
|
||||
|
||||
it('should reject short extranonce2 submissions', async () => {
|
||||
const shortMessage = plainToInstance(
|
||||
MiningSubmitMessage,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, Length } from 'class-val
|
||||
import { eRequestMethod } from '../enums/eRequestMethod';
|
||||
import { EXTRANONCE2_SIZE_BYTES } from '../stratum.constants';
|
||||
import { StratumBaseMessage } from './StratumBaseMessage';
|
||||
import { hash256 } from '../../utils/hash.utils';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
|
||||
|
||||
export class MiningSubmitMessage extends StratumBaseMessage {
|
||||
@@ -69,7 +69,7 @@ export class MiningSubmitMessage extends StratumBaseMessage {
|
||||
|
||||
public hash(): string{
|
||||
const buffer = Buffer.from(this.versionMask + this.nonce + this.extraNonce2 + this.ntime + this.jobId);
|
||||
return hash256(buffer).toString('base64');
|
||||
return bitcoinjs.crypto.hash256(buffer).toString('base64');
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export const EXTRANONCE1_SIZE_BYTES = 4;
|
||||
export const EXTRANONCE2_SIZE_BYTES = 8;
|
||||
export const TOTAL_EXTRANONCE_SIZE_BYTES = EXTRANONCE1_SIZE_BYTES + EXTRANONCE2_SIZE_BYTES;
|
||||
export const SV2_EXTENDED_EXTRANONCE2_SIZE_BYTES = 10;
|
||||
export const SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES = EXTRANONCE1_SIZE_BYTES + SV2_EXTENDED_EXTRANONCE2_SIZE_BYTES;
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user