mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-30 17:41:08 -07:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb367ac5f0 | ||
|
|
3d6c8b3134 | ||
|
|
2a37cb1f06 | ||
|
|
f2d2283b47 | ||
|
|
4cab56a1a0 | ||
|
|
c5169e105e | ||
|
|
2d6d2afacb | ||
|
|
327be48ade |
@@ -15,10 +15,21 @@ BITCOIN_RPC_TIMEOUT=10000
|
|||||||
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
|
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
|
||||||
|
|
||||||
API_PORT=3334
|
API_PORT=3334
|
||||||
|
|
||||||
|
# Plain TCP Stratum ports accept both SV1 JSON-RPC and SV2 Noise/binary traffic.
|
||||||
STRATUM_PORTS=3333,3332,3331,3330
|
STRATUM_PORTS=3333,3332,3331,3330
|
||||||
|
|
||||||
|
# Optional additional SV2-only mining ports. Usually unnecessary when STRATUM_PORTS are exposed.
|
||||||
|
#STRATUM_V2_PORTS=
|
||||||
|
#SV2_START_DIFFICULTY=100000
|
||||||
|
#SV2_TARGET_SHARES_PER_MINUTE=2
|
||||||
|
#SV2_DIFFICULTY_CHECK_INTERVAL_MS=60000
|
||||||
|
#SV2_AUTHORITY_PRIVKEY=
|
||||||
|
|
||||||
STRATUM_SECURE=true
|
STRATUM_SECURE=true
|
||||||
SECURE_STRATUM_PORTS=4333,4332,4331,4330
|
SECURE_STRATUM_PORTS=4333,4332,4331,4330
|
||||||
|
STRATUM_MAX_CONNECTIONS_PER_LISTENER=10000
|
||||||
|
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS=10000
|
||||||
|
|
||||||
#optional telegram bot
|
#optional telegram bot
|
||||||
#TELEGRAM_BOT_TOKEN=
|
#TELEGRAM_BOT_TOKEN=
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
# Docker build environment #
|
# Docker build environment #
|
||||||
############################
|
############################
|
||||||
|
|
||||||
FROM node:18.16.1-bookworm-slim AS build
|
FROM node:24.16.0-bookworm-slim AS build
|
||||||
|
|
||||||
# Upgrade all packages and install dependencies
|
# Upgrade all packages and install dependencies
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
@@ -24,7 +24,7 @@ RUN npm i && npm run build
|
|||||||
# Docker final environment #
|
# Docker final environment #
|
||||||
############################
|
############################
|
||||||
|
|
||||||
FROM node:18.16.1-bookworm-slim
|
FROM node:24.16.0-bookworm-slim
|
||||||
|
|
||||||
# Expose ports for Stratum and Bitcoin RPC
|
# Expose ports for Stratum and Bitcoin RPC
|
||||||
EXPOSE 3333 3334 8332
|
EXPOSE 3333 3334 8332
|
||||||
|
|||||||
@@ -45,6 +45,20 @@ Install pm2 (https://pm2.keymetrics.io/)
|
|||||||
$ pm2 start dist/main.js
|
$ pm2 start dist/main.js
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When running the worker app in PM2 cluster mode, start the PM2 daemon with OS-level
|
||||||
|
connection scheduling. The environment variable must be present when the PM2 daemon
|
||||||
|
starts, not only in the worker configuration.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ NODE_CLUSTER_SCHED_POLICY=none pm2 start ecosystem.config.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Cluster-mode connection dropping requires Node.js `22.12.0` or newer.
|
||||||
|
|
||||||
|
`STRATUM_MAX_CONNECTIONS_PER_LISTENER` is enforced per worker and Stratum port.
|
||||||
|
Size it using the busiest port: `worker count * limit`. For example, 28 workers
|
||||||
|
with the default limit of `10000` allow up to `280000` connections on one port.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Build container:
|
Build container:
|
||||||
|
|||||||
Generated
+128
@@ -18,6 +18,7 @@
|
|||||||
"@nestjs/platform-fastify": "^11.1.19",
|
"@nestjs/platform-fastify": "^11.1.19",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
|
"@scure/btc-signer": "^2.2.0",
|
||||||
"axios": "^1.16.0",
|
"axios": "^1.16.0",
|
||||||
"better-sqlite3": "^12.9.0",
|
"better-sqlite3": "^12.9.0",
|
||||||
"bitcoin-address-validation": "^2.2.3",
|
"bitcoin-address-validation": "^2.2.3",
|
||||||
@@ -60,6 +61,9 @@
|
|||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aminya/node-gyp-build": {
|
"node_modules/@aminya/node-gyp-build": {
|
||||||
@@ -2514,6 +2518,33 @@
|
|||||||
"typeorm": "^0.3.0 || ^1.0.0-dev"
|
"typeorm": "^0.3.0 || ^1.0.0-dev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/curves": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@noble/hashes": {
|
"node_modules/@noble/hashes": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
|
||||||
@@ -2676,6 +2707,42 @@
|
|||||||
"npm": ">=7.0.0"
|
"npm": ">=7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@scure/base": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scure/btc-signer": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZXZ08sZqSZKEcOuEQnxTF66ouHtl6+UA6U/QfQM06K9WiOlEkXF4LviZCaSgkdiFh9cyMt9+xdup7JtEv3p0fw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/curves": "~2.2.0",
|
||||||
|
"@noble/hashes": "~2.2.0",
|
||||||
|
"@scure/base": "~2.2.0",
|
||||||
|
"micro-packed": "~0.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scure/btc-signer/node_modules/@noble/hashes": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@sinclair/typebox": {
|
"node_modules/@sinclair/typebox": {
|
||||||
"version": "0.27.10",
|
"version": "0.27.10",
|
||||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
|
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
|
||||||
@@ -8268,6 +8335,21 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/micro-packed": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-gFdaWTxEXOwtSOcpxulO4AuXVtp3HWIRmB8eq8+3m1Zku0ubgva0UGpi03YhcvsTJasHngG9gTIUK5kHNKdesg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@scure/base": "~2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/micromatch": {
|
"node_modules/micromatch": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
@@ -13595,6 +13677,21 @@
|
|||||||
"integrity": "sha512-8rw/nKT0S+L+MkzgE9F2/mox7mAgsPlwfzmW9gsESN1lmQtIrVEfiiBwC2O8+guS1jBfQehJIdcdUj2OAp4VUQ==",
|
"integrity": "sha512-8rw/nKT0S+L+MkzgE9F2/mox7mAgsPlwfzmW9gsESN1lmQtIrVEfiiBwC2O8+guS1jBfQehJIdcdUj2OAp4VUQ==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
|
"@noble/curves": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==",
|
||||||
|
"requires": {
|
||||||
|
"@noble/hashes": "2.2.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"@noble/hashes": {
|
"@noble/hashes": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
|
||||||
@@ -13701,6 +13798,29 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
|
||||||
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="
|
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="
|
||||||
},
|
},
|
||||||
|
"@scure/base": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="
|
||||||
|
},
|
||||||
|
"@scure/btc-signer": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZXZ08sZqSZKEcOuEQnxTF66ouHtl6+UA6U/QfQM06K9WiOlEkXF4LviZCaSgkdiFh9cyMt9+xdup7JtEv3p0fw==",
|
||||||
|
"requires": {
|
||||||
|
"@noble/curves": "~2.2.0",
|
||||||
|
"@noble/hashes": "~2.2.0",
|
||||||
|
"@scure/base": "~2.2.0",
|
||||||
|
"micro-packed": "~0.9.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"@sinclair/typebox": {
|
"@sinclair/typebox": {
|
||||||
"version": "0.27.10",
|
"version": "0.27.10",
|
||||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
|
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
|
||||||
@@ -17602,6 +17722,14 @@
|
|||||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"micro-packed": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-gFdaWTxEXOwtSOcpxulO4AuXVtp3HWIRmB8eq8+3m1Zku0ubgva0UGpi03YhcvsTJasHngG9gTIUK5kHNKdesg==",
|
||||||
|
"requires": {
|
||||||
|
"@scure/base": "~2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"micromatch": {
|
"micromatch": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
|
|||||||
+15
-1
@@ -5,6 +5,9 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
@@ -29,6 +32,7 @@
|
|||||||
"@nestjs/platform-fastify": "^11.1.19",
|
"@nestjs/platform-fastify": "^11.1.19",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
|
"@scure/btc-signer": "^2.2.0",
|
||||||
"axios": "^1.16.0",
|
"axios": "^1.16.0",
|
||||||
"better-sqlite3": "^12.9.0",
|
"better-sqlite3": "^12.9.0",
|
||||||
"bitcoin-address-validation": "^2.2.3",
|
"bitcoin-address-validation": "^2.2.3",
|
||||||
@@ -86,8 +90,18 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": [
|
||||||
|
"ts-jest",
|
||||||
|
{
|
||||||
|
"tsconfig": {
|
||||||
|
"allowJs": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
|
"transformIgnorePatterns": [
|
||||||
|
"node_modules/(?!(@scure|@noble|micro-packed))"
|
||||||
|
],
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -15,17 +15,14 @@ export class AddressSettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getSettings(address: string, createIfNotFound: boolean) {
|
public async getSettings(address: string, createIfNotFound: boolean) {
|
||||||
let settings = await this.addressSettingsRepository.findOne({ where: { address } });
|
const settings = await this.addressSettingsRepository.findOne({ where: { address } });
|
||||||
if (createIfNotFound === true && settings == null) {
|
if (createIfNotFound == true && settings == null) {
|
||||||
await this.addressSettingsRepository
|
// It's possible to have a race condition here so if we get a PK violation, fetch it
|
||||||
.createQueryBuilder()
|
try {
|
||||||
.insert()
|
return await this.createNew(address);
|
||||||
.into(AddressSettingsEntity)
|
} catch (e) {
|
||||||
.values({ address })
|
return await this.addressSettingsRepository.findOne({ where: { address } });
|
||||||
.orIgnore()
|
}
|
||||||
.execute();
|
|
||||||
|
|
||||||
settings = await this.addressSettingsRepository.findOne({ where: { address } });
|
|
||||||
}
|
}
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { DateTimeTransformer } from './DateTimeTransformer';
|
|
||||||
|
|
||||||
describe('DateTimeTransformer', () => {
|
|
||||||
const transformer = new DateTimeTransformer();
|
|
||||||
|
|
||||||
it('should pass Date values through as Date instances', () => {
|
|
||||||
const date = new Date('2026-05-06T12:00:00.000Z');
|
|
||||||
|
|
||||||
expect(transformer.to(date)).toBe(date);
|
|
||||||
expect(transformer.from(date)).toBe(date);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse ISO strings from the database', () => {
|
|
||||||
const date = transformer.from('2026-05-06T12:00:00.000Z');
|
|
||||||
|
|
||||||
expect(date).toBeInstanceOf(Date);
|
|
||||||
expect((date as Date).toISOString()).toBe('2026-05-06T12:00:00.000Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse legacy locale strings', () => {
|
|
||||||
const usDate = transformer.from('5/6/2026, 1:02:03 PM') as Date;
|
|
||||||
const europeanDate = transformer.from('6.5.2026, 13:02:03') as Date;
|
|
||||||
|
|
||||||
expect(usDate.getFullYear()).toBe(2026);
|
|
||||||
expect(usDate.getMonth()).toBe(4);
|
|
||||||
expect(usDate.getDate()).toBe(6);
|
|
||||||
expect(usDate.getHours()).toBe(13);
|
|
||||||
expect(europeanDate.getFullYear()).toBe(2026);
|
|
||||||
expect(europeanDate.getMonth()).toBe(4);
|
|
||||||
expect(europeanDate.getDate()).toBe(6);
|
|
||||||
expect(europeanDate.getHours()).toBe(13);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve null and undefined values', () => {
|
|
||||||
expect(transformer.to(null)).toBeNull();
|
|
||||||
expect(transformer.from(undefined)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,113 +1,14 @@
|
|||||||
import { ValueTransformer } from 'typeorm';
|
import { ValueTransformer } from 'typeorm';
|
||||||
|
|
||||||
const EUROPEAN_LOCALE_REGEX =
|
export class DateTimeTransformer implements ValueTransformer {
|
||||||
/^(\d{1,2})\.(\d{1,2})\.(\d{4}),\s*(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(AM|PM))?$/i;
|
to(value: Date): any {
|
||||||
const US_LOCALE_REGEX =
|
// Convert the local time to UTC before saving to the database
|
||||||
/^(\d{1,2})\/(\d{1,2})\/(\d{4}),\s*(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)$/i;
|
const utcTime = value?.toLocaleString();
|
||||||
|
return utcTime;
|
||||||
function parseLegacyLocaleString(value: string): Date | null {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
|
|
||||||
const matchEuropean = trimmed.match(EUROPEAN_LOCALE_REGEX);
|
|
||||||
if (matchEuropean) {
|
|
||||||
const [, day, month, year, hours, minutes, seconds = '0', meridiem] = matchEuropean;
|
|
||||||
return buildDateFromParts({
|
|
||||||
year,
|
|
||||||
month,
|
|
||||||
day,
|
|
||||||
hours,
|
|
||||||
minutes,
|
|
||||||
seconds,
|
|
||||||
meridiem,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchUs = trimmed.match(US_LOCALE_REGEX);
|
from(value: any): Date {
|
||||||
if (matchUs) {
|
// Convert the UTC time from the database to the local time zone
|
||||||
const [, month, day, year, hours, minutes, seconds = '0', meridiem] = matchUs;
|
|
||||||
return buildDateFromParts({
|
|
||||||
year,
|
|
||||||
month,
|
|
||||||
day,
|
|
||||||
hours,
|
|
||||||
minutes,
|
|
||||||
seconds,
|
|
||||||
meridiem,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDateFromParts({
|
|
||||||
year,
|
|
||||||
month,
|
|
||||||
day,
|
|
||||||
hours,
|
|
||||||
minutes,
|
|
||||||
seconds,
|
|
||||||
meridiem,
|
|
||||||
}: {
|
|
||||||
year: string;
|
|
||||||
month: string;
|
|
||||||
day: string;
|
|
||||||
hours: string;
|
|
||||||
minutes: string;
|
|
||||||
seconds: string;
|
|
||||||
meridiem?: string;
|
|
||||||
}): Date {
|
|
||||||
let hourValue = Number(hours);
|
|
||||||
if (meridiem) {
|
|
||||||
const normalized = meridiem.toUpperCase();
|
|
||||||
if (normalized === 'PM' && hourValue < 12) {
|
|
||||||
hourValue += 12;
|
|
||||||
} else if (normalized === 'AM' && hourValue === 12) {
|
|
||||||
hourValue = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(
|
|
||||||
Number(year),
|
|
||||||
Number(month) - 1,
|
|
||||||
Number(day),
|
|
||||||
hourValue,
|
|
||||||
Number(minutes),
|
|
||||||
Number(seconds),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDate(value: Date | string): Date {
|
|
||||||
if (value instanceof Date) {
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyDate = parseLegacyLocaleString(value);
|
|
||||||
if (legacyDate) {
|
|
||||||
return legacyDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(value);
|
|
||||||
if (!Number.isNaN(date.getTime())) {
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Invalid date value received: ${value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DateTimeTransformer implements ValueTransformer {
|
|
||||||
to(value: Date | string | null | undefined): Date | null | undefined {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return value === undefined ? undefined : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizeDate(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
from(value: Date | string | null | undefined): Date | null | undefined {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return value === undefined ? undefined : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizeDate(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import { ClientService } from './ORM/client/client.service';
|
|||||||
import { HomeGraphService } from './ORM/home-graph/home-graph.service';
|
import { HomeGraphService } from './ORM/home-graph/home-graph.service';
|
||||||
import { BitcoinRpcService } from './services/bitcoin-rpc.service';
|
import { BitcoinRpcService } from './services/bitcoin-rpc.service';
|
||||||
import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
|
import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
|
||||||
|
import { StratumV2Service } from './services/stratum-v2.service';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
@@ -25,7 +26,8 @@ export class AppController {
|
|||||||
private readonly bitcoinRpcService: BitcoinRpcService,
|
private readonly bitcoinRpcService: BitcoinRpcService,
|
||||||
private readonly homeGraphService: HomeGraphService,
|
private readonly homeGraphService: HomeGraphService,
|
||||||
private readonly addressSettingsService: AddressSettingsService,
|
private readonly addressSettingsService: AddressSettingsService,
|
||||||
private readonly userAgentReportService: UserAgentReportService
|
private readonly userAgentReportService: UserAgentReportService,
|
||||||
|
private readonly stratumV2Service: StratumV2Service
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@Get('info')
|
@Get('info')
|
||||||
@@ -42,6 +44,7 @@ export class AppController {
|
|||||||
|
|
||||||
const blockData = await this.blocksService.getFoundBlocks();
|
const blockData = await this.blocksService.getFoundBlocks();
|
||||||
const highScores = await this.addressSettingsService.getHighScores();
|
const highScores = await this.addressSettingsService.getHighScores();
|
||||||
|
const poolAuthority = await this.stratumV2Service.getPoolAuthorityPublicKey();
|
||||||
|
|
||||||
const other: {
|
const other: {
|
||||||
count: number,
|
count: number,
|
||||||
@@ -72,6 +75,10 @@ export class AppController {
|
|||||||
blockData,
|
blockData,
|
||||||
userAgents,
|
userAgents,
|
||||||
highScores,
|
highScores,
|
||||||
|
sv2: {
|
||||||
|
poolAuthorityPublicKey: poolAuthority.publicKey,
|
||||||
|
authorityKeyConfigured: poolAuthority.configured
|
||||||
|
},
|
||||||
uptime: this.uptime
|
uptime: this.uptime
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { DiscordService } from './services/discord.service';
|
|||||||
import { NotificationService } from './services/notification.service';
|
import { NotificationService } from './services/notification.service';
|
||||||
import { StratumV1JobsService } from './services/stratum-v1-jobs.service';
|
import { StratumV1JobsService } from './services/stratum-v1-jobs.service';
|
||||||
import { StratumV1Service } from './services/stratum-v1.service';
|
import { StratumV1Service } from './services/stratum-v1.service';
|
||||||
|
import { StratumV2Service } from './services/stratum-v2.service';
|
||||||
import { TelegramService } from './services/telegram.service';
|
import { TelegramService } from './services/telegram.service';
|
||||||
|
|
||||||
|
|
||||||
@@ -100,6 +101,7 @@ const ORMModules = [
|
|||||||
NotificationService,
|
NotificationService,
|
||||||
BitcoinAddressValidator,
|
BitcoinAddressValidator,
|
||||||
StratumV1JobsService,
|
StratumV1JobsService,
|
||||||
|
StratumV2Service,
|
||||||
BTCPayService,
|
BTCPayService,
|
||||||
BraiinsService
|
BraiinsService
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -48,6 +48,22 @@ describe('MiningJob', () => {
|
|||||||
expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
|
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 update block nonce, timestamp, version mask, and coinbase script', () => {
|
it('should update block nonce, timestamp, version mask, and coinbase script', () => {
|
||||||
const extraNonce1 = '57a6f098';
|
const extraNonce1 = '57a6f098';
|
||||||
const extraNonce2 = 'c708000000000000';
|
const extraNonce2 = 'c708000000000000';
|
||||||
@@ -109,32 +125,4 @@ describe('MiningJob', () => {
|
|||||||
|
|
||||||
expect(fastHeader.equals(updatedBlock.toBuffer(true))).toBe(true);
|
expect(fastHeader.equals(updatedBlock.toBuffer(true))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should expose coinbase helpers for downstream validation', () => {
|
|
||||||
const notify = JSON.parse(job.response(jobTemplate));
|
|
||||||
|
|
||||||
expect(job.getCoinbaseTxHex()).toContain(notify.params[2]);
|
|
||||||
expect(job.getCoinbasePrefixBuffer().toString('hex')).toBe(notify.params[2]);
|
|
||||||
expect(job.getCoinbaseSuffixBuffer().toString('hex')).toBe(notify.params[3]);
|
|
||||||
const clone = job.cloneCoinbaseTransaction();
|
|
||||||
const nonWitnessCoinbase = bitcoinjs.Transaction.fromHex(job.getCoinbaseTxHex());
|
|
||||||
expect(clone.ins[0].script.toString('hex')).toBe(nonWitnessCoinbase.ins[0].script.toString('hex'));
|
|
||||||
expect(clone.outs).toHaveLength(nonWitnessCoinbase.outs.length);
|
|
||||||
expect(clone.ins[0].witness[0]).toHaveLength(32);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should omit oversized pool identifiers from the coinbase script', () => {
|
|
||||||
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
||||||
const oversizedIdentifier = 'x'.repeat(120);
|
|
||||||
const oversizedJob = new MiningJob(
|
|
||||||
bitcoinjs.networks.testnet,
|
|
||||||
'2',
|
|
||||||
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
|
|
||||||
jobTemplate,
|
|
||||||
oversizedIdentifier
|
|
||||||
);
|
|
||||||
const coinbase = bitcoinjs.Transaction.fromHex(oversizedJob.getCoinbaseTxHex());
|
|
||||||
|
|
||||||
expect(coinbase.ins[0].script.toString()).not.toContain(oversizedIdentifier);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-37
@@ -3,10 +3,9 @@ import * as bitcoinjs from 'bitcoinjs-lib';
|
|||||||
|
|
||||||
import { IJobTemplate } from '../services/stratum-v1-jobs.service';
|
import { IJobTemplate } from '../services/stratum-v1-jobs.service';
|
||||||
import { eResponseMethod } from './enums/eResponseMethod';
|
import { eResponseMethod } from './enums/eResponseMethod';
|
||||||
|
import { IMiningNotify } from './stratum-messages/IMiningNotify';
|
||||||
import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants';
|
import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants';
|
||||||
|
|
||||||
const MAX_BLOCK_WEIGHT = 4000000;
|
|
||||||
const MAX_SCRIPT_SIZE = 100;
|
|
||||||
|
|
||||||
interface AddressObject {
|
interface AddressObject {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -20,19 +19,16 @@ export class MiningJob {
|
|||||||
private coinbasePart1Buffer: Buffer;
|
private coinbasePart1Buffer: Buffer;
|
||||||
private coinbasePart2Buffer: Buffer;
|
private coinbasePart2Buffer: Buffer;
|
||||||
private merkleBranchBuffers: Buffer[];
|
private merkleBranchBuffers: Buffer[];
|
||||||
private notifyStaticParams: string;
|
|
||||||
|
|
||||||
public jobTemplateId: string;
|
public jobTemplateId: string;
|
||||||
public networkDifficulty: number;
|
public networkDifficulty: number;
|
||||||
public creation: number;
|
public creation: number;
|
||||||
public retiredAt?: number;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private network: bitcoinjs.networks.Network,
|
private network: bitcoinjs.networks.Network,
|
||||||
public jobId: string,
|
public jobId: string,
|
||||||
payoutInformation: AddressObject[],
|
payoutInformation: AddressObject[],
|
||||||
jobTemplate: IJobTemplate,
|
jobTemplate: IJobTemplate
|
||||||
poolIdentifier: string = process.env.POOL_IDENTIFIER || 'Public-Pool'
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
this.creation = new Date().getTime();
|
this.creation = new Date().getTime();
|
||||||
@@ -49,7 +45,7 @@ export class MiningJob {
|
|||||||
// 32-byte - Commitment hash: Double-SHA256(witness root hash|witness reserved value)
|
// 32-byte - Commitment hash: Double-SHA256(witness root hash|witness reserved value)
|
||||||
|
|
||||||
// 39th byte onwards: Optional data with no consensus meaning
|
// 39th byte onwards: Optional data with no consensus meaning
|
||||||
const extra = Buffer.from(poolIdentifier);
|
const extra = Buffer.from('Public-Pool');
|
||||||
|
|
||||||
// Encode the block height
|
// Encode the block height
|
||||||
// https://github.com/bitcoin/bips/blob/master/bip-0034.mediawiki
|
// https://github.com/bitcoin/bips/blob/master/bip-0034.mediawiki
|
||||||
@@ -62,21 +58,10 @@ export class MiningJob {
|
|||||||
const padding = Buffer.alloc(TOTAL_EXTRANONCE_SIZE_BYTES + (3 - blockHeightEncoded.length), 0)
|
const padding = Buffer.alloc(TOTAL_EXTRANONCE_SIZE_BYTES + (3 - blockHeightEncoded.length), 0)
|
||||||
|
|
||||||
// build the script
|
// build the script
|
||||||
let script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, extra, padding]);
|
this.coinbaseTransaction.ins[0].script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, extra, padding])
|
||||||
if (script.length > MAX_SCRIPT_SIZE) {
|
|
||||||
console.warn('Pool identifier is too long, removing the pool identifier');
|
|
||||||
script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, padding]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.coinbaseTransaction.ins[0].script = script;
|
|
||||||
|
|
||||||
this.coinbaseTransaction.addOutput(bitcoinjs.script.compile([bitcoinjs.opcodes.OP_RETURN, Buffer.concat([segwitMagicBits, jobTemplate.block.witnessCommit])]), 0);
|
this.coinbaseTransaction.addOutput(bitcoinjs.script.compile([bitcoinjs.opcodes.OP_RETURN, Buffer.concat([segwitMagicBits, jobTemplate.block.witnessCommit])]), 0);
|
||||||
|
|
||||||
if ((this.coinbaseTransaction.weight() + jobTemplate.block.weight()) > MAX_BLOCK_WEIGHT) {
|
|
||||||
console.warn('Block weight exceeds the maximum allowed weight, removing the pool identifier');
|
|
||||||
this.coinbaseTransaction.ins[0].script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, padding]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// get the non-witness coinbase tx
|
// get the non-witness coinbase tx
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
const serializedCoinbaseTx = this.coinbaseTransaction.__toBuffer().toString('hex');
|
const serializedCoinbaseTx = this.coinbaseTransaction.__toBuffer().toString('hex');
|
||||||
@@ -89,22 +74,12 @@ export class MiningJob {
|
|||||||
this.coinbasePart2 = serializedCoinbaseTx.slice(partOneIndex);
|
this.coinbasePart2 = serializedCoinbaseTx.slice(partOneIndex);
|
||||||
this.coinbasePart1Buffer = Buffer.from(this.coinbasePart1, 'hex');
|
this.coinbasePart1Buffer = Buffer.from(this.coinbasePart1, 'hex');
|
||||||
this.coinbasePart2Buffer = Buffer.from(this.coinbasePart2, 'hex');
|
this.coinbasePart2Buffer = Buffer.from(this.coinbasePart2, 'hex');
|
||||||
this.notifyStaticParams = JSON.stringify([
|
|
||||||
this.swapEndianWords(jobTemplate.block.prevHash).toString('hex'),
|
|
||||||
this.coinbasePart1,
|
|
||||||
this.coinbasePart2,
|
|
||||||
jobTemplate.merkle_branch,
|
|
||||||
jobTemplate.block.version.toString(16),
|
|
||||||
jobTemplate.block.bits.toString(16),
|
|
||||||
jobTemplate.block.timestamp.toString(16),
|
|
||||||
jobTemplate.blockData.clearJobs
|
|
||||||
]).slice(1);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCoinbaseTxHex(): string {
|
public cloneCoinbaseTransaction(): bitcoinjs.Transaction {
|
||||||
//@ts-ignore
|
return bitcoinjs.Transaction.fromBuffer(this.coinbaseTransaction.toBuffer());
|
||||||
return this.coinbaseTransaction.__toBuffer().toString('hex');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCoinbasePrefixBuffer(): Buffer {
|
public getCoinbasePrefixBuffer(): Buffer {
|
||||||
@@ -115,10 +90,6 @@ export class MiningJob {
|
|||||||
return Buffer.from(this.coinbasePart2Buffer);
|
return Buffer.from(this.coinbasePart2Buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
public cloneCoinbaseTransaction(): bitcoinjs.Transaction {
|
|
||||||
return bitcoinjs.Transaction.fromBuffer(this.coinbaseTransaction.toBuffer());
|
|
||||||
}
|
|
||||||
|
|
||||||
public buildHeaderBuffer(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): Buffer {
|
public buildHeaderBuffer(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): Buffer {
|
||||||
const coinbaseBuffer = Buffer.concat([
|
const coinbaseBuffer = Buffer.concat([
|
||||||
this.coinbasePart1Buffer,
|
this.coinbasePart1Buffer,
|
||||||
@@ -246,7 +217,24 @@ export class MiningJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public response(jobTemplate: IJobTemplate): string {
|
public response(jobTemplate: IJobTemplate): string {
|
||||||
return `{"id":null,"method":"${eResponseMethod.MINING_NOTIFY}","params":[${JSON.stringify(this.jobId)},${this.notifyStaticParams}}\n`;
|
|
||||||
|
const job: IMiningNotify = {
|
||||||
|
id: null,
|
||||||
|
method: eResponseMethod.MINING_NOTIFY,
|
||||||
|
params: [
|
||||||
|
this.jobId,
|
||||||
|
this.swapEndianWords(jobTemplate.block.prevHash).toString('hex'),
|
||||||
|
this.coinbasePart1,
|
||||||
|
this.coinbasePart2,
|
||||||
|
jobTemplate.merkle_branch,
|
||||||
|
jobTemplate.block.version.toString(16),
|
||||||
|
jobTemplate.block.bits.toString(16),
|
||||||
|
jobTemplate.block.timestamp.toString(16),
|
||||||
|
jobTemplate.blockData.clearJobs
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(job) + '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { NotificationService } from '../services/notification.service';
|
|||||||
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
|
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
|
||||||
import { IBlockTemplate } from './bitcoin-rpc/IBlockTemplate';
|
import { IBlockTemplate } from './bitcoin-rpc/IBlockTemplate';
|
||||||
import { MiningJob } from './MiningJob';
|
import { MiningJob } from './MiningJob';
|
||||||
import { effectiveJobDifficulty, StratumV1Client } from './StratumV1Client';
|
import { StratumV1Client } from './StratumV1Client';
|
||||||
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
|
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
|
||||||
|
|
||||||
|
|
||||||
@@ -199,27 +199,6 @@ describe('StratumV1Client', () => {
|
|||||||
expect(socket.on).toHaveBeenCalled();
|
expect(socket.on).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should process batched socket messages sequentially', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
jest.spyOn(client as any, 'handleMessage').mockImplementation(async (message: string) => {
|
|
||||||
calls.push(`start:${message}`);
|
|
||||||
await Promise.resolve();
|
|
||||||
calls.push(`end:${message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
socketEmitter(Buffer.from('first\nsecond\n'));
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(calls).toEqual([
|
|
||||||
'start:first',
|
|
||||||
'end:first',
|
|
||||||
'start:second',
|
|
||||||
'end:second',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should respond to mining.subscribe', async () => {
|
it('should respond to mining.subscribe', async () => {
|
||||||
jest.spyOn(socket, 'write').mockImplementation((data) => true);
|
jest.spyOn(socket, 'write').mockImplementation((data) => true);
|
||||||
|
|
||||||
@@ -597,14 +576,12 @@ describe('StratumV1Client', () => {
|
|||||||
|
|
||||||
it('should submit and persist found blocks', async () => {
|
it('should submit and persist found blocks', async () => {
|
||||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
||||||
(bitcoinRpcService.SUBMIT_BLOCK as jest.Mock).mockResolvedValue('SUCCESS!');
|
|
||||||
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
|
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
|
||||||
submissionDifficulty: Number.MAX_SAFE_INTEGER,
|
submissionDifficulty: Number.MAX_SAFE_INTEGER,
|
||||||
submissionHash: 'block-share'
|
submissionHash: 'block-share'
|
||||||
});
|
});
|
||||||
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
|
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
|
||||||
const resetSpy = jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined);
|
jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined);
|
||||||
resetSpy.mockClear();
|
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
||||||
@@ -628,43 +605,6 @@ describe('StratumV1Client', () => {
|
|||||||
expect((client as any).write).lastCalledWith(`{"id":5,"error":null,"result":true}\n`);
|
expect((client as any).write).lastCalledWith(`{"id":5,"error":null,"result":true}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not persist or notify rejected block submissions', async () => {
|
|
||||||
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
|
|
||||||
(bitcoinRpcService.SUBMIT_BLOCK as jest.Mock).mockResolvedValue('bad-prevblk');
|
|
||||||
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
|
|
||||||
submissionDifficulty: Number.MAX_SAFE_INTEGER,
|
|
||||||
submissionHash: 'block-share'
|
|
||||||
});
|
|
||||||
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
|
|
||||||
const resetSpy = jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined);
|
|
||||||
resetSpy.mockClear();
|
|
||||||
|
|
||||||
emitMessage(MockRecording1.MINING_SUBSCRIBE);
|
|
||||||
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
|
|
||||||
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(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledWith(expect.any(String));
|
|
||||||
expect(blocksService.save).not.toHaveBeenCalled();
|
|
||||||
expect(notificationService.notifySubscribersBlockFound).not.toHaveBeenCalled();
|
|
||||||
expect(resetSpy).not.toHaveBeenCalled();
|
|
||||||
expect((client as any).write).lastCalledWith(`{"id":5,"error":null,"result":true}\n`);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('effectiveJobDifficulty', () => {
|
|
||||||
it('should use the old difficulty for jobs issued before a difficulty change', () => {
|
|
||||||
expect(effectiveJobDifficulty(9, 128, 32, 10)).toBe(32);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use the current difficulty for jobs issued after a difficulty change', () => {
|
|
||||||
expect(effectiveJobDifficulty(10, 128, 32, 10)).toBe(128);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -32,22 +32,6 @@ const TRUE_DIFF_ONE = 2.695953529101131e67;
|
|||||||
const BLOCKED_USER_AGENT_LOG_INTERVAL_MS = 60 * 1000;
|
const BLOCKED_USER_AGENT_LOG_INTERVAL_MS = 60 * 1000;
|
||||||
const VALIDATION_ERROR_LOG_INTERVAL_MS = 60 * 1000;
|
const VALIDATION_ERROR_LOG_INTERVAL_MS = 60 * 1000;
|
||||||
|
|
||||||
export function effectiveJobDifficulty(
|
|
||||||
jobIdInt: number,
|
|
||||||
currentDiff: number,
|
|
||||||
oldDiff: number,
|
|
||||||
diffChangeJobId: number | null,
|
|
||||||
): number {
|
|
||||||
if (diffChangeJobId == null || !Number.isFinite(jobIdInt)) {
|
|
||||||
return currentDiff;
|
|
||||||
}
|
|
||||||
if (jobIdInt < diffChangeJobId) {
|
|
||||||
return Math.min(currentDiff, oldDiff);
|
|
||||||
}
|
|
||||||
return currentDiff;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class StratumV1Client {
|
export class StratumV1Client {
|
||||||
private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>();
|
private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>();
|
||||||
private static validationErrorLogState = new Map<string, { nextLogAt: number, suppressed: number, sample: string }>();
|
private static validationErrorLogState = new Map<string, { nextLogAt: number, suppressed: number, sample: string }>();
|
||||||
@@ -63,8 +47,6 @@ export class StratumV1Client {
|
|||||||
private stratumInitialized = false;
|
private stratumInitialized = false;
|
||||||
private usedSuggestedDifficulty = false;
|
private usedSuggestedDifficulty = false;
|
||||||
private sessionDifficulty: number = 100000;
|
private sessionDifficulty: number = 100000;
|
||||||
private oldSessionDifficulty: number = 100000;
|
|
||||||
private diffChangeJobId: number | null = null;
|
|
||||||
|
|
||||||
private clientEntity: ClientEntity;
|
private clientEntity: ClientEntity;
|
||||||
private creatingEntity: Promise<void>;
|
private creatingEntity: Promise<void>;
|
||||||
@@ -76,6 +58,7 @@ export class StratumV1Client {
|
|||||||
|
|
||||||
private buffer: string = '';
|
private buffer: string = '';
|
||||||
private connectionClosed = false;
|
private connectionClosed = false;
|
||||||
|
private lastSentMiningJobTimestamp: number = null;
|
||||||
|
|
||||||
private miningSubmissionHashes = new Set<string>()
|
private miningSubmissionHashes = new Set<string>()
|
||||||
|
|
||||||
@@ -255,7 +238,6 @@ export class StratumV1Client {
|
|||||||
this.clientAuthorization = authorizationMessage;
|
this.clientAuthorization = authorizationMessage;
|
||||||
if (this.clientSuggestedDifficulty == null && this.clientAuthorization.startingDiff != null && this.clientAuthorization.startingDiff > this.sessionDifficulty) {
|
if (this.clientSuggestedDifficulty == null && this.clientAuthorization.startingDiff != null && this.clientAuthorization.startingDiff > this.sessionDifficulty) {
|
||||||
this.sessionDifficulty = this.clientAuthorization.startingDiff;
|
this.sessionDifficulty = this.clientAuthorization.startingDiff;
|
||||||
this.oldSessionDifficulty = this.sessionDifficulty;
|
|
||||||
}
|
}
|
||||||
const success = await this.write(JSON.stringify(this.clientAuthorization.response()) + '\n');
|
const success = await this.write(JSON.stringify(this.clientAuthorization.response()) + '\n');
|
||||||
if (!success) {
|
if (!success) {
|
||||||
@@ -297,7 +279,6 @@ export class StratumV1Client {
|
|||||||
|
|
||||||
this.clientSuggestedDifficulty = suggestDifficultyMessage;
|
this.clientSuggestedDifficulty = suggestDifficultyMessage;
|
||||||
this.sessionDifficulty = suggestDifficultyMessage.suggestedDifficulty;
|
this.sessionDifficulty = suggestDifficultyMessage.suggestedDifficulty;
|
||||||
this.oldSessionDifficulty = this.sessionDifficulty;
|
|
||||||
const success = await this.write(JSON.stringify(this.clientSuggestedDifficulty.response(this.sessionDifficulty)) + '\n');
|
const success = await this.write(JSON.stringify(this.clientSuggestedDifficulty.response(this.sessionDifficulty)) + '\n');
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return;
|
return;
|
||||||
@@ -395,7 +376,6 @@ export class StratumV1Client {
|
|||||||
switch (this.clientSubscription.userAgent) {
|
switch (this.clientSubscription.userAgent) {
|
||||||
case 'cpuminer': {
|
case 'cpuminer': {
|
||||||
this.sessionDifficulty = 0.1;
|
this.sessionDifficulty = 0.1;
|
||||||
this.oldSessionDifficulty = this.sessionDifficulty;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,8 +458,7 @@ export class StratumV1Client {
|
|||||||
network,
|
network,
|
||||||
this.stratumV1JobsService.getNextId(),
|
this.stratumV1JobsService.getNextId(),
|
||||||
payoutInformation,
|
payoutInformation,
|
||||||
jobTemplate,
|
jobTemplate
|
||||||
this.configService.get('POOL_IDENTIFIER') || 'Public-Pool'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.stratumV1JobsService.addJob(job);
|
this.stratumV1JobsService.addJob(job);
|
||||||
@@ -489,6 +468,7 @@ export class StratumV1Client {
|
|||||||
if (!success) {
|
if (!success) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.lastSentMiningJobTimestamp = jobTemplate.block.timestamp;
|
||||||
|
|
||||||
|
|
||||||
//console.log(`Sent new job to ${this.clientAuthorization.worker}.${this.extraNonceAndSessionId}. (clearJobs: ${jobTemplate.blockData.clearJobs}, fee?: ${!this.noFee})`)
|
//console.log(`Sent new job to ${this.clientAuthorization.worker}.${this.extraNonceAndSessionId}. (clearJobs: ${jobTemplate.blockData.clearJobs}, fee?: ${!this.noFee})`)
|
||||||
@@ -550,19 +530,6 @@ export class StratumV1Client {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const classification = this.stratumV1JobsService.classifyJobForShare(job);
|
|
||||||
if (classification === 'stale-rejected') {
|
|
||||||
const err = new StratumErrorMessage(
|
|
||||||
submission.id,
|
|
||||||
eStratumErrorCode.JobNotFound,
|
|
||||||
'stale').response();
|
|
||||||
const success = await this.write(err);
|
|
||||||
if (!success) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const submissionHash = [
|
const submissionHash = [
|
||||||
submission.jobId,
|
submission.jobId,
|
||||||
submission.extraNonce2,
|
submission.extraNonce2,
|
||||||
@@ -599,15 +566,9 @@ export class StratumV1Client {
|
|||||||
const { submissionDifficulty } = this.calculateDifficulty(header);
|
const { submissionDifficulty } = this.calculateDifficulty(header);
|
||||||
|
|
||||||
//console.log(`DIFF: ${submissionDifficulty} of ${this.sessionDifficulty} from ${this.clientAuthorization.worker + '.' + this.extraNonceAndSessionId}`);
|
//console.log(`DIFF: ${submissionDifficulty} of ${this.sessionDifficulty} from ${this.clientAuthorization.worker + '.' + this.extraNonceAndSessionId}`);
|
||||||
const effectiveDiff = effectiveJobDifficulty(
|
|
||||||
parseInt(submission.jobId, 16),
|
|
||||||
this.sessionDifficulty,
|
|
||||||
this.oldSessionDifficulty,
|
|
||||||
this.diffChangeJobId,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
if (submissionDifficulty >= effectiveDiff) {
|
if (submissionDifficulty >= this.sessionDifficulty) {
|
||||||
const success = await this.write(JSON.stringify(submission.response()) + '\n');
|
const success = await this.write(JSON.stringify(submission.response()) + '\n');
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return false;
|
return false;
|
||||||
@@ -625,24 +586,23 @@ export class StratumV1Client {
|
|||||||
);
|
);
|
||||||
const blockHex = updatedJobBlock.toHex(false);
|
const blockHex = updatedJobBlock.toHex(false);
|
||||||
const result = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
|
const result = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
|
||||||
if (result === 'SUCCESS!') {
|
await this.blocksService.save({
|
||||||
await this.blocksService.save({
|
height: jobTemplate.blockData.height,
|
||||||
height: jobTemplate.blockData.height,
|
minerAddress: this.clientAuthorization.address,
|
||||||
minerAddress: this.clientAuthorization.address,
|
worker: this.clientAuthorization.worker,
|
||||||
worker: this.clientAuthorization.worker,
|
sessionId: this.extraNonceAndSessionId,
|
||||||
sessionId: this.extraNonceAndSessionId,
|
blockData: blockHex
|
||||||
blockData: blockHex
|
});
|
||||||
});
|
|
||||||
|
|
||||||
await this.notificationService.notifySubscribersBlockFound(this.clientAuthorization.address, jobTemplate.blockData.height, updatedJobBlock, result);
|
await this.notificationService.notifySubscribersBlockFound(this.clientAuthorization.address, jobTemplate.blockData.height, updatedJobBlock, result);
|
||||||
|
//success
|
||||||
|
if (result == null) {
|
||||||
await this.addressSettingsService.resetBestDifficultyAndShares();
|
await this.addressSettingsService.resetBestDifficultyAndShares();
|
||||||
} else {
|
|
||||||
console.warn(`[Block submit rejected at height ${jobTemplate.blockData.height}]: ${result}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.ensureClientEntity();
|
await this.ensureClientEntity();
|
||||||
try {
|
try {
|
||||||
await this.statistics.addShares(this.clientEntity, effectiveDiff);
|
await this.statistics.addShares(this.clientEntity, this.sessionDifficulty);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// only update every minute
|
// only update every minute
|
||||||
//if (this.clientEntity.updatedAt == null || now.getTime() - this.clientEntity.updatedAt.getTime() > 1000 * 60) {
|
//if (this.clientEntity.updatedAt == null || now.getTime() - this.clientEntity.updatedAt.getTime() > 1000 * 60) {
|
||||||
@@ -689,11 +649,6 @@ export class StratumV1Client {
|
|||||||
|
|
||||||
if (targetDiff != this.sessionDifficulty) {
|
if (targetDiff != this.sessionDifficulty) {
|
||||||
//console.log(`Adjusting ${this.extraNonceAndSessionId} difficulty from ${this.sessionDifficulty} to ${targetDiff}`);
|
//console.log(`Adjusting ${this.extraNonceAndSessionId} difficulty from ${this.sessionDifficulty} to ${targetDiff}`);
|
||||||
if (!Number.isFinite(targetDiff)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.oldSessionDifficulty = this.sessionDifficulty;
|
|
||||||
this.diffChangeJobId = parseInt(this.stratumV1JobsService.getNextId(), 16);
|
|
||||||
this.sessionDifficulty = targetDiff;
|
this.sessionDifficulty = targetDiff;
|
||||||
|
|
||||||
const data = JSON.stringify({
|
const data = JSON.stringify({
|
||||||
@@ -703,15 +658,24 @@ export class StratumV1Client {
|
|||||||
}) + '\n';
|
}) + '\n';
|
||||||
|
|
||||||
|
|
||||||
const success = await this.write(data);
|
await this.socket.write(data);
|
||||||
if (!success) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$);
|
const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$);
|
||||||
// we need to clear the jobs so that the difficulty set takes effect. Otherwise the different miner implementations can cause issues
|
const nextTimestamp = Math.max(
|
||||||
jobTemplate.blockData.clearJobs = true;
|
jobTemplate.block.timestamp,
|
||||||
await this.sendNewMiningJob(jobTemplate);
|
Math.floor(Date.now() / 1000),
|
||||||
|
(this.lastSentMiningJobTimestamp ?? 0) + 1
|
||||||
|
);
|
||||||
|
// We need to clear jobs so the difficulty takes effect, but avoid mutating or
|
||||||
|
// re-sending the shared cached template with byte-identical work.
|
||||||
|
const refreshedJobTemplate: IJobTemplate = {
|
||||||
|
...jobTemplate,
|
||||||
|
block: Object.assign(new bitcoinjs.Block(), jobTemplate.block, {
|
||||||
|
timestamp: nextTimestamp
|
||||||
|
}),
|
||||||
|
blockData: { ...jobTemplate.blockData, clearJobs: true }
|
||||||
|
};
|
||||||
|
await this.sendNewMiningJob(refreshedJobTemplate);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,10 +81,10 @@ describe('StratumV1ClientStatistics', () => {
|
|||||||
expect(statistics.getSuggestedDifficulty(64)).toBeNull();
|
expect(statistics.getSuggestedDifficulty(64)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should lower difficulty when a miner has not submitted shares for more than a minute', () => {
|
it('should lower difficulty when a miner has not submitted shares for several minutes', () => {
|
||||||
jest.setSystemTime(new Date('2026-05-06T12:01:01Z'));
|
jest.setSystemTime(new Date('2026-05-06T12:06:00Z'));
|
||||||
|
|
||||||
expect(statistics.getSuggestedDifficulty(64)).toBe(12);
|
expect(statistics.getSuggestedDifficulty(64)).toBe(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should increase difficulty for rapid submissions', async () => {
|
it('should increase difficulty for rapid submissions', async () => {
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ export class StratumV1ClientStatistics {
|
|||||||
|
|
||||||
// miner hasn't submitted shares in one minute
|
// miner hasn't submitted shares in one minute
|
||||||
if (this.submissionCache.length < 5) {
|
if (this.submissionCache.length < 5) {
|
||||||
if ((new Date().getTime() - this.submissionCacheStart.getTime()) / 1000 > 60) {
|
if ((new Date().getTime() - this.submissionCacheStart.getTime()) / 5000 > 60) {
|
||||||
return this.nearestDifficultyStep(clientDifficulty / 6);
|
return this.nearestPowerOfTwo(clientDifficulty / 6);
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -116,45 +116,39 @@ export class StratumV1ClientStatistics {
|
|||||||
return pre;
|
return pre;
|
||||||
}, 0);
|
}, 0);
|
||||||
const diffSeconds = (this.submissionCache[this.submissionCache.length - 1].time.getTime() - this.submissionCache[0].time.getTime()) / 1000;
|
const diffSeconds = (this.submissionCache[this.submissionCache.length - 1].time.getTime() - this.submissionCache[0].time.getTime()) / 1000;
|
||||||
if (diffSeconds <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const difficultyPerSecond = sum / diffSeconds;
|
const difficultyPerSecond = sum / diffSeconds;
|
||||||
|
|
||||||
const targetDifficulty = difficultyPerSecond * this.targetSubmitShareEveryNSeconds;
|
const targetDifficulty = difficultyPerSecond * this.targetSubmitShareEveryNSeconds;
|
||||||
if (!Number.isFinite(difficultyPerSecond) || !Number.isFinite(targetDifficulty)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((clientDifficulty * 2) < targetDifficulty || (clientDifficulty / 2) > targetDifficulty) {
|
if ((clientDifficulty * 2) < targetDifficulty || (clientDifficulty / 2) > targetDifficulty) {
|
||||||
return this.nearestDifficultyStep(targetDifficulty)
|
return this.nearestPowerOfTwo(targetDifficulty)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private nearestDifficultyStep(val: number): number {
|
private nearestPowerOfTwo(val): number {
|
||||||
if (val === 0) {
|
if (val === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (val < MIN_DIFF) {
|
if (val < MIN_DIFF) {
|
||||||
return MIN_DIFF;
|
return MIN_DIFF;
|
||||||
}
|
}
|
||||||
|
let x = val | (val >> 1);
|
||||||
const exponent = Math.floor(Math.log2(val));
|
x = x | (x >> 2);
|
||||||
const lower = 2 ** exponent;
|
x = x | (x >> 4);
|
||||||
const middle = lower + lower / 2;
|
x = x | (x >> 8);
|
||||||
const upper = lower * 2;
|
x = x | (x >> 16);
|
||||||
|
x = x | (x >> 32);
|
||||||
const distances = [
|
const res = x - (x >> 1);
|
||||||
{ value: lower, diff: Math.abs(val - lower) },
|
if (res == 0 && val * 100 < MIN_DIFF) {
|
||||||
{ value: middle, diff: Math.abs(val - middle) },
|
return MIN_DIFF;
|
||||||
{ value: upper, diff: Math.abs(val - upper) },
|
}
|
||||||
];
|
if (res == 0) {
|
||||||
|
return this.nearestPowerOfTwo(val * 100) / 100;
|
||||||
distances.sort((a, b) => a.diff - b.diff);
|
}
|
||||||
return distances[0].value;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Socket } from 'net';
|
||||||
|
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { MockRecording1 } from '../../test/models/MockRecording1';
|
||||||
|
import { ClientEntity } from '../ORM/client/client.entity';
|
||||||
|
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
|
||||||
|
import { BufferReader } from './sv2/sv2-binary-codec';
|
||||||
|
import { SV2_CHANNEL_MSG_FLAG, Sv2MsgType } from './sv2/sv2-constants';
|
||||||
|
import {
|
||||||
|
deserializeOpenExtendedMiningChannelSuccess,
|
||||||
|
deserializeNewExtendedMiningJob,
|
||||||
|
serializeOpenExtendedMiningChannel,
|
||||||
|
serializeSubmitSharesExtended,
|
||||||
|
} from './sv2/sv2-extended-messages';
|
||||||
|
import { deserializeSetNewPrevHash, deserializeSubmitSharesError } from './sv2/sv2-messages';
|
||||||
|
import { StratumV2Client } from './StratumV2Client';
|
||||||
|
|
||||||
|
describe('StratumV2Client extended channels', () => {
|
||||||
|
let consoleLogSpy: jest.SpyInstance;
|
||||||
|
let consoleWarnSpy: jest.SpyInstance;
|
||||||
|
let consoleErrorSpy: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
|
||||||
|
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
|
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleLogSpy.mockRestore();
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens an extended channel and sends initial extended work', async () => {
|
||||||
|
const { client, sentFrames } = await createClient();
|
||||||
|
const maxTarget = Buffer.alloc(32, 0xff);
|
||||||
|
|
||||||
|
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
|
||||||
|
requestId: 7,
|
||||||
|
userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker',
|
||||||
|
nominalHashRate: 0,
|
||||||
|
maxTarget,
|
||||||
|
minExtranonceSize: 8,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const successFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.OPEN_EXTENDED_MINING_CHANNEL_SUCCESS);
|
||||||
|
const jobFrames = sentFrames.filter(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB);
|
||||||
|
const jobFrame = jobFrames[jobFrames.length - 1];
|
||||||
|
const prevHashFrames = sentFrames.filter(frame => frame.msgType === Sv2MsgType.SET_NEW_PREV_HASH);
|
||||||
|
const prevHashFrame = prevHashFrames[prevHashFrames.length - 1];
|
||||||
|
|
||||||
|
expect(successFrame?.extensionType).toBe(0);
|
||||||
|
const success = deserializeOpenExtendedMiningChannelSuccess(new BufferReader(successFrame.payload));
|
||||||
|
expect(success.requestId).toBe(7);
|
||||||
|
expect(success.channelId).toBe(1);
|
||||||
|
expect(success.extranonceSize).toBe(8);
|
||||||
|
expect(success.extranoncePrefix).toEqual(Buffer.from('00000001', 'hex'));
|
||||||
|
|
||||||
|
expect(jobFrame?.extensionType).toBe(SV2_CHANNEL_MSG_FLAG);
|
||||||
|
const job = deserializeNewExtendedMiningJob(new BufferReader(jobFrame.payload));
|
||||||
|
expect(job.channelId).toBe(1);
|
||||||
|
expect(job.minNtime).toBeNull();
|
||||||
|
expect(job.coinbasePrefix.length).toBeGreaterThan(0);
|
||||||
|
expect(job.coinbaseSuffix.length).toBeGreaterThan(0);
|
||||||
|
expect(job.merklePath.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(prevHashFrame?.extensionType).toBe(SV2_CHANNEL_MSG_FLAG);
|
||||||
|
const prevHash = deserializeSetNewPrevHash(new BufferReader(prevHashFrame.payload));
|
||||||
|
expect(prevHash.channelId).toBe(1);
|
||||||
|
expect(prevHash.jobId).toBe(job.jobId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects extended shares with the wrong negotiated extranonce size', async () => {
|
||||||
|
const { client, sentFrames } = await createClient();
|
||||||
|
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
|
||||||
|
requestId: 1,
|
||||||
|
userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker',
|
||||||
|
nominalHashRate: 0,
|
||||||
|
maxTarget: Buffer.alloc(32, 0xff),
|
||||||
|
minExtranonceSize: 8,
|
||||||
|
}));
|
||||||
|
const jobFrames = sentFrames.filter(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB);
|
||||||
|
const jobFrame = jobFrames[jobFrames.length - 1];
|
||||||
|
const job = deserializeNewExtendedMiningJob(new BufferReader(jobFrame.payload));
|
||||||
|
sentFrames.length = 0;
|
||||||
|
|
||||||
|
await (client as any).handleSubmitSharesExtended(serializeSubmitSharesExtended({
|
||||||
|
channelId: 1,
|
||||||
|
sequenceNumber: 42,
|
||||||
|
jobId: job.jobId,
|
||||||
|
nonce: 0,
|
||||||
|
ntime: parseInt(MockRecording1.TIME, 16),
|
||||||
|
version: job.version,
|
||||||
|
extranonce: Buffer.alloc(7),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const errorFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.SUBMIT_SHARES_ERROR);
|
||||||
|
expect(errorFrame?.extensionType).toBe(SV2_CHANNEL_MSG_FLAG);
|
||||||
|
const error = deserializeSubmitSharesError(new BufferReader(errorFrame.payload));
|
||||||
|
expect(error.channelId).toBe(1);
|
||||||
|
expect(error.sequenceNumber).toBe(42);
|
||||||
|
expect(error.errorCode).toBe('invalid-extranonce-size');
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createClient(): Promise<{ client: StratumV2Client; sentFrames: 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);
|
||||||
|
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',
|
||||||
|
bestDifficulty: 0,
|
||||||
|
} as unknown as ClientEntity;
|
||||||
|
|
||||||
|
let nextChannelId = 1;
|
||||||
|
const sentFrames: any[] = [];
|
||||||
|
const client = new StratumV2Client(
|
||||||
|
socket,
|
||||||
|
Buffer.alloc(0),
|
||||||
|
{
|
||||||
|
getNoiseConfig: () => ({
|
||||||
|
staticKeypair: {
|
||||||
|
privateKey: Buffer.alloc(32, 1),
|
||||||
|
publicKey: Buffer.alloc(64, 2),
|
||||||
|
},
|
||||||
|
certificateMessage: {
|
||||||
|
version: 0,
|
||||||
|
validFrom: 0,
|
||||||
|
notValidAfter: 1,
|
||||||
|
signature: Buffer.alloc(64),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getNextChannelId: () => nextChannelId++,
|
||||||
|
generateExtranoncePrefix: () => Buffer.from('00000002', 'hex'),
|
||||||
|
allocateExtendedExtranoncePrefix: jest.fn(() => Buffer.from('00000001', 'hex')),
|
||||||
|
releaseExtendedExtranoncePrefix: jest.fn(),
|
||||||
|
getExtendedMinerExtranonceSize: () => 8,
|
||||||
|
} as any,
|
||||||
|
stratumV1JobsService,
|
||||||
|
bitcoinRpcService as any,
|
||||||
|
{
|
||||||
|
insert: jest.fn().mockResolvedValue(clientEntity),
|
||||||
|
delete: jest.fn().mockResolvedValue(undefined),
|
||||||
|
heartbeatBulkAsync: jest.fn(),
|
||||||
|
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
insert: jest.fn().mockResolvedValue(undefined),
|
||||||
|
updateBulkAsync: jest.fn(),
|
||||||
|
} as any,
|
||||||
|
{ notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined) } as any,
|
||||||
|
{ save: jest.fn().mockResolvedValue(undefined) } as any,
|
||||||
|
{
|
||||||
|
get: jest.fn((key: string) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'NETWORK':
|
||||||
|
return 'testnet';
|
||||||
|
case 'SV2_START_DIFFICULTY':
|
||||||
|
return '1';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
} as unknown as ConfigService,
|
||||||
|
{
|
||||||
|
resetBestDifficultyAndShares: jest.fn().mockResolvedValue(undefined),
|
||||||
|
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
|
||||||
|
} as any,
|
||||||
|
);
|
||||||
|
(client as any).sendFrame = jest.fn((msgType: number, payload: Buffer, extensionType = 0) => {
|
||||||
|
sentFrames.push({ msgType, payload, extensionType });
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { client, sentFrames };
|
||||||
|
}
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -58,29 +58,6 @@ describe('MiningSubmitMessage', () => {
|
|||||||
|
|
||||||
expect(errors.some(error => error.property === 'extraNonce2')).toBe(true);
|
expect(errors.some(error => error.property === 'extraNonce2')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should hash submit fields without concatenation ambiguity', () => {
|
|
||||||
const first = plainToInstance(
|
|
||||||
MiningSubmitMessage,
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
method: 'mining.submit',
|
|
||||||
params: ['worker', 'e', '9902000000000000', 'd', 'bc', 'a']
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const second = plainToInstance(
|
|
||||||
MiningSubmitMessage,
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
method: 'mining.submit',
|
|
||||||
params: ['worker', 'e', '9902000000000000', 'd', 'c', 'ab']
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(first.versionMask + first.nonce + first.extraNonce2 + first.ntime + first.jobId)
|
|
||||||
.toBe(second.versionMask + second.nonce + second.extraNonce2 + second.ntime + second.jobId);
|
|
||||||
expect(first.hash()).not.toBe(second.hash());
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -68,14 +68,8 @@ export class MiningSubmitMessage extends StratumBaseMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public hash(): string{
|
public hash(): string{
|
||||||
const canonical = JSON.stringify({
|
const buffer = Buffer.from(this.versionMask + this.nonce + this.extraNonce2 + this.ntime + this.jobId);
|
||||||
versionMask: this.versionMask ?? '',
|
return bitcoinjs.crypto.hash256(buffer).toString('base64');
|
||||||
nonce: this.nonce,
|
|
||||||
extraNonce2: this.extraNonce2,
|
|
||||||
ntime: this.ntime,
|
|
||||||
jobId: this.jobId,
|
|
||||||
});
|
|
||||||
return bitcoinjs.crypto.hash256(Buffer.from(canonical)).toString('base64');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { encodeSv2AuthorityPublicKey } from './sv2-authority-key';
|
||||||
|
|
||||||
|
describe('SV2 authority key encoding', () => {
|
||||||
|
it('matches the protocol security test vector', () => {
|
||||||
|
const rawPublicKey = Buffer.from([
|
||||||
|
118, 99, 112, 0, 151, 156, 28, 17,
|
||||||
|
175, 12, 48, 11, 205, 140, 127, 228,
|
||||||
|
134, 16, 252, 233, 185, 193, 30, 61,
|
||||||
|
174, 227, 90, 224, 176, 138, 116, 85,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(encodeSv2AuthorityPublicKey(rawPublicKey))
|
||||||
|
.toBe('9bXiEd8boQVhq7WddEcERUL5tyyJVFYdU8th3HfbNXK3Yw6GRXh');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-x-only public keys', () => {
|
||||||
|
expect(() => encodeSv2AuthorityPublicKey(Buffer.alloc(33))).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as bs58 from 'bs58';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
const AUTHORITY_KEY_VERSION_PREFIX = Buffer.from([1, 0]);
|
||||||
|
|
||||||
|
export function encodeSv2AuthorityPublicKey(publicKey: Buffer): string {
|
||||||
|
if (publicKey.length !== 32) {
|
||||||
|
throw new RangeError(`SV2 authority public key must be 32 bytes, got ${publicKey.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionedKey = Buffer.concat([AUTHORITY_KEY_VERSION_PREFIX, publicKey]);
|
||||||
|
const checksum = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(crypto.createHash('sha256').update(versionedKey).digest())
|
||||||
|
.digest()
|
||||||
|
.subarray(0, 4);
|
||||||
|
|
||||||
|
return bs58.encode(Buffer.concat([versionedKey, checksum]));
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { BufferReader, BufferWriter } from './sv2-binary-codec';
|
||||||
|
|
||||||
|
describe('SV2 Binary Codec', () => {
|
||||||
|
// ── Round-trip Tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('BufferWriter + BufferReader round-trips', () => {
|
||||||
|
it('should round-trip Bool', () => {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeBool(true);
|
||||||
|
w.writeBool(false);
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
expect(r.readBool()).toBe(true);
|
||||||
|
expect(r.readBool()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round-trip U8', () => {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU8(0);
|
||||||
|
w.writeU8(255);
|
||||||
|
w.writeU8(42);
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
expect(r.readU8()).toBe(0);
|
||||||
|
expect(r.readU8()).toBe(255);
|
||||||
|
expect(r.readU8()).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round-trip U16', () => {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU16(0);
|
||||||
|
w.writeU16(65535);
|
||||||
|
w.writeU16(1234);
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
expect(r.readU16()).toBe(0);
|
||||||
|
expect(r.readU16()).toBe(65535);
|
||||||
|
expect(r.readU16()).toBe(1234);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round-trip U24', () => {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU24(0);
|
||||||
|
w.writeU24(0xffffff);
|
||||||
|
w.writeU24(123456);
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
expect(r.readU24()).toBe(0);
|
||||||
|
expect(r.readU24()).toBe(0xffffff);
|
||||||
|
expect(r.readU24()).toBe(123456);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round-trip U32', () => {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(0);
|
||||||
|
w.writeU32(0xffffffff);
|
||||||
|
w.writeU32(305419896);
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
expect(r.readU32()).toBe(0);
|
||||||
|
expect(r.readU32()).toBe(0xffffffff);
|
||||||
|
expect(r.readU32()).toBe(305419896);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round-trip U64', () => {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU64(0n);
|
||||||
|
w.writeU64(0xffffffffffffffffn);
|
||||||
|
w.writeU64(123456789012345678n);
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
expect(r.readU64()).toBe(0n);
|
||||||
|
expect(r.readU64()).toBe(0xffffffffffffffffn);
|
||||||
|
expect(r.readU64()).toBe(123456789012345678n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round-trip U256', () => {
|
||||||
|
const val = Buffer.alloc(32);
|
||||||
|
for (let i = 0; i < 32; i++) val[i] = i;
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU256(val);
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
expect(r.readU256()).toEqual(val);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round-trip F32', () => {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeF32(3.14);
|
||||||
|
w.writeF32(0);
|
||||||
|
w.writeF32(-1.5);
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
expect(r.readF32()).toBeCloseTo(3.14, 2);
|
||||||
|
expect(r.readF32()).toBe(0);
|
||||||
|
expect(r.readF32()).toBe(-1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round-trip Str0_255', () => {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeStr0_255('');
|
||||||
|
w.writeStr0_255('hello');
|
||||||
|
w.writeStr0_255('mining/pool/1.0');
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
expect(r.readStr0_255()).toBe('');
|
||||||
|
expect(r.readStr0_255()).toBe('hello');
|
||||||
|
expect(r.readStr0_255()).toBe('mining/pool/1.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round-trip B0_32', () => {
|
||||||
|
const val = Buffer.from([1, 2, 3, 4]);
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeB0_32(Buffer.alloc(0));
|
||||||
|
w.writeB0_32(val);
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
expect(r.readB0_32()).toEqual(Buffer.alloc(0));
|
||||||
|
expect(r.readB0_32()).toEqual(val);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round-trip B0_255', () => {
|
||||||
|
const val = Buffer.alloc(100, 0xab);
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeB0_255(val);
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
expect(r.readB0_255()).toEqual(val);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round-trip B0_64K', () => {
|
||||||
|
const val = Buffer.alloc(1000, 0xcd);
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeB0_64K(val);
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
expect(r.readB0_64K()).toEqual(val);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round-trip Seq0_255', () => {
|
||||||
|
const items = [10, 20, 30];
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeSeq0_255(items, (wr, v) => wr.writeU32(v));
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
const result = r.readSeq0_255((rd) => rd.readU32());
|
||||||
|
expect(result).toEqual(items);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round-trip Seq0_64K', () => {
|
||||||
|
const items = ['alpha', 'beta'];
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeSeq0_64K(items, (wr, v) => wr.writeStr0_255(v));
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
const result = r.readSeq0_64K((rd) => rd.readStr0_255());
|
||||||
|
expect(result).toEqual(items);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Little-endian Verification ──────────────────────────────────────
|
||||||
|
|
||||||
|
describe('little-endian encoding', () => {
|
||||||
|
it('U16 should be little-endian', () => {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU16(0x0102);
|
||||||
|
const buf = w.toBuffer();
|
||||||
|
expect(buf[0]).toBe(0x02); // low byte first
|
||||||
|
expect(buf[1]).toBe(0x01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('U32 should be little-endian', () => {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(0x01020304);
|
||||||
|
const buf = w.toBuffer();
|
||||||
|
expect(buf[0]).toBe(0x04);
|
||||||
|
expect(buf[1]).toBe(0x03);
|
||||||
|
expect(buf[2]).toBe(0x02);
|
||||||
|
expect(buf[3]).toBe(0x01);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Bounds Checking ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('bounds checking', () => {
|
||||||
|
it('BufferReader should throw on underflow', () => {
|
||||||
|
const r = new BufferReader(Buffer.alloc(1));
|
||||||
|
r.readU8(); // consumes the one byte
|
||||||
|
expect(() => r.readU8()).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BufferReader should throw when reading U32 from 2-byte buffer', () => {
|
||||||
|
const r = new BufferReader(Buffer.alloc(2));
|
||||||
|
expect(() => r.readU32()).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('B0_32 should reject payloads > 32 bytes', () => {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
expect(() => w.writeB0_32(Buffer.alloc(33))).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('B0_32 reader should reject length > 32', () => {
|
||||||
|
// Craft a buffer with length prefix = 33
|
||||||
|
const buf = Buffer.alloc(34);
|
||||||
|
buf[0] = 33;
|
||||||
|
const r = new BufferReader(buf);
|
||||||
|
expect(() => r.readB0_32()).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('U256 writer should reject non-32-byte buffers', () => {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
expect(() => w.writeU256(Buffer.alloc(31))).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Str0_255 should reject strings > 255 bytes', () => {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
expect(() => w.writeStr0_255('a'.repeat(256))).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remaining should track consumed bytes', () => {
|
||||||
|
const r = new BufferReader(Buffer.alloc(10));
|
||||||
|
expect(r.remaining).toBe(10);
|
||||||
|
r.readU32();
|
||||||
|
expect(r.remaining).toBe(6);
|
||||||
|
expect(r.position).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Edge Cases ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('empty buffer reader has 0 remaining', () => {
|
||||||
|
const r = new BufferReader(Buffer.alloc(0));
|
||||||
|
expect(r.remaining).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty Seq0_255 round-trips', () => {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeSeq0_255([], (wr, v) => wr.writeU8(v as number));
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
expect(r.readSeq0_255((rd) => rd.readU8())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maximum B0_32 (32 bytes) round-trips', () => {
|
||||||
|
const val = Buffer.alloc(32, 0xff);
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeB0_32(val);
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
expect(r.readB0_32()).toEqual(val);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writeU32 handles signed int32 from bitcoinjs block.version', () => {
|
||||||
|
// bitcoinjs-lib stores block.version as signed Int32 (readInt32).
|
||||||
|
// Version rolling or BIP9 signaling can set bit 31, producing
|
||||||
|
// a negative JS number. writeU32 must handle this via >>> 0.
|
||||||
|
const signedVersion = -1554660091; // 0xA355C505 unsigned
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(signedVersion);
|
||||||
|
const r = new BufferReader(w.toBuffer());
|
||||||
|
expect(r.readU32()).toBe(0xA355C505);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
// ── SV2 Binary Codec ────────────────────────────────────────────────
|
||||||
|
// Sequential Buffer reader / accumulator-pattern writer for all SV2
|
||||||
|
// data types. All multi-byte integers are little-endian per spec.
|
||||||
|
|
||||||
|
// ── BufferReader ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class BufferReader {
|
||||||
|
private offset = 0;
|
||||||
|
|
||||||
|
constructor(private readonly buf: Buffer) {}
|
||||||
|
|
||||||
|
get remaining(): number {
|
||||||
|
return this.buf.length - this.offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
get position(): number {
|
||||||
|
return this.offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensure(n: number): void {
|
||||||
|
if (this.offset + n > this.buf.length) {
|
||||||
|
throw new RangeError(
|
||||||
|
`BufferReader: need ${n} bytes at offset ${this.offset}, only ${this.remaining} available`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readBool(): boolean {
|
||||||
|
this.ensure(1);
|
||||||
|
return this.buf[this.offset++] !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
readU8(): number {
|
||||||
|
this.ensure(1);
|
||||||
|
return this.buf[this.offset++];
|
||||||
|
}
|
||||||
|
|
||||||
|
readU16(): number {
|
||||||
|
this.ensure(2);
|
||||||
|
const v = this.buf.readUInt16LE(this.offset);
|
||||||
|
this.offset += 2;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
readU24(): number {
|
||||||
|
this.ensure(3);
|
||||||
|
const v =
|
||||||
|
this.buf[this.offset] |
|
||||||
|
(this.buf[this.offset + 1] << 8) |
|
||||||
|
(this.buf[this.offset + 2] << 16);
|
||||||
|
this.offset += 3;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
readU32(): number {
|
||||||
|
this.ensure(4);
|
||||||
|
const v = this.buf.readUInt32LE(this.offset);
|
||||||
|
this.offset += 4;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read Sv2Option<u32>: 1-byte flag (0=None, 1=Some) + optional 4-byte u32 LE */
|
||||||
|
readOptionU32(): number | null {
|
||||||
|
this.ensure(1);
|
||||||
|
const flag = this.buf[this.offset++];
|
||||||
|
if (flag === 0) return null;
|
||||||
|
this.ensure(4);
|
||||||
|
const v = this.buf.readUInt32LE(this.offset);
|
||||||
|
this.offset += 4;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
readU64(): bigint {
|
||||||
|
this.ensure(8);
|
||||||
|
const v = this.buf.readBigUInt64LE(this.offset);
|
||||||
|
this.offset += 8;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
readU256(): Buffer {
|
||||||
|
this.ensure(32);
|
||||||
|
const v = Buffer.from(this.buf.subarray(this.offset, this.offset + 32));
|
||||||
|
this.offset += 32;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
readF32(): number {
|
||||||
|
this.ensure(4);
|
||||||
|
const v = this.buf.readFloatLE(this.offset);
|
||||||
|
this.offset += 4;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
readBytes(n: number): Buffer {
|
||||||
|
this.ensure(n);
|
||||||
|
const v = Buffer.from(this.buf.subarray(this.offset, this.offset + n));
|
||||||
|
this.offset += n;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** STR0_255: 1-byte length prefix + UTF-8 payload */
|
||||||
|
readStr0_255(): string {
|
||||||
|
const len = this.readU8();
|
||||||
|
return this.readBytes(len).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** B0_32: 1-byte length prefix, max 32 bytes */
|
||||||
|
readB0_32(): Buffer {
|
||||||
|
const len = this.readU8();
|
||||||
|
if (len > 32) throw new RangeError(`B0_32: length ${len} > 32`);
|
||||||
|
return this.readBytes(len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** B0_255: 1-byte length prefix, max 255 bytes */
|
||||||
|
readB0_255(): Buffer {
|
||||||
|
const len = this.readU8();
|
||||||
|
return this.readBytes(len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** B0_64K: 2-byte length prefix */
|
||||||
|
readB0_64K(): Buffer {
|
||||||
|
const len = this.readU16();
|
||||||
|
return this.readBytes(len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** B0_16M: 3-byte length prefix, max 16,777,215 bytes */
|
||||||
|
readB0_16M(): Buffer {
|
||||||
|
const len = this.readU24();
|
||||||
|
return this.readBytes(len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SEQ0_255: 1-byte count + repeated elements */
|
||||||
|
readSeq0_255<T>(readFn: (reader: BufferReader) => T): T[] {
|
||||||
|
const count = this.readU8();
|
||||||
|
const result: T[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
result.push(readFn(this));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SEQ0_64K: 2-byte count + repeated elements */
|
||||||
|
readSeq0_64K<T>(readFn: (reader: BufferReader) => T): T[] {
|
||||||
|
const count = this.readU16();
|
||||||
|
const result: T[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
result.push(readFn(this));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BufferWriter ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class BufferWriter {
|
||||||
|
private buf: Buffer;
|
||||||
|
private offset = 0;
|
||||||
|
|
||||||
|
constructor(initialSize = 256) {
|
||||||
|
this.buf = Buffer.allocUnsafe(initialSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCapacity(n: number): void {
|
||||||
|
if (this.offset + n > this.buf.length) {
|
||||||
|
let newSize = this.buf.length * 2;
|
||||||
|
while (newSize < this.offset + n) newSize *= 2;
|
||||||
|
const newBuf = Buffer.allocUnsafe(newSize);
|
||||||
|
this.buf.copy(newBuf, 0, 0, this.offset);
|
||||||
|
this.buf = newBuf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBool(v: boolean): void {
|
||||||
|
this.ensureCapacity(1);
|
||||||
|
this.buf[this.offset++] = v ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeU8(v: number): void {
|
||||||
|
this.ensureCapacity(1);
|
||||||
|
this.buf[this.offset++] = v & 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write Sv2Option<u32>: 1-byte flag + optional 4-byte u32 LE */
|
||||||
|
writeOptionU32(v: number | null): void {
|
||||||
|
if (v === null) {
|
||||||
|
this.writeU8(0);
|
||||||
|
} else {
|
||||||
|
this.writeU8(1);
|
||||||
|
this.writeU32(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeU16(v: number): void {
|
||||||
|
this.ensureCapacity(2);
|
||||||
|
this.buf.writeUInt16LE(v, this.offset);
|
||||||
|
this.offset += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeU24(v: number): void {
|
||||||
|
this.ensureCapacity(3);
|
||||||
|
this.buf[this.offset] = v & 0xff;
|
||||||
|
this.buf[this.offset + 1] = (v >> 8) & 0xff;
|
||||||
|
this.buf[this.offset + 2] = (v >> 16) & 0xff;
|
||||||
|
this.offset += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeU32(v: number): void {
|
||||||
|
this.ensureCapacity(4);
|
||||||
|
this.buf.writeUInt32LE(v >>> 0, this.offset);
|
||||||
|
this.offset += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeU64(v: bigint): void {
|
||||||
|
this.ensureCapacity(8);
|
||||||
|
this.buf.writeBigUInt64LE(v, this.offset);
|
||||||
|
this.offset += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeU256(v: Buffer): void {
|
||||||
|
if (v.length !== 32) throw new RangeError('U256 must be 32 bytes');
|
||||||
|
this.ensureCapacity(32);
|
||||||
|
v.copy(this.buf, this.offset);
|
||||||
|
this.offset += 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeF32(v: number): void {
|
||||||
|
this.ensureCapacity(4);
|
||||||
|
this.buf.writeFloatLE(v, this.offset);
|
||||||
|
this.offset += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBytes(v: Buffer): void {
|
||||||
|
this.ensureCapacity(v.length);
|
||||||
|
v.copy(this.buf, this.offset);
|
||||||
|
this.offset += v.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** STR0_255: 1-byte length prefix + UTF-8 payload */
|
||||||
|
writeStr0_255(v: string): void {
|
||||||
|
const payload = Buffer.from(v, 'utf8');
|
||||||
|
if (payload.length > 255)
|
||||||
|
throw new RangeError(`STR0_255: length ${payload.length} > 255`);
|
||||||
|
this.writeU8(payload.length);
|
||||||
|
this.writeBytes(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** B0_32: 1-byte length prefix, max 32 bytes */
|
||||||
|
writeB0_32(v: Buffer): void {
|
||||||
|
if (v.length > 32) throw new RangeError(`B0_32: length ${v.length} > 32`);
|
||||||
|
this.writeU8(v.length);
|
||||||
|
this.writeBytes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** B0_255: 1-byte length prefix, max 255 bytes */
|
||||||
|
writeB0_255(v: Buffer): void {
|
||||||
|
if (v.length > 255)
|
||||||
|
throw new RangeError(`B0_255: length ${v.length} > 255`);
|
||||||
|
this.writeU8(v.length);
|
||||||
|
this.writeBytes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** B0_64K: 2-byte length prefix */
|
||||||
|
writeB0_64K(v: Buffer): void {
|
||||||
|
if (v.length > 65535)
|
||||||
|
throw new RangeError(`B0_64K: length ${v.length} > 65535`);
|
||||||
|
this.writeU16(v.length);
|
||||||
|
this.writeBytes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** B0_16M: 3-byte length prefix, max 16,777,215 bytes */
|
||||||
|
writeB0_16M(v: Buffer): void {
|
||||||
|
if (v.length > 16777215)
|
||||||
|
throw new RangeError(`B0_16M: length ${v.length} > 16777215`);
|
||||||
|
this.writeU24(v.length);
|
||||||
|
this.writeBytes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SEQ0_255: 1-byte count + repeated elements */
|
||||||
|
writeSeq0_255<T>(items: T[], writeFn: (writer: BufferWriter, item: T) => void): void {
|
||||||
|
if (items.length > 255)
|
||||||
|
throw new RangeError(`SEQ0_255: count ${items.length} > 255`);
|
||||||
|
this.writeU8(items.length);
|
||||||
|
for (const item of items) {
|
||||||
|
writeFn(this, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SEQ0_64K: 2-byte count + repeated elements */
|
||||||
|
writeSeq0_64K<T>(items: T[], writeFn: (writer: BufferWriter, item: T) => void): void {
|
||||||
|
if (items.length > 65535)
|
||||||
|
throw new RangeError(`SEQ0_64K: count ${items.length} > 65535`);
|
||||||
|
this.writeU16(items.length);
|
||||||
|
for (const item of items) {
|
||||||
|
writeFn(this, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toBuffer(): Buffer {
|
||||||
|
return Buffer.from(this.buf.subarray(0, this.offset));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// ── SV2 Protocol Constants ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Standard (unencrypted) frame header: 2-byte ext+type + 3-byte length + 1-byte channel bit = 6 */
|
||||||
|
export const SV2_HEADER_SIZE = 6;
|
||||||
|
|
||||||
|
/** Poly1305 MAC appended to every AEAD ciphertext block */
|
||||||
|
export const SV2_MAC_SIZE = 16;
|
||||||
|
|
||||||
|
/** Encrypted frame header size: SV2_HEADER_SIZE + SV2_MAC_SIZE = 22 */
|
||||||
|
export const SV2_ENCRYPTED_HEADER_SIZE = SV2_HEADER_SIZE + SV2_MAC_SIZE;
|
||||||
|
|
||||||
|
/** Max plaintext bytes in a single AEAD chunk (2^16 - 1 - MAC) */
|
||||||
|
export const SV2_MAX_PLAINTEXT_CHUNK = 65519;
|
||||||
|
|
||||||
|
/** Max ciphertext bytes in a single AEAD chunk (2^16 - 1) */
|
||||||
|
export const SV2_MAX_CIPHERTEXT_CHUNK = 65535;
|
||||||
|
|
||||||
|
/** EllSwift-encoded public key size (64 bytes) */
|
||||||
|
export const SV2_ELLSWIFT_KEY_SIZE = 64;
|
||||||
|
|
||||||
|
/** Bit flag in extension_type indicating a channel message */
|
||||||
|
export const SV2_CHANNEL_MSG_FLAG = 0x8000;
|
||||||
|
|
||||||
|
/** Protocol identifier string used in SetupConnection */
|
||||||
|
export const SV2_PROTOCOL_NAME = 'MiningProtocol';
|
||||||
|
|
||||||
|
// ── Noise NX Handshake Constants ────────────────────────────────────
|
||||||
|
|
||||||
|
/** Noise NX protocol name used to initialize h (ChaCha20-Poly1305) */
|
||||||
|
export const SV2_NOISE_PROTOCOL_NAME = 'Noise_NX_Secp256k1+EllSwift_ChaChaPoly_SHA256';
|
||||||
|
|
||||||
|
/** Noise NX protocol name for AES-256-GCM cipher */
|
||||||
|
export const SV2_NOISE_PROTOCOL_NAME_AESGCM = 'Noise_NX_Secp256k1+EllSwift_AESGCM_SHA256';
|
||||||
|
|
||||||
|
/** Noise NX Act 1 size: 64-byte initiator ephemeral EllSwift key */
|
||||||
|
export const SV2_NOISE_ACT1_SIZE = 64;
|
||||||
|
|
||||||
|
/** Noise NX Act 2 size: 64 (server ephemeral) + 80 (encrypted static) + 90 (encrypted cert) = 234 */
|
||||||
|
export const SV2_NOISE_ACT2_SIZE = 234;
|
||||||
|
|
||||||
|
/** SIGNATURE_NOISE_MESSAGE payload size (without MAC): version(2) + validFrom(4) + notValidAfter(4) + signature(64) = 74 */
|
||||||
|
export const SV2_SIGNATURE_NOISE_MSG_SIZE = 74;
|
||||||
|
|
||||||
|
// ── Message Type Enum ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export enum Sv2MsgType {
|
||||||
|
// Common messages
|
||||||
|
SETUP_CONNECTION = 0x00,
|
||||||
|
SETUP_CONNECTION_SUCCESS = 0x01,
|
||||||
|
SETUP_CONNECTION_ERROR = 0x02,
|
||||||
|
CHANNEL_ENDPOINT_CHANGED = 0x03,
|
||||||
|
|
||||||
|
// Mining channel messages
|
||||||
|
OPEN_STANDARD_MINING_CHANNEL = 0x10,
|
||||||
|
OPEN_STANDARD_MINING_CHANNEL_SUCCESS = 0x11,
|
||||||
|
OPEN_STANDARD_MINING_CHANNEL_ERROR = 0x12,
|
||||||
|
CLOSE_CHANNEL = 0x18,
|
||||||
|
|
||||||
|
// Share submission
|
||||||
|
SUBMIT_SHARES_STANDARD = 0x1a,
|
||||||
|
SUBMIT_SHARES_SUCCESS = 0x1c,
|
||||||
|
SUBMIT_SHARES_ERROR = 0x1d,
|
||||||
|
|
||||||
|
// Extended Mining Channels
|
||||||
|
OPEN_EXTENDED_MINING_CHANNEL = 0x13,
|
||||||
|
OPEN_EXTENDED_MINING_CHANNEL_SUCCESS = 0x14,
|
||||||
|
SET_EXTRANONCE_PREFIX = 0x19,
|
||||||
|
SET_GROUP_CHANNEL = 0x25,
|
||||||
|
|
||||||
|
// Share submission (extended)
|
||||||
|
SUBMIT_SHARES_EXTENDED = 0x1b,
|
||||||
|
|
||||||
|
// Job & target
|
||||||
|
NEW_MINING_JOB = 0x15,
|
||||||
|
NEW_EXTENDED_MINING_JOB = 0x1f,
|
||||||
|
SET_NEW_PREV_HASH = 0x20,
|
||||||
|
SET_TARGET = 0x21,
|
||||||
|
|
||||||
|
// Channel management
|
||||||
|
UPDATE_CHANNEL = 0x16,
|
||||||
|
UPDATE_CHANNEL_ERROR = 0x17,
|
||||||
|
|
||||||
|
// Mining Protocol - Custom Job Bridge
|
||||||
|
SET_CUSTOM_MINING_JOB = 0x22,
|
||||||
|
SET_CUSTOM_MINING_JOB_SUCCESS = 0x23,
|
||||||
|
SET_CUSTOM_MINING_JOB_ERROR = 0x24,
|
||||||
|
|
||||||
|
// Reconnect
|
||||||
|
RECONNECT = 0x04,
|
||||||
|
|
||||||
|
// Job Declaration Protocol
|
||||||
|
JDP_ALLOCATE_MINING_JOB_TOKEN = 0x50,
|
||||||
|
JDP_ALLOCATE_MINING_JOB_TOKEN_SUCCESS = 0x51,
|
||||||
|
JDP_PROVIDE_MISSING_TRANSACTIONS = 0x55,
|
||||||
|
JDP_PROVIDE_MISSING_TRANSACTIONS_SUCCESS = 0x56,
|
||||||
|
JDP_DECLARE_MINING_JOB = 0x57,
|
||||||
|
JDP_DECLARE_MINING_JOB_SUCCESS = 0x58,
|
||||||
|
JDP_DECLARE_MINING_JOB_ERROR = 0x59,
|
||||||
|
JDP_PUSH_SOLUTION = 0x60,
|
||||||
|
|
||||||
|
// Template Distribution Protocol
|
||||||
|
TDP_COINBASE_OUTPUT_CONSTRAINTS = 0x70,
|
||||||
|
TDP_NEW_TEMPLATE = 0x71,
|
||||||
|
TDP_SET_NEW_PREV_HASH = 0x72,
|
||||||
|
TDP_REQUEST_TRANSACTION_DATA = 0x73,
|
||||||
|
TDP_REQUEST_TRANSACTION_DATA_SUCCESS = 0x74,
|
||||||
|
TDP_REQUEST_TRANSACTION_DATA_ERROR = 0x75,
|
||||||
|
TDP_SUBMIT_SOLUTION = 0x76,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Protocol Enum ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export enum Sv2Protocol {
|
||||||
|
MINING = 0,
|
||||||
|
JOB_DECLARATION = 1,
|
||||||
|
TEMPLATE_DISTRIBUTION = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mining Setup Flags ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export enum Sv2MiningSetupFlags {
|
||||||
|
REQUIRES_STANDARD_JOBS = 1 << 0,
|
||||||
|
REQUIRES_WORK_SELECTION = 1 << 1,
|
||||||
|
REQUIRES_VERSION_ROLLING = 1 << 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SetupConnection.Success Flags (Mining Protocol) ─────────────────
|
||||||
|
|
||||||
|
export enum Sv2MiningSetupSuccessFlags {
|
||||||
|
REQUIRES_FIXED_VERSION = 1 << 0,
|
||||||
|
REQUIRES_EXTENDED_CHANNELS = 1 << 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SetupConnection Flags (Job Declaration Protocol) ────────────────
|
||||||
|
|
||||||
|
export enum Sv2JdpSetupFlags {
|
||||||
|
DECLARE_TX_DATA = 1 << 0,
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { BufferReader } from './sv2-binary-codec';
|
||||||
|
import {
|
||||||
|
Sv2OpenExtendedMiningChannel,
|
||||||
|
serializeOpenExtendedMiningChannel,
|
||||||
|
deserializeOpenExtendedMiningChannel,
|
||||||
|
Sv2OpenExtendedMiningChannelSuccess,
|
||||||
|
serializeOpenExtendedMiningChannelSuccess,
|
||||||
|
deserializeOpenExtendedMiningChannelSuccess,
|
||||||
|
Sv2NewExtendedMiningJob,
|
||||||
|
serializeNewExtendedMiningJob,
|
||||||
|
deserializeNewExtendedMiningJob,
|
||||||
|
Sv2SubmitSharesExtended,
|
||||||
|
serializeSubmitSharesExtended,
|
||||||
|
deserializeSubmitSharesExtended,
|
||||||
|
Sv2SetExtranoncePrefix,
|
||||||
|
serializeSetExtranoncePrefix,
|
||||||
|
deserializeSetExtranoncePrefix,
|
||||||
|
Sv2SetGroupChannel,
|
||||||
|
serializeSetGroupChannel,
|
||||||
|
deserializeSetGroupChannel,
|
||||||
|
} from './sv2-extended-messages';
|
||||||
|
|
||||||
|
function roundTrip<T>(
|
||||||
|
msg: T,
|
||||||
|
serialize: (m: T) => Buffer,
|
||||||
|
deserialize: (r: BufferReader) => T,
|
||||||
|
): T {
|
||||||
|
const buf = serialize(msg);
|
||||||
|
return deserialize(new BufferReader(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SV2 Extended Mining Channel Messages', () => {
|
||||||
|
it('OpenExtendedMiningChannel round-trips', () => {
|
||||||
|
const maxTarget = Buffer.alloc(32, 0x00);
|
||||||
|
maxTarget[0] = 0xff;
|
||||||
|
const msg: Sv2OpenExtendedMiningChannel = {
|
||||||
|
requestId: 1,
|
||||||
|
userIdentity: 'bc1qtest.worker1',
|
||||||
|
nominalHashRate: 500000.0,
|
||||||
|
maxTarget,
|
||||||
|
minExtranonceSize: 8,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeOpenExtendedMiningChannel, deserializeOpenExtendedMiningChannel);
|
||||||
|
expect(result.requestId).toBe(msg.requestId);
|
||||||
|
expect(result.userIdentity).toBe(msg.userIdentity);
|
||||||
|
expect(result.nominalHashRate).toBeCloseTo(msg.nominalHashRate, 0);
|
||||||
|
expect(result.maxTarget).toEqual(msg.maxTarget);
|
||||||
|
expect(result.minExtranonceSize).toBe(msg.minExtranonceSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OpenExtendedMiningChannelSuccess round-trips', () => {
|
||||||
|
const target = Buffer.alloc(32);
|
||||||
|
target[0] = 0xff;
|
||||||
|
const msg: Sv2OpenExtendedMiningChannelSuccess = {
|
||||||
|
requestId: 1,
|
||||||
|
channelId: 42,
|
||||||
|
target,
|
||||||
|
extranonceSize: 8,
|
||||||
|
extranoncePrefix: Buffer.from([0x00, 0x00, 0x00, 0x01]),
|
||||||
|
groupChannelId: 0,
|
||||||
|
};
|
||||||
|
const result = roundTrip(
|
||||||
|
msg,
|
||||||
|
serializeOpenExtendedMiningChannelSuccess,
|
||||||
|
deserializeOpenExtendedMiningChannelSuccess,
|
||||||
|
);
|
||||||
|
expect(result.requestId).toBe(msg.requestId);
|
||||||
|
expect(result.channelId).toBe(msg.channelId);
|
||||||
|
expect(result.target).toEqual(msg.target);
|
||||||
|
expect(result.extranonceSize).toBe(msg.extranonceSize);
|
||||||
|
expect(result.extranoncePrefix).toEqual(msg.extranoncePrefix);
|
||||||
|
expect(result.groupChannelId).toBe(msg.groupChannelId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NewExtendedMiningJob round-trips with minNtime (mine immediately)', () => {
|
||||||
|
const hash1 = Buffer.alloc(32, 0xaa);
|
||||||
|
const hash2 = Buffer.alloc(32, 0xbb);
|
||||||
|
const msg: Sv2NewExtendedMiningJob = {
|
||||||
|
channelId: 1,
|
||||||
|
jobId: 100,
|
||||||
|
minNtime: 1700000000,
|
||||||
|
version: 0x20000000,
|
||||||
|
versionRollingAllowed: true,
|
||||||
|
merklePath: [hash1, hash2],
|
||||||
|
coinbasePrefix: Buffer.from('deadbeef', 'hex'),
|
||||||
|
coinbaseSuffix: Buffer.from('cafebabe', 'hex'),
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeNewExtendedMiningJob, deserializeNewExtendedMiningJob);
|
||||||
|
expect(result.channelId).toBe(msg.channelId);
|
||||||
|
expect(result.jobId).toBe(msg.jobId);
|
||||||
|
expect(result.minNtime).toBe(msg.minNtime);
|
||||||
|
expect(result.version).toBe(msg.version);
|
||||||
|
expect(result.versionRollingAllowed).toBe(msg.versionRollingAllowed);
|
||||||
|
expect(result.merklePath).toHaveLength(2);
|
||||||
|
expect(result.merklePath[0]).toEqual(hash1);
|
||||||
|
expect(result.merklePath[1]).toEqual(hash2);
|
||||||
|
expect(result.coinbasePrefix).toEqual(msg.coinbasePrefix);
|
||||||
|
expect(result.coinbaseSuffix).toEqual(msg.coinbaseSuffix);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NewExtendedMiningJob with null minNtime (future job)', () => {
|
||||||
|
const msg: Sv2NewExtendedMiningJob = {
|
||||||
|
channelId: 1,
|
||||||
|
jobId: 1,
|
||||||
|
minNtime: null,
|
||||||
|
version: 0x20000000,
|
||||||
|
versionRollingAllowed: false,
|
||||||
|
merklePath: [],
|
||||||
|
coinbasePrefix: Buffer.alloc(0),
|
||||||
|
coinbaseSuffix: Buffer.alloc(0),
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeNewExtendedMiningJob, deserializeNewExtendedMiningJob);
|
||||||
|
expect(result.merklePath).toHaveLength(0);
|
||||||
|
expect(result.minNtime).toBeNull();
|
||||||
|
expect(result.versionRollingAllowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SubmitSharesExtended round-trips', () => {
|
||||||
|
const msg: Sv2SubmitSharesExtended = {
|
||||||
|
channelId: 1,
|
||||||
|
sequenceNumber: 42,
|
||||||
|
jobId: 100,
|
||||||
|
nonce: 0xdeadbeef,
|
||||||
|
ntime: 1700000000,
|
||||||
|
version: 0x20000000,
|
||||||
|
extranonce: Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]),
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeSubmitSharesExtended, deserializeSubmitSharesExtended);
|
||||||
|
expect(result.channelId).toBe(msg.channelId);
|
||||||
|
expect(result.sequenceNumber).toBe(msg.sequenceNumber);
|
||||||
|
expect(result.jobId).toBe(msg.jobId);
|
||||||
|
expect(result.nonce).toBe(msg.nonce);
|
||||||
|
expect(result.ntime).toBe(msg.ntime);
|
||||||
|
expect(result.version).toBe(msg.version);
|
||||||
|
expect(result.extranonce).toEqual(msg.extranonce);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SubmitSharesExtended with short extranonce', () => {
|
||||||
|
const msg: Sv2SubmitSharesExtended = {
|
||||||
|
channelId: 1,
|
||||||
|
sequenceNumber: 1,
|
||||||
|
jobId: 1,
|
||||||
|
nonce: 0,
|
||||||
|
ntime: 1700000000,
|
||||||
|
version: 0x20000000,
|
||||||
|
extranonce: Buffer.from([0xab]),
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeSubmitSharesExtended, deserializeSubmitSharesExtended);
|
||||||
|
expect(result.extranonce).toEqual(Buffer.from([0xab]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SetExtranoncePrefix round-trips', () => {
|
||||||
|
const msg: Sv2SetExtranoncePrefix = {
|
||||||
|
channelId: 5,
|
||||||
|
extranoncePrefix: Buffer.from([0x00, 0x00, 0x00, 0x02]),
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeSetExtranoncePrefix, deserializeSetExtranoncePrefix);
|
||||||
|
expect(result.channelId).toBe(msg.channelId);
|
||||||
|
expect(result.extranoncePrefix).toEqual(msg.extranoncePrefix);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SetGroupChannel round-trips', () => {
|
||||||
|
const msg: Sv2SetGroupChannel = {
|
||||||
|
groupChannelId: 10,
|
||||||
|
channelIds: [42, 43, 44],
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeSetGroupChannel, deserializeSetGroupChannel);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serialized SubmitSharesExtended has correct byte layout', () => {
|
||||||
|
const msg: Sv2SubmitSharesExtended = {
|
||||||
|
channelId: 1,
|
||||||
|
sequenceNumber: 2,
|
||||||
|
jobId: 3,
|
||||||
|
nonce: 4,
|
||||||
|
ntime: 5,
|
||||||
|
version: 6,
|
||||||
|
extranonce: Buffer.from([0xab, 0xcd]),
|
||||||
|
};
|
||||||
|
const buf = serializeSubmitSharesExtended(msg);
|
||||||
|
// 6 * U32(4) = 24 + B0_32(1+2) = 27
|
||||||
|
expect(buf.length).toBe(27);
|
||||||
|
expect(buf.readUInt32LE(0)).toBe(1);
|
||||||
|
expect(buf.readUInt32LE(4)).toBe(2);
|
||||||
|
expect(buf.readUInt32LE(8)).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// ── SV2 Extended Mining Channel Messages ────────────────────────────
|
||||||
|
// Interfaces + serialize/deserialize for extended channel message types.
|
||||||
|
|
||||||
|
import { BufferReader, BufferWriter } from './sv2-binary-codec';
|
||||||
|
|
||||||
|
// ── OpenExtendedMiningChannel (0x13) ───────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2OpenExtendedMiningChannel {
|
||||||
|
requestId: number; // U32
|
||||||
|
userIdentity: string; // STR0_255
|
||||||
|
nominalHashRate: number; // F32
|
||||||
|
maxTarget: Buffer; // U256 (32 bytes LE)
|
||||||
|
minExtranonceSize: number; // U16
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeOpenExtendedMiningChannel(msg: Sv2OpenExtendedMiningChannel): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.requestId);
|
||||||
|
w.writeStr0_255(msg.userIdentity);
|
||||||
|
w.writeF32(msg.nominalHashRate);
|
||||||
|
w.writeU256(msg.maxTarget);
|
||||||
|
w.writeU16(msg.minExtranonceSize);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeOpenExtendedMiningChannel(reader: BufferReader): Sv2OpenExtendedMiningChannel {
|
||||||
|
return {
|
||||||
|
requestId: reader.readU32(),
|
||||||
|
userIdentity: reader.readStr0_255(),
|
||||||
|
nominalHashRate: reader.readF32(),
|
||||||
|
maxTarget: reader.readU256(),
|
||||||
|
minExtranonceSize: reader.readU16(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OpenExtendedMiningChannel.Success (0x14) ───────────────────────
|
||||||
|
|
||||||
|
export interface Sv2OpenExtendedMiningChannelSuccess {
|
||||||
|
requestId: number; // U32
|
||||||
|
channelId: number; // U32
|
||||||
|
target: Buffer; // U256
|
||||||
|
extranonceSize: number; // U16
|
||||||
|
extranoncePrefix: Buffer; // B0_32
|
||||||
|
groupChannelId: number; // U32
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeOpenExtendedMiningChannelSuccess(msg: Sv2OpenExtendedMiningChannelSuccess): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.requestId);
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeU256(msg.target);
|
||||||
|
w.writeU16(msg.extranonceSize);
|
||||||
|
w.writeB0_32(msg.extranoncePrefix);
|
||||||
|
w.writeU32(msg.groupChannelId);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeOpenExtendedMiningChannelSuccess(reader: BufferReader): Sv2OpenExtendedMiningChannelSuccess {
|
||||||
|
return {
|
||||||
|
requestId: reader.readU32(),
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
target: reader.readU256(),
|
||||||
|
extranonceSize: reader.readU16(),
|
||||||
|
extranoncePrefix: reader.readB0_32(),
|
||||||
|
groupChannelId: reader.readU32(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NewExtendedMiningJob (0x1f) ────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2NewExtendedMiningJob {
|
||||||
|
channelId: number; // U32
|
||||||
|
jobId: number; // U32
|
||||||
|
minNtime: number | null; // Sv2Option<U32> - null = future job (activated by SetNewPrevHash), Some(ts) = mine immediately
|
||||||
|
version: number; // U32
|
||||||
|
versionRollingAllowed: boolean; // BOOL
|
||||||
|
merklePath: Buffer[]; // SEQ0_255<U256>
|
||||||
|
coinbasePrefix: Buffer; // B0_64K
|
||||||
|
coinbaseSuffix: Buffer; // B0_64K
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeNewExtendedMiningJob(msg: Sv2NewExtendedMiningJob): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeU32(msg.jobId);
|
||||||
|
w.writeOptionU32(msg.minNtime);
|
||||||
|
w.writeU32(msg.version);
|
||||||
|
w.writeBool(msg.versionRollingAllowed);
|
||||||
|
w.writeSeq0_255(msg.merklePath, (writer, hash) => writer.writeU256(hash));
|
||||||
|
w.writeB0_64K(msg.coinbasePrefix);
|
||||||
|
w.writeB0_64K(msg.coinbaseSuffix);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeNewExtendedMiningJob(reader: BufferReader): Sv2NewExtendedMiningJob {
|
||||||
|
return {
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
jobId: reader.readU32(),
|
||||||
|
minNtime: reader.readOptionU32(),
|
||||||
|
version: reader.readU32(),
|
||||||
|
versionRollingAllowed: reader.readBool(),
|
||||||
|
merklePath: reader.readSeq0_255((r) => r.readU256()),
|
||||||
|
coinbasePrefix: reader.readB0_64K(),
|
||||||
|
coinbaseSuffix: reader.readB0_64K(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SubmitSharesExtended (0x1b) ────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2SubmitSharesExtended {
|
||||||
|
channelId: number; // U32
|
||||||
|
sequenceNumber: number; // U32
|
||||||
|
jobId: number; // U32
|
||||||
|
nonce: number; // U32
|
||||||
|
ntime: number; // U32
|
||||||
|
version: number; // U32
|
||||||
|
extranonce: Buffer; // B0_32
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSubmitSharesExtended(msg: Sv2SubmitSharesExtended): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeU32(msg.sequenceNumber);
|
||||||
|
w.writeU32(msg.jobId);
|
||||||
|
w.writeU32(msg.nonce);
|
||||||
|
w.writeU32(msg.ntime);
|
||||||
|
w.writeU32(msg.version);
|
||||||
|
w.writeB0_32(msg.extranonce);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeSubmitSharesExtended(reader: BufferReader): Sv2SubmitSharesExtended {
|
||||||
|
return {
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
sequenceNumber: reader.readU32(),
|
||||||
|
jobId: reader.readU32(),
|
||||||
|
nonce: reader.readU32(),
|
||||||
|
ntime: reader.readU32(),
|
||||||
|
version: reader.readU32(),
|
||||||
|
extranonce: reader.readB0_32(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SetExtranoncePrefix (0x19) ─────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2SetExtranoncePrefix {
|
||||||
|
channelId: number; // U32
|
||||||
|
extranoncePrefix: Buffer; // B0_32
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSetExtranoncePrefix(msg: Sv2SetExtranoncePrefix): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeB0_32(msg.extranoncePrefix);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeSetExtranoncePrefix(reader: BufferReader): Sv2SetExtranoncePrefix {
|
||||||
|
return {
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
extranoncePrefix: reader.readB0_32(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SetGroupChannel (0x25) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2SetGroupChannel {
|
||||||
|
groupChannelId: number; // U32
|
||||||
|
channelIds: number[]; // SEQ0_64K[U32]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSetGroupChannel(msg: Sv2SetGroupChannel): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.groupChannelId);
|
||||||
|
w.writeSeq0_64K(msg.channelIds, (writer, id) => writer.writeU32(id));
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeSetGroupChannel(reader: BufferReader): Sv2SetGroupChannel {
|
||||||
|
return {
|
||||||
|
groupChannelId: reader.readU32(),
|
||||||
|
channelIds: reader.readSeq0_64K((r) => r.readU32()),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Sv2ExtranonceManager } from './sv2-extranonce-manager';
|
||||||
|
|
||||||
|
|
||||||
|
describe('Sv2ExtranonceManager', () => {
|
||||||
|
it('allocates unique prefixes for different channels', () => {
|
||||||
|
const mgr = new Sv2ExtranonceManager();
|
||||||
|
const p1 = mgr.allocate(1);
|
||||||
|
const p2 = mgr.allocate(2);
|
||||||
|
const p3 = mgr.allocate(3);
|
||||||
|
|
||||||
|
expect(p1).not.toEqual(p2);
|
||||||
|
expect(p2).not.toEqual(p3);
|
||||||
|
expect(p1).not.toEqual(p3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns same prefix for same channel on re-allocation', () => {
|
||||||
|
const mgr = new Sv2ExtranonceManager();
|
||||||
|
const p1a = mgr.allocate(1);
|
||||||
|
const p1b = mgr.allocate(1);
|
||||||
|
expect(p1a).toEqual(p1b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefix is 4 bytes by default', () => {
|
||||||
|
const mgr = new Sv2ExtranonceManager();
|
||||||
|
const p = mgr.allocate(1);
|
||||||
|
expect(p.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('minerExtranonceSize is total minus prefix', () => {
|
||||||
|
const mgr = new Sv2ExtranonceManager(4, 8);
|
||||||
|
expect(mgr.minerExtranonceSize).toBe(4);
|
||||||
|
|
||||||
|
const mgr2 = new Sv2ExtranonceManager(2, 6);
|
||||||
|
expect(mgr2.minerExtranonceSize).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases prefix and allows reuse', () => {
|
||||||
|
const mgr = new Sv2ExtranonceManager();
|
||||||
|
const p1 = mgr.allocate(1);
|
||||||
|
expect(mgr.allocatedCount).toBe(1);
|
||||||
|
|
||||||
|
mgr.release(1);
|
||||||
|
expect(mgr.allocatedCount).toBe(0);
|
||||||
|
expect(mgr.getPrefix(1)).toBeUndefined();
|
||||||
|
|
||||||
|
// Should be able to allocate again (may get same or different prefix)
|
||||||
|
const p2 = mgr.allocate(10);
|
||||||
|
expect(p2.length).toBe(4);
|
||||||
|
expect(mgr.allocatedCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('release is idempotent for unknown channels', () => {
|
||||||
|
const mgr = new Sv2ExtranonceManager();
|
||||||
|
expect(() => mgr.release(999)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPrefix returns undefined for unallocated channel', () => {
|
||||||
|
const mgr = new Sv2ExtranonceManager();
|
||||||
|
expect(mgr.getPrefix(42)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPrefix returns the allocated prefix', () => {
|
||||||
|
const mgr = new Sv2ExtranonceManager();
|
||||||
|
const p = mgr.allocate(1);
|
||||||
|
expect(mgr.getPrefix(1)).toEqual(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles many allocations without collision', () => {
|
||||||
|
const mgr = new Sv2ExtranonceManager();
|
||||||
|
const prefixes = new Set<string>();
|
||||||
|
|
||||||
|
for (let i = 1; i <= 1000; i++) {
|
||||||
|
const p = mgr.allocate(i);
|
||||||
|
const hex = p.toString('hex');
|
||||||
|
expect(prefixes.has(hex)).toBe(false);
|
||||||
|
prefixes.add(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mgr.allocatedCount).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with 2-byte prefix size', () => {
|
||||||
|
const mgr = new Sv2ExtranonceManager(2, 4);
|
||||||
|
const p = mgr.allocate(1);
|
||||||
|
expect(p.length).toBe(2);
|
||||||
|
expect(mgr.minerExtranonceSize).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses released prefix slot', () => {
|
||||||
|
const mgr = new Sv2ExtranonceManager();
|
||||||
|
const p1 = mgr.allocate(1);
|
||||||
|
mgr.allocate(2);
|
||||||
|
mgr.release(1);
|
||||||
|
|
||||||
|
// Allocate a new channel - p1's prefix should be available
|
||||||
|
const p3 = mgr.allocate(3);
|
||||||
|
expect(p3.length).toBe(4);
|
||||||
|
expect(mgr.allocatedCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks allocatedCount correctly', () => {
|
||||||
|
const mgr = new Sv2ExtranonceManager();
|
||||||
|
expect(mgr.allocatedCount).toBe(0);
|
||||||
|
mgr.allocate(1);
|
||||||
|
expect(mgr.allocatedCount).toBe(1);
|
||||||
|
mgr.allocate(2);
|
||||||
|
expect(mgr.allocatedCount).toBe(2);
|
||||||
|
mgr.release(1);
|
||||||
|
expect(mgr.allocatedCount).toBe(1);
|
||||||
|
mgr.release(2);
|
||||||
|
expect(mgr.allocatedCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// ── SV2 Extranonce Manager ──────────────────────────────────────────
|
||||||
|
// Manages extranonce prefix allocation for extended mining channels.
|
||||||
|
// Ensures no two channels share the same prefix to prevent hash collisions.
|
||||||
|
|
||||||
|
export class Sv2ExtranonceManager {
|
||||||
|
private nextPrefix: number;
|
||||||
|
private readonly workerOffset: number;
|
||||||
|
private readonly maxPrefix: number;
|
||||||
|
private allocatedPrefixes = new Map<number, number>(); // channelId → prefix
|
||||||
|
private usedPrefixes = new Set<number>();
|
||||||
|
private readonly prefixSize: number;
|
||||||
|
private readonly totalExtranonceSize: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param prefixSize Bytes used for pool-assigned prefix (default 4)
|
||||||
|
* @param totalExtranonceSize Total extranonce bytes (default 12: 4 prefix + 8 miner-controlled).
|
||||||
|
* Must match MiningJob.ts's coinbase slot size (12 bytes) — both
|
||||||
|
* protocols share the same coinbase template. Bumped from the
|
||||||
|
* previous 8-byte total because the Braiins Hashpower marketplace
|
||||||
|
* requires extranonce2_size >= 7 (compatibility spec).
|
||||||
|
*/
|
||||||
|
constructor(prefixSize = 4, totalExtranonceSize = 12) {
|
||||||
|
this.prefixSize = prefixSize;
|
||||||
|
this.totalExtranonceSize = totalExtranonceSize;
|
||||||
|
|
||||||
|
const workerId = 0;
|
||||||
|
const bitsPerWorker = (prefixSize - 1) * 8;
|
||||||
|
this.workerOffset = workerId * Math.pow(2, bitsPerWorker);
|
||||||
|
this.maxPrefix = Math.pow(2, bitsPerWorker) - 1; // max within this worker's slice
|
||||||
|
this.nextPrefix = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
get minerExtranonceSize(): number {
|
||||||
|
return this.totalExtranonceSize - this.prefixSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocate a unique extranonce prefix for a channel.
|
||||||
|
* Returns a Buffer of `prefixSize` bytes.
|
||||||
|
*/
|
||||||
|
allocate(channelId: number): Buffer {
|
||||||
|
// Check if already allocated
|
||||||
|
if (this.allocatedPrefixes.has(channelId)) {
|
||||||
|
const existing = this.allocatedPrefixes.get(channelId)!;
|
||||||
|
return this.prefixToBuffer(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find next available prefix within this worker's partition
|
||||||
|
let localPrefix = this.nextPrefix;
|
||||||
|
let attempts = 0;
|
||||||
|
const globalPrefix = () => this.workerOffset + localPrefix;
|
||||||
|
|
||||||
|
while (this.usedPrefixes.has(globalPrefix()) && attempts <= this.maxPrefix) {
|
||||||
|
localPrefix = localPrefix + 1;
|
||||||
|
if (localPrefix > this.maxPrefix) localPrefix = 1; // Wrap within partition, skip 0
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.usedPrefixes.has(globalPrefix())) {
|
||||||
|
throw new Error('Extranonce prefix space exhausted');
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = globalPrefix();
|
||||||
|
this.allocatedPrefixes.set(channelId, prefix);
|
||||||
|
this.usedPrefixes.add(prefix);
|
||||||
|
this.nextPrefix = localPrefix + 1;
|
||||||
|
if (this.nextPrefix > this.maxPrefix) this.nextPrefix = 1;
|
||||||
|
|
||||||
|
return this.prefixToBuffer(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release the prefix allocated to a channel.
|
||||||
|
*/
|
||||||
|
release(channelId: number): void {
|
||||||
|
const prefix = this.allocatedPrefixes.get(channelId);
|
||||||
|
if (prefix !== undefined) {
|
||||||
|
this.allocatedPrefixes.delete(channelId);
|
||||||
|
this.usedPrefixes.delete(prefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the prefix for a channel, or undefined if not allocated.
|
||||||
|
*/
|
||||||
|
getPrefix(channelId: number): Buffer | undefined {
|
||||||
|
const prefix = this.allocatedPrefixes.get(channelId);
|
||||||
|
if (prefix === undefined) return undefined;
|
||||||
|
return this.prefixToBuffer(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
get allocatedCount(): number {
|
||||||
|
return this.allocatedPrefixes.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
private prefixToBuffer(prefix: number): Buffer {
|
||||||
|
const buf = Buffer.alloc(this.prefixSize);
|
||||||
|
// Write as big-endian so prefix 1 = 0x00000001
|
||||||
|
if (this.prefixSize === 4) {
|
||||||
|
buf.writeUInt32BE(prefix, 0);
|
||||||
|
} else if (this.prefixSize === 2) {
|
||||||
|
buf.writeUInt16BE(prefix, 0);
|
||||||
|
} else {
|
||||||
|
// Generic: write big-endian
|
||||||
|
let val = prefix;
|
||||||
|
for (let i = this.prefixSize - 1; i >= 0; i--) {
|
||||||
|
buf[i] = val & 0xff;
|
||||||
|
val = val >>> 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import {
|
||||||
|
encodeFrameHeader,
|
||||||
|
decodeFrameHeader,
|
||||||
|
isChannelMessage,
|
||||||
|
plaintextToCiphertextLength,
|
||||||
|
ciphertextToPlaintextLength,
|
||||||
|
Sv2FrameReader,
|
||||||
|
Sv2FrameWriter,
|
||||||
|
Sv2FrameHeader,
|
||||||
|
} from './sv2-frame';
|
||||||
|
import { SV2_CHANNEL_MSG_FLAG, SV2_MAC_SIZE, SV2_MAX_PLAINTEXT_CHUNK } from './sv2-constants';
|
||||||
|
|
||||||
|
describe('SV2 Frame', () => {
|
||||||
|
// ── Header Encode / Decode ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('frame header', () => {
|
||||||
|
it('should encode and decode a basic header', () => {
|
||||||
|
const header: Sv2FrameHeader = {
|
||||||
|
extensionType: 0x0000,
|
||||||
|
msgType: 0x10,
|
||||||
|
msgLength: 42,
|
||||||
|
};
|
||||||
|
const buf = encodeFrameHeader(header);
|
||||||
|
expect(buf.length).toBe(6);
|
||||||
|
const decoded = decodeFrameHeader(buf);
|
||||||
|
expect(decoded).toEqual(header);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle max msgLength (24-bit)', () => {
|
||||||
|
const header: Sv2FrameHeader = {
|
||||||
|
extensionType: 0,
|
||||||
|
msgType: 0xff,
|
||||||
|
msgLength: 0xffffff,
|
||||||
|
};
|
||||||
|
const buf = encodeFrameHeader(header);
|
||||||
|
const decoded = decodeFrameHeader(buf);
|
||||||
|
expect(decoded.msgLength).toBe(0xffffff);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve channel message flag', () => {
|
||||||
|
const header: Sv2FrameHeader = {
|
||||||
|
extensionType: SV2_CHANNEL_MSG_FLAG | 0x01,
|
||||||
|
msgType: 0x1a,
|
||||||
|
msgLength: 24,
|
||||||
|
};
|
||||||
|
const buf = encodeFrameHeader(header);
|
||||||
|
const decoded = decodeFrameHeader(buf);
|
||||||
|
expect(decoded.extensionType).toBe(SV2_CHANNEL_MSG_FLAG | 0x01);
|
||||||
|
expect(isChannelMessage(decoded)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect non-channel messages', () => {
|
||||||
|
const header: Sv2FrameHeader = {
|
||||||
|
extensionType: 0x0000,
|
||||||
|
msgType: 0x00,
|
||||||
|
msgLength: 10,
|
||||||
|
};
|
||||||
|
expect(isChannelMessage(header)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on buffer too short', () => {
|
||||||
|
expect(() => decodeFrameHeader(Buffer.alloc(5))).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Ciphertext Length Calculations ──────────────────────────────────
|
||||||
|
|
||||||
|
describe('ciphertext length calculations', () => {
|
||||||
|
it('should return 0 for 0-length plaintext', () => {
|
||||||
|
expect(plaintextToCiphertextLength(0)).toBe(0);
|
||||||
|
expect(ciphertextToPlaintextLength(0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add one MAC for small payload', () => {
|
||||||
|
expect(plaintextToCiphertextLength(100)).toBe(100 + SV2_MAC_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle exactly one full chunk', () => {
|
||||||
|
const ct = plaintextToCiphertextLength(SV2_MAX_PLAINTEXT_CHUNK);
|
||||||
|
expect(ct).toBe(SV2_MAX_PLAINTEXT_CHUNK + SV2_MAC_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle one full chunk + 1 byte remainder', () => {
|
||||||
|
const ct = plaintextToCiphertextLength(SV2_MAX_PLAINTEXT_CHUNK + 1);
|
||||||
|
expect(ct).toBe(SV2_MAX_PLAINTEXT_CHUNK + SV2_MAC_SIZE + 1 + SV2_MAC_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plaintext -> ciphertext -> plaintext round-trips', () => {
|
||||||
|
const sizes = [0, 1, 100, SV2_MAX_PLAINTEXT_CHUNK, SV2_MAX_PLAINTEXT_CHUNK + 1, SV2_MAX_PLAINTEXT_CHUNK * 2 + 500];
|
||||||
|
for (const pt of sizes) {
|
||||||
|
const ct = plaintextToCiphertextLength(pt);
|
||||||
|
expect(ciphertextToPlaintextLength(ct)).toBe(pt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for invalid ciphertext length (remainder too small)', () => {
|
||||||
|
// A remainder of exactly SV2_MAC_SIZE would mean 0 plaintext bytes but still a chunk
|
||||||
|
// Actually that's valid (0 plaintext + 16 MAC). Let's test remainder < MAC
|
||||||
|
expect(() => ciphertextToPlaintextLength(5)).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Plaintext Frame Reader / Writer ─────────────────────────────────
|
||||||
|
|
||||||
|
describe('plaintext framing', () => {
|
||||||
|
it('should write and read a complete plaintext frame', () => {
|
||||||
|
const writer = new Sv2FrameWriter(null);
|
||||||
|
const header: Sv2FrameHeader = {
|
||||||
|
extensionType: 0,
|
||||||
|
msgType: 0x00,
|
||||||
|
msgLength: 5,
|
||||||
|
};
|
||||||
|
const payload = Buffer.from('hello');
|
||||||
|
const wire = writer.writeFrame(header, payload);
|
||||||
|
|
||||||
|
const reader = new Sv2FrameReader(null);
|
||||||
|
const frames = reader.feed(wire);
|
||||||
|
expect(frames.length).toBe(1);
|
||||||
|
expect(frames[0].header.msgType).toBe(0x00);
|
||||||
|
expect(frames[0].payload.toString()).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle fragmented TCP delivery', () => {
|
||||||
|
const writer = new Sv2FrameWriter(null);
|
||||||
|
const header: Sv2FrameHeader = {
|
||||||
|
extensionType: 0,
|
||||||
|
msgType: 0x10,
|
||||||
|
msgLength: 10,
|
||||||
|
};
|
||||||
|
const payload = Buffer.alloc(10, 0xab);
|
||||||
|
const wire = writer.writeFrame(header, payload);
|
||||||
|
|
||||||
|
const reader = new Sv2FrameReader(null);
|
||||||
|
|
||||||
|
// Feed byte by byte
|
||||||
|
let frames: any[] = [];
|
||||||
|
for (let i = 0; i < wire.length; i++) {
|
||||||
|
const chunk = wire.subarray(i, i + 1);
|
||||||
|
frames = frames.concat(reader.feed(Buffer.from(chunk)));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(frames.length).toBe(1);
|
||||||
|
expect(frames[0].header.msgType).toBe(0x10);
|
||||||
|
expect(frames[0].payload).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse multiple frames in one feed', () => {
|
||||||
|
const writer = new Sv2FrameWriter(null);
|
||||||
|
const frames: Buffer[] = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const header: Sv2FrameHeader = {
|
||||||
|
extensionType: 0,
|
||||||
|
msgType: i,
|
||||||
|
msgLength: 4,
|
||||||
|
};
|
||||||
|
frames.push(writer.writeFrame(header, Buffer.alloc(4, i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new Sv2FrameReader(null);
|
||||||
|
const result = reader.feed(Buffer.concat(frames));
|
||||||
|
expect(result.length).toBe(3);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
expect(result[i].header.msgType).toBe(i);
|
||||||
|
expect(result[i].payload).toEqual(Buffer.alloc(4, i));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero-length payload', () => {
|
||||||
|
const writer = new Sv2FrameWriter(null);
|
||||||
|
const header: Sv2FrameHeader = {
|
||||||
|
extensionType: 0,
|
||||||
|
msgType: 0x01,
|
||||||
|
msgLength: 0,
|
||||||
|
};
|
||||||
|
const wire = writer.writeFrame(header, Buffer.alloc(0));
|
||||||
|
|
||||||
|
const reader = new Sv2FrameReader(null);
|
||||||
|
const frames = reader.feed(wire);
|
||||||
|
expect(frames.length).toBe(1);
|
||||||
|
expect(frames[0].payload.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Encrypted Frame Reader / Writer ─────────────────────────────────
|
||||||
|
|
||||||
|
describe('encrypted framing', () => {
|
||||||
|
it('should write and read encrypted frames with mock cipher', () => {
|
||||||
|
// Simple XOR "encryption" for testing frame logic
|
||||||
|
const xorKey = 0x42;
|
||||||
|
const encrypt = (pt: Buffer) => {
|
||||||
|
const ct = Buffer.alloc(pt.length + SV2_MAC_SIZE);
|
||||||
|
for (let i = 0; i < pt.length; i++) ct[i] = pt[i] ^ xorKey;
|
||||||
|
// Fake MAC: zeros
|
||||||
|
return ct;
|
||||||
|
};
|
||||||
|
const decrypt = (ct: Buffer) => {
|
||||||
|
const pt = Buffer.alloc(ct.length - SV2_MAC_SIZE);
|
||||||
|
for (let i = 0; i < pt.length; i++) pt[i] = ct[i] ^ xorKey;
|
||||||
|
return pt;
|
||||||
|
};
|
||||||
|
|
||||||
|
const writer = new Sv2FrameWriter(encrypt);
|
||||||
|
const header: Sv2FrameHeader = {
|
||||||
|
extensionType: 0,
|
||||||
|
msgType: 0x10,
|
||||||
|
msgLength: 8,
|
||||||
|
};
|
||||||
|
const payload = Buffer.from('testdata');
|
||||||
|
const wire = writer.writeFrame(header, payload);
|
||||||
|
|
||||||
|
const reader = new Sv2FrameReader(decrypt);
|
||||||
|
const frames = reader.feed(wire);
|
||||||
|
expect(frames.length).toBe(1);
|
||||||
|
expect(frames[0].header.msgType).toBe(0x10);
|
||||||
|
expect(frames[0].payload.toString()).toBe('testdata');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should switch from plaintext to encrypted mode', () => {
|
||||||
|
const encrypt = (pt: Buffer) => Buffer.concat([pt, Buffer.alloc(SV2_MAC_SIZE)]);
|
||||||
|
const decrypt = (ct: Buffer) => ct.subarray(0, ct.length - SV2_MAC_SIZE);
|
||||||
|
|
||||||
|
// Start in plaintext mode
|
||||||
|
const writer = new Sv2FrameWriter(null);
|
||||||
|
const reader = new Sv2FrameReader(null);
|
||||||
|
|
||||||
|
const plainHeader: Sv2FrameHeader = { extensionType: 0, msgType: 0x00, msgLength: 3 };
|
||||||
|
const plainWire = writer.writeFrame(plainHeader, Buffer.from('abc'));
|
||||||
|
let frames = reader.feed(plainWire);
|
||||||
|
expect(frames.length).toBe(1);
|
||||||
|
expect(frames[0].payload.toString()).toBe('abc');
|
||||||
|
|
||||||
|
// Switch to encrypted
|
||||||
|
writer.setEncryptFn(encrypt);
|
||||||
|
reader.setDecryptFn(decrypt);
|
||||||
|
|
||||||
|
const encHeader: Sv2FrameHeader = { extensionType: 0, msgType: 0x01, msgLength: 3 };
|
||||||
|
const encWire = writer.writeFrame(encHeader, Buffer.from('xyz'));
|
||||||
|
frames = reader.feed(encWire);
|
||||||
|
expect(frames.length).toBe(1);
|
||||||
|
expect(frames[0].payload.toString()).toBe('xyz');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// ── SV2 Frame Encoding / Decoding ───────────────────────────────────
|
||||||
|
// Handles 6-byte frame headers, encrypted framing with ChaCha20-Poly1305
|
||||||
|
// AEAD chunking, and incremental TCP reassembly.
|
||||||
|
|
||||||
|
import {
|
||||||
|
SV2_HEADER_SIZE,
|
||||||
|
SV2_MAC_SIZE,
|
||||||
|
SV2_ENCRYPTED_HEADER_SIZE,
|
||||||
|
SV2_MAX_PLAINTEXT_CHUNK,
|
||||||
|
SV2_CHANNEL_MSG_FLAG,
|
||||||
|
} from './sv2-constants';
|
||||||
|
|
||||||
|
// ── Frame Header ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2FrameHeader {
|
||||||
|
extensionType: number; // U16 (includes channel msg bit)
|
||||||
|
msgType: number; // U8
|
||||||
|
msgLength: number; // U24 (payload byte count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a 6-byte SV2 frame header.
|
||||||
|
* extension_type[0..1] | msg_type[2] | msg_length[3..5]
|
||||||
|
*/
|
||||||
|
export function encodeFrameHeader(header: Sv2FrameHeader): Buffer {
|
||||||
|
const buf = Buffer.alloc(SV2_HEADER_SIZE);
|
||||||
|
buf.writeUInt16LE(header.extensionType, 0);
|
||||||
|
buf[2] = header.msgType & 0xff;
|
||||||
|
buf[3] = header.msgLength & 0xff;
|
||||||
|
buf[4] = (header.msgLength >> 8) & 0xff;
|
||||||
|
buf[5] = (header.msgLength >> 16) & 0xff;
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a 6-byte SV2 frame header.
|
||||||
|
*/
|
||||||
|
export function decodeFrameHeader(buf: Buffer): Sv2FrameHeader {
|
||||||
|
if (buf.length < SV2_HEADER_SIZE) {
|
||||||
|
throw new RangeError(`Frame header requires ${SV2_HEADER_SIZE} bytes, got ${buf.length}`);
|
||||||
|
}
|
||||||
|
const extensionType = buf.readUInt16LE(0);
|
||||||
|
const msgType = buf[2];
|
||||||
|
const msgLength = buf[3] | (buf[4] << 8) | (buf[5] << 16);
|
||||||
|
return { extensionType, msgType, msgLength };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a frame header indicates a channel message.
|
||||||
|
*/
|
||||||
|
export function isChannelMessage(header: Sv2FrameHeader): boolean {
|
||||||
|
return (header.extensionType & SV2_CHANNEL_MSG_FLAG) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ciphertext / Plaintext Size Calculations ────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate ciphertext length from plaintext length.
|
||||||
|
* Each chunk of up to 65519 plaintext bytes gets a 16-byte MAC.
|
||||||
|
*/
|
||||||
|
export function plaintextToCiphertextLength(plaintextLen: number): number {
|
||||||
|
if (plaintextLen === 0) return 0;
|
||||||
|
const fullChunks = Math.floor(plaintextLen / SV2_MAX_PLAINTEXT_CHUNK);
|
||||||
|
const remainder = plaintextLen % SV2_MAX_PLAINTEXT_CHUNK;
|
||||||
|
let len = fullChunks * (SV2_MAX_PLAINTEXT_CHUNK + SV2_MAC_SIZE);
|
||||||
|
if (remainder > 0) {
|
||||||
|
len += remainder + SV2_MAC_SIZE;
|
||||||
|
}
|
||||||
|
return len;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate plaintext length from ciphertext length.
|
||||||
|
*/
|
||||||
|
export function ciphertextToPlaintextLength(ciphertextLen: number): number {
|
||||||
|
if (ciphertextLen === 0) return 0;
|
||||||
|
const chunkSize = SV2_MAX_PLAINTEXT_CHUNK + SV2_MAC_SIZE;
|
||||||
|
const fullChunks = Math.floor(ciphertextLen / chunkSize);
|
||||||
|
const remainder = ciphertextLen % chunkSize;
|
||||||
|
let len = fullChunks * SV2_MAX_PLAINTEXT_CHUNK;
|
||||||
|
if (remainder > 0) {
|
||||||
|
if (remainder <= SV2_MAC_SIZE) {
|
||||||
|
throw new RangeError('Invalid ciphertext length: remainder chunk too small for MAC');
|
||||||
|
}
|
||||||
|
len += remainder - SV2_MAC_SIZE;
|
||||||
|
}
|
||||||
|
return len;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decryption / Encryption Function Types ──────────────────────────
|
||||||
|
|
||||||
|
export type DecryptFn = (ciphertext: Buffer) => Buffer;
|
||||||
|
export type EncryptFn = (plaintext: Buffer) => Buffer;
|
||||||
|
|
||||||
|
// ── Frame Reader (TCP reassembly + decryption) ──────────────────────
|
||||||
|
|
||||||
|
interface ParsedFrame {
|
||||||
|
header: Sv2FrameHeader;
|
||||||
|
payload: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enum ReaderState {
|
||||||
|
READING_HEADER,
|
||||||
|
READING_PAYLOAD,
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Sv2FrameReader {
|
||||||
|
private decryptFn: DecryptFn | null;
|
||||||
|
private buf: Buffer = Buffer.alloc(0);
|
||||||
|
private state = ReaderState.READING_HEADER;
|
||||||
|
private currentHeader: Sv2FrameHeader | null = null;
|
||||||
|
|
||||||
|
constructor(decryptFn: DecryptFn | null) {
|
||||||
|
this.decryptFn = decryptFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch to encrypted mode (after handshake completes). */
|
||||||
|
setDecryptFn(fn: DecryptFn): void {
|
||||||
|
this.decryptFn = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expected header size: 22 bytes encrypted, 6 bytes plaintext. */
|
||||||
|
private get headerSize(): number {
|
||||||
|
return this.decryptFn ? SV2_ENCRYPTED_HEADER_SIZE : SV2_HEADER_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed incoming TCP data and return any complete frames.
|
||||||
|
*/
|
||||||
|
feed(data: Buffer): ParsedFrame[] {
|
||||||
|
this.buf = this.buf.length === 0 ? data : Buffer.concat([this.buf, data]);
|
||||||
|
const frames: ParsedFrame[] = [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (this.state === ReaderState.READING_HEADER) {
|
||||||
|
if (this.buf.length < this.headerSize) break;
|
||||||
|
|
||||||
|
let headerBytes: Buffer;
|
||||||
|
if (this.decryptFn) {
|
||||||
|
headerBytes = this.decryptFn(this.buf.subarray(0, SV2_ENCRYPTED_HEADER_SIZE));
|
||||||
|
} else {
|
||||||
|
headerBytes = this.buf.subarray(0, SV2_HEADER_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentHeader = decodeFrameHeader(headerBytes);
|
||||||
|
this.buf = Buffer.from(this.buf.subarray(this.headerSize));
|
||||||
|
this.state = ReaderState.READING_PAYLOAD;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state === ReaderState.READING_PAYLOAD) {
|
||||||
|
const payloadWireLen = this.decryptFn
|
||||||
|
? plaintextToCiphertextLength(this.currentHeader!.msgLength)
|
||||||
|
: this.currentHeader!.msgLength;
|
||||||
|
|
||||||
|
if (this.buf.length < payloadWireLen) break;
|
||||||
|
|
||||||
|
let payload: Buffer;
|
||||||
|
if (this.decryptFn && payloadWireLen > 0) {
|
||||||
|
payload = this.decryptPayloadChunks(this.buf.subarray(0, payloadWireLen));
|
||||||
|
} else {
|
||||||
|
payload = Buffer.from(this.buf.subarray(0, payloadWireLen));
|
||||||
|
}
|
||||||
|
|
||||||
|
frames.push({ header: this.currentHeader!, payload });
|
||||||
|
this.buf = Buffer.from(this.buf.subarray(payloadWireLen));
|
||||||
|
this.currentHeader = null;
|
||||||
|
this.state = ReaderState.READING_HEADER;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptPayloadChunks(ciphertext: Buffer): Buffer {
|
||||||
|
const chunkSize = SV2_MAX_PLAINTEXT_CHUNK + SV2_MAC_SIZE;
|
||||||
|
const parts: Buffer[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (offset < ciphertext.length) {
|
||||||
|
const remaining = ciphertext.length - offset;
|
||||||
|
const thisChunkLen = Math.min(remaining, chunkSize);
|
||||||
|
const chunk = ciphertext.subarray(offset, offset + thisChunkLen);
|
||||||
|
parts.push(this.decryptFn!(Buffer.from(chunk)));
|
||||||
|
offset += thisChunkLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat(parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Frame Writer (serialization + encryption) ───────────────────────
|
||||||
|
|
||||||
|
export class Sv2FrameWriter {
|
||||||
|
private encryptFn: EncryptFn | null;
|
||||||
|
|
||||||
|
constructor(encryptFn: EncryptFn | null) {
|
||||||
|
this.encryptFn = encryptFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch to encrypted mode (after handshake completes). */
|
||||||
|
setEncryptFn(fn: EncryptFn): void {
|
||||||
|
this.encryptFn = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a complete frame (header + payload) ready for the wire.
|
||||||
|
*/
|
||||||
|
writeFrame(header: Sv2FrameHeader, payload: Buffer): Buffer {
|
||||||
|
const headerBuf = encodeFrameHeader(header);
|
||||||
|
|
||||||
|
if (!this.encryptFn) {
|
||||||
|
return Buffer.concat([headerBuf, payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt header
|
||||||
|
const encHeader = this.encryptFn(headerBuf);
|
||||||
|
|
||||||
|
// Encrypt payload in chunks
|
||||||
|
const parts: Buffer[] = [encHeader];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (offset < payload.length) {
|
||||||
|
const remaining = payload.length - offset;
|
||||||
|
const chunkLen = Math.min(remaining, SV2_MAX_PLAINTEXT_CHUNK);
|
||||||
|
const chunk = payload.subarray(offset, offset + chunkLen);
|
||||||
|
parts.push(this.encryptFn(Buffer.from(chunk)));
|
||||||
|
offset += chunkLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat(parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { BufferReader } from './sv2-binary-codec';
|
||||||
|
import {
|
||||||
|
Sv2AllocateMiningJobToken,
|
||||||
|
serializeAllocateMiningJobToken,
|
||||||
|
deserializeAllocateMiningJobToken,
|
||||||
|
Sv2AllocateMiningJobTokenSuccess,
|
||||||
|
serializeAllocateMiningJobTokenSuccess,
|
||||||
|
deserializeAllocateMiningJobTokenSuccess,
|
||||||
|
Sv2DeclareMiningJob,
|
||||||
|
serializeDeclareMiningJob,
|
||||||
|
deserializeDeclareMiningJob,
|
||||||
|
Sv2DeclareMiningJobSuccess,
|
||||||
|
serializeDeclareMiningJobSuccess,
|
||||||
|
deserializeDeclareMiningJobSuccess,
|
||||||
|
Sv2DeclareMiningJobError,
|
||||||
|
serializeDeclareMiningJobError,
|
||||||
|
deserializeDeclareMiningJobError,
|
||||||
|
Sv2ProvideMissingTransactions,
|
||||||
|
serializeProvideMissingTransactions,
|
||||||
|
deserializeProvideMissingTransactions,
|
||||||
|
Sv2ProvideMissingTransactionsSuccess,
|
||||||
|
serializeProvideMissingTransactionsSuccess,
|
||||||
|
deserializeProvideMissingTransactionsSuccess,
|
||||||
|
Sv2SetCustomMiningJob,
|
||||||
|
serializeSetCustomMiningJob,
|
||||||
|
deserializeSetCustomMiningJob,
|
||||||
|
Sv2SetCustomMiningJobSuccess,
|
||||||
|
serializeSetCustomMiningJobSuccess,
|
||||||
|
deserializeSetCustomMiningJobSuccess,
|
||||||
|
Sv2SetCustomMiningJobError,
|
||||||
|
serializeSetCustomMiningJobError,
|
||||||
|
deserializeSetCustomMiningJobError,
|
||||||
|
Sv2PushSolution,
|
||||||
|
serializePushSolution,
|
||||||
|
deserializePushSolution,
|
||||||
|
} from './sv2-jdp-messages';
|
||||||
|
|
||||||
|
function roundTrip<T>(
|
||||||
|
msg: T,
|
||||||
|
serialize: (m: T) => Buffer,
|
||||||
|
deserialize: (r: BufferReader) => T,
|
||||||
|
): T {
|
||||||
|
const buf = serialize(msg);
|
||||||
|
return deserialize(new BufferReader(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SV2 JDP Messages', () => {
|
||||||
|
it('AllocateMiningJobToken round-trips', () => {
|
||||||
|
const msg: Sv2AllocateMiningJobToken = {
|
||||||
|
userIdentifier: 'miner-001',
|
||||||
|
requestId: 1,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeAllocateMiningJobToken, deserializeAllocateMiningJobToken);
|
||||||
|
expect(result.userIdentifier).toBe(msg.userIdentifier);
|
||||||
|
expect(result.requestId).toBe(msg.requestId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AllocateMiningJobTokenSuccess round-trips', () => {
|
||||||
|
const msg: Sv2AllocateMiningJobTokenSuccess = {
|
||||||
|
requestId: 1,
|
||||||
|
miningJobToken: Buffer.from('token123', 'utf8'),
|
||||||
|
coinbaseOutputs: Buffer.from('cafebabe', 'hex'),
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeAllocateMiningJobTokenSuccess, deserializeAllocateMiningJobTokenSuccess);
|
||||||
|
expect(result.requestId).toBe(msg.requestId);
|
||||||
|
expect(result.miningJobToken).toEqual(msg.miningJobToken);
|
||||||
|
expect(result.coinbaseOutputs).toEqual(msg.coinbaseOutputs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AllocateMiningJobTokenSuccess with empty coinbaseOutputs', () => {
|
||||||
|
const msg: Sv2AllocateMiningJobTokenSuccess = {
|
||||||
|
requestId: 42,
|
||||||
|
miningJobToken: Buffer.from('tok'),
|
||||||
|
coinbaseOutputs: Buffer.alloc(0),
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeAllocateMiningJobTokenSuccess, deserializeAllocateMiningJobTokenSuccess);
|
||||||
|
expect(result.coinbaseOutputs.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DeclareMiningJob round-trips', () => {
|
||||||
|
const wtxid1 = Buffer.alloc(32, 0x11);
|
||||||
|
const wtxid2 = Buffer.alloc(32, 0x22);
|
||||||
|
const msg: Sv2DeclareMiningJob = {
|
||||||
|
requestId: 42,
|
||||||
|
miningJobToken: Buffer.from('mytoken'),
|
||||||
|
version: 0x20000000,
|
||||||
|
coinbaseTxPrefix: Buffer.from('deadbeef', 'hex'),
|
||||||
|
coinbaseTxSuffix: Buffer.from('cafebabe', 'hex'),
|
||||||
|
wtxidList: [wtxid1, wtxid2],
|
||||||
|
excessData: Buffer.from('extra'),
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeDeclareMiningJob, deserializeDeclareMiningJob);
|
||||||
|
expect(result.requestId).toBe(msg.requestId);
|
||||||
|
expect(result.miningJobToken).toEqual(msg.miningJobToken);
|
||||||
|
expect(result.version).toBe(msg.version);
|
||||||
|
expect(result.coinbaseTxPrefix).toEqual(msg.coinbaseTxPrefix);
|
||||||
|
expect(result.coinbaseTxSuffix).toEqual(msg.coinbaseTxSuffix);
|
||||||
|
expect(result.wtxidList).toHaveLength(2);
|
||||||
|
expect(result.wtxidList[0]).toEqual(wtxid1);
|
||||||
|
expect(result.wtxidList[1]).toEqual(wtxid2);
|
||||||
|
expect(result.excessData).toEqual(msg.excessData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DeclareMiningJob with empty tx list', () => {
|
||||||
|
const msg: Sv2DeclareMiningJob = {
|
||||||
|
requestId: 1,
|
||||||
|
miningJobToken: Buffer.alloc(0),
|
||||||
|
version: 0x20000000,
|
||||||
|
coinbaseTxPrefix: Buffer.alloc(0),
|
||||||
|
coinbaseTxSuffix: Buffer.alloc(0),
|
||||||
|
wtxidList: [],
|
||||||
|
excessData: Buffer.alloc(0),
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeDeclareMiningJob, deserializeDeclareMiningJob);
|
||||||
|
expect(result.wtxidList).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DeclareMiningJobSuccess round-trips', () => {
|
||||||
|
const msg: Sv2DeclareMiningJobSuccess = {
|
||||||
|
requestId: 42,
|
||||||
|
newMiningJobToken: Buffer.from('newtoken'),
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeDeclareMiningJobSuccess, deserializeDeclareMiningJobSuccess);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DeclareMiningJobError round-trips', () => {
|
||||||
|
const msg: Sv2DeclareMiningJobError = {
|
||||||
|
requestId: 42,
|
||||||
|
errorCode: 'invalid-mining-job-token',
|
||||||
|
errorDetails: Buffer.from('Token expired'),
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeDeclareMiningJobError, deserializeDeclareMiningJobError);
|
||||||
|
expect(result.requestId).toBe(msg.requestId);
|
||||||
|
expect(result.errorCode).toBe(msg.errorCode);
|
||||||
|
expect(result.errorDetails).toEqual(msg.errorDetails);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ProvideMissingTransactions round-trips', () => {
|
||||||
|
const msg: Sv2ProvideMissingTransactions = {
|
||||||
|
requestId: 7,
|
||||||
|
unknownTxPositionList: [0, 3, 7, 15],
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeProvideMissingTransactions, deserializeProvideMissingTransactions);
|
||||||
|
expect(result.requestId).toBe(msg.requestId);
|
||||||
|
expect(result.unknownTxPositionList).toEqual([0, 3, 7, 15]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ProvideMissingTransactionsSuccess round-trips (B0_16M inner type)', () => {
|
||||||
|
const tx1 = Buffer.from('0100000001', 'hex');
|
||||||
|
const tx2 = Buffer.from('0200000002abcdef', 'hex');
|
||||||
|
const msg: Sv2ProvideMissingTransactionsSuccess = {
|
||||||
|
requestId: 7,
|
||||||
|
transactionList: [tx1, tx2],
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeProvideMissingTransactionsSuccess, deserializeProvideMissingTransactionsSuccess);
|
||||||
|
expect(result.requestId).toBe(msg.requestId);
|
||||||
|
expect(result.transactionList).toHaveLength(2);
|
||||||
|
expect(result.transactionList[0]).toEqual(tx1);
|
||||||
|
expect(result.transactionList[1]).toEqual(tx2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ProvideMissingTransactionsSuccess with large transaction (>64KB)', () => {
|
||||||
|
const largeTx = Buffer.alloc(100000, 0xab); // 100KB > 64KB limit of B0_64K
|
||||||
|
const msg: Sv2ProvideMissingTransactionsSuccess = {
|
||||||
|
requestId: 1,
|
||||||
|
transactionList: [largeTx],
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeProvideMissingTransactionsSuccess, deserializeProvideMissingTransactionsSuccess);
|
||||||
|
expect(result.transactionList[0].length).toBe(100000);
|
||||||
|
expect(result.transactionList[0]).toEqual(largeTx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SetCustomMiningJob round-trips', () => {
|
||||||
|
const hash1 = Buffer.alloc(32, 0xaa);
|
||||||
|
const msg: Sv2SetCustomMiningJob = {
|
||||||
|
channelId: 1,
|
||||||
|
requestId: 100,
|
||||||
|
token: Buffer.from('mytoken'),
|
||||||
|
version: 0x20000000,
|
||||||
|
prevHash: Buffer.alloc(32, 0xbb),
|
||||||
|
minNtime: 1700000000,
|
||||||
|
nBits: 0x1d00ffff,
|
||||||
|
coinbaseTxVersion: 2,
|
||||||
|
coinbasePrefix: Buffer.from('deadbeef', 'hex'),
|
||||||
|
coinbaseTxInputNSequence: 0xffffffff,
|
||||||
|
coinbaseTxOutputs: Buffer.from('outputs'),
|
||||||
|
coinbaseTxLocktime: 0,
|
||||||
|
merklePath: [hash1],
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeSetCustomMiningJob, deserializeSetCustomMiningJob);
|
||||||
|
expect(result.channelId).toBe(msg.channelId);
|
||||||
|
expect(result.requestId).toBe(msg.requestId);
|
||||||
|
expect(result.token).toEqual(msg.token);
|
||||||
|
expect(result.version).toBe(msg.version);
|
||||||
|
expect(result.prevHash).toEqual(msg.prevHash);
|
||||||
|
expect(result.minNtime).toBe(msg.minNtime);
|
||||||
|
expect(result.nBits).toBe(msg.nBits);
|
||||||
|
expect(result.coinbaseTxVersion).toBe(msg.coinbaseTxVersion);
|
||||||
|
expect(result.coinbasePrefix).toEqual(msg.coinbasePrefix);
|
||||||
|
expect(result.coinbaseTxInputNSequence).toBe(msg.coinbaseTxInputNSequence);
|
||||||
|
expect(result.coinbaseTxOutputs).toEqual(msg.coinbaseTxOutputs);
|
||||||
|
expect(result.coinbaseTxLocktime).toBe(msg.coinbaseTxLocktime);
|
||||||
|
expect(result.merklePath).toHaveLength(1);
|
||||||
|
expect(result.merklePath[0]).toEqual(hash1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SetCustomMiningJobSuccess round-trips', () => {
|
||||||
|
const msg: Sv2SetCustomMiningJobSuccess = {
|
||||||
|
channelId: 1,
|
||||||
|
requestId: 100,
|
||||||
|
jobId: 42,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeSetCustomMiningJobSuccess, deserializeSetCustomMiningJobSuccess);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SetCustomMiningJobError round-trips', () => {
|
||||||
|
const msg: Sv2SetCustomMiningJobError = {
|
||||||
|
channelId: 1,
|
||||||
|
requestId: 100,
|
||||||
|
errorCode: 'invalid-mining-job-token',
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeSetCustomMiningJobError, deserializeSetCustomMiningJobError);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serialized DeclareMiningJob has correct structure', () => {
|
||||||
|
const msg: Sv2DeclareMiningJob = {
|
||||||
|
requestId: 1,
|
||||||
|
miningJobToken: Buffer.from([0xab]),
|
||||||
|
version: 2,
|
||||||
|
coinbaseTxPrefix: Buffer.alloc(0),
|
||||||
|
coinbaseTxSuffix: Buffer.alloc(0),
|
||||||
|
wtxidList: [],
|
||||||
|
excessData: Buffer.alloc(0),
|
||||||
|
};
|
||||||
|
const buf = serializeDeclareMiningJob(msg);
|
||||||
|
// U32(4) + B0_255(1+1) + U32(4) + B0_64K(2+0) + B0_64K(2+0) + SEQ0_64K(2+0) + B0_64K(2+0)
|
||||||
|
// = 4 + 2 + 4 + 2 + 2 + 2 + 2 = 18
|
||||||
|
expect(buf.length).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PushSolution round-trips', () => {
|
||||||
|
const msg: Sv2PushSolution = {
|
||||||
|
extranonce: Buffer.from('aabbccdd', 'hex'),
|
||||||
|
prevHash: Buffer.alloc(32, 0x11),
|
||||||
|
nonce: 0xdeadbeef,
|
||||||
|
ntime: 1700000000,
|
||||||
|
nBits: 0x1d00ffff,
|
||||||
|
version: 0x20000000,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializePushSolution, deserializePushSolution);
|
||||||
|
expect(result.extranonce).toEqual(msg.extranonce);
|
||||||
|
expect(result.prevHash).toEqual(msg.prevHash);
|
||||||
|
expect(result.nonce).toBe(msg.nonce);
|
||||||
|
expect(result.ntime).toBe(msg.ntime);
|
||||||
|
expect(result.nBits).toBe(msg.nBits);
|
||||||
|
expect(result.version).toBe(msg.version);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PushSolution with empty extranonce', () => {
|
||||||
|
const msg: Sv2PushSolution = {
|
||||||
|
extranonce: Buffer.alloc(0),
|
||||||
|
prevHash: Buffer.alloc(32, 0xff),
|
||||||
|
nonce: 0,
|
||||||
|
ntime: 0,
|
||||||
|
nBits: 0,
|
||||||
|
version: 0,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializePushSolution, deserializePushSolution);
|
||||||
|
expect(result.extranonce.length).toBe(0);
|
||||||
|
expect(result.prevHash).toEqual(msg.prevHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serialized PushSolution has correct size', () => {
|
||||||
|
const msg: Sv2PushSolution = {
|
||||||
|
extranonce: Buffer.alloc(8),
|
||||||
|
prevHash: Buffer.alloc(32),
|
||||||
|
nonce: 0,
|
||||||
|
ntime: 0,
|
||||||
|
nBits: 0,
|
||||||
|
version: 0,
|
||||||
|
};
|
||||||
|
const buf = serializePushSolution(msg);
|
||||||
|
// B0_32(1+8) + U256(32) + U32(4) + U32(4) + U32(4) + U32(4) = 57
|
||||||
|
expect(buf.length).toBe(57);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serialized SetCustomMiningJob has correct fixed overhead', () => {
|
||||||
|
const msg: Sv2SetCustomMiningJob = {
|
||||||
|
channelId: 1,
|
||||||
|
requestId: 2,
|
||||||
|
token: Buffer.alloc(0),
|
||||||
|
version: 0x20000000,
|
||||||
|
prevHash: Buffer.alloc(32),
|
||||||
|
minNtime: 0,
|
||||||
|
nBits: 0,
|
||||||
|
coinbaseTxVersion: 2,
|
||||||
|
coinbasePrefix: Buffer.alloc(0),
|
||||||
|
coinbaseTxInputNSequence: 0xffffffff,
|
||||||
|
coinbaseTxOutputs: Buffer.alloc(0),
|
||||||
|
coinbaseTxLocktime: 0,
|
||||||
|
merklePath: [],
|
||||||
|
};
|
||||||
|
const buf = serializeSetCustomMiningJob(msg);
|
||||||
|
// U32(4) + U32(4) + B0_255(1+0) + U32(4) + U256(32) + U32(4) + U32(4) + U32(4) +
|
||||||
|
// B0_255(1+0) + U32(4) + B0_64K(2+0) + U32(4) + SEQ0_255(1+0)
|
||||||
|
// = 4+4+1+4+32+4+4+4+1+4+2+4+1 = 69
|
||||||
|
expect(buf.length).toBe(69);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
// ── SV2 Job Declaration Protocol (JDP) Messages ────────────────────
|
||||||
|
// Interfaces + serialize/deserialize for all JDP message types and
|
||||||
|
// the Mining Protocol custom job bridge messages.
|
||||||
|
// Aligned with Rust SV2 reference (stratum/sv2/subprotocols/job-declaration).
|
||||||
|
|
||||||
|
import { BufferReader, BufferWriter } from './sv2-binary-codec';
|
||||||
|
|
||||||
|
// ── AllocateMiningJobToken (0x50) ──────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2AllocateMiningJobToken {
|
||||||
|
userIdentifier: string; // STR0_255
|
||||||
|
requestId: number; // U32
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeAllocateMiningJobToken(msg: Sv2AllocateMiningJobToken): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeStr0_255(msg.userIdentifier);
|
||||||
|
w.writeU32(msg.requestId);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeAllocateMiningJobToken(reader: BufferReader): Sv2AllocateMiningJobToken {
|
||||||
|
return {
|
||||||
|
userIdentifier: reader.readStr0_255(),
|
||||||
|
requestId: reader.readU32(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AllocateMiningJobToken.Success (0x51) ──────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2AllocateMiningJobTokenSuccess {
|
||||||
|
requestId: number; // U32
|
||||||
|
miningJobToken: Buffer; // B0_255
|
||||||
|
coinbaseOutputs: Buffer; // B0_64K
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeAllocateMiningJobTokenSuccess(msg: Sv2AllocateMiningJobTokenSuccess): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.requestId);
|
||||||
|
w.writeB0_255(msg.miningJobToken);
|
||||||
|
w.writeB0_64K(msg.coinbaseOutputs);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeAllocateMiningJobTokenSuccess(reader: BufferReader): Sv2AllocateMiningJobTokenSuccess {
|
||||||
|
return {
|
||||||
|
requestId: reader.readU32(),
|
||||||
|
miningJobToken: reader.readB0_255(),
|
||||||
|
coinbaseOutputs: reader.readB0_64K(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DeclareMiningJob (0x57) ────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2DeclareMiningJob {
|
||||||
|
requestId: number; // U32
|
||||||
|
miningJobToken: Buffer; // B0_255
|
||||||
|
version: number; // U32
|
||||||
|
coinbaseTxPrefix: Buffer; // B0_64K
|
||||||
|
coinbaseTxSuffix: Buffer; // B0_64K
|
||||||
|
wtxidList: Buffer[]; // SEQ0_64K<U256>
|
||||||
|
excessData: Buffer; // B0_64K
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeDeclareMiningJob(msg: Sv2DeclareMiningJob): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.requestId);
|
||||||
|
w.writeB0_255(msg.miningJobToken);
|
||||||
|
w.writeU32(msg.version);
|
||||||
|
w.writeB0_64K(msg.coinbaseTxPrefix);
|
||||||
|
w.writeB0_64K(msg.coinbaseTxSuffix);
|
||||||
|
w.writeSeq0_64K(msg.wtxidList, (writer, hash) => writer.writeU256(hash));
|
||||||
|
w.writeB0_64K(msg.excessData);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeDeclareMiningJob(reader: BufferReader): Sv2DeclareMiningJob {
|
||||||
|
return {
|
||||||
|
requestId: reader.readU32(),
|
||||||
|
miningJobToken: reader.readB0_255(),
|
||||||
|
version: reader.readU32(),
|
||||||
|
coinbaseTxPrefix: reader.readB0_64K(),
|
||||||
|
coinbaseTxSuffix: reader.readB0_64K(),
|
||||||
|
wtxidList: reader.readSeq0_64K((r) => r.readU256()),
|
||||||
|
excessData: reader.readB0_64K(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DeclareMiningJob.Success (0x58) ────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2DeclareMiningJobSuccess {
|
||||||
|
requestId: number; // U32
|
||||||
|
newMiningJobToken: Buffer; // B0_255
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeDeclareMiningJobSuccess(msg: Sv2DeclareMiningJobSuccess): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.requestId);
|
||||||
|
w.writeB0_255(msg.newMiningJobToken);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeDeclareMiningJobSuccess(reader: BufferReader): Sv2DeclareMiningJobSuccess {
|
||||||
|
return {
|
||||||
|
requestId: reader.readU32(),
|
||||||
|
newMiningJobToken: reader.readB0_255(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DeclareMiningJob.Error (0x59) ──────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2DeclareMiningJobError {
|
||||||
|
requestId: number; // U32
|
||||||
|
errorCode: string; // STR0_255
|
||||||
|
errorDetails: Buffer; // B0_64K
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeDeclareMiningJobError(msg: Sv2DeclareMiningJobError): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.requestId);
|
||||||
|
w.writeStr0_255(msg.errorCode);
|
||||||
|
w.writeB0_64K(msg.errorDetails);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeDeclareMiningJobError(reader: BufferReader): Sv2DeclareMiningJobError {
|
||||||
|
return {
|
||||||
|
requestId: reader.readU32(),
|
||||||
|
errorCode: reader.readStr0_255(),
|
||||||
|
errorDetails: reader.readB0_64K(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ProvideMissingTransactions (0x55) ──────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2ProvideMissingTransactions {
|
||||||
|
requestId: number; // U32
|
||||||
|
unknownTxPositionList: number[]; // SEQ0_64K<U16>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeProvideMissingTransactions(msg: Sv2ProvideMissingTransactions): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.requestId);
|
||||||
|
w.writeSeq0_64K(msg.unknownTxPositionList, (writer, pos) => writer.writeU16(pos));
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeProvideMissingTransactions(reader: BufferReader): Sv2ProvideMissingTransactions {
|
||||||
|
return {
|
||||||
|
requestId: reader.readU32(),
|
||||||
|
unknownTxPositionList: reader.readSeq0_64K((r) => r.readU16()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ProvideMissingTransactions.Success (0x56) ──────────────────────
|
||||||
|
|
||||||
|
export interface Sv2ProvideMissingTransactionsSuccess {
|
||||||
|
requestId: number; // U32
|
||||||
|
transactionList: Buffer[]; // SEQ0_64K<B0_16M>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeProvideMissingTransactionsSuccess(msg: Sv2ProvideMissingTransactionsSuccess): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.requestId);
|
||||||
|
w.writeSeq0_64K(msg.transactionList, (writer, tx) => writer.writeB0_16M(tx));
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeProvideMissingTransactionsSuccess(reader: BufferReader): Sv2ProvideMissingTransactionsSuccess {
|
||||||
|
return {
|
||||||
|
requestId: reader.readU32(),
|
||||||
|
transactionList: reader.readSeq0_64K((r) => r.readB0_16M()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PushSolution (0x60) ────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2PushSolution {
|
||||||
|
extranonce: Buffer; // B0_32
|
||||||
|
prevHash: Buffer; // U256
|
||||||
|
nonce: number; // U32
|
||||||
|
ntime: number; // U32
|
||||||
|
nBits: number; // U32
|
||||||
|
version: number; // U32
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializePushSolution(msg: Sv2PushSolution): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeB0_32(msg.extranonce);
|
||||||
|
w.writeU256(msg.prevHash);
|
||||||
|
w.writeU32(msg.nonce);
|
||||||
|
w.writeU32(msg.ntime);
|
||||||
|
w.writeU32(msg.nBits);
|
||||||
|
w.writeU32(msg.version);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializePushSolution(reader: BufferReader): Sv2PushSolution {
|
||||||
|
return {
|
||||||
|
extranonce: reader.readB0_32(),
|
||||||
|
prevHash: reader.readU256(),
|
||||||
|
nonce: reader.readU32(),
|
||||||
|
ntime: reader.readU32(),
|
||||||
|
nBits: reader.readU32(),
|
||||||
|
version: reader.readU32(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SetCustomMiningJob (0x22) ──────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2SetCustomMiningJob {
|
||||||
|
channelId: number; // U32
|
||||||
|
requestId: number; // U32
|
||||||
|
token: Buffer; // B0_255
|
||||||
|
version: number; // U32
|
||||||
|
prevHash: Buffer; // U256
|
||||||
|
minNtime: number; // U32
|
||||||
|
nBits: number; // U32
|
||||||
|
coinbaseTxVersion: number; // U32
|
||||||
|
coinbasePrefix: Buffer; // B0_255
|
||||||
|
coinbaseTxInputNSequence: number; // U32
|
||||||
|
coinbaseTxOutputs: Buffer; // B0_64K
|
||||||
|
coinbaseTxLocktime: number; // U32
|
||||||
|
merklePath: Buffer[]; // SEQ0_255<U256>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSetCustomMiningJob(msg: Sv2SetCustomMiningJob): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeU32(msg.requestId);
|
||||||
|
w.writeB0_255(msg.token);
|
||||||
|
w.writeU32(msg.version);
|
||||||
|
w.writeU256(msg.prevHash);
|
||||||
|
w.writeU32(msg.minNtime);
|
||||||
|
w.writeU32(msg.nBits);
|
||||||
|
w.writeU32(msg.coinbaseTxVersion);
|
||||||
|
w.writeB0_255(msg.coinbasePrefix);
|
||||||
|
w.writeU32(msg.coinbaseTxInputNSequence);
|
||||||
|
w.writeB0_64K(msg.coinbaseTxOutputs);
|
||||||
|
w.writeU32(msg.coinbaseTxLocktime);
|
||||||
|
w.writeSeq0_255(msg.merklePath, (writer, hash) => writer.writeU256(hash));
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeSetCustomMiningJob(reader: BufferReader): Sv2SetCustomMiningJob {
|
||||||
|
return {
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
requestId: reader.readU32(),
|
||||||
|
token: reader.readB0_255(),
|
||||||
|
version: reader.readU32(),
|
||||||
|
prevHash: reader.readU256(),
|
||||||
|
minNtime: reader.readU32(),
|
||||||
|
nBits: reader.readU32(),
|
||||||
|
coinbaseTxVersion: reader.readU32(),
|
||||||
|
coinbasePrefix: reader.readB0_255(),
|
||||||
|
coinbaseTxInputNSequence: reader.readU32(),
|
||||||
|
coinbaseTxOutputs: reader.readB0_64K(),
|
||||||
|
coinbaseTxLocktime: reader.readU32(),
|
||||||
|
merklePath: reader.readSeq0_255((r) => r.readU256()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SetCustomMiningJob.Success (0x23) ──────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2SetCustomMiningJobSuccess {
|
||||||
|
channelId: number; // U32
|
||||||
|
requestId: number; // U32
|
||||||
|
jobId: number; // U32
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSetCustomMiningJobSuccess(msg: Sv2SetCustomMiningJobSuccess): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeU32(msg.requestId);
|
||||||
|
w.writeU32(msg.jobId);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeSetCustomMiningJobSuccess(reader: BufferReader): Sv2SetCustomMiningJobSuccess {
|
||||||
|
return {
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
requestId: reader.readU32(),
|
||||||
|
jobId: reader.readU32(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SetCustomMiningJob.Error (0x24) ────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2SetCustomMiningJobError {
|
||||||
|
channelId: number; // U32
|
||||||
|
requestId: number; // U32
|
||||||
|
errorCode: string; // STR0_255
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSetCustomMiningJobError(msg: Sv2SetCustomMiningJobError): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeU32(msg.requestId);
|
||||||
|
w.writeStr0_255(msg.errorCode);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeSetCustomMiningJobError(reader: BufferReader): Sv2SetCustomMiningJobError {
|
||||||
|
return {
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
requestId: reader.readU32(),
|
||||||
|
errorCode: reader.readStr0_255(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { BufferReader } from './sv2-binary-codec';
|
||||||
|
import {
|
||||||
|
Sv2SetupConnection, serializeSetupConnection, deserializeSetupConnection,
|
||||||
|
Sv2SetupConnectionSuccess, serializeSetupConnectionSuccess, deserializeSetupConnectionSuccess,
|
||||||
|
Sv2SetupConnectionError, serializeSetupConnectionError, deserializeSetupConnectionError,
|
||||||
|
Sv2ChannelEndpointChanged, serializeChannelEndpointChanged, deserializeChannelEndpointChanged,
|
||||||
|
Sv2OpenStandardMiningChannel, serializeOpenStandardMiningChannel, deserializeOpenStandardMiningChannel,
|
||||||
|
Sv2OpenStandardMiningChannelSuccess, serializeOpenStandardMiningChannelSuccess, deserializeOpenStandardMiningChannelSuccess,
|
||||||
|
Sv2NewMiningJob, serializeNewMiningJob, deserializeNewMiningJob,
|
||||||
|
Sv2SetNewPrevHash, serializeSetNewPrevHash, deserializeSetNewPrevHash,
|
||||||
|
Sv2SetTarget, serializeSetTarget, deserializeSetTarget,
|
||||||
|
Sv2SubmitSharesStandard, serializeSubmitSharesStandard, deserializeSubmitSharesStandard,
|
||||||
|
Sv2SubmitSharesSuccess, serializeSubmitSharesSuccess, deserializeSubmitSharesSuccess,
|
||||||
|
Sv2SubmitSharesError, serializeSubmitSharesError, deserializeSubmitSharesError,
|
||||||
|
Sv2CloseChannel, serializeCloseChannel, deserializeCloseChannel,
|
||||||
|
Sv2UpdateChannel, serializeUpdateChannel, deserializeUpdateChannel,
|
||||||
|
Sv2UpdateChannelError, serializeUpdateChannelError, deserializeUpdateChannelError,
|
||||||
|
Sv2Reconnect, serializeReconnect, deserializeReconnect,
|
||||||
|
} from './sv2-messages';
|
||||||
|
|
||||||
|
function roundTrip<T>(
|
||||||
|
msg: T,
|
||||||
|
serialize: (m: T) => Buffer,
|
||||||
|
deserialize: (r: BufferReader) => T,
|
||||||
|
): T {
|
||||||
|
const buf = serialize(msg);
|
||||||
|
return deserialize(new BufferReader(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SV2 Messages', () => {
|
||||||
|
it('SetupConnection round-trips', () => {
|
||||||
|
const msg: Sv2SetupConnection = {
|
||||||
|
protocol: 0,
|
||||||
|
minVersion: 2,
|
||||||
|
maxVersion: 2,
|
||||||
|
flags: 0x07,
|
||||||
|
endpoint_host: 'pool.example.com',
|
||||||
|
endpoint_port: 3333,
|
||||||
|
vendor: 'TestMiner',
|
||||||
|
hardwareVersion: '1.0',
|
||||||
|
firmwareVersion: '2.1',
|
||||||
|
deviceId: 'device-001',
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeSetupConnection, deserializeSetupConnection);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SetupConnectionSuccess round-trips', () => {
|
||||||
|
const msg: Sv2SetupConnectionSuccess = {
|
||||||
|
usedVersion: 2,
|
||||||
|
flags: 0x05,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeSetupConnectionSuccess, deserializeSetupConnectionSuccess);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SetupConnectionError round-trips', () => {
|
||||||
|
const msg: Sv2SetupConnectionError = {
|
||||||
|
flags: 0x00,
|
||||||
|
errorCode: 'unsupported-feature-flags',
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeSetupConnectionError, deserializeSetupConnectionError);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ChannelEndpointChanged round-trips', () => {
|
||||||
|
const msg: Sv2ChannelEndpointChanged = {
|
||||||
|
channelId: 7,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeChannelEndpointChanged, deserializeChannelEndpointChanged);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OpenStandardMiningChannel round-trips', () => {
|
||||||
|
const maxTarget = Buffer.alloc(32, 0x00);
|
||||||
|
maxTarget[0] = 0xff;
|
||||||
|
maxTarget[28] = 0x1d;
|
||||||
|
const msg: Sv2OpenStandardMiningChannel = {
|
||||||
|
requestId: 1,
|
||||||
|
user_identity: 'worker1',
|
||||||
|
nominalHashRate: 1000000.0,
|
||||||
|
maxTarget,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeOpenStandardMiningChannel, deserializeOpenStandardMiningChannel);
|
||||||
|
expect(result.requestId).toBe(msg.requestId);
|
||||||
|
expect(result.user_identity).toBe(msg.user_identity);
|
||||||
|
expect(result.nominalHashRate).toBeCloseTo(msg.nominalHashRate, 0);
|
||||||
|
expect(result.maxTarget).toEqual(msg.maxTarget);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OpenStandardMiningChannelSuccess round-trips', () => {
|
||||||
|
const target = Buffer.alloc(32);
|
||||||
|
target[0] = 0xff;
|
||||||
|
target[31] = 0x01;
|
||||||
|
const msg: Sv2OpenStandardMiningChannelSuccess = {
|
||||||
|
requestId: 1,
|
||||||
|
channelId: 42,
|
||||||
|
target,
|
||||||
|
extranonce_prefix: Buffer.from([0x01, 0x02, 0x03]),
|
||||||
|
groupChannelId: 0,
|
||||||
|
};
|
||||||
|
const result = roundTrip(
|
||||||
|
msg,
|
||||||
|
serializeOpenStandardMiningChannelSuccess,
|
||||||
|
deserializeOpenStandardMiningChannelSuccess,
|
||||||
|
);
|
||||||
|
expect(result.requestId).toBe(msg.requestId);
|
||||||
|
expect(result.channelId).toBe(msg.channelId);
|
||||||
|
expect(result.target).toEqual(msg.target);
|
||||||
|
expect(result.extranonce_prefix).toEqual(msg.extranonce_prefix);
|
||||||
|
expect(result.groupChannelId).toBe(msg.groupChannelId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NewMiningJob round-trips', () => {
|
||||||
|
const merkleRoot = Buffer.alloc(32, 0xab);
|
||||||
|
const msg: Sv2NewMiningJob = {
|
||||||
|
channelId: 1,
|
||||||
|
jobId: 100,
|
||||||
|
minNtime: null, // Sv2Option<U32>: null = future job
|
||||||
|
version: 0x20000000,
|
||||||
|
merkleRoot,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeNewMiningJob, deserializeNewMiningJob);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NewMiningJob round-trips with minNtime value', () => {
|
||||||
|
const merkleRoot = Buffer.alloc(32, 0xab);
|
||||||
|
const msg: Sv2NewMiningJob = {
|
||||||
|
channelId: 1,
|
||||||
|
jobId: 101,
|
||||||
|
minNtime: 1700000000, // Sv2Option<U32>: Some(timestamp) = ready to mine
|
||||||
|
version: 0x20000000,
|
||||||
|
merkleRoot,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeNewMiningJob, deserializeNewMiningJob);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SetNewPrevHash round-trips', () => {
|
||||||
|
const prevHash = Buffer.alloc(32, 0xcd);
|
||||||
|
const msg: Sv2SetNewPrevHash = {
|
||||||
|
channelId: 1,
|
||||||
|
jobId: 100,
|
||||||
|
prevHash,
|
||||||
|
minNtime: 1700000000,
|
||||||
|
nBits: 0x1d00ffff,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeSetNewPrevHash, deserializeSetNewPrevHash);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SetTarget round-trips', () => {
|
||||||
|
const maxTarget = Buffer.alloc(32, 0x00);
|
||||||
|
maxTarget[0] = 0xff;
|
||||||
|
const msg: Sv2SetTarget = {
|
||||||
|
channelId: 5,
|
||||||
|
maxTarget,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeSetTarget, deserializeSetTarget);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SubmitSharesStandard round-trips', () => {
|
||||||
|
const msg: Sv2SubmitSharesStandard = {
|
||||||
|
channelId: 1,
|
||||||
|
sequenceNumber: 42,
|
||||||
|
jobId: 100,
|
||||||
|
nonce: 0xdeadbeef,
|
||||||
|
ntime: 1700000000,
|
||||||
|
version: 0x20000000,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeSubmitSharesStandard, deserializeSubmitSharesStandard);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SubmitSharesSuccess round-trips', () => {
|
||||||
|
const msg: Sv2SubmitSharesSuccess = {
|
||||||
|
channelId: 1,
|
||||||
|
lastSequenceNumber: 42,
|
||||||
|
newSubmitsAcceptedCount: 10,
|
||||||
|
newSharesSum: 123456789012345n,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeSubmitSharesSuccess, deserializeSubmitSharesSuccess);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SubmitSharesError round-trips', () => {
|
||||||
|
const msg: Sv2SubmitSharesError = {
|
||||||
|
channelId: 1,
|
||||||
|
sequenceNumber: 42,
|
||||||
|
errorCode: 'stale-share',
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeSubmitSharesError, deserializeSubmitSharesError);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CloseChannel round-trips', () => {
|
||||||
|
const msg: Sv2CloseChannel = {
|
||||||
|
channelId: 7,
|
||||||
|
reasonCode: 'client-disconnect',
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeCloseChannel, deserializeCloseChannel);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UpdateChannel round-trips', () => {
|
||||||
|
const maximumTarget = Buffer.alloc(32, 0x00);
|
||||||
|
maximumTarget[0] = 0xff;
|
||||||
|
const msg: Sv2UpdateChannel = {
|
||||||
|
channelId: 3,
|
||||||
|
nominalHashRate: 500000.0,
|
||||||
|
maximumTarget,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeUpdateChannel, deserializeUpdateChannel);
|
||||||
|
expect(result.channelId).toBe(msg.channelId);
|
||||||
|
expect(result.nominalHashRate).toBeCloseTo(msg.nominalHashRate, 0);
|
||||||
|
expect(result.maximumTarget).toEqual(msg.maximumTarget);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UpdateChannelError round-trips', () => {
|
||||||
|
const msg: Sv2UpdateChannelError = {
|
||||||
|
channelId: 3,
|
||||||
|
errorCode: 'invalid-channel',
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeUpdateChannelError, deserializeUpdateChannelError);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Reconnect round-trips', () => {
|
||||||
|
const msg: Sv2Reconnect = {
|
||||||
|
newHost: 'pool2.example.com',
|
||||||
|
newPort: 3334,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeReconnect, deserializeReconnect);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serialized bytes match expected layout (SetupConnectionSuccess)', () => {
|
||||||
|
const msg: Sv2SetupConnectionSuccess = {
|
||||||
|
usedVersion: 2,
|
||||||
|
flags: 0x01,
|
||||||
|
};
|
||||||
|
const buf = serializeSetupConnectionSuccess(msg);
|
||||||
|
// U16 LE(2) + U32 LE(1) = 6 bytes
|
||||||
|
expect(buf.length).toBe(6);
|
||||||
|
expect(buf.readUInt16LE(0)).toBe(2);
|
||||||
|
expect(buf.readUInt32LE(2)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
// ── SV2 Mining Protocol Messages ────────────────────────────────────
|
||||||
|
// Interfaces + serialize/deserialize for each message needed for
|
||||||
|
// standard mining channels.
|
||||||
|
|
||||||
|
import { BufferReader, BufferWriter } from './sv2-binary-codec';
|
||||||
|
|
||||||
|
// ── SetupConnection (0x00) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2SetupConnection {
|
||||||
|
protocol: number; // U8 (0 = Mining, 1 = Job Declaration, 2 = Template Distribution)
|
||||||
|
minVersion: number; // U16
|
||||||
|
maxVersion: number; // U16
|
||||||
|
flags: number; // U32
|
||||||
|
endpoint_host: string; // STR0_255
|
||||||
|
endpoint_port: number; // U16
|
||||||
|
vendor: string; // STR0_255
|
||||||
|
hardwareVersion: string; // STR0_255
|
||||||
|
firmwareVersion: string; // STR0_255
|
||||||
|
deviceId: string; // STR0_255
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSetupConnection(msg: Sv2SetupConnection): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU8(msg.protocol);
|
||||||
|
w.writeU16(msg.minVersion);
|
||||||
|
w.writeU16(msg.maxVersion);
|
||||||
|
w.writeU32(msg.flags);
|
||||||
|
w.writeStr0_255(msg.endpoint_host);
|
||||||
|
w.writeU16(msg.endpoint_port);
|
||||||
|
w.writeStr0_255(msg.vendor);
|
||||||
|
w.writeStr0_255(msg.hardwareVersion);
|
||||||
|
w.writeStr0_255(msg.firmwareVersion);
|
||||||
|
w.writeStr0_255(msg.deviceId);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeSetupConnection(reader: BufferReader): Sv2SetupConnection {
|
||||||
|
return {
|
||||||
|
protocol: reader.readU8(),
|
||||||
|
minVersion: reader.readU16(),
|
||||||
|
maxVersion: reader.readU16(),
|
||||||
|
flags: reader.readU32(),
|
||||||
|
endpoint_host: reader.readStr0_255(),
|
||||||
|
endpoint_port: reader.readU16(),
|
||||||
|
vendor: reader.readStr0_255(),
|
||||||
|
hardwareVersion: reader.readStr0_255(),
|
||||||
|
firmwareVersion: reader.readStr0_255(),
|
||||||
|
deviceId: reader.readStr0_255(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SetupConnectionSuccess (0x01) ───────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2SetupConnectionSuccess {
|
||||||
|
usedVersion: number; // U16
|
||||||
|
flags: number; // U32
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSetupConnectionSuccess(msg: Sv2SetupConnectionSuccess): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU16(msg.usedVersion);
|
||||||
|
w.writeU32(msg.flags);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeSetupConnectionSuccess(reader: BufferReader): Sv2SetupConnectionSuccess {
|
||||||
|
return {
|
||||||
|
usedVersion: reader.readU16(),
|
||||||
|
flags: reader.readU32(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SetupConnectionError (0x02) ─────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2SetupConnectionError {
|
||||||
|
flags: number; // U32
|
||||||
|
errorCode: string; // STR0_255
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSetupConnectionError(msg: Sv2SetupConnectionError): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.flags);
|
||||||
|
w.writeStr0_255(msg.errorCode);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeSetupConnectionError(reader: BufferReader): Sv2SetupConnectionError {
|
||||||
|
return {
|
||||||
|
flags: reader.readU32(),
|
||||||
|
errorCode: reader.readStr0_255(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ChannelEndpointChanged (0x03) ───────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2ChannelEndpointChanged {
|
||||||
|
channelId: number; // U32
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeChannelEndpointChanged(msg: Sv2ChannelEndpointChanged): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeChannelEndpointChanged(reader: BufferReader): Sv2ChannelEndpointChanged {
|
||||||
|
return {
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OpenStandardMiningChannel (0x10) ────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2OpenStandardMiningChannel {
|
||||||
|
requestId: number; // U32
|
||||||
|
user_identity: string; // STR0_255
|
||||||
|
nominalHashRate: number; // F32
|
||||||
|
maxTarget: Buffer; // U256 (32 bytes LE)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeOpenStandardMiningChannel(msg: Sv2OpenStandardMiningChannel): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.requestId);
|
||||||
|
w.writeStr0_255(msg.user_identity);
|
||||||
|
w.writeF32(msg.nominalHashRate);
|
||||||
|
w.writeU256(msg.maxTarget);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeOpenStandardMiningChannel(
|
||||||
|
reader: BufferReader,
|
||||||
|
): Sv2OpenStandardMiningChannel {
|
||||||
|
return {
|
||||||
|
requestId: reader.readU32(),
|
||||||
|
user_identity: reader.readStr0_255(),
|
||||||
|
nominalHashRate: reader.readF32(),
|
||||||
|
maxTarget: reader.readU256(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OpenStandardMiningChannelSuccess (0x11) ─────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2OpenStandardMiningChannelSuccess {
|
||||||
|
requestId: number; // U32
|
||||||
|
channelId: number; // U32
|
||||||
|
target: Buffer; // U256 (32 bytes LE)
|
||||||
|
extranonce_prefix: Buffer; // B0_32
|
||||||
|
groupChannelId: number; // U32
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeOpenStandardMiningChannelSuccess(
|
||||||
|
msg: Sv2OpenStandardMiningChannelSuccess,
|
||||||
|
): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.requestId);
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeU256(msg.target);
|
||||||
|
w.writeB0_32(msg.extranonce_prefix);
|
||||||
|
w.writeU32(msg.groupChannelId);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeOpenStandardMiningChannelSuccess(
|
||||||
|
reader: BufferReader,
|
||||||
|
): Sv2OpenStandardMiningChannelSuccess {
|
||||||
|
return {
|
||||||
|
requestId: reader.readU32(),
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
target: reader.readU256(),
|
||||||
|
extranonce_prefix: reader.readB0_32(),
|
||||||
|
groupChannelId: reader.readU32(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OpenMiningChannel.Error (0x12) ──────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2OpenMiningChannelError {
|
||||||
|
requestId: number; // U32
|
||||||
|
errorCode: string; // STR0_255
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeOpenMiningChannelError(msg: Sv2OpenMiningChannelError): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.requestId);
|
||||||
|
w.writeStr0_255(msg.errorCode);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeOpenMiningChannelError(reader: BufferReader): Sv2OpenMiningChannelError {
|
||||||
|
return {
|
||||||
|
requestId: reader.readU32(),
|
||||||
|
errorCode: reader.readStr0_255(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NewMiningJob (0x15) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2NewMiningJob {
|
||||||
|
channelId: number; // U32
|
||||||
|
jobId: number; // U32
|
||||||
|
minNtime: number | null; // Sv2Option<U32> - null = future job, number = min ntime value
|
||||||
|
version: number; // U32
|
||||||
|
merkleRoot: Buffer; // U256 (32 bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeNewMiningJob(msg: Sv2NewMiningJob): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeU32(msg.jobId);
|
||||||
|
w.writeOptionU32(msg.minNtime);
|
||||||
|
w.writeU32(msg.version);
|
||||||
|
w.writeU256(msg.merkleRoot);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeNewMiningJob(reader: BufferReader): Sv2NewMiningJob {
|
||||||
|
return {
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
jobId: reader.readU32(),
|
||||||
|
minNtime: reader.readOptionU32(),
|
||||||
|
version: reader.readU32(),
|
||||||
|
merkleRoot: reader.readU256(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SetNewPrevHash (0x20) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2SetNewPrevHash {
|
||||||
|
channelId: number; // U32
|
||||||
|
jobId: number; // U32
|
||||||
|
prevHash: Buffer; // U256 (32 bytes)
|
||||||
|
minNtime: number; // U32
|
||||||
|
nBits: number; // U32
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSetNewPrevHash(msg: Sv2SetNewPrevHash): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeU32(msg.jobId);
|
||||||
|
w.writeU256(msg.prevHash);
|
||||||
|
w.writeU32(msg.minNtime);
|
||||||
|
w.writeU32(msg.nBits);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeSetNewPrevHash(reader: BufferReader): Sv2SetNewPrevHash {
|
||||||
|
return {
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
jobId: reader.readU32(),
|
||||||
|
prevHash: reader.readU256(),
|
||||||
|
minNtime: reader.readU32(),
|
||||||
|
nBits: reader.readU32(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SetTarget (0x21) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2SetTarget {
|
||||||
|
channelId: number; // U32
|
||||||
|
maxTarget: Buffer; // U256 (32 bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSetTarget(msg: Sv2SetTarget): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeU256(msg.maxTarget);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeSetTarget(reader: BufferReader): Sv2SetTarget {
|
||||||
|
return {
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
maxTarget: reader.readU256(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SubmitSharesStandard (0x1a) ─────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2SubmitSharesStandard {
|
||||||
|
channelId: number; // U32
|
||||||
|
sequenceNumber: number; // U32
|
||||||
|
jobId: number; // U32
|
||||||
|
nonce: number; // U32
|
||||||
|
ntime: number; // U32
|
||||||
|
version: number; // U32
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSubmitSharesStandard(msg: Sv2SubmitSharesStandard): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeU32(msg.sequenceNumber);
|
||||||
|
w.writeU32(msg.jobId);
|
||||||
|
w.writeU32(msg.nonce);
|
||||||
|
w.writeU32(msg.ntime);
|
||||||
|
w.writeU32(msg.version);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeSubmitSharesStandard(reader: BufferReader): Sv2SubmitSharesStandard {
|
||||||
|
return {
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
sequenceNumber: reader.readU32(),
|
||||||
|
jobId: reader.readU32(),
|
||||||
|
nonce: reader.readU32(),
|
||||||
|
ntime: reader.readU32(),
|
||||||
|
version: reader.readU32(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SubmitSharesSuccess (0x1c) ──────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2SubmitSharesSuccess {
|
||||||
|
channelId: number; // U32
|
||||||
|
lastSequenceNumber: number; // U32
|
||||||
|
newSubmitsAcceptedCount: number; // U32
|
||||||
|
newSharesSum: bigint; // U64
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSubmitSharesSuccess(msg: Sv2SubmitSharesSuccess): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeU32(msg.lastSequenceNumber);
|
||||||
|
w.writeU32(msg.newSubmitsAcceptedCount);
|
||||||
|
w.writeU64(msg.newSharesSum);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeSubmitSharesSuccess(reader: BufferReader): Sv2SubmitSharesSuccess {
|
||||||
|
return {
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
lastSequenceNumber: reader.readU32(),
|
||||||
|
newSubmitsAcceptedCount: reader.readU32(),
|
||||||
|
newSharesSum: reader.readU64(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SubmitSharesError (0x1d) ────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2SubmitSharesError {
|
||||||
|
channelId: number; // U32
|
||||||
|
sequenceNumber: number; // U32
|
||||||
|
errorCode: string; // STR0_255
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSubmitSharesError(msg: Sv2SubmitSharesError): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeU32(msg.sequenceNumber);
|
||||||
|
w.writeStr0_255(msg.errorCode);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeSubmitSharesError(reader: BufferReader): Sv2SubmitSharesError {
|
||||||
|
return {
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
sequenceNumber: reader.readU32(),
|
||||||
|
errorCode: reader.readStr0_255(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CloseChannel (0x18) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2CloseChannel {
|
||||||
|
channelId: number; // U32
|
||||||
|
reasonCode: string; // STR0_255
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeCloseChannel(msg: Sv2CloseChannel): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeStr0_255(msg.reasonCode);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeCloseChannel(reader: BufferReader): Sv2CloseChannel {
|
||||||
|
return {
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
reasonCode: reader.readStr0_255(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UpdateChannel (0x16) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2UpdateChannel {
|
||||||
|
channelId: number; // U32
|
||||||
|
nominalHashRate: number; // F32
|
||||||
|
maximumTarget: Buffer; // U256 (32 bytes LE)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeUpdateChannel(msg: Sv2UpdateChannel): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeF32(msg.nominalHashRate);
|
||||||
|
w.writeU256(msg.maximumTarget);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeUpdateChannel(reader: BufferReader): Sv2UpdateChannel {
|
||||||
|
return {
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
nominalHashRate: reader.readF32(),
|
||||||
|
maximumTarget: reader.readU256(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UpdateChannel.Error (0x17) ──────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2UpdateChannelError {
|
||||||
|
channelId: number; // U32
|
||||||
|
errorCode: string; // STR0_255
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeUpdateChannelError(msg: Sv2UpdateChannelError): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.channelId);
|
||||||
|
w.writeStr0_255(msg.errorCode);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeUpdateChannelError(reader: BufferReader): Sv2UpdateChannelError {
|
||||||
|
return {
|
||||||
|
channelId: reader.readU32(),
|
||||||
|
errorCode: reader.readStr0_255(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Extension 1: Extensions Negotiation (extension_type = 0x0001) ───
|
||||||
|
// Spec: extensions/0x0001-extensions-negotiation.md
|
||||||
|
// RequestExtensions msgType 0x00 (client → pool)
|
||||||
|
// RequestExtensions.Success msgType 0x01 (pool → client)
|
||||||
|
// RequestExtensions.Error msgType 0x02 (pool → client)
|
||||||
|
|
||||||
|
export const SV2_EXTENSION_NEGOTIATION_ID = 0x0001;
|
||||||
|
|
||||||
|
export interface Sv2RequestExtensions {
|
||||||
|
requestId: number; // U16
|
||||||
|
requestedExtensions: number[]; // SEQ0_64K[U16]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeRequestExtensions(reader: BufferReader): Sv2RequestExtensions {
|
||||||
|
return {
|
||||||
|
requestId: reader.readU16(),
|
||||||
|
requestedExtensions: reader.readSeq0_64K((r) => r.readU16()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sv2RequestExtensionsSuccess {
|
||||||
|
requestId: number; // U16
|
||||||
|
supportedExtensions: number[]; // SEQ0_64K[U16]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeRequestExtensionsSuccess(msg: Sv2RequestExtensionsSuccess): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU16(msg.requestId);
|
||||||
|
w.writeSeq0_64K(msg.supportedExtensions, (writer, ext) => writer.writeU16(ext));
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reconnect (0x04) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2Reconnect {
|
||||||
|
newHost: string; // STR0_255
|
||||||
|
newPort: number; // U16
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeReconnect(msg: Sv2Reconnect): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeStr0_255(msg.newHost);
|
||||||
|
w.writeU16(msg.newPort);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeReconnect(reader: BufferReader): Sv2Reconnect {
|
||||||
|
return {
|
||||||
|
newHost: reader.readStr0_255(),
|
||||||
|
newPort: reader.readU16(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
import * as crypto from 'crypto';
|
||||||
|
import {
|
||||||
|
hmacHash,
|
||||||
|
sha256,
|
||||||
|
hkdf2,
|
||||||
|
Sv2CipherState,
|
||||||
|
Sv2CipherAlgorithm,
|
||||||
|
Sv2NoiseSession,
|
||||||
|
Sv2NoiseInitiator,
|
||||||
|
generateServerKeypair,
|
||||||
|
xOnlyPubKeyFromPriv,
|
||||||
|
createSignatureNoiseMessage,
|
||||||
|
validateSignatureNoiseMessage,
|
||||||
|
serializeSignatureNoiseMessage,
|
||||||
|
deserializeSignatureNoiseMessage,
|
||||||
|
loadEllSwift,
|
||||||
|
} from './sv2-noise';
|
||||||
|
import { SV2_MAC_SIZE } from './sv2-constants';
|
||||||
|
|
||||||
|
describe('SV2 Noise', () => {
|
||||||
|
// ── HMAC-SHA256 Test Vectors ──────────────────────────────────────
|
||||||
|
|
||||||
|
describe('HMAC-SHA256', () => {
|
||||||
|
it('should match known test vector (RFC 4231 test 1)', () => {
|
||||||
|
const key = Buffer.from('0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b', 'hex');
|
||||||
|
const data = Buffer.from('Hi There');
|
||||||
|
const expected = 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7';
|
||||||
|
expect(hmacHash(key, data).toString('hex')).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match known test vector (RFC 4231 test 2)', () => {
|
||||||
|
const key = Buffer.from('Jefe');
|
||||||
|
const data = Buffer.from('what do ya want for nothing?');
|
||||||
|
const expected = '5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843';
|
||||||
|
expect(hmacHash(key, data).toString('hex')).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── HKDF2 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('HKDF2', () => {
|
||||||
|
it('should produce two 32-byte outputs', () => {
|
||||||
|
const ck = Buffer.alloc(32, 0x01);
|
||||||
|
const ikm = Buffer.alloc(32, 0x02);
|
||||||
|
const [out1, out2] = hkdf2(ck, ikm);
|
||||||
|
expect(out1.length).toBe(32);
|
||||||
|
expect(out2.length).toBe(32);
|
||||||
|
expect(out1.equals(out2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be deterministic', () => {
|
||||||
|
const ck = crypto.randomBytes(32);
|
||||||
|
const ikm = crypto.randomBytes(32);
|
||||||
|
const [a1, a2] = hkdf2(ck, ikm);
|
||||||
|
const [b1, b2] = hkdf2(ck, ikm);
|
||||||
|
expect(a1.equals(b1)).toBe(true);
|
||||||
|
expect(a2.equals(b2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with empty IKM', () => {
|
||||||
|
const ck = Buffer.alloc(32, 0xaa);
|
||||||
|
const [out1, out2] = hkdf2(ck, Buffer.alloc(0));
|
||||||
|
expect(out1.length).toBe(32);
|
||||||
|
expect(out2.length).toBe(32);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── CipherState ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CipherState', () => {
|
||||||
|
it('should encrypt and decrypt with matching keys', () => {
|
||||||
|
const key = crypto.randomBytes(32);
|
||||||
|
const enc = new Sv2CipherState();
|
||||||
|
const dec = new Sv2CipherState();
|
||||||
|
enc.initializeKey(key);
|
||||||
|
dec.initializeKey(key);
|
||||||
|
|
||||||
|
const ad = Buffer.alloc(0);
|
||||||
|
const plaintext = Buffer.from('test message for cipher state');
|
||||||
|
const ciphertext = enc.encryptWithAd(ad, plaintext);
|
||||||
|
|
||||||
|
expect(ciphertext.length).toBe(plaintext.length + SV2_MAC_SIZE);
|
||||||
|
expect(ciphertext.subarray(0, plaintext.length).equals(plaintext)).toBe(false);
|
||||||
|
|
||||||
|
const decrypted = dec.decryptWithAd(ad, ciphertext);
|
||||||
|
expect(decrypted.equals(plaintext)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail decryption with wrong key', () => {
|
||||||
|
const enc = new Sv2CipherState();
|
||||||
|
const dec = new Sv2CipherState();
|
||||||
|
enc.initializeKey(crypto.randomBytes(32));
|
||||||
|
dec.initializeKey(crypto.randomBytes(32));
|
||||||
|
|
||||||
|
const ct = enc.encryptWithAd(Buffer.alloc(0), Buffer.from('secret'));
|
||||||
|
expect(() => dec.decryptWithAd(Buffer.alloc(0), ct)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail decryption with wrong AD', () => {
|
||||||
|
const key = crypto.randomBytes(32);
|
||||||
|
const enc = new Sv2CipherState();
|
||||||
|
const dec = new Sv2CipherState();
|
||||||
|
enc.initializeKey(key);
|
||||||
|
dec.initializeKey(key);
|
||||||
|
|
||||||
|
const ct = enc.encryptWithAd(Buffer.from('ad1'), Buffer.from('data'));
|
||||||
|
expect(() => dec.decryptWithAd(Buffer.from('ad2'), ct)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should increment nonce (different ciphertext for same plaintext)', () => {
|
||||||
|
const key = crypto.randomBytes(32);
|
||||||
|
const enc = new Sv2CipherState();
|
||||||
|
enc.initializeKey(key);
|
||||||
|
|
||||||
|
const ad = Buffer.alloc(0);
|
||||||
|
const pt = Buffer.from('same');
|
||||||
|
const ct1 = enc.encryptWithAd(ad, pt);
|
||||||
|
const ct2 = enc.encryptWithAd(ad, pt);
|
||||||
|
expect(ct1.equals(ct2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass through plaintext when key is not set', () => {
|
||||||
|
const cs = new Sv2CipherState();
|
||||||
|
const ad = Buffer.alloc(0);
|
||||||
|
const pt = Buffer.from('hello');
|
||||||
|
expect(cs.encryptWithAd(ad, pt).equals(pt)).toBe(true);
|
||||||
|
expect(cs.decryptWithAd(ad, pt).equals(pt)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track hasKey', () => {
|
||||||
|
const cs = new Sv2CipherState();
|
||||||
|
expect(cs.hasKey).toBe(false);
|
||||||
|
cs.initializeKey(crypto.randomBytes(32));
|
||||||
|
expect(cs.hasKey).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject non-32-byte keys', () => {
|
||||||
|
const cs = new Sv2CipherState();
|
||||||
|
expect(() => cs.initializeKey(Buffer.alloc(16))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject ciphertext shorter than MAC', () => {
|
||||||
|
const cs = new Sv2CipherState();
|
||||||
|
cs.initializeKey(crypto.randomBytes(32));
|
||||||
|
expect(() => cs.decryptWithAd(Buffer.alloc(0), Buffer.alloc(10))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple sequential encryptions and decryptions', () => {
|
||||||
|
const key = crypto.randomBytes(32);
|
||||||
|
const enc = new Sv2CipherState();
|
||||||
|
const dec = new Sv2CipherState();
|
||||||
|
enc.initializeKey(key);
|
||||||
|
dec.initializeKey(key);
|
||||||
|
|
||||||
|
const ad = Buffer.alloc(0);
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const pt = Buffer.from(`message ${i}`);
|
||||||
|
const ct = enc.encryptWithAd(ad, pt);
|
||||||
|
const result = dec.decryptWithAd(ad, ct);
|
||||||
|
expect(result.toString()).toBe(`message ${i}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Certificate / Signature ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Certificate', () => {
|
||||||
|
it('should create and validate a signature noise message', () => {
|
||||||
|
const authorityPrivKey = crypto.randomBytes(32);
|
||||||
|
const authorityPubKey = xOnlyPubKeyFromPriv(authorityPrivKey);
|
||||||
|
const staticPubKeyXOnly = crypto.randomBytes(32);
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const cert = createSignatureNoiseMessage(
|
||||||
|
authorityPrivKey,
|
||||||
|
staticPubKeyXOnly,
|
||||||
|
now - 3600,
|
||||||
|
now + 86400,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cert.version).toBe(0);
|
||||||
|
expect(cert.signature.length).toBe(64);
|
||||||
|
expect(validateSignatureNoiseMessage(cert, authorityPubKey, staticPubKeyXOnly)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail validation with wrong authority key', () => {
|
||||||
|
const authorityPrivKey = crypto.randomBytes(32);
|
||||||
|
const wrongPubKey = xOnlyPubKeyFromPriv(crypto.randomBytes(32));
|
||||||
|
const staticPubKeyXOnly = crypto.randomBytes(32);
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const cert = createSignatureNoiseMessage(authorityPrivKey, staticPubKeyXOnly, now, now + 86400);
|
||||||
|
|
||||||
|
expect(validateSignatureNoiseMessage(cert, wrongPubKey, staticPubKeyXOnly)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serialize and deserialize signature noise message', () => {
|
||||||
|
const msg = {
|
||||||
|
version: 0,
|
||||||
|
validFrom: 1700000000,
|
||||||
|
notValidAfter: 1700086400,
|
||||||
|
signature: crypto.randomBytes(64),
|
||||||
|
};
|
||||||
|
const buf = serializeSignatureNoiseMessage(msg);
|
||||||
|
expect(buf.length).toBe(74);
|
||||||
|
const result = deserializeSignatureNoiseMessage(buf);
|
||||||
|
expect(result.version).toBe(msg.version);
|
||||||
|
expect(result.validFrom).toBe(msg.validFrom);
|
||||||
|
expect(result.notValidAfter).toBe(msg.notValidAfter);
|
||||||
|
expect(result.signature.equals(msg.signature)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Key Generation ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Key Generation', () => {
|
||||||
|
it('should generate server keypair with correct sizes', async () => {
|
||||||
|
const kp = await generateServerKeypair();
|
||||||
|
expect(kp.privateKey.length).toBe(32);
|
||||||
|
expect(kp.publicKey.length).toBe(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce unique keypairs', async () => {
|
||||||
|
const kp1 = await generateServerKeypair();
|
||||||
|
const kp2 = await generateServerKeypair();
|
||||||
|
expect(kp1.privateKey.equals(kp2.privateKey)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should derive x-only pubkey from privkey', () => {
|
||||||
|
const privKey = crypto.randomBytes(32);
|
||||||
|
const xOnly = xOnlyPubKeyFromPriv(privKey);
|
||||||
|
expect(xOnly.length).toBe(32);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── EllSwift Loader ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('EllSwift Loader', () => {
|
||||||
|
it('should load EllSwift API', async () => {
|
||||||
|
const es = await loadEllSwift();
|
||||||
|
expect(typeof es.keygen).toBe('function');
|
||||||
|
expect(typeof es.getSharedSecret).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cache the loaded module', async () => {
|
||||||
|
const es1 = await loadEllSwift();
|
||||||
|
const es2 = await loadEllSwift();
|
||||||
|
expect(es1).toBe(es2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Full Noise NX Handshake Self-Test ─────────────────────────────
|
||||||
|
|
||||||
|
describe('Full Handshake', () => {
|
||||||
|
it('should complete NX handshake and exchange encrypted messages', async () => {
|
||||||
|
// 1. Generate server config
|
||||||
|
const serverKeypair = await generateServerKeypair();
|
||||||
|
const authorityPrivKey = crypto.randomBytes(32);
|
||||||
|
const authorityPubKey = xOnlyPubKeyFromPriv(authorityPrivKey);
|
||||||
|
const staticXOnly = xOnlyPubKeyFromPriv(serverKeypair.privateKey);
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const cert = createSignatureNoiseMessage(
|
||||||
|
authorityPrivKey,
|
||||||
|
staticXOnly,
|
||||||
|
now - 3600,
|
||||||
|
now + 86400,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Create sessions
|
||||||
|
const responder = new Sv2NoiseSession({
|
||||||
|
staticKeypair: serverKeypair,
|
||||||
|
certificateMessage: cert,
|
||||||
|
});
|
||||||
|
const initiator = new Sv2NoiseInitiator();
|
||||||
|
|
||||||
|
// 3. Act 1: Initiator -> Responder (64 bytes)
|
||||||
|
const act1 = await initiator.generateAct1();
|
||||||
|
expect(act1.length).toBe(64);
|
||||||
|
|
||||||
|
// 4. Act 2: Responder -> Initiator (234 bytes)
|
||||||
|
const act2 = await responder.processAct1(act1);
|
||||||
|
expect(act2.length).toBe(234);
|
||||||
|
|
||||||
|
// 5. Initiator processes Act 2
|
||||||
|
const { serverStaticKey, certificate } = initiator.processAct2(act2);
|
||||||
|
expect(serverStaticKey.length).toBe(64);
|
||||||
|
expect(certificate.version).toBe(0);
|
||||||
|
|
||||||
|
// 6. Validate certificate
|
||||||
|
expect(
|
||||||
|
validateSignatureNoiseMessage(certificate, authorityPubKey, staticXOnly),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// 7. Both sides should be ready
|
||||||
|
expect(responder.isHandshakeComplete).toBe(true);
|
||||||
|
expect(initiator.isHandshakeComplete).toBe(true);
|
||||||
|
|
||||||
|
// 8. Exchange encrypted messages: initiator -> responder
|
||||||
|
const msg1 = Buffer.from('hello from initiator');
|
||||||
|
const enc1 = initiator.encrypt(msg1);
|
||||||
|
expect(enc1.length).toBe(msg1.length + SV2_MAC_SIZE);
|
||||||
|
const dec1 = responder.decrypt(enc1);
|
||||||
|
expect(dec1.toString()).toBe('hello from initiator');
|
||||||
|
|
||||||
|
// 9. Exchange encrypted messages: responder -> initiator
|
||||||
|
const msg2 = Buffer.from('hello from responder');
|
||||||
|
const enc2 = responder.encrypt(msg2);
|
||||||
|
const dec2 = initiator.decrypt(enc2);
|
||||||
|
expect(dec2.toString()).toBe('hello from responder');
|
||||||
|
|
||||||
|
// 10. Multiple messages with nonce increment
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const pt = Buffer.from(`bidirectional msg ${i}`);
|
||||||
|
const ct = responder.encrypt(pt);
|
||||||
|
expect(initiator.decrypt(ct).toString()).toBe(`bidirectional msg ${i}`);
|
||||||
|
|
||||||
|
const pt2 = Buffer.from(`reply ${i}`);
|
||||||
|
const ct2 = initiator.encrypt(pt2);
|
||||||
|
expect(responder.decrypt(ct2).toString()).toBe(`reply ${i}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail handshake with wrong Act 1 size', async () => {
|
||||||
|
const serverKeypair = await generateServerKeypair();
|
||||||
|
const authorityPrivKey = crypto.randomBytes(32);
|
||||||
|
const staticXOnly = xOnlyPubKeyFromPriv(serverKeypair.privateKey);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const cert = createSignatureNoiseMessage(authorityPrivKey, staticXOnly, now, now + 86400);
|
||||||
|
|
||||||
|
const responder = new Sv2NoiseSession({
|
||||||
|
staticKeypair: serverKeypair,
|
||||||
|
certificateMessage: cert,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(responder.processAct1(Buffer.alloc(32))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not encrypt before handshake is complete', () => {
|
||||||
|
const session = new Sv2NoiseInitiator();
|
||||||
|
expect(() => session.encrypt(Buffer.from('test'))).toThrow('Handshake not complete');
|
||||||
|
expect(() => session.decrypt(Buffer.alloc(32))).toThrow('Handshake not complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should complete NX handshake with AES-256-GCM and exchange encrypted messages', async () => {
|
||||||
|
// 1. Generate server config
|
||||||
|
const serverKeypair = await generateServerKeypair();
|
||||||
|
const authorityPrivKey = crypto.randomBytes(32);
|
||||||
|
const authorityPubKey = xOnlyPubKeyFromPriv(authorityPrivKey);
|
||||||
|
const staticXOnly = xOnlyPubKeyFromPriv(serverKeypair.privateKey);
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const cert = createSignatureNoiseMessage(
|
||||||
|
authorityPrivKey,
|
||||||
|
staticXOnly,
|
||||||
|
now - 3600,
|
||||||
|
now + 86400,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cipher: Sv2CipherAlgorithm = 'aes-256-gcm';
|
||||||
|
|
||||||
|
// 2. Create sessions with AES-GCM
|
||||||
|
const responder = new Sv2NoiseSession(
|
||||||
|
{ staticKeypair: serverKeypair, certificateMessage: cert },
|
||||||
|
cipher,
|
||||||
|
);
|
||||||
|
const initiator = new Sv2NoiseInitiator(cipher);
|
||||||
|
|
||||||
|
// 3. Act 1: Initiator -> Responder (64 bytes)
|
||||||
|
const act1 = await initiator.generateAct1();
|
||||||
|
expect(act1.length).toBe(64);
|
||||||
|
|
||||||
|
// 4. Act 2: Responder -> Initiator (234 bytes)
|
||||||
|
const act2 = await responder.processAct1(act1);
|
||||||
|
expect(act2.length).toBe(234);
|
||||||
|
|
||||||
|
// 5. Initiator processes Act 2
|
||||||
|
const { serverStaticKey, certificate } = initiator.processAct2(act2);
|
||||||
|
expect(serverStaticKey.length).toBe(64);
|
||||||
|
expect(certificate.version).toBe(0);
|
||||||
|
|
||||||
|
// 6. Validate certificate
|
||||||
|
expect(
|
||||||
|
validateSignatureNoiseMessage(certificate, authorityPubKey, staticXOnly),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// 7. Both sides should be ready
|
||||||
|
expect(responder.isHandshakeComplete).toBe(true);
|
||||||
|
expect(initiator.isHandshakeComplete).toBe(true);
|
||||||
|
|
||||||
|
// 8. Exchange encrypted messages: initiator -> responder
|
||||||
|
const msg1 = Buffer.from('hello from AES-GCM initiator');
|
||||||
|
const enc1 = initiator.encrypt(msg1);
|
||||||
|
expect(enc1.length).toBe(msg1.length + SV2_MAC_SIZE);
|
||||||
|
const dec1 = responder.decrypt(enc1);
|
||||||
|
expect(dec1.toString()).toBe('hello from AES-GCM initiator');
|
||||||
|
|
||||||
|
// 9. Exchange encrypted messages: responder -> initiator
|
||||||
|
const msg2 = Buffer.from('hello from AES-GCM responder');
|
||||||
|
const enc2 = responder.encrypt(msg2);
|
||||||
|
const dec2 = initiator.decrypt(enc2);
|
||||||
|
expect(dec2.toString()).toBe('hello from AES-GCM responder');
|
||||||
|
|
||||||
|
// 10. Multiple messages with nonce increment
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const pt = Buffer.from(`AES-GCM bidirectional msg ${i}`);
|
||||||
|
const ct = responder.encrypt(pt);
|
||||||
|
expect(initiator.decrypt(ct).toString()).toBe(`AES-GCM bidirectional msg ${i}`);
|
||||||
|
|
||||||
|
const pt2 = Buffer.from(`AES-GCM reply ${i}`);
|
||||||
|
const ct2 = initiator.encrypt(pt2);
|
||||||
|
expect(responder.decrypt(ct2).toString()).toBe(`AES-GCM reply ${i}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not cross-decrypt between ChaCha20 and AES-GCM sessions', async () => {
|
||||||
|
const serverKeypair = await generateServerKeypair();
|
||||||
|
const authorityPrivKey = crypto.randomBytes(32);
|
||||||
|
const staticXOnly = xOnlyPubKeyFromPriv(serverKeypair.privateKey);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const cert = createSignatureNoiseMessage(authorityPrivKey, staticXOnly, now, now + 86400);
|
||||||
|
|
||||||
|
// ChaCha session
|
||||||
|
const chachaResp = new Sv2NoiseSession(
|
||||||
|
{ staticKeypair: serverKeypair, certificateMessage: cert },
|
||||||
|
);
|
||||||
|
const chachaInit = new Sv2NoiseInitiator();
|
||||||
|
const chachaAct1 = await chachaInit.generateAct1();
|
||||||
|
const chachaAct2 = await chachaResp.processAct1(chachaAct1);
|
||||||
|
chachaInit.processAct2(chachaAct2);
|
||||||
|
|
||||||
|
// AES-GCM session (different keys, so ciphertexts are incompatible)
|
||||||
|
const serverKeypair2 = await generateServerKeypair();
|
||||||
|
const staticXOnly2 = xOnlyPubKeyFromPriv(serverKeypair2.privateKey);
|
||||||
|
const cert2 = createSignatureNoiseMessage(authorityPrivKey, staticXOnly2, now, now + 86400);
|
||||||
|
const aesResp = new Sv2NoiseSession(
|
||||||
|
{ staticKeypair: serverKeypair2, certificateMessage: cert2 },
|
||||||
|
'aes-256-gcm',
|
||||||
|
);
|
||||||
|
const aesInit = new Sv2NoiseInitiator('aes-256-gcm');
|
||||||
|
const aesAct1 = await aesInit.generateAct1();
|
||||||
|
const aesAct2 = await aesResp.processAct1(aesAct1);
|
||||||
|
aesInit.processAct2(aesAct2);
|
||||||
|
|
||||||
|
// Encrypt with ChaCha, try to decrypt with AES-GCM -> should fail
|
||||||
|
const ct = chachaInit.encrypt(Buffer.from('test'));
|
||||||
|
expect(() => aesResp.decrypt(ct)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AES-GCM CipherState ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CipherState AES-256-GCM', () => {
|
||||||
|
it('should encrypt and decrypt with AES-256-GCM', () => {
|
||||||
|
const key = crypto.randomBytes(32);
|
||||||
|
const enc = new Sv2CipherState('aes-256-gcm');
|
||||||
|
const dec = new Sv2CipherState('aes-256-gcm');
|
||||||
|
enc.initializeKey(key);
|
||||||
|
dec.initializeKey(key);
|
||||||
|
|
||||||
|
const ad = Buffer.alloc(0);
|
||||||
|
const plaintext = Buffer.from('test message for AES-GCM cipher state');
|
||||||
|
const ciphertext = enc.encryptWithAd(ad, plaintext);
|
||||||
|
|
||||||
|
expect(ciphertext.length).toBe(plaintext.length + SV2_MAC_SIZE);
|
||||||
|
const decrypted = dec.decryptWithAd(ad, ciphertext);
|
||||||
|
expect(decrypted.equals(plaintext)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple sequential encryptions with AES-256-GCM', () => {
|
||||||
|
const key = crypto.randomBytes(32);
|
||||||
|
const enc = new Sv2CipherState('aes-256-gcm');
|
||||||
|
const dec = new Sv2CipherState('aes-256-gcm');
|
||||||
|
enc.initializeKey(key);
|
||||||
|
dec.initializeKey(key);
|
||||||
|
|
||||||
|
const ad = Buffer.alloc(0);
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const pt = Buffer.from(`AES-GCM message ${i}`);
|
||||||
|
const ct = enc.encryptWithAd(ad, pt);
|
||||||
|
const result = dec.decryptWithAd(ad, ct);
|
||||||
|
expect(result.toString()).toBe(`AES-GCM message ${i}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should encrypt and decrypt with non-empty AD using AES-256-GCM', () => {
|
||||||
|
const key = crypto.randomBytes(32);
|
||||||
|
const enc = new Sv2CipherState('aes-256-gcm');
|
||||||
|
const dec = new Sv2CipherState('aes-256-gcm');
|
||||||
|
enc.initializeKey(key);
|
||||||
|
dec.initializeKey(key);
|
||||||
|
|
||||||
|
const ad = Buffer.from('additional data');
|
||||||
|
const plaintext = Buffer.from('secret payload');
|
||||||
|
const ciphertext = enc.encryptWithAd(ad, plaintext);
|
||||||
|
const decrypted = dec.decryptWithAd(ad, ciphertext);
|
||||||
|
expect(decrypted.equals(plaintext)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
// ── SV2 Noise NX Handshake & Encrypted Transport ────────────────────
|
||||||
|
// Implements Noise_NX_Secp256k1+EllSwift_ChaChaPoly_SHA256 for SV2.
|
||||||
|
// Server (responder) side: receives Act 1, sends Act 2, then
|
||||||
|
// encrypted transport in both directions.
|
||||||
|
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as ecc from 'tiny-secp256k1';
|
||||||
|
import {
|
||||||
|
SV2_NOISE_PROTOCOL_NAME,
|
||||||
|
SV2_NOISE_PROTOCOL_NAME_AESGCM,
|
||||||
|
SV2_ELLSWIFT_KEY_SIZE,
|
||||||
|
SV2_MAC_SIZE,
|
||||||
|
SV2_SIGNATURE_NOISE_MSG_SIZE,
|
||||||
|
} from './sv2-constants';
|
||||||
|
|
||||||
|
// ── Cipher Algorithm Type ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export type Sv2CipherAlgorithm = 'chacha20-poly1305' | 'aes-256-gcm';
|
||||||
|
|
||||||
|
function getNoiseProtocolName(cipher: Sv2CipherAlgorithm): string {
|
||||||
|
return cipher === 'aes-256-gcm'
|
||||||
|
? SV2_NOISE_PROTOCOL_NAME_AESGCM
|
||||||
|
: SV2_NOISE_PROTOCOL_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EllSwift Loader (ESM dynamic import) ────────────────────────────
|
||||||
|
|
||||||
|
interface EllSwiftApi {
|
||||||
|
keygen(): { privateKey: Uint8Array; publicKey: Uint8Array };
|
||||||
|
getSharedSecret(privKey: Uint8Array, pubKey: Uint8Array): Uint8Array;
|
||||||
|
getSharedSecretBip324(
|
||||||
|
privateKeyOurs: Uint8Array,
|
||||||
|
publicKeyTheirs: Uint8Array,
|
||||||
|
publicKeyOurs: Uint8Array,
|
||||||
|
initiating: boolean,
|
||||||
|
): Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ellSwiftCache: EllSwiftApi | null = null;
|
||||||
|
|
||||||
|
export async function loadEllSwift(): Promise<EllSwiftApi> {
|
||||||
|
if (ellSwiftCache) return ellSwiftCache;
|
||||||
|
let mod: any;
|
||||||
|
try {
|
||||||
|
// Works in Jest (ts-jest transforms ESM to CJS) and bundled environments
|
||||||
|
mod = require('@scure/btc-signer/p2p.js');
|
||||||
|
} catch {
|
||||||
|
// Runtime fallback: dynamic import for ESM-only packages in Node CJS mode
|
||||||
|
const importFn = new Function('specifier', 'return import(specifier)');
|
||||||
|
mod = await importFn('@scure/btc-signer/p2p.js');
|
||||||
|
}
|
||||||
|
ellSwiftCache = mod.elligatorSwift as EllSwiftApi;
|
||||||
|
return ellSwiftCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Crypto Primitives ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function hmacHash(key: Buffer, data: Buffer): Buffer {
|
||||||
|
return crypto.createHmac('sha256', key).update(data).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sha256(data: Buffer): Buffer {
|
||||||
|
return crypto.createHash('sha256').update(data).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SV2 HKDF with 2 outputs (not RFC 5869).
|
||||||
|
* temp_key = HMAC(chaining_key, input_key_material)
|
||||||
|
* output1 = HMAC(temp_key, 0x01)
|
||||||
|
* output2 = HMAC(temp_key, output1 || 0x02)
|
||||||
|
*/
|
||||||
|
export function hkdf2(chainingKey: Buffer, ikm: Buffer): [Buffer, Buffer] {
|
||||||
|
const tempKey = hmacHash(chainingKey, ikm);
|
||||||
|
const out1 = hmacHash(tempKey, Buffer.from([0x01]));
|
||||||
|
const out2 = hmacHash(tempKey, Buffer.concat([out1, Buffer.from([0x02])]));
|
||||||
|
return [out1, out2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CipherState ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nonce encoding per Noise spec:
|
||||||
|
* - ChaCha20-Poly1305 (Section 12.3): 4 zero bytes + LE64(n)
|
||||||
|
* - AES-256-GCM (Section 12.4): 4 zero bytes + BE64(n)
|
||||||
|
*
|
||||||
|
* Set SV2_NONCE_LE=true to force LE for all ciphers (matches SRI Rust behavior).
|
||||||
|
*/
|
||||||
|
function encodeNonce(n: bigint, algorithm: Sv2CipherAlgorithm): Buffer {
|
||||||
|
const buf = Buffer.alloc(12);
|
||||||
|
if (algorithm === 'aes-256-gcm' && process.env.SV2_NONCE_LE !== 'true') {
|
||||||
|
buf.writeBigUInt64BE(n, 4);
|
||||||
|
} else {
|
||||||
|
buf.writeBigUInt64LE(n, 4);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Sv2CipherState {
|
||||||
|
private k: Buffer | null = null;
|
||||||
|
private n = 0n;
|
||||||
|
private readonly algorithm: Sv2CipherAlgorithm;
|
||||||
|
|
||||||
|
constructor(algorithm: Sv2CipherAlgorithm = 'chacha20-poly1305') {
|
||||||
|
this.algorithm = algorithm;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasKey(): boolean {
|
||||||
|
return this.k !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeKey(key: Buffer): void {
|
||||||
|
if (key.length !== 32) throw new Error('CipherState key must be 32 bytes');
|
||||||
|
this.k = key;
|
||||||
|
this.n = 0n;
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptWithAd(ad: Buffer, plaintext: Buffer): Buffer {
|
||||||
|
if (!this.k) return plaintext;
|
||||||
|
const nonce = encodeNonce(this.n, this.algorithm);
|
||||||
|
const cipher = crypto.createCipheriv(this.algorithm, this.k, nonce, {
|
||||||
|
authTagLength: SV2_MAC_SIZE,
|
||||||
|
} as any) as crypto.CipherGCM;
|
||||||
|
if (this.algorithm === 'aes-256-gcm') {
|
||||||
|
cipher.setAAD(ad, { plaintextLength: plaintext.length } as any);
|
||||||
|
} else {
|
||||||
|
cipher.setAAD(ad, {} as any);
|
||||||
|
}
|
||||||
|
const encrypted = cipher.update(plaintext);
|
||||||
|
cipher.final();
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
this.n++;
|
||||||
|
return Buffer.concat([encrypted, tag]);
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptWithAd(ad: Buffer, ciphertext: Buffer): Buffer {
|
||||||
|
if (!this.k) return ciphertext;
|
||||||
|
if (ciphertext.length < SV2_MAC_SIZE) {
|
||||||
|
throw new Error('Ciphertext too short for MAC');
|
||||||
|
}
|
||||||
|
const nonce = encodeNonce(this.n, this.algorithm);
|
||||||
|
const encData = ciphertext.subarray(0, ciphertext.length - SV2_MAC_SIZE);
|
||||||
|
const tag = ciphertext.subarray(ciphertext.length - SV2_MAC_SIZE);
|
||||||
|
const decipher = crypto.createDecipheriv(this.algorithm, this.k, nonce, {
|
||||||
|
authTagLength: SV2_MAC_SIZE,
|
||||||
|
} as any) as crypto.DecipherGCM;
|
||||||
|
if (this.algorithm === 'aes-256-gcm') {
|
||||||
|
decipher.setAAD(ad, { plaintextLength: encData.length } as any);
|
||||||
|
} else {
|
||||||
|
decipher.setAAD(ad, {} as any);
|
||||||
|
}
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
const decrypted = decipher.update(encData);
|
||||||
|
decipher.final();
|
||||||
|
this.n++;
|
||||||
|
return Buffer.from(decrypted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Certificate / Signature Noise Message ───────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2SignatureNoiseMessage {
|
||||||
|
version: number; // U16
|
||||||
|
validFrom: number; // U32 (unix timestamp)
|
||||||
|
notValidAfter: number; // U32 (unix timestamp)
|
||||||
|
signature: Buffer; // 64-byte Schnorr signature
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a SignatureNoiseMessage to 74 bytes.
|
||||||
|
*/
|
||||||
|
export function serializeSignatureNoiseMessage(msg: Sv2SignatureNoiseMessage): Buffer {
|
||||||
|
const buf = Buffer.alloc(SV2_SIGNATURE_NOISE_MSG_SIZE);
|
||||||
|
buf.writeUInt16LE(msg.version, 0);
|
||||||
|
buf.writeUInt32LE(msg.validFrom, 2);
|
||||||
|
buf.writeUInt32LE(msg.notValidAfter, 6);
|
||||||
|
msg.signature.copy(buf, 10);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize a 74-byte SignatureNoiseMessage.
|
||||||
|
*/
|
||||||
|
export function deserializeSignatureNoiseMessage(buf: Buffer): Sv2SignatureNoiseMessage {
|
||||||
|
if (buf.length < SV2_SIGNATURE_NOISE_MSG_SIZE) {
|
||||||
|
throw new Error(`SignatureNoiseMessage requires ${SV2_SIGNATURE_NOISE_MSG_SIZE} bytes`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: buf.readUInt16LE(0),
|
||||||
|
validFrom: buf.readUInt32LE(2),
|
||||||
|
notValidAfter: buf.readUInt32LE(6),
|
||||||
|
signature: Buffer.from(buf.subarray(10, 74)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a SignatureNoiseMessage that certifies a static key.
|
||||||
|
* Signs: version || validFrom || notValidAfter || staticPubKey (x-only, 32 bytes)
|
||||||
|
* using the authority's private key (BIP-340 Schnorr).
|
||||||
|
*/
|
||||||
|
export function createSignatureNoiseMessage(
|
||||||
|
authorityPrivKey: Buffer,
|
||||||
|
staticPubKeyXOnly: Buffer,
|
||||||
|
validFrom: number,
|
||||||
|
notValidAfter: number,
|
||||||
|
): Sv2SignatureNoiseMessage {
|
||||||
|
const version = 0;
|
||||||
|
const msgBuf = Buffer.alloc(2 + 4 + 4 + 32);
|
||||||
|
msgBuf.writeUInt16LE(version, 0);
|
||||||
|
msgBuf.writeUInt32LE(validFrom, 2);
|
||||||
|
msgBuf.writeUInt32LE(notValidAfter, 6);
|
||||||
|
staticPubKeyXOnly.copy(msgBuf, 10);
|
||||||
|
|
||||||
|
const msgHash = sha256(msgBuf);
|
||||||
|
const sig = ecc.signSchnorr(msgHash, authorityPrivKey);
|
||||||
|
if (!sig) throw new Error('Schnorr signing failed');
|
||||||
|
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
validFrom,
|
||||||
|
notValidAfter,
|
||||||
|
signature: Buffer.from(sig),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a SignatureNoiseMessage against an authority public key.
|
||||||
|
*/
|
||||||
|
export function validateSignatureNoiseMessage(
|
||||||
|
msg: Sv2SignatureNoiseMessage,
|
||||||
|
authorityPubKeyXOnly: Buffer,
|
||||||
|
staticPubKeyXOnly: Buffer,
|
||||||
|
): boolean {
|
||||||
|
const msgBuf = Buffer.alloc(2 + 4 + 4 + 32);
|
||||||
|
msgBuf.writeUInt16LE(msg.version, 0);
|
||||||
|
msgBuf.writeUInt32LE(msg.validFrom, 2);
|
||||||
|
msgBuf.writeUInt32LE(msg.notValidAfter, 6);
|
||||||
|
staticPubKeyXOnly.copy(msgBuf, 10);
|
||||||
|
|
||||||
|
const msgHash = sha256(msgBuf);
|
||||||
|
return ecc.verifySchnorr(msgHash, authorityPubKeyXOnly, msg.signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server Keypair Generation ───────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2ServerKeypair {
|
||||||
|
privateKey: Buffer; // 32-byte secret scalar
|
||||||
|
publicKey: Buffer; // 64-byte EllSwift-encoded public key
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateServerKeypair(): Promise<Sv2ServerKeypair> {
|
||||||
|
const es = await loadEllSwift();
|
||||||
|
const kp = es.keygen();
|
||||||
|
return {
|
||||||
|
privateKey: Buffer.from(kp.privateKey),
|
||||||
|
publicKey: Buffer.from(kp.publicKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the x-only (32-byte) public key from a 32-byte private key.
|
||||||
|
*/
|
||||||
|
export function xOnlyPubKeyFromPriv(privKey: Buffer): Buffer {
|
||||||
|
const xOnly = ecc.xOnlyPointFromScalar(privKey);
|
||||||
|
if (!xOnly) throw new Error('Failed to derive x-only public key');
|
||||||
|
return Buffer.from(xOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Noise NX Session (Responder / Server side) ──────────────────────
|
||||||
|
|
||||||
|
export interface Sv2NoiseConfig {
|
||||||
|
/** Server's static keypair (EllSwift). */
|
||||||
|
staticKeypair: Sv2ServerKeypair;
|
||||||
|
/** Pre-computed certificate message (signed by authority). */
|
||||||
|
certificateMessage: Sv2SignatureNoiseMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Sv2NoiseSession {
|
||||||
|
private h: Buffer; // handshake hash
|
||||||
|
private ck: Buffer; // chaining key
|
||||||
|
private tempCipher: Sv2CipherState;
|
||||||
|
private sendCipher: Sv2CipherState;
|
||||||
|
private recvCipher: Sv2CipherState;
|
||||||
|
private handshakeComplete = false;
|
||||||
|
private ellSwift!: EllSwiftApi;
|
||||||
|
private readonly cipherAlgorithm: Sv2CipherAlgorithm;
|
||||||
|
|
||||||
|
private serverEphemeralPriv!: Buffer;
|
||||||
|
private serverEphemeralPub!: Buffer;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: Sv2NoiseConfig,
|
||||||
|
cipherAlgorithm: Sv2CipherAlgorithm = 'chacha20-poly1305',
|
||||||
|
prologue: Buffer = Buffer.alloc(0),
|
||||||
|
) {
|
||||||
|
this.cipherAlgorithm = cipherAlgorithm;
|
||||||
|
|
||||||
|
const protocolNameStr = getNoiseProtocolName(cipherAlgorithm);
|
||||||
|
|
||||||
|
this.tempCipher = new Sv2CipherState(cipherAlgorithm);
|
||||||
|
this.sendCipher = new Sv2CipherState(cipherAlgorithm);
|
||||||
|
this.recvCipher = new Sv2CipherState(cipherAlgorithm);
|
||||||
|
|
||||||
|
// Initialize h = HASH(protocol_name) if len > 32, else pad
|
||||||
|
const protocolName = Buffer.from(protocolNameStr, 'ascii');
|
||||||
|
if (protocolName.length <= 32) {
|
||||||
|
this.h = Buffer.alloc(32);
|
||||||
|
protocolName.copy(this.h);
|
||||||
|
} else {
|
||||||
|
this.h = sha256(protocolName);
|
||||||
|
}
|
||||||
|
// ck = h
|
||||||
|
this.ck = Buffer.from(this.h);
|
||||||
|
// MixHash(prologue) per Noise spec: h = HASH(h || prologue)
|
||||||
|
this.h = sha256(Buffer.concat([this.h, prologue]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Noise Symmetric State Helpers ─────────────────────────────────
|
||||||
|
|
||||||
|
private mixHash(data: Buffer): void {
|
||||||
|
this.h = sha256(Buffer.concat([this.h, data]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private mixKey(ikm: Buffer): void {
|
||||||
|
const [newCk, tempK] = hkdf2(this.ck, ikm);
|
||||||
|
this.ck = newCk;
|
||||||
|
this.tempCipher = new Sv2CipherState(this.cipherAlgorithm);
|
||||||
|
this.tempCipher.initializeKey(tempK);
|
||||||
|
}
|
||||||
|
|
||||||
|
private encryptAndHash(plaintext: Buffer): Buffer {
|
||||||
|
const ciphertext = this.tempCipher.encryptWithAd(this.h, plaintext);
|
||||||
|
this.mixHash(ciphertext);
|
||||||
|
return ciphertext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptAndHash(ciphertext: Buffer): Buffer {
|
||||||
|
const plaintext = this.tempCipher.decryptWithAd(this.h, ciphertext);
|
||||||
|
this.mixHash(ciphertext);
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ECDH via EllSwift ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute BIP-324 EllSwift ECDH.
|
||||||
|
* @param localPriv - Our private key (32 bytes)
|
||||||
|
* @param localPub - Our EllSwift public key (64 bytes)
|
||||||
|
* @param remotePub - Their EllSwift public key (64 bytes)
|
||||||
|
* @param weAreInitiator - Party bit: true for initiator, false for responder
|
||||||
|
*/
|
||||||
|
private ecdh(localPriv: Buffer, localPub: Buffer, remotePub: Buffer, weAreInitiator: boolean): Buffer {
|
||||||
|
const secret = this.ellSwift.getSharedSecretBip324(
|
||||||
|
new Uint8Array(localPriv),
|
||||||
|
new Uint8Array(remotePub),
|
||||||
|
new Uint8Array(localPub),
|
||||||
|
weAreInitiator,
|
||||||
|
);
|
||||||
|
return Buffer.from(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handshake: Process Act 1, Generate Act 2 ─────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the initiator's Act 1 ephemeral key and produce Act 2.
|
||||||
|
* EllSwift mode: 64-byte Act 1 -> 234-byte Act 2
|
||||||
|
*/
|
||||||
|
async processAct1(act1: Buffer): Promise<Buffer> {
|
||||||
|
if (act1.length !== SV2_ELLSWIFT_KEY_SIZE) {
|
||||||
|
throw new Error(`Act 1 must be ${SV2_ELLSWIFT_KEY_SIZE} bytes, got ${act1.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const debug = process.env.SV2_NOISE_DEBUG === 'true';
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
const protocolNameStr = getNoiseProtocolName(this.cipherAlgorithm);
|
||||||
|
console.log(`[NOISE] Protocol name: "${protocolNameStr}" (${protocolNameStr.length} bytes)`);
|
||||||
|
console.log(`[NOISE] Cipher: ${this.cipherAlgorithm}`);
|
||||||
|
console.log(`[NOISE] Init ck=${this.ck.toString('hex')}`);
|
||||||
|
console.log(`[NOISE] Init h=${this.h.toString('hex')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ellSwift = await loadEllSwift();
|
||||||
|
|
||||||
|
const remoteEphemeral = act1;
|
||||||
|
this.mixHash(remoteEphemeral);
|
||||||
|
if (debug) console.log(`[NOISE] After MixHash(re): h=${this.h.toString('hex')}`);
|
||||||
|
|
||||||
|
this.decryptAndHash(Buffer.alloc(0));
|
||||||
|
if (debug) console.log(`[NOISE] After DecryptAndHash(empty): h=${this.h.toString('hex')}`);
|
||||||
|
|
||||||
|
const serverEph = this.ellSwift.keygen();
|
||||||
|
this.serverEphemeralPriv = Buffer.from(serverEph.privateKey);
|
||||||
|
this.serverEphemeralPub = Buffer.from(serverEph.publicKey);
|
||||||
|
|
||||||
|
if (debug) console.log(`[NOISE] Server eph pub: ${this.serverEphemeralPub.toString('hex')}`);
|
||||||
|
|
||||||
|
this.mixHash(this.serverEphemeralPub);
|
||||||
|
if (debug) console.log(`[NOISE] After MixHash(se): h=${this.h.toString('hex')}`);
|
||||||
|
|
||||||
|
const eeDH = this.ecdh(
|
||||||
|
this.serverEphemeralPriv,
|
||||||
|
this.serverEphemeralPub,
|
||||||
|
remoteEphemeral,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (debug) console.log(`[NOISE] ee DH shared secret: ${eeDH.toString('hex')}`);
|
||||||
|
|
||||||
|
this.mixKey(eeDH);
|
||||||
|
if (debug) console.log(`[NOISE] After MixKey(ee): ck=${this.ck.toString('hex')}`);
|
||||||
|
|
||||||
|
const staticPubKey = this.config.staticKeypair.publicKey;
|
||||||
|
if (debug) {
|
||||||
|
console.log(`[NOISE] h (AD for encrypt static): ${this.h.toString('hex')}`);
|
||||||
|
console.log(`[NOISE] Static pub (plaintext): ${staticPubKey.toString('hex')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedStatic = this.encryptAndHash(staticPubKey);
|
||||||
|
if (debug) console.log(`[NOISE] Encrypted static (${encryptedStatic.length} bytes): ${encryptedStatic.toString('hex')}`);
|
||||||
|
|
||||||
|
const esDH = this.ecdh(
|
||||||
|
this.config.staticKeypair.privateKey,
|
||||||
|
this.config.staticKeypair.publicKey,
|
||||||
|
remoteEphemeral,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (debug) console.log(`[NOISE] es DH shared secret: ${esDH.toString('hex')}`);
|
||||||
|
|
||||||
|
this.mixKey(esDH);
|
||||||
|
if (debug) console.log(`[NOISE] After MixKey(es): ck=${this.ck.toString('hex')}`);
|
||||||
|
|
||||||
|
const certPayload = serializeSignatureNoiseMessage(this.config.certificateMessage);
|
||||||
|
if (debug) console.log(`[NOISE] h (AD for encrypt cert): ${this.h.toString('hex')}`);
|
||||||
|
if (debug) console.log(`[NOISE] Cert payload (${certPayload.length} bytes): ${certPayload.toString('hex')}`);
|
||||||
|
|
||||||
|
const encryptedCert = this.encryptAndHash(certPayload);
|
||||||
|
if (debug) console.log(`[NOISE] Encrypted cert (${encryptedCert.length} bytes)`);
|
||||||
|
|
||||||
|
const [k1, k2] = hkdf2(this.ck, Buffer.alloc(0));
|
||||||
|
this.recvCipher.initializeKey(k1);
|
||||||
|
this.sendCipher.initializeKey(k2);
|
||||||
|
this.handshakeComplete = true;
|
||||||
|
|
||||||
|
const act2 = Buffer.concat([this.serverEphemeralPub, encryptedStatic, encryptedCert]);
|
||||||
|
if (debug) console.log(`[NOISE] Act 2 total: ${act2.length} bytes`);
|
||||||
|
|
||||||
|
return act2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Post-Handshake Transport ──────────────────────────────────────
|
||||||
|
|
||||||
|
encrypt(plaintext: Buffer): Buffer {
|
||||||
|
if (!this.handshakeComplete) throw new Error('Handshake not complete');
|
||||||
|
return this.sendCipher.encryptWithAd(Buffer.alloc(0), plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypt(ciphertext: Buffer): Buffer {
|
||||||
|
if (!this.handshakeComplete) throw new Error('Handshake not complete');
|
||||||
|
return this.recvCipher.decryptWithAd(Buffer.alloc(0), ciphertext);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isHandshakeComplete(): boolean {
|
||||||
|
return this.handshakeComplete;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initiator Session (for testing) ─────────────────────────────────
|
||||||
|
|
||||||
|
export class Sv2NoiseInitiator {
|
||||||
|
private h: Buffer;
|
||||||
|
private ck: Buffer;
|
||||||
|
private tempCipher: Sv2CipherState;
|
||||||
|
private sendCipher: Sv2CipherState;
|
||||||
|
private recvCipher: Sv2CipherState;
|
||||||
|
private handshakeComplete = false;
|
||||||
|
private ellSwift!: EllSwiftApi;
|
||||||
|
private readonly cipherAlgorithm: Sv2CipherAlgorithm;
|
||||||
|
|
||||||
|
private ephemeralPriv!: Buffer;
|
||||||
|
private ephemeralPub!: Buffer;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
cipherAlgorithm: Sv2CipherAlgorithm = 'chacha20-poly1305',
|
||||||
|
prologue: Buffer = Buffer.alloc(0),
|
||||||
|
) {
|
||||||
|
this.cipherAlgorithm = cipherAlgorithm;
|
||||||
|
|
||||||
|
const protocolNameStr = getNoiseProtocolName(cipherAlgorithm);
|
||||||
|
|
||||||
|
this.tempCipher = new Sv2CipherState(cipherAlgorithm);
|
||||||
|
this.sendCipher = new Sv2CipherState(cipherAlgorithm);
|
||||||
|
this.recvCipher = new Sv2CipherState(cipherAlgorithm);
|
||||||
|
|
||||||
|
const protocolName = Buffer.from(protocolNameStr, 'ascii');
|
||||||
|
if (protocolName.length <= 32) {
|
||||||
|
this.h = Buffer.alloc(32);
|
||||||
|
protocolName.copy(this.h);
|
||||||
|
} else {
|
||||||
|
this.h = sha256(protocolName);
|
||||||
|
}
|
||||||
|
this.ck = Buffer.from(this.h);
|
||||||
|
// MixHash(prologue) per Noise spec: h = HASH(h || prologue)
|
||||||
|
this.h = sha256(Buffer.concat([this.h, prologue]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private mixHash(data: Buffer): void {
|
||||||
|
this.h = sha256(Buffer.concat([this.h, data]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private mixKey(ikm: Buffer): void {
|
||||||
|
const [newCk, tempK] = hkdf2(this.ck, ikm);
|
||||||
|
this.ck = newCk;
|
||||||
|
this.tempCipher = new Sv2CipherState(this.cipherAlgorithm);
|
||||||
|
this.tempCipher.initializeKey(tempK);
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptAndHash(ciphertext: Buffer): Buffer {
|
||||||
|
const plaintext = this.tempCipher.decryptWithAd(this.h, ciphertext);
|
||||||
|
this.mixHash(ciphertext);
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ecdh(localPriv: Buffer, localPub: Buffer, remotePub: Buffer, weAreInitiator: boolean): Buffer {
|
||||||
|
const secret = this.ellSwift.getSharedSecretBip324(
|
||||||
|
new Uint8Array(localPriv),
|
||||||
|
new Uint8Array(remotePub),
|
||||||
|
new Uint8Array(localPub),
|
||||||
|
weAreInitiator,
|
||||||
|
);
|
||||||
|
return Buffer.from(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate Act 1: 64-byte EllSwift ephemeral key. */
|
||||||
|
async generateAct1(): Promise<Buffer> {
|
||||||
|
this.ellSwift = await loadEllSwift();
|
||||||
|
const kp = this.ellSwift.keygen();
|
||||||
|
this.ephemeralPriv = Buffer.from(kp.privateKey);
|
||||||
|
this.ephemeralPub = Buffer.from(kp.publicKey);
|
||||||
|
|
||||||
|
this.mixHash(this.ephemeralPub);
|
||||||
|
this.mixHash(Buffer.alloc(0));
|
||||||
|
return this.ephemeralPub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Process Act 2 from responder, returns the decrypted server static key and cert. */
|
||||||
|
processAct2(act2: Buffer): {
|
||||||
|
serverStaticKey: Buffer;
|
||||||
|
certificate: Sv2SignatureNoiseMessage;
|
||||||
|
} {
|
||||||
|
if (act2.length !== 234) {
|
||||||
|
throw new Error(`Act 2 must be 234 bytes, got ${act2.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverEphemeral = act2.subarray(0, 64);
|
||||||
|
const encryptedStatic = act2.subarray(64, 144);
|
||||||
|
const encryptedCert = act2.subarray(144, 234);
|
||||||
|
|
||||||
|
this.mixHash(Buffer.from(serverEphemeral));
|
||||||
|
|
||||||
|
const eeDH = this.ecdh(
|
||||||
|
this.ephemeralPriv,
|
||||||
|
this.ephemeralPub,
|
||||||
|
Buffer.from(serverEphemeral),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.mixKey(eeDH);
|
||||||
|
|
||||||
|
const serverStaticKey = this.decryptAndHash(Buffer.from(encryptedStatic));
|
||||||
|
|
||||||
|
const esDH = this.ecdh(
|
||||||
|
this.ephemeralPriv,
|
||||||
|
this.ephemeralPub,
|
||||||
|
serverStaticKey,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.mixKey(esDH);
|
||||||
|
|
||||||
|
const certPayload = this.decryptAndHash(Buffer.from(encryptedCert));
|
||||||
|
const certificate = deserializeSignatureNoiseMessage(certPayload);
|
||||||
|
|
||||||
|
const [k1, k2] = hkdf2(this.ck, Buffer.alloc(0));
|
||||||
|
this.sendCipher.initializeKey(k1);
|
||||||
|
this.recvCipher.initializeKey(k2);
|
||||||
|
this.handshakeComplete = true;
|
||||||
|
|
||||||
|
return { serverStaticKey, certificate };
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypt(plaintext: Buffer): Buffer {
|
||||||
|
if (!this.handshakeComplete) throw new Error('Handshake not complete');
|
||||||
|
return this.sendCipher.encryptWithAd(Buffer.alloc(0), plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypt(ciphertext: Buffer): Buffer {
|
||||||
|
if (!this.handshakeComplete) throw new Error('Handshake not complete');
|
||||||
|
return this.recvCipher.decryptWithAd(Buffer.alloc(0), ciphertext);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isHandshakeComplete(): boolean {
|
||||||
|
return this.handshakeComplete;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { sipHash24, shortTxId, sipHashKeyFromNonce } from './sv2-siphash';
|
||||||
|
|
||||||
|
describe('SipHash-2-4', () => {
|
||||||
|
// Official SipHash-2-4 test vectors from the reference paper
|
||||||
|
// Key: 00 01 02 ... 0f
|
||||||
|
// Input: empty, 00, 00 01, 00 01 02, ...
|
||||||
|
// Expected outputs (first few from Appendix A of the paper)
|
||||||
|
const testKey = Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);
|
||||||
|
|
||||||
|
// Reference vectors from vectors.h: uint8_t vectors_sip64[64][8]
|
||||||
|
// These are the raw output bytes, so we compare directly with our output buffer.
|
||||||
|
const expectedOutputs = [
|
||||||
|
'310e0edd47db6f72', // empty input
|
||||||
|
'fd67dc93c539f874', // [0x00]
|
||||||
|
'5a4fa9d909806c0d', // [0x00, 0x01]
|
||||||
|
'2d7efbd796666785', // [0x00, 0x01, 0x02]
|
||||||
|
'b7877127e09427cf', // [0x00, 0x01, 0x02, 0x03]
|
||||||
|
'8da699cd64557618', // [0x00, ..., 0x04]
|
||||||
|
'cee3fe586e46c9cb', // [0x00, ..., 0x05]
|
||||||
|
'37d1018bf50002ab', // [0x00, ..., 0x06]
|
||||||
|
'6224939a79f5f593', // [0x00, ..., 0x07]
|
||||||
|
'b0e4a90bdf82009e', // [0x00, ..., 0x08]
|
||||||
|
'f3b9dd94c5bb5d7a', // [0x00, ..., 0x09]
|
||||||
|
'a7ad6b22462fb3f4', // [0x00, ..., 0x0a]
|
||||||
|
'fbe50e86bc8f1e75', // [0x00, ..., 0x0b]
|
||||||
|
'903d84c02756ea14', // [0x00, ..., 0x0c]
|
||||||
|
'eef27a8e90ca23f7', // [0x00, ..., 0x0d]
|
||||||
|
'e545be4961ca29a1', // [0x00, ..., 0x0e]
|
||||||
|
];
|
||||||
|
|
||||||
|
expectedOutputs.forEach((expected, i) => {
|
||||||
|
it(`matches test vector for ${i}-byte input`, () => {
|
||||||
|
const input = Buffer.from(Array.from({ length: i }, (_, j) => j));
|
||||||
|
const result = sipHash24(testKey, input);
|
||||||
|
// Compare raw output bytes directly
|
||||||
|
expect(result.toString('hex')).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for invalid key length', () => {
|
||||||
|
expect(() => sipHash24(Buffer.alloc(8), Buffer.alloc(0))).toThrow('SipHash key must be 16 bytes');
|
||||||
|
expect(() => sipHash24(Buffer.alloc(32), Buffer.alloc(0))).toThrow('SipHash key must be 16 bytes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces 8-byte output', () => {
|
||||||
|
const result = sipHash24(testKey, Buffer.from('hello'));
|
||||||
|
expect(result.length).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces consistent results', () => {
|
||||||
|
const data = Buffer.from('test data');
|
||||||
|
const r1 = sipHash24(testKey, data);
|
||||||
|
const r2 = sipHash24(testKey, data);
|
||||||
|
expect(r1).toEqual(r2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('different keys produce different results', () => {
|
||||||
|
const key2 = Buffer.alloc(16, 0xff);
|
||||||
|
const data = Buffer.from('hello');
|
||||||
|
const r1 = sipHash24(testKey, data);
|
||||||
|
const r2 = sipHash24(key2, data);
|
||||||
|
expect(r1).not.toEqual(r2);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shortTxId', () => {
|
||||||
|
it('produces 6-byte output', () => {
|
||||||
|
const txHash = Buffer.alloc(32, 0xab);
|
||||||
|
const result = shortTxId(testKey, txHash);
|
||||||
|
expect(result.length).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is the first 6 bytes of sipHash24', () => {
|
||||||
|
const txHash = Buffer.alloc(32, 0xcd);
|
||||||
|
const full = sipHash24(testKey, txHash);
|
||||||
|
const short = shortTxId(testKey, txHash);
|
||||||
|
expect(short).toEqual(full.subarray(0, 6));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('different tx hashes produce different short IDs', () => {
|
||||||
|
const tx1 = Buffer.alloc(32, 0x01);
|
||||||
|
const tx2 = Buffer.alloc(32, 0x02);
|
||||||
|
const s1 = shortTxId(testKey, tx1);
|
||||||
|
const s2 = shortTxId(testKey, tx2);
|
||||||
|
expect(s1).not.toEqual(s2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sipHashKeyFromNonce', () => {
|
||||||
|
it('produces 16-byte key', () => {
|
||||||
|
const key = sipHashKeyFromNonce(42n);
|
||||||
|
expect(key.length).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nonce is stored in first 8 bytes LE', () => {
|
||||||
|
const key = sipHashKeyFromNonce(0x0102030405060708n);
|
||||||
|
expect(key.readBigUInt64LE(0)).toBe(0x0102030405060708n);
|
||||||
|
// Second 8 bytes should be zero
|
||||||
|
expect(key.readBigUInt64LE(8)).toBe(0n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces valid SipHash key', () => {
|
||||||
|
const key = sipHashKeyFromNonce(12345n);
|
||||||
|
const result = sipHash24(key, Buffer.from('test'));
|
||||||
|
expect(result.length).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// ── SipHash-2-4 ────────────────────────────────────────────────────
|
||||||
|
// Pure TypeScript implementation of SipHash-2-4 for SV2 JDP short
|
||||||
|
// transaction IDs. Produces 8-byte (64-bit) hashes, truncated to
|
||||||
|
// 6 bytes per the SV2 spec for SHORT_TX_ID.
|
||||||
|
//
|
||||||
|
// Reference: https://131002.net/siphash/siphash.pdf
|
||||||
|
|
||||||
|
function add64(a: [number, number], b: [number, number]): [number, number] {
|
||||||
|
const lo = (a[1] + b[1]) >>> 0;
|
||||||
|
const hi = ((a[0] + b[0]) >>> 0) + (lo < a[1] ? 1 : 0);
|
||||||
|
return [(hi >>> 0), lo];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotl64(v: [number, number], n: number): [number, number] {
|
||||||
|
if (n === 0) return v;
|
||||||
|
if (n === 32) return [v[1], v[0]];
|
||||||
|
if (n < 32) {
|
||||||
|
return [
|
||||||
|
((v[0] << n) | (v[1] >>> (32 - n))) >>> 0,
|
||||||
|
((v[1] << n) | (v[0] >>> (32 - n))) >>> 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const s = n - 32;
|
||||||
|
return [
|
||||||
|
((v[1] << s) | (v[0] >>> (32 - s))) >>> 0,
|
||||||
|
((v[0] << s) | (v[1] >>> (32 - s))) >>> 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function xor64(a: [number, number], b: [number, number]): [number, number] {
|
||||||
|
return [(a[0] ^ b[0]) >>> 0, (a[1] ^ b[1]) >>> 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sipRound(
|
||||||
|
v0: [number, number],
|
||||||
|
v1: [number, number],
|
||||||
|
v2: [number, number],
|
||||||
|
v3: [number, number],
|
||||||
|
): [[number, number], [number, number], [number, number], [number, number]] {
|
||||||
|
v0 = add64(v0, v1);
|
||||||
|
v1 = rotl64(v1, 13);
|
||||||
|
v1 = xor64(v1, v0);
|
||||||
|
v0 = rotl64(v0, 32);
|
||||||
|
|
||||||
|
v2 = add64(v2, v3);
|
||||||
|
v3 = rotl64(v3, 16);
|
||||||
|
v3 = xor64(v3, v2);
|
||||||
|
|
||||||
|
v2 = add64(v2, v1);
|
||||||
|
v1 = rotl64(v1, 17);
|
||||||
|
v1 = xor64(v1, v2);
|
||||||
|
v2 = rotl64(v2, 32);
|
||||||
|
|
||||||
|
v0 = add64(v0, v3);
|
||||||
|
v3 = rotl64(v3, 21);
|
||||||
|
v3 = xor64(v3, v0);
|
||||||
|
|
||||||
|
return [v0, v1, v2, v3];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readU64LE(buf: Buffer, offset: number): [number, number] {
|
||||||
|
const lo = buf.readUInt32LE(offset);
|
||||||
|
const hi = buf.readUInt32LE(offset + 4);
|
||||||
|
return [hi, lo];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SipHash-2-4 producing an 8-byte hash.
|
||||||
|
* @param key 16-byte key
|
||||||
|
* @param data input data
|
||||||
|
* @returns 8-byte Buffer (little-endian hash)
|
||||||
|
*/
|
||||||
|
export function sipHash24(key: Buffer, data: Buffer): Buffer {
|
||||||
|
if (key.length !== 16) throw new Error('SipHash key must be 16 bytes');
|
||||||
|
|
||||||
|
const k0 = readU64LE(key, 0);
|
||||||
|
const k1 = readU64LE(key, 8);
|
||||||
|
|
||||||
|
let v0: [number, number] = xor64(k0, [0x736f6d65, 0x70736575]); // "somepseu"
|
||||||
|
let v1: [number, number] = xor64(k1, [0x646f7261, 0x6e646f6d]); // "dorandom"
|
||||||
|
let v2: [number, number] = xor64(k0, [0x6c796765, 0x6e657261]); // "lygenera"
|
||||||
|
let v3: [number, number] = xor64(k1, [0x74656462, 0x79746573]); // "tedbytes"
|
||||||
|
|
||||||
|
const len = data.length;
|
||||||
|
const blocks = Math.floor(len / 8);
|
||||||
|
|
||||||
|
// Process 8-byte blocks
|
||||||
|
for (let i = 0; i < blocks; i++) {
|
||||||
|
const m = readU64LE(data, i * 8);
|
||||||
|
v3 = xor64(v3, m);
|
||||||
|
// 2 rounds (SipHash-2-4)
|
||||||
|
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
|
||||||
|
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
|
||||||
|
v0 = xor64(v0, m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last block: remaining bytes + length in the high byte
|
||||||
|
const remaining = len - blocks * 8;
|
||||||
|
let last: [number, number] = [0, 0];
|
||||||
|
|
||||||
|
// High byte of hi word = (len & 0xff) << 24
|
||||||
|
last[0] = ((len & 0xff) << 24) >>> 0;
|
||||||
|
|
||||||
|
// Read remaining bytes
|
||||||
|
const offset = blocks * 8;
|
||||||
|
if (remaining >= 7) last[0] |= (data[offset + 6] << 16) >>> 0;
|
||||||
|
if (remaining >= 6) last[0] |= (data[offset + 5] << 8) >>> 0;
|
||||||
|
if (remaining >= 5) last[0] |= data[offset + 4];
|
||||||
|
if (remaining >= 4) last[1] = (last[1] | ((data[offset + 3] << 24) >>> 0)) >>> 0;
|
||||||
|
if (remaining >= 3) last[1] = (last[1] | ((data[offset + 2] << 16) >>> 0)) >>> 0;
|
||||||
|
if (remaining >= 2) last[1] = (last[1] | ((data[offset + 1] << 8) >>> 0)) >>> 0;
|
||||||
|
if (remaining >= 1) last[1] = (last[1] | data[offset]) >>> 0;
|
||||||
|
|
||||||
|
v3 = xor64(v3, last);
|
||||||
|
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
|
||||||
|
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
|
||||||
|
v0 = xor64(v0, last);
|
||||||
|
|
||||||
|
// Finalization
|
||||||
|
v2 = xor64(v2, [0, 0xff]);
|
||||||
|
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
|
||||||
|
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
|
||||||
|
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
|
||||||
|
[v0, v1, v2, v3] = sipRound(v0, v1, v2, v3);
|
||||||
|
|
||||||
|
const result = xor64(xor64(v0, v1), xor64(v2, v3));
|
||||||
|
|
||||||
|
const out = Buffer.alloc(8);
|
||||||
|
out.writeUInt32LE(result[1], 0);
|
||||||
|
out.writeUInt32LE(result[0], 4);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a 6-byte SHORT_TX_ID per SV2 spec.
|
||||||
|
* SipHash-2-4(key, txid) truncated to first 6 bytes.
|
||||||
|
* @param key 16-byte key (derived from nonce)
|
||||||
|
* @param txHash 32-byte transaction hash
|
||||||
|
* @returns 6-byte Buffer
|
||||||
|
*/
|
||||||
|
export function shortTxId(key: Buffer, txHash: Buffer): Buffer {
|
||||||
|
const hash = sipHash24(key, txHash);
|
||||||
|
return hash.subarray(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a 16-byte SipHash key from a U64 nonce.
|
||||||
|
* First 8 bytes = nonce LE, second 8 bytes = zeros.
|
||||||
|
*/
|
||||||
|
export function sipHashKeyFromNonce(nonce: bigint): Buffer {
|
||||||
|
const key = Buffer.alloc(16);
|
||||||
|
key.writeBigUInt64LE(nonce, 0);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { BufferReader } from './sv2-binary-codec';
|
||||||
|
import {
|
||||||
|
Sv2TdpCoinbaseOutputConstraints,
|
||||||
|
serializeTdpCoinbaseOutputConstraints,
|
||||||
|
deserializeTdpCoinbaseOutputConstraints,
|
||||||
|
Sv2TdpNewTemplate,
|
||||||
|
serializeTdpNewTemplate,
|
||||||
|
deserializeTdpNewTemplate,
|
||||||
|
Sv2TdpSetNewPrevHash,
|
||||||
|
serializeTdpSetNewPrevHash,
|
||||||
|
deserializeTdpSetNewPrevHash,
|
||||||
|
Sv2TdpRequestTransactionData,
|
||||||
|
serializeTdpRequestTransactionData,
|
||||||
|
deserializeTdpRequestTransactionData,
|
||||||
|
Sv2TdpRequestTransactionDataSuccess,
|
||||||
|
serializeTdpRequestTransactionDataSuccess,
|
||||||
|
deserializeTdpRequestTransactionDataSuccess,
|
||||||
|
Sv2TdpRequestTransactionDataError,
|
||||||
|
serializeTdpRequestTransactionDataError,
|
||||||
|
deserializeTdpRequestTransactionDataError,
|
||||||
|
Sv2TdpSubmitSolution,
|
||||||
|
serializeTdpSubmitSolution,
|
||||||
|
deserializeTdpSubmitSolution,
|
||||||
|
} from './sv2-tdp-messages';
|
||||||
|
|
||||||
|
function roundTrip<T>(
|
||||||
|
msg: T,
|
||||||
|
serialize: (m: T) => Buffer,
|
||||||
|
deserialize: (r: BufferReader) => T,
|
||||||
|
): T {
|
||||||
|
const buf = serialize(msg);
|
||||||
|
return deserialize(new BufferReader(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SV2 TDP Messages', () => {
|
||||||
|
it('CoinbaseOutputConstraints round-trips', () => {
|
||||||
|
const msg: Sv2TdpCoinbaseOutputConstraints = {
|
||||||
|
coinbaseOutputMaxAdditionalSize: 128,
|
||||||
|
coinbaseOutputMaxAdditionalSigops: 400,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeTdpCoinbaseOutputConstraints, deserializeTdpCoinbaseOutputConstraints);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NewTemplate round-trips with merkle path', () => {
|
||||||
|
const hash1 = Buffer.alloc(32, 0xaa);
|
||||||
|
const hash2 = Buffer.alloc(32, 0xbb);
|
||||||
|
const msg: Sv2TdpNewTemplate = {
|
||||||
|
templateId: 42n,
|
||||||
|
futureTemplate: false,
|
||||||
|
version: 0x20000000,
|
||||||
|
coinbaseTxVersion: 2,
|
||||||
|
coinbasePrefix: Buffer.from('deadbeef', 'hex'),
|
||||||
|
coinbaseTxInputSequence: 0xffffffff,
|
||||||
|
coinbaseTxValueRemaining: 625000000n,
|
||||||
|
coinbaseTxOutputsCount: 1,
|
||||||
|
coinbaseTxOutputs: Buffer.from('cafebabe', 'hex'),
|
||||||
|
coinbaseTxLocktime: 0,
|
||||||
|
merklePath: [hash1, hash2],
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeTdpNewTemplate, deserializeTdpNewTemplate);
|
||||||
|
expect(result.templateId).toBe(msg.templateId);
|
||||||
|
expect(result.futureTemplate).toBe(msg.futureTemplate);
|
||||||
|
expect(result.version).toBe(msg.version);
|
||||||
|
expect(result.coinbasePrefix).toEqual(msg.coinbasePrefix);
|
||||||
|
expect(result.coinbaseTxVersion).toBe(msg.coinbaseTxVersion);
|
||||||
|
expect(result.coinbaseTxInputSequence).toBe(msg.coinbaseTxInputSequence);
|
||||||
|
expect(result.coinbaseTxValueRemaining).toBe(msg.coinbaseTxValueRemaining);
|
||||||
|
expect(result.coinbaseTxOutputsCount).toBe(msg.coinbaseTxOutputsCount);
|
||||||
|
expect(result.coinbaseTxOutputs).toEqual(msg.coinbaseTxOutputs);
|
||||||
|
expect(result.coinbaseTxLocktime).toBe(msg.coinbaseTxLocktime);
|
||||||
|
expect(result.merklePath).toHaveLength(2);
|
||||||
|
expect(result.merklePath[0]).toEqual(hash1);
|
||||||
|
expect(result.merklePath[1]).toEqual(hash2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NewTemplate round-trips with empty merkle path', () => {
|
||||||
|
const msg: Sv2TdpNewTemplate = {
|
||||||
|
templateId: 1n,
|
||||||
|
futureTemplate: true,
|
||||||
|
version: 0x20000000,
|
||||||
|
coinbaseTxVersion: 2,
|
||||||
|
coinbasePrefix: Buffer.alloc(0),
|
||||||
|
coinbaseTxInputSequence: 0xffffffff,
|
||||||
|
coinbaseTxValueRemaining: 0n,
|
||||||
|
coinbaseTxOutputsCount: 0,
|
||||||
|
coinbaseTxOutputs: Buffer.alloc(0),
|
||||||
|
coinbaseTxLocktime: 0,
|
||||||
|
merklePath: [],
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeTdpNewTemplate, deserializeTdpNewTemplate);
|
||||||
|
expect(result.merklePath).toHaveLength(0);
|
||||||
|
expect(result.futureTemplate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NewTemplate round-trips with large coinbase data', () => {
|
||||||
|
const largeCoinbase = Buffer.alloc(100, 0xcc); // B0_255 max 255 bytes
|
||||||
|
const largeOutputs = Buffer.alloc(2000, 0xdd);
|
||||||
|
const msg: Sv2TdpNewTemplate = {
|
||||||
|
templateId: 999n,
|
||||||
|
futureTemplate: false,
|
||||||
|
version: 0x20400000,
|
||||||
|
coinbaseTxVersion: 2,
|
||||||
|
coinbasePrefix: largeCoinbase,
|
||||||
|
coinbaseTxInputSequence: 0xfffffffe,
|
||||||
|
coinbaseTxValueRemaining: 312500000n,
|
||||||
|
coinbaseTxOutputsCount: 3,
|
||||||
|
coinbaseTxOutputs: largeOutputs,
|
||||||
|
coinbaseTxLocktime: 500000,
|
||||||
|
merklePath: Array.from({ length: 12 }, (_, i) => Buffer.alloc(32, i)),
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeTdpNewTemplate, deserializeTdpNewTemplate);
|
||||||
|
expect(result.coinbasePrefix).toEqual(largeCoinbase); // B0_255, 100 bytes
|
||||||
|
expect(result.coinbaseTxOutputs).toEqual(largeOutputs);
|
||||||
|
expect(result.merklePath).toHaveLength(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SetNewPrevHash round-trips', () => {
|
||||||
|
const prevHash = Buffer.alloc(32, 0xab);
|
||||||
|
const target = Buffer.alloc(32, 0x00);
|
||||||
|
target[0] = 0xff;
|
||||||
|
const msg: Sv2TdpSetNewPrevHash = {
|
||||||
|
templateId: 42n,
|
||||||
|
prevHash,
|
||||||
|
headerTimestamp: 1700000000,
|
||||||
|
nBits: 0x1d00ffff,
|
||||||
|
target,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeTdpSetNewPrevHash, deserializeTdpSetNewPrevHash);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RequestTransactionData round-trips', () => {
|
||||||
|
const msg: Sv2TdpRequestTransactionData = {
|
||||||
|
templateId: 100n,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeTdpRequestTransactionData, deserializeTdpRequestTransactionData);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RequestTransactionData.Success round-trips with transactions', () => {
|
||||||
|
const tx1 = Buffer.from('0100000001', 'hex');
|
||||||
|
const tx2 = Buffer.from('0200000002abcd', 'hex');
|
||||||
|
const msg: Sv2TdpRequestTransactionDataSuccess = {
|
||||||
|
templateId: 100n,
|
||||||
|
excessData: Buffer.from('extra', 'utf8'),
|
||||||
|
transactionList: [tx1, tx2],
|
||||||
|
};
|
||||||
|
const result = roundTrip(
|
||||||
|
msg,
|
||||||
|
serializeTdpRequestTransactionDataSuccess,
|
||||||
|
deserializeTdpRequestTransactionDataSuccess,
|
||||||
|
);
|
||||||
|
expect(result.templateId).toBe(msg.templateId);
|
||||||
|
expect(result.excessData).toEqual(msg.excessData);
|
||||||
|
expect(result.transactionList).toHaveLength(2);
|
||||||
|
expect(result.transactionList[0]).toEqual(tx1);
|
||||||
|
expect(result.transactionList[1]).toEqual(tx2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RequestTransactionData.Success round-trips with empty transaction list', () => {
|
||||||
|
const msg: Sv2TdpRequestTransactionDataSuccess = {
|
||||||
|
templateId: 1n,
|
||||||
|
excessData: Buffer.alloc(0),
|
||||||
|
transactionList: [],
|
||||||
|
};
|
||||||
|
const result = roundTrip(
|
||||||
|
msg,
|
||||||
|
serializeTdpRequestTransactionDataSuccess,
|
||||||
|
deserializeTdpRequestTransactionDataSuccess,
|
||||||
|
);
|
||||||
|
expect(result.transactionList).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RequestTransactionData.Error round-trips', () => {
|
||||||
|
const msg: Sv2TdpRequestTransactionDataError = {
|
||||||
|
templateId: 42n,
|
||||||
|
errorCode: 'template-not-found',
|
||||||
|
};
|
||||||
|
const result = roundTrip(
|
||||||
|
msg,
|
||||||
|
serializeTdpRequestTransactionDataError,
|
||||||
|
deserializeTdpRequestTransactionDataError,
|
||||||
|
);
|
||||||
|
expect(result).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SubmitSolution round-trips', () => {
|
||||||
|
const coinbaseTx = Buffer.alloc(250, 0xef);
|
||||||
|
const msg: Sv2TdpSubmitSolution = {
|
||||||
|
templateId: 42n,
|
||||||
|
version: 0x20000000,
|
||||||
|
headerTimestamp: 1700000000,
|
||||||
|
headerNonce: 0xdeadbeef,
|
||||||
|
coinbaseTx,
|
||||||
|
};
|
||||||
|
const result = roundTrip(msg, serializeTdpSubmitSolution, deserializeTdpSubmitSolution);
|
||||||
|
expect(result.templateId).toBe(msg.templateId);
|
||||||
|
expect(result.version).toBe(msg.version);
|
||||||
|
expect(result.headerTimestamp).toBe(msg.headerTimestamp);
|
||||||
|
expect(result.headerNonce).toBe(msg.headerNonce);
|
||||||
|
expect(result.coinbaseTx).toEqual(coinbaseTx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serialized NewTemplate has correct byte layout', () => {
|
||||||
|
const msg: Sv2TdpNewTemplate = {
|
||||||
|
templateId: 1n,
|
||||||
|
futureTemplate: false,
|
||||||
|
version: 0x20000000,
|
||||||
|
coinbaseTxVersion: 2,
|
||||||
|
coinbasePrefix: Buffer.from([0xab]),
|
||||||
|
coinbaseTxInputSequence: 0xffffffff,
|
||||||
|
coinbaseTxValueRemaining: 100n,
|
||||||
|
coinbaseTxOutputsCount: 0,
|
||||||
|
coinbaseTxOutputs: Buffer.alloc(0),
|
||||||
|
coinbaseTxLocktime: 0,
|
||||||
|
merklePath: [],
|
||||||
|
};
|
||||||
|
const buf = serializeTdpNewTemplate(msg);
|
||||||
|
// U64(8) + BOOL(1) + U32(4) + U32(4) + B0_255(1+1) + U32(4) + U64(8) + U32(4) + B0_64K(2+0) + U32(4) + SEQ0_255(1+0)
|
||||||
|
// = 8 + 1 + 4 + 4 + 2 + 4 + 8 + 4 + 2 + 4 + 1 = 42
|
||||||
|
expect(buf.length).toBe(42);
|
||||||
|
expect(buf.readBigUInt64LE(0)).toBe(1n);
|
||||||
|
expect(buf[8]).toBe(0); // futureTemplate = false
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
// ── SV2 Template Distribution Protocol (TDP) Messages ──────────────
|
||||||
|
// Interfaces + serialize/deserialize for all TDP message types.
|
||||||
|
|
||||||
|
import { BufferReader, BufferWriter } from './sv2-binary-codec';
|
||||||
|
|
||||||
|
// ── CoinbaseOutputConstraints (0x70) ────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2TdpCoinbaseOutputConstraints {
|
||||||
|
coinbaseOutputMaxAdditionalSize: number; // U32
|
||||||
|
coinbaseOutputMaxAdditionalSigops: number; // U16
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeTdpCoinbaseOutputConstraints(msg: Sv2TdpCoinbaseOutputConstraints): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU32(msg.coinbaseOutputMaxAdditionalSize);
|
||||||
|
w.writeU16(msg.coinbaseOutputMaxAdditionalSigops);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeTdpCoinbaseOutputConstraints(reader: BufferReader): Sv2TdpCoinbaseOutputConstraints {
|
||||||
|
return {
|
||||||
|
coinbaseOutputMaxAdditionalSize: reader.readU32(),
|
||||||
|
coinbaseOutputMaxAdditionalSigops: reader.readU16(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NewTemplate (0x71) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2TdpNewTemplate {
|
||||||
|
templateId: bigint; // U64
|
||||||
|
futureTemplate: boolean; // BOOL
|
||||||
|
version: number; // U32
|
||||||
|
coinbaseTxVersion: number; // U32
|
||||||
|
coinbasePrefix: Buffer; // B0_255
|
||||||
|
coinbaseTxInputSequence: number; // U32
|
||||||
|
coinbaseTxValueRemaining: bigint; // U64
|
||||||
|
coinbaseTxOutputsCount: number; // U32
|
||||||
|
coinbaseTxOutputs: Buffer; // B0_64K (serialized outputs)
|
||||||
|
coinbaseTxLocktime: number; // U32
|
||||||
|
merklePath: Buffer[]; // SEQ0_255<U256>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeTdpNewTemplate(msg: Sv2TdpNewTemplate): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU64(msg.templateId);
|
||||||
|
w.writeBool(msg.futureTemplate);
|
||||||
|
w.writeU32(msg.version);
|
||||||
|
w.writeU32(msg.coinbaseTxVersion);
|
||||||
|
w.writeB0_255(msg.coinbasePrefix);
|
||||||
|
w.writeU32(msg.coinbaseTxInputSequence);
|
||||||
|
w.writeU64(msg.coinbaseTxValueRemaining);
|
||||||
|
w.writeU32(msg.coinbaseTxOutputsCount);
|
||||||
|
w.writeB0_64K(msg.coinbaseTxOutputs);
|
||||||
|
w.writeU32(msg.coinbaseTxLocktime);
|
||||||
|
w.writeSeq0_255(msg.merklePath, (writer, hash) => writer.writeU256(hash));
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeTdpNewTemplate(reader: BufferReader): Sv2TdpNewTemplate {
|
||||||
|
return {
|
||||||
|
templateId: reader.readU64(),
|
||||||
|
futureTemplate: reader.readBool(),
|
||||||
|
version: reader.readU32(),
|
||||||
|
coinbaseTxVersion: reader.readU32(),
|
||||||
|
coinbasePrefix: reader.readB0_255(),
|
||||||
|
coinbaseTxInputSequence: reader.readU32(),
|
||||||
|
coinbaseTxValueRemaining: reader.readU64(),
|
||||||
|
coinbaseTxOutputsCount: reader.readU32(),
|
||||||
|
coinbaseTxOutputs: reader.readB0_64K(),
|
||||||
|
coinbaseTxLocktime: reader.readU32(),
|
||||||
|
merklePath: reader.readSeq0_255((r) => r.readU256()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SetNewPrevHash (0x72) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2TdpSetNewPrevHash {
|
||||||
|
templateId: bigint; // U64
|
||||||
|
prevHash: Buffer; // U256
|
||||||
|
headerTimestamp: number; // U32
|
||||||
|
nBits: number; // U32
|
||||||
|
target: Buffer; // U256
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeTdpSetNewPrevHash(msg: Sv2TdpSetNewPrevHash): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU64(msg.templateId);
|
||||||
|
w.writeU256(msg.prevHash);
|
||||||
|
w.writeU32(msg.headerTimestamp);
|
||||||
|
w.writeU32(msg.nBits);
|
||||||
|
w.writeU256(msg.target);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeTdpSetNewPrevHash(reader: BufferReader): Sv2TdpSetNewPrevHash {
|
||||||
|
return {
|
||||||
|
templateId: reader.readU64(),
|
||||||
|
prevHash: reader.readU256(),
|
||||||
|
headerTimestamp: reader.readU32(),
|
||||||
|
nBits: reader.readU32(),
|
||||||
|
target: reader.readU256(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RequestTransactionData (0x73) ──────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2TdpRequestTransactionData {
|
||||||
|
templateId: bigint; // U64
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeTdpRequestTransactionData(msg: Sv2TdpRequestTransactionData): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU64(msg.templateId);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeTdpRequestTransactionData(reader: BufferReader): Sv2TdpRequestTransactionData {
|
||||||
|
return {
|
||||||
|
templateId: reader.readU64(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RequestTransactionData.Success (0x74) ──────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2TdpRequestTransactionDataSuccess {
|
||||||
|
templateId: bigint; // U64
|
||||||
|
excessData: Buffer; // B0_64K
|
||||||
|
transactionList: Buffer[]; // SEQ0_64K<B0_16M>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeTdpRequestTransactionDataSuccess(msg: Sv2TdpRequestTransactionDataSuccess): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU64(msg.templateId);
|
||||||
|
w.writeB0_64K(msg.excessData);
|
||||||
|
w.writeSeq0_64K(msg.transactionList, (writer, tx) => writer.writeB0_16M(tx));
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeTdpRequestTransactionDataSuccess(reader: BufferReader): Sv2TdpRequestTransactionDataSuccess {
|
||||||
|
return {
|
||||||
|
templateId: reader.readU64(),
|
||||||
|
excessData: reader.readB0_64K(),
|
||||||
|
transactionList: reader.readSeq0_64K((r) => r.readB0_16M()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RequestTransactionData.Error (0x75) ────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2TdpRequestTransactionDataError {
|
||||||
|
templateId: bigint; // U64
|
||||||
|
errorCode: string; // STR0_255
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeTdpRequestTransactionDataError(msg: Sv2TdpRequestTransactionDataError): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU64(msg.templateId);
|
||||||
|
w.writeStr0_255(msg.errorCode);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeTdpRequestTransactionDataError(reader: BufferReader): Sv2TdpRequestTransactionDataError {
|
||||||
|
return {
|
||||||
|
templateId: reader.readU64(),
|
||||||
|
errorCode: reader.readStr0_255(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SubmitSolution (0x76) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Sv2TdpSubmitSolution {
|
||||||
|
templateId: bigint; // U64
|
||||||
|
version: number; // U32
|
||||||
|
headerTimestamp: number; // U32
|
||||||
|
headerNonce: number; // U32
|
||||||
|
coinbaseTx: Buffer; // B0_64K
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeTdpSubmitSolution(msg: Sv2TdpSubmitSolution): Buffer {
|
||||||
|
const w = new BufferWriter();
|
||||||
|
w.writeU64(msg.templateId);
|
||||||
|
w.writeU32(msg.version);
|
||||||
|
w.writeU32(msg.headerTimestamp);
|
||||||
|
w.writeU32(msg.headerNonce);
|
||||||
|
w.writeB0_64K(msg.coinbaseTx);
|
||||||
|
return w.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeTdpSubmitSolution(reader: BufferReader): Sv2TdpSubmitSolution {
|
||||||
|
return {
|
||||||
|
templateId: reader.readU64(),
|
||||||
|
version: reader.readU32(),
|
||||||
|
headerTimestamp: reader.readU32(),
|
||||||
|
headerNonce: reader.readU32(),
|
||||||
|
coinbaseTx: reader.readB0_64K(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import * as zmq from 'zeromq';
|
|||||||
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
|
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
|
||||||
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
|
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
|
||||||
import * as PGPubsub from 'pg-pubsub';
|
import * as PGPubsub from 'pg-pubsub';
|
||||||
import * as fs from 'node:fs';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BitcoinRpcService implements OnModuleInit {
|
export class BitcoinRpcService implements OnModuleInit {
|
||||||
@@ -36,17 +35,10 @@ export class BitcoinRpcService implements OnModuleInit {
|
|||||||
|
|
||||||
|
|
||||||
const url = this.configService.get('BITCOIN_RPC_URL');
|
const url = this.configService.get('BITCOIN_RPC_URL');
|
||||||
let user = this.configService.get('BITCOIN_RPC_USER');
|
const user = this.configService.get('BITCOIN_RPC_USER');
|
||||||
let pass = this.configService.get('BITCOIN_RPC_PASSWORD');
|
const pass = this.configService.get('BITCOIN_RPC_PASSWORD');
|
||||||
const port = parseInt(this.configService.get('BITCOIN_RPC_PORT'));
|
const port = parseInt(this.configService.get('BITCOIN_RPC_PORT'));
|
||||||
const timeout = parseInt(this.configService.get('BITCOIN_RPC_TIMEOUT'));
|
const timeout = parseInt(this.configService.get('BITCOIN_RPC_TIMEOUT'));
|
||||||
const cookiefile = this.configService.get('BITCOIN_RPC_COOKIEFILE');
|
|
||||||
|
|
||||||
if (cookiefile != null && cookiefile !== '') {
|
|
||||||
const [cookieUser, cookiePass] = fs.readFileSync(cookiefile).toString().trim().split(':');
|
|
||||||
user = cookieUser;
|
|
||||||
pass = cookiePass;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseURL = this.buildRpcUrl(url, port);
|
const baseURL = this.buildRpcUrl(url, port);
|
||||||
this.client = axios.create({
|
this.client = axios.create({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ describe('StratumV1JobsService', () => {
|
|||||||
|
|
||||||
const createTemplate = (height = MockRecording1.BLOCK_TEMPLATE.height): IBlockTemplate => ({
|
const createTemplate = (height = MockRecording1.BLOCK_TEMPLATE.height): IBlockTemplate => ({
|
||||||
...MockRecording1.BLOCK_TEMPLATE,
|
...MockRecording1.BLOCK_TEMPLATE,
|
||||||
|
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(tx => ({ ...tx })),
|
||||||
height
|
height
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ describe('StratumV1JobsService', () => {
|
|||||||
expect(service.getJobTemplateById('1')).toBe(jobTemplate);
|
expect(service.getJobTemplateById('1')).toBe(jobTemplate);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should retire jobs when the block height changes', async () => {
|
it('should clear jobs when the block height changes', async () => {
|
||||||
const firstTemplate = await firstValueFrom(service.newMiningJob$);
|
const firstTemplate = await firstValueFrom(service.newMiningJob$);
|
||||||
service.addJob({ jobId: 'old-job', creation: Date.now() } as any);
|
service.addJob({ jobId: 'old-job', creation: Date.now() } as any);
|
||||||
|
|
||||||
@@ -57,50 +58,59 @@ describe('StratumV1JobsService', () => {
|
|||||||
const jobTemplate = await nextTemplate;
|
const jobTemplate = await nextTemplate;
|
||||||
|
|
||||||
expect(jobTemplate.blockData.clearJobs).toBe(true);
|
expect(jobTemplate.blockData.clearJobs).toBe(true);
|
||||||
expect(service.getJobById('old-job')).toEqual(expect.objectContaining({
|
expect(service.getJobById('old-job')).toBeUndefined();
|
||||||
jobId: 'old-job',
|
expect(service.getJobTemplateById(firstTemplate.blockData.id)).toBeUndefined();
|
||||||
retiredAt: Date.now()
|
|
||||||
}));
|
|
||||||
expect(service.getJobTemplateById(firstTemplate.blockData.id).blockData.retiredAt).toBe(Date.now());
|
|
||||||
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
|
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should age retired jobs and templates after the retention window', async () => {
|
it('should skip identical non-clean template refreshes', async () => {
|
||||||
const firstTemplate = await firstValueFrom(service.newMiningJob$);
|
await firstValueFrom(service.newMiningJob$);
|
||||||
|
|
||||||
|
blockTemplate$.next(createTemplate());
|
||||||
|
|
||||||
|
expect(service.latestJobTemplateId).toBe(2);
|
||||||
|
expect(Object.keys(service.blocks)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit when transaction identity changes without changing transaction count', async () => {
|
||||||
|
await firstValueFrom(service.newMiningJob$);
|
||||||
|
|
||||||
|
const reorderedTemplate = createTemplate();
|
||||||
|
reorderedTemplate.transactions = [
|
||||||
|
reorderedTemplate.transactions[1],
|
||||||
|
reorderedTemplate.transactions[0],
|
||||||
|
...reorderedTemplate.transactions.slice(2)
|
||||||
|
];
|
||||||
|
|
||||||
|
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
|
||||||
|
blockTemplate$.next(reorderedTemplate);
|
||||||
|
const jobTemplate = await nextTemplate;
|
||||||
|
|
||||||
|
expect(jobTemplate.blockData.clearJobs).toBe(false);
|
||||||
|
expect(jobTemplate.blockData.id).toBe('2');
|
||||||
|
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should age old jobs and templates after five minutes', async () => {
|
||||||
|
await firstValueFrom(service.newMiningJob$);
|
||||||
const oldCreation = Date.now() - (1000 * 60 * 11);
|
const oldCreation = Date.now() - (1000 * 60 * 11);
|
||||||
const retiredAt = Date.now() - (1000 * 60 * 11);
|
service.jobs['old-job'] = { jobId: 'old-job', creation: oldCreation } as any;
|
||||||
firstTemplate.blockData.retiredAt = retiredAt;
|
service.blocks['old-template'] = {
|
||||||
for (let i = 4; i >= 0; i--) {
|
blockData: { creation: oldCreation }
|
||||||
service.jobs[`old-job-${i}`] = { jobId: `old-job-${i}`, creation: oldCreation - i, retiredAt } as any;
|
} as any;
|
||||||
(service as any).trackJob(`old-job-${i}`);
|
|
||||||
service.blocks[`old-template-${i}`] = {
|
|
||||||
blockData: { creation: oldCreation - i, retiredAt }
|
|
||||||
} as any;
|
|
||||||
(service as any).trackBlock(`old-template-${i}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
bitcoinRpcService.miningInfo.blocks = MockRecording1.BLOCK_TEMPLATE.height;
|
bitcoinRpcService.miningInfo.blocks = MockRecording1.BLOCK_TEMPLATE.height;
|
||||||
|
jest.setSystemTime(new Date(Date.now() + 1000));
|
||||||
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
|
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
|
||||||
blockTemplate$.next(createTemplate());
|
blockTemplate$.next(createTemplate());
|
||||||
const jobTemplate = await nextTemplate;
|
const jobTemplate = await nextTemplate;
|
||||||
|
|
||||||
expect(jobTemplate.blockData.clearJobs).toBe(false);
|
expect(jobTemplate.blockData.clearJobs).toBe(false);
|
||||||
expect(service.getJobById('old-job-4')).toBeUndefined();
|
expect(service.getJobById('old-job')).toBeUndefined();
|
||||||
expect(service.getJobTemplateById('old-template-4')).toBeUndefined();
|
expect(service.getJobTemplateById('old-template')).toBeUndefined();
|
||||||
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
|
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should classify retired jobs inside and outside the stale grace window', () => {
|
|
||||||
const job = { jobId: '1', creation: Date.now(), retiredAt: Date.now() - 1000 } as any;
|
|
||||||
|
|
||||||
expect(service.classifyJobForShare(job, Date.now())).toBe('stale-creditable');
|
|
||||||
|
|
||||||
job.retiredAt = Date.now() - 6000;
|
|
||||||
|
|
||||||
expect(service.classifyJobForShare(job, Date.now())).toBe('stale-rejected');
|
|
||||||
expect(service.classifyJobForShare({ jobId: '2', creation: Date.now() } as any, Date.now())).toBe('active');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should increment job ids when jobs are added', () => {
|
it('should increment job ids when jobs are added', () => {
|
||||||
expect(service.getNextId()).toBe('1');
|
expect(service.getNextId()).toBe('1');
|
||||||
|
|
||||||
|
|||||||
@@ -18,14 +18,9 @@ export interface IJobTemplate {
|
|||||||
networkDifficulty: number;
|
networkDifficulty: number;
|
||||||
height: number;
|
height: number;
|
||||||
clearJobs: boolean;
|
clearJobs: boolean;
|
||||||
retiredAt?: number;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const STALE_GRACE_MS = parseInt(process.env.STRATUM_STALE_GRACE_MS) || 5000;
|
|
||||||
const MIN_RETAINED = 3;
|
|
||||||
export { STALE_GRACE_MS };
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StratumV1JobsService {
|
export class StratumV1JobsService {
|
||||||
|
|
||||||
@@ -36,11 +31,7 @@ export class StratumV1JobsService {
|
|||||||
public blocks: { [id: number]: IJobTemplate } = {};
|
public blocks: { [id: number]: IJobTemplate } = {};
|
||||||
|
|
||||||
private lastBlockHeight = 0;
|
private lastBlockHeight = 0;
|
||||||
private jobRetentionMs = parseInt(process.env.JOB_RETENTION_MS) || 600000;
|
private lastWorkSignature: string;
|
||||||
private jobOrder: string[] = [];
|
|
||||||
private blockOrder: string[] = [];
|
|
||||||
private jobOrderSet = new Set<string>();
|
|
||||||
private blockOrderSet = new Set<string>();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly bitcoinRpcService: BitcoinRpcService
|
private readonly bitcoinRpcService: BitcoinRpcService
|
||||||
@@ -63,13 +54,29 @@ export class StratumV1JobsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentTime = Math.floor(new Date().getTime() / 1000);
|
const currentTime = Math.floor(new Date().getTime() / 1000);
|
||||||
|
const timestamp = blockTemplate.mintime > currentTime ? blockTemplate.mintime : currentTime;
|
||||||
|
const workSignature = [
|
||||||
|
blockTemplate.previousblockhash,
|
||||||
|
blockTemplate.version,
|
||||||
|
blockTemplate.bits,
|
||||||
|
timestamp,
|
||||||
|
blockTemplate.height,
|
||||||
|
blockTemplate.coinbasevalue,
|
||||||
|
...blockTemplate.transactions.map(tx => tx.hash ?? tx.txid ?? tx.data)
|
||||||
|
].join('|');
|
||||||
|
|
||||||
|
if (!clearJobs && workSignature === this.lastWorkSignature) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.lastWorkSignature = workSignature;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: blockTemplate.version,
|
version: blockTemplate.version,
|
||||||
bits: parseInt(blockTemplate.bits, 16),
|
bits: parseInt(blockTemplate.bits, 16),
|
||||||
prevHash: this.convertToLittleEndian(blockTemplate.previousblockhash),
|
prevHash: this.convertToLittleEndian(blockTemplate.previousblockhash),
|
||||||
transactions: blockTemplate.transactions.map(t => bitcoinjs.Transaction.fromHex(t.data)),
|
transactions: blockTemplate.transactions.map(t => bitcoinjs.Transaction.fromHex(t.data)),
|
||||||
coinbasevalue: blockTemplate.coinbasevalue,
|
coinbasevalue: blockTemplate.coinbasevalue,
|
||||||
timestamp: blockTemplate.mintime > currentTime ? blockTemplate.mintime : currentTime,
|
timestamp,
|
||||||
networkDifficulty: this.calculateNetworkDifficulty(parseInt(blockTemplate.bits, 16)),
|
networkDifficulty: this.calculateNetworkDifficulty(parseInt(blockTemplate.bits, 16)),
|
||||||
clearJobs,
|
clearJobs,
|
||||||
height: blockTemplate.height
|
height: blockTemplate.height
|
||||||
@@ -119,9 +126,30 @@ export class StratumV1JobsService {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
tap((data) => {
|
tap((data) => {
|
||||||
this.cleanup(data.blockData.clearJobs);
|
if (data.blockData.clearJobs) {
|
||||||
|
this.blocks = {};
|
||||||
|
this.jobs = {};
|
||||||
|
}else{
|
||||||
|
let templatesDeleted = 0;
|
||||||
|
let jobsDeleted = 0;
|
||||||
|
const now = new Date().getTime();
|
||||||
|
// Delete old templates (5 minutes)
|
||||||
|
for(const templateId in this.blocks){
|
||||||
|
if(now - this.blocks[templateId].blockData.creation > (1000 * 60 * 5)){
|
||||||
|
delete this.blocks[templateId];
|
||||||
|
templatesDeleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Delete old jobs (5 minutes)
|
||||||
|
for (const jobId in this.jobs) {
|
||||||
|
if(now - this.jobs[jobId].creation > (1000 * 60 * 5)){
|
||||||
|
delete this.jobs[jobId];
|
||||||
|
jobsDeleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//console.log(`Deleted ${templatesDeleted} templates and ${jobsDeleted} jobs.`)
|
||||||
|
}
|
||||||
this.blocks[data.blockData.id] = data;
|
this.blocks[data.blockData.id] = data;
|
||||||
this.trackBlock(data.blockData.id);
|
|
||||||
}),
|
}),
|
||||||
shareReplay({ refCount: true, bufferSize: 1 })
|
shareReplay({ refCount: true, bufferSize: 1 })
|
||||||
)
|
)
|
||||||
@@ -151,80 +179,8 @@ export class StratumV1JobsService {
|
|||||||
return this.blocks[jobTemplateId];
|
return this.blocks[jobTemplateId];
|
||||||
}
|
}
|
||||||
|
|
||||||
public cleanup(clearJobs: boolean, now: number = Date.now()) {
|
|
||||||
if (clearJobs) {
|
|
||||||
for (const id of this.blockOrder) {
|
|
||||||
const block = this.blocks[id];
|
|
||||||
if (block != null && block.blockData.retiredAt === undefined) {
|
|
||||||
block.blockData.retiredAt = now;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const jobId of this.jobOrder) {
|
|
||||||
const job = this.jobs[jobId];
|
|
||||||
if (job != null && job.retiredAt === undefined) {
|
|
||||||
job.retiredAt = now;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ageEntries(
|
|
||||||
this.blocks,
|
|
||||||
this.blockOrder,
|
|
||||||
this.blockOrderSet,
|
|
||||||
now,
|
|
||||||
entry => entry.blockData.creation,
|
|
||||||
entry => entry.blockData.retiredAt,
|
|
||||||
);
|
|
||||||
this.ageEntries(
|
|
||||||
this.jobs,
|
|
||||||
this.jobOrder,
|
|
||||||
this.jobOrderSet,
|
|
||||||
now,
|
|
||||||
entry => entry.creation,
|
|
||||||
entry => entry.retiredAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ageEntries<T>(
|
|
||||||
map: Record<string, T>,
|
|
||||||
order: string[],
|
|
||||||
orderSet: Set<string>,
|
|
||||||
now: number,
|
|
||||||
getCreation: (entry: T) => number,
|
|
||||||
getRetiredAt: (entry: T) => number | undefined,
|
|
||||||
): void {
|
|
||||||
while (order.length > MIN_RETAINED) {
|
|
||||||
const id = order[0];
|
|
||||||
const entry = map[id];
|
|
||||||
if (entry == null) {
|
|
||||||
order.shift();
|
|
||||||
orderSet.delete(id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const retiredAt = getRetiredAt(entry);
|
|
||||||
|
|
||||||
if (retiredAt !== undefined && now - retiredAt > this.jobRetentionMs) {
|
|
||||||
delete map[id];
|
|
||||||
order.shift();
|
|
||||||
orderSet.delete(id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (retiredAt === undefined && now - getCreation(entry) > this.jobRetentionMs * 2) {
|
|
||||||
delete map[id];
|
|
||||||
order.shift();
|
|
||||||
orderSet.delete(id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public addJob(job: MiningJob) {
|
public addJob(job: MiningJob) {
|
||||||
this.jobs[job.jobId] = job;
|
this.jobs[job.jobId] = job;
|
||||||
this.trackJob(job.jobId);
|
|
||||||
this.latestJobId++;
|
this.latestJobId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,13 +188,6 @@ export class StratumV1JobsService {
|
|||||||
return this.jobs[jobId];
|
return this.jobs[jobId];
|
||||||
}
|
}
|
||||||
|
|
||||||
public classifyJobForShare(job: MiningJob, now: number = Date.now()): 'active' | 'stale-creditable' | 'stale-rejected' {
|
|
||||||
if (job.retiredAt === undefined) {
|
|
||||||
return 'active';
|
|
||||||
}
|
|
||||||
return (now - job.retiredAt) <= STALE_GRACE_MS ? 'stale-creditable' : 'stale-rejected';
|
|
||||||
}
|
|
||||||
|
|
||||||
public getNextTemplateId() {
|
public getNextTemplateId() {
|
||||||
return this.latestJobTemplateId.toString(16);
|
return this.latestJobTemplateId.toString(16);
|
||||||
}
|
}
|
||||||
@@ -246,20 +195,4 @@ export class StratumV1JobsService {
|
|||||||
return this.latestJobId.toString(16);
|
return this.latestJobId.toString(16);
|
||||||
}
|
}
|
||||||
|
|
||||||
private trackJob(jobId: string): void {
|
|
||||||
if (this.jobOrderSet.has(jobId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.jobOrderSet.add(jobId);
|
|
||||||
this.jobOrder.push(jobId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private trackBlock(blockId: string): void {
|
|
||||||
if (this.blockOrderSet.has(blockId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.blockOrderSet.add(blockId);
|
|
||||||
this.blockOrder.push(blockId);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,25 @@ describe('StratumV1Service', () => {
|
|||||||
const originalStratumPorts = process.env.STRATUM_PORTS;
|
const originalStratumPorts = process.env.STRATUM_PORTS;
|
||||||
const originalStratumSecure = process.env.STRATUM_SECURE;
|
const originalStratumSecure = process.env.STRATUM_SECURE;
|
||||||
const originalSecureStratumPorts = process.env.SECURE_STRATUM_PORTS;
|
const originalSecureStratumPorts = process.env.SECURE_STRATUM_PORTS;
|
||||||
|
const originalBackpressureEnabled = process.env.STRATUM_BACKPRESSURE_ENABLED;
|
||||||
|
const originalMaxConnectionsPerListener = process.env.STRATUM_MAX_CONNECTIONS_PER_LISTENER;
|
||||||
|
const originalTlsHandshakeTimeoutMs = process.env.STRATUM_TLS_HANDSHAKE_TIMEOUT_MS;
|
||||||
|
|
||||||
let service: StratumV1Service;
|
let service: StratumV1Service;
|
||||||
let clientService;
|
let clientService;
|
||||||
|
let stratumV2Service;
|
||||||
let consoleLogSpy: jest.SpyInstance;
|
let consoleLogSpy: jest.SpyInstance;
|
||||||
|
let consoleWarnSpy: jest.SpyInstance;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
clientService = {
|
clientService = {
|
||||||
deleteAll: jest.fn().mockResolvedValue(undefined)
|
deleteAll: jest.fn().mockResolvedValue(undefined)
|
||||||
};
|
};
|
||||||
|
stratumV2Service = {
|
||||||
|
ensureInitialized: jest.fn().mockResolvedValue(undefined),
|
||||||
|
createClient: jest.fn()
|
||||||
|
};
|
||||||
service = new StratumV1Service(
|
service = new StratumV1Service(
|
||||||
{} as any,
|
{} as any,
|
||||||
clientService,
|
clientService,
|
||||||
@@ -23,9 +32,11 @@ describe('StratumV1Service', () => {
|
|||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any
|
{} as any,
|
||||||
|
stratumV2Service as any
|
||||||
);
|
);
|
||||||
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -33,7 +44,11 @@ describe('StratumV1Service', () => {
|
|||||||
restoreEnv('STRATUM_PORTS', originalStratumPorts);
|
restoreEnv('STRATUM_PORTS', originalStratumPorts);
|
||||||
restoreEnv('STRATUM_SECURE', originalStratumSecure);
|
restoreEnv('STRATUM_SECURE', originalStratumSecure);
|
||||||
restoreEnv('SECURE_STRATUM_PORTS', originalSecureStratumPorts);
|
restoreEnv('SECURE_STRATUM_PORTS', originalSecureStratumPorts);
|
||||||
|
restoreEnv('STRATUM_BACKPRESSURE_ENABLED', originalBackpressureEnabled);
|
||||||
|
restoreEnv('STRATUM_MAX_CONNECTIONS_PER_LISTENER', originalMaxConnectionsPerListener);
|
||||||
|
restoreEnv('STRATUM_TLS_HANDSHAKE_TIMEOUT_MS', originalTlsHandshakeTimeoutMs);
|
||||||
consoleLogSpy.mockRestore();
|
consoleLogSpy.mockRestore();
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,6 +83,116 @@ describe('StratumV1Service', () => {
|
|||||||
expect(startSecureSocketServerSpy).toHaveBeenCalledWith(4333);
|
expect(startSecureSocketServerSpy).toHaveBeenCalledWith(4333);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should pause listeners when worker backpressure is high', () => {
|
||||||
|
const close = jest.fn((callback?: (error?: Error) => void) => callback?.());
|
||||||
|
(service as any).listeners.push({
|
||||||
|
port: 3333,
|
||||||
|
secure: false,
|
||||||
|
server: { close },
|
||||||
|
paused: false
|
||||||
|
});
|
||||||
|
jest.spyOn(service as any, 'getEventLoopP95Ms').mockReturnValue(5000);
|
||||||
|
jest.spyOn(service as any, 'getBackpressureEventLoopP95Ms').mockReturnValue(2000);
|
||||||
|
|
||||||
|
(service as any).checkBackpressure();
|
||||||
|
|
||||||
|
expect(close).toHaveBeenCalled();
|
||||||
|
expect((service as any).listeners[0].paused).toBe(true);
|
||||||
|
expect((service as any).listeners[0].server).toBeNull();
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Pausing Stratum accepts'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resume listeners after consecutive healthy backpressure checks', () => {
|
||||||
|
(service as any).listeners.push({
|
||||||
|
port: 3333,
|
||||||
|
secure: false,
|
||||||
|
server: null,
|
||||||
|
paused: true
|
||||||
|
});
|
||||||
|
jest.spyOn(service as any, 'getEventLoopP95Ms').mockReturnValue(50);
|
||||||
|
jest.spyOn(service as any, 'getBackpressureEventLoopP95Ms').mockReturnValue(2000);
|
||||||
|
jest.spyOn(service as any, 'getBackpressureResumeEventLoopP95Ms').mockReturnValue(250);
|
||||||
|
jest.spyOn(service as any, 'getBackpressureResumeRssMb').mockReturnValue(Number.MAX_SAFE_INTEGER);
|
||||||
|
jest.spyOn(service as any, 'getBackpressureHealthyChecks').mockReturnValue(2);
|
||||||
|
const listenSpy = jest.spyOn(service as any, 'listen').mockImplementation((listener: any) => {
|
||||||
|
listener.server = {};
|
||||||
|
listener.paused = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
(service as any).checkBackpressure();
|
||||||
|
expect(listenSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
(service as any).checkBackpressure();
|
||||||
|
|
||||||
|
expect(listenSpy).toHaveBeenCalledWith((service as any).listeners[0]);
|
||||||
|
expect((service as any).listeners[0].paused).toBe(false);
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Resuming Stratum accepts'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cap listener connections and drop excess cluster connections', () => {
|
||||||
|
process.env.STRATUM_MAX_CONNECTIONS_PER_LISTENER = '250';
|
||||||
|
const server = {} as any;
|
||||||
|
|
||||||
|
(service as any).configureConnectionLimit(server);
|
||||||
|
|
||||||
|
expect(server.maxConnections).toBe(250);
|
||||||
|
expect(server.dropMaxConnection).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow high-volume pools by default', () => {
|
||||||
|
delete process.env.STRATUM_MAX_CONNECTIONS_PER_LISTENER;
|
||||||
|
|
||||||
|
expect((service as any).getMaxConnectionsPerListener()).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use an explicit TLS handshake timeout', () => {
|
||||||
|
process.env.STRATUM_TLS_HANDSHAKE_TIMEOUT_MS = '5000';
|
||||||
|
|
||||||
|
expect((service as any).getTlsHandshakeTimeoutMs()).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect JSON-RPC as Stratum V1', () => {
|
||||||
|
const firstChunk = Buffer.from('{"id":1,"method":"mining.subscribe","params":[]}\n');
|
||||||
|
|
||||||
|
expect((service as any).detectProtocol(firstChunk)).toBe('v1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect JSON-RPC as Stratum V1 even with non-printable trailing bytes', () => {
|
||||||
|
const firstChunk = Buffer.concat([
|
||||||
|
Buffer.from('{"id": 1, "method": "mining.subscribe", "params": []}\n'),
|
||||||
|
Buffer.from([0x00, 0xff])
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect((service as any).detectProtocol(firstChunk)).toBe('v1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect binary Noise traffic as Stratum V2', () => {
|
||||||
|
const firstChunk = Buffer.concat([
|
||||||
|
Buffer.from([0x01, 0x02, 0x03, 0x04]),
|
||||||
|
Buffer.alloc(60, 0xaa)
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect((service as any).detectProtocol(firstChunk)).toBe('v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject recognizable TLS client hello on the unified plain stratum port', () => {
|
||||||
|
expect((service as any).detectProtocol(Buffer.from([0x16, 0x03, 0x01]))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should route binary data that only shares a TLS first byte to Stratum V2', () => {
|
||||||
|
expect((service as any).detectProtocol(Buffer.from([0x16, 0xaa, 0xbb]))).toBe('v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not route plaintext PROXY protocol headers to Stratum V2', () => {
|
||||||
|
const firstChunk = Buffer.from('PROXY TCP4 203.0.113.10 192.0.2.10 54321 3333\\r\\n');
|
||||||
|
|
||||||
|
expect((service as any).detectProtocol(firstChunk)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not route malformed plaintext to Stratum V2', () => {
|
||||||
|
expect((service as any).detectProtocol(Buffer.from('mining.subscribe\\n'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
function restoreEnv(key: string, value: string | undefined) {
|
function restoreEnv(key: string, value: string | undefined) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
delete process.env[key];
|
delete process.env[key];
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Server, Socket } from 'net';
|
import { Server, Socket } from 'net';
|
||||||
|
import { monitorEventLoopDelay } from 'perf_hooks';
|
||||||
|
|
||||||
import { StratumV1Client } from '../models/StratumV1Client';
|
import { StratumV1Client } from '../models/StratumV1Client';
|
||||||
|
import { StratumV2Client } from '../models/StratumV2Client';
|
||||||
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
|
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
|
||||||
import { BlocksService } from '../ORM/blocks/blocks.service';
|
import { BlocksService } from '../ORM/blocks/blocks.service';
|
||||||
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
|
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
|
||||||
@@ -10,11 +12,28 @@ import { ClientService } from '../ORM/client/client.service';
|
|||||||
import { BitcoinRpcService } from './bitcoin-rpc.service';
|
import { BitcoinRpcService } from './bitcoin-rpc.service';
|
||||||
import { NotificationService } from './notification.service';
|
import { NotificationService } from './notification.service';
|
||||||
import { StratumV1JobsService } from './stratum-v1-jobs.service';
|
import { StratumV1JobsService } from './stratum-v1-jobs.service';
|
||||||
|
import { StratumV2Service } from './stratum-v2.service';
|
||||||
|
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { TlsOptions, TLSSocket, createServer } from 'tls';
|
import { TlsOptions, TLSSocket, createServer } from 'tls';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
interface StratumListenerState {
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
server: Server | null;
|
||||||
|
paused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BACKPRESSURE_CHECK_INTERVAL_MS = 5000;
|
||||||
|
const DEFAULT_BACKPRESSURE_EVENT_LOOP_P95_MS = 2000;
|
||||||
|
const DEFAULT_BACKPRESSURE_EVENT_LOOP_RESUME_P95_MS = 250;
|
||||||
|
const DEFAULT_BACKPRESSURE_RSS_MB = 2500;
|
||||||
|
const DEFAULT_BACKPRESSURE_RESUME_RSS_MB = 2000;
|
||||||
|
const DEFAULT_BACKPRESSURE_HEALTHY_CHECKS = 3;
|
||||||
|
const DEFAULT_MAX_CONNECTIONS_PER_LISTENER = 10000;
|
||||||
|
const DEFAULT_TLS_HANDSHAKE_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -24,6 +43,10 @@ export class StratumV1Service implements OnModuleInit {
|
|||||||
private emptySocket = 0;
|
private emptySocket = 0;
|
||||||
private normalClosure = 0;
|
private normalClosure = 0;
|
||||||
private errorClosure = 0;
|
private errorClosure = 0;
|
||||||
|
private readonly listeners: StratumListenerState[] = [];
|
||||||
|
private readonly eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
|
||||||
|
private backpressureMonitor: NodeJS.Timeout | null = null;
|
||||||
|
private healthyBackpressureChecks = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly bitcoinRpcService: BitcoinRpcService,
|
private readonly bitcoinRpcService: BitcoinRpcService,
|
||||||
@@ -33,7 +56,8 @@ export class StratumV1Service implements OnModuleInit {
|
|||||||
private readonly blocksService: BlocksService,
|
private readonly blocksService: BlocksService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly stratumV1JobsService: StratumV1JobsService,
|
private readonly stratumV1JobsService: StratumV1JobsService,
|
||||||
private readonly addressSettingsService: AddressSettingsService
|
private readonly addressSettingsService: AddressSettingsService,
|
||||||
|
private readonly stratumV2Service: StratumV2Service
|
||||||
) {
|
) {
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -66,28 +90,32 @@ export class StratumV1Service implements OnModuleInit {
|
|||||||
this.errorClosure = 0;
|
this.errorClosure = 0;
|
||||||
}, 1000 * 60);
|
}, 1000 * 60);
|
||||||
|
|
||||||
|
this.startBackpressureMonitor();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private startSocketServer(port: number) {
|
private startSocketServer(port: number) {
|
||||||
|
const listener: StratumListenerState = {
|
||||||
|
port,
|
||||||
|
secure: false,
|
||||||
|
server: null,
|
||||||
|
paused: false
|
||||||
|
};
|
||||||
|
this.listeners.push(listener);
|
||||||
|
this.listen(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSocketServer(): Server {
|
||||||
const server = new Server(async (socket: Socket) => {
|
const server = new Server(async (socket: Socket) => {
|
||||||
// Set 15-minute timeout
|
// Set 15-minute timeout
|
||||||
socket.setTimeout(1000 * 60 * 15);
|
socket.setTimeout(1000 * 60 * 15);
|
||||||
|
|
||||||
const client = new StratumV1Client(
|
let client: StratumV1Client | StratumV2Client = null;
|
||||||
socket,
|
let protocol: 'v1' | 'v2' | null = null;
|
||||||
this.stratumV1JobsService,
|
|
||||||
this.bitcoinRpcService,
|
|
||||||
this.clientService,
|
|
||||||
this.clientStatisticsService,
|
|
||||||
this.notificationService,
|
|
||||||
this.blocksService,
|
|
||||||
this.configService,
|
|
||||||
this.addressSettingsService
|
|
||||||
);
|
|
||||||
|
|
||||||
// Unified cleanup function
|
// Unified cleanup function
|
||||||
const cleanup = async (reason: string) => {
|
const cleanup = async (reason: string) => {
|
||||||
if (client.extraNonceAndSessionId != null) {
|
if (client != null && (protocol === 'v2' || (client as StratumV1Client).extraNonceAndSessionId != null)) {
|
||||||
await client.destroy();
|
await client.destroy();
|
||||||
if (reason == 'Error') {
|
if (reason == 'Error') {
|
||||||
this.errorClosure++;
|
this.errorClosure++;
|
||||||
@@ -121,8 +149,30 @@ export class StratumV1Service implements OnModuleInit {
|
|||||||
await cleanup("Error");
|
await cleanup("Error");
|
||||||
});
|
});
|
||||||
|
|
||||||
//
|
socket.once('data', async (firstChunk: Buffer) => {
|
||||||
|
try {
|
||||||
|
protocol = this.detectProtocol(firstChunk);
|
||||||
|
if (protocol === 'v1') {
|
||||||
|
client = this.createV1Client(socket);
|
||||||
|
socket.emit('data', firstChunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (protocol === 'v2') {
|
||||||
|
await this.stratumV2Service.ensureInitialized();
|
||||||
|
client = this.stratumV2Service.createClient(socket, firstChunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.end();
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Protocol detection failed: ${error.message}`);
|
||||||
|
await cleanup('Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,14 +180,37 @@ export class StratumV1Service implements OnModuleInit {
|
|||||||
server.on('error', (err) => {
|
server.on('error', (err) => {
|
||||||
console.error(`Server error: ${err.message}`);
|
console.error(`Server error: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
this.configureConnectionLimit(server);
|
||||||
|
|
||||||
server.listen(port, () => {
|
return server;
|
||||||
console.log(`Stratum server is listening on port ${port}`);
|
}
|
||||||
});
|
|
||||||
|
|
||||||
|
private createV1Client(socket: Socket): StratumV1Client {
|
||||||
|
return new StratumV1Client(
|
||||||
|
socket,
|
||||||
|
this.stratumV1JobsService,
|
||||||
|
this.bitcoinRpcService,
|
||||||
|
this.clientService,
|
||||||
|
this.clientStatisticsService,
|
||||||
|
this.notificationService,
|
||||||
|
this.blocksService,
|
||||||
|
this.configService,
|
||||||
|
this.addressSettingsService
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private startSecureSocketServer(port: number) {
|
private startSecureSocketServer(port: number) {
|
||||||
|
const listener: StratumListenerState = {
|
||||||
|
port,
|
||||||
|
secure: true,
|
||||||
|
server: null,
|
||||||
|
paused: false
|
||||||
|
};
|
||||||
|
this.listeners.push(listener);
|
||||||
|
this.listen(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSecureSocketServer(): Server {
|
||||||
|
|
||||||
const currentDirectory = process.cwd();
|
const currentDirectory = process.cwd();
|
||||||
const keyPath = path.join(currentDirectory, 'secrets', 'key.pem');
|
const keyPath = path.join(currentDirectory, 'secrets', 'key.pem');
|
||||||
@@ -146,23 +219,14 @@ export class StratumV1Service implements OnModuleInit {
|
|||||||
const tlsOptions: TlsOptions = {
|
const tlsOptions: TlsOptions = {
|
||||||
key: readFileSync(keyPath),
|
key: readFileSync(keyPath),
|
||||||
cert: readFileSync(certPath),
|
cert: readFileSync(certPath),
|
||||||
|
handshakeTimeout: this.getTlsHandshakeTimeoutMs()
|
||||||
};
|
};
|
||||||
|
|
||||||
const server = createServer(tlsOptions, async (socket: TLSSocket) => {
|
const server = createServer(tlsOptions, async (socket: TLSSocket) => {
|
||||||
// Set 15-minute timeout
|
// Set 15-minute timeout
|
||||||
socket.setTimeout(1000 * 60 * 15);
|
socket.setTimeout(1000 * 60 * 15);
|
||||||
|
|
||||||
const client = new StratumV1Client(
|
const client = this.createV1Client(socket);
|
||||||
socket,
|
|
||||||
this.stratumV1JobsService,
|
|
||||||
this.bitcoinRpcService,
|
|
||||||
this.clientService,
|
|
||||||
this.clientStatisticsService,
|
|
||||||
this.notificationService,
|
|
||||||
this.blocksService,
|
|
||||||
this.configService,
|
|
||||||
this.addressSettingsService
|
|
||||||
);
|
|
||||||
|
|
||||||
const cleanup = async (reason: string) => {
|
const cleanup = async (reason: string) => {
|
||||||
if (client.extraNonceAndSessionId != null) {
|
if (client.extraNonceAndSessionId != null) {
|
||||||
@@ -202,10 +266,245 @@ export class StratumV1Service implements OnModuleInit {
|
|||||||
server.on('error', (err) => {
|
server.on('error', (err) => {
|
||||||
console.error(`Server error: ${err.message}`);
|
console.error(`Server error: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
this.configureConnectionLimit(server);
|
||||||
|
|
||||||
server.listen(port, () => {
|
return server;
|
||||||
console.log(`Stratum TLS server is listening on port ${port}`);
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private listen(listener: StratumListenerState) {
|
||||||
|
if (listener.server != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = listener.secure ? this.createSecureSocketServer() : this.createSocketServer();
|
||||||
|
listener.server = server;
|
||||||
|
listener.paused = false;
|
||||||
|
|
||||||
|
server.listen(listener.port, () => {
|
||||||
|
console.log(`${listener.secure ? 'Stratum TLS' : 'Stratum'} server is listening on port ${listener.port}`);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private startBackpressureMonitor() {
|
||||||
|
if (this.isBackpressureDisabled() || this.backpressureMonitor != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eventLoopDelay.enable();
|
||||||
|
this.backpressureMonitor = setInterval(() => {
|
||||||
|
this.checkBackpressure();
|
||||||
|
}, this.getBackpressureCheckIntervalMs());
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkBackpressure() {
|
||||||
|
const eventLoopP95Ms = this.getEventLoopP95Ms();
|
||||||
|
const rssMb = Math.round(process.memoryUsage().rss / 1024 / 1024);
|
||||||
|
const overloaded = eventLoopP95Ms >= this.getBackpressureEventLoopP95Ms()
|
||||||
|
|| rssMb >= this.getBackpressureRssMb();
|
||||||
|
const paused = this.listeners.some(listener => listener.paused);
|
||||||
|
|
||||||
|
if (overloaded) {
|
||||||
|
this.healthyBackpressureChecks = 0;
|
||||||
|
if (!paused) {
|
||||||
|
this.pauseAccepting(eventLoopP95Ms, rssMb);
|
||||||
|
}
|
||||||
|
this.eventLoopDelay.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paused) {
|
||||||
|
this.eventLoopDelay.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthy = eventLoopP95Ms <= this.getBackpressureResumeEventLoopP95Ms()
|
||||||
|
&& rssMb <= this.getBackpressureResumeRssMb();
|
||||||
|
if (!healthy) {
|
||||||
|
this.healthyBackpressureChecks = 0;
|
||||||
|
this.eventLoopDelay.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.healthyBackpressureChecks++;
|
||||||
|
if (this.healthyBackpressureChecks >= this.getBackpressureHealthyChecks()) {
|
||||||
|
this.resumeAccepting(eventLoopP95Ms, rssMb);
|
||||||
|
this.healthyBackpressureChecks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eventLoopDelay.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private pauseAccepting(eventLoopP95Ms: number, rssMb: number) {
|
||||||
|
console.warn(`Pausing Stratum accepts: eventLoopP95Ms=${eventLoopP95Ms}, rssMb=${rssMb}`);
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
if (listener.paused || listener.server == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = listener.server;
|
||||||
|
listener.server = null;
|
||||||
|
listener.paused = true;
|
||||||
|
server.close((error) => {
|
||||||
|
if (error != null) {
|
||||||
|
console.error(`Error while pausing Stratum listener on port ${listener.port}: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resumeAccepting(eventLoopP95Ms: number, rssMb: number) {
|
||||||
|
console.warn(`Resuming Stratum accepts: eventLoopP95Ms=${eventLoopP95Ms}, rssMb=${rssMb}`);
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
if (!listener.paused || listener.server != null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.listen(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEventLoopP95Ms() {
|
||||||
|
return Math.round(this.eventLoopDelay.percentile(95) / 1e6);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isBackpressureDisabled() {
|
||||||
|
return process.env.STRATUM_BACKPRESSURE_ENABLED?.toLowerCase() === 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBackpressureCheckIntervalMs() {
|
||||||
|
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_CHECK_INTERVAL_MS', DEFAULT_BACKPRESSURE_CHECK_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBackpressureEventLoopP95Ms() {
|
||||||
|
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_EVENT_LOOP_P95_MS', DEFAULT_BACKPRESSURE_EVENT_LOOP_P95_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBackpressureResumeEventLoopP95Ms() {
|
||||||
|
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_EVENT_LOOP_RESUME_P95_MS', DEFAULT_BACKPRESSURE_EVENT_LOOP_RESUME_P95_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBackpressureRssMb() {
|
||||||
|
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_RSS_MB', DEFAULT_BACKPRESSURE_RSS_MB);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBackpressureResumeRssMb() {
|
||||||
|
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_RESUME_RSS_MB', DEFAULT_BACKPRESSURE_RESUME_RSS_MB);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBackpressureHealthyChecks() {
|
||||||
|
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_HEALTHY_CHECKS', DEFAULT_BACKPRESSURE_HEALTHY_CHECKS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private configureConnectionLimit(server: Server) {
|
||||||
|
server.maxConnections = this.getMaxConnectionsPerListener();
|
||||||
|
(server as Server & { dropMaxConnection: boolean }).dropMaxConnection = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMaxConnectionsPerListener() {
|
||||||
|
return this.getPositiveIntegerEnv('STRATUM_MAX_CONNECTIONS_PER_LISTENER', DEFAULT_MAX_CONNECTIONS_PER_LISTENER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTlsHandshakeTimeoutMs() {
|
||||||
|
return this.getPositiveIntegerEnv('STRATUM_TLS_HANDSHAKE_TIMEOUT_MS', DEFAULT_TLS_HANDSHAKE_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectProtocol(firstChunk: Buffer): 'v1' | 'v2' | null {
|
||||||
|
if (firstChunk.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.looksLikeJsonRpc(firstChunk)) {
|
||||||
|
return 'v1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLS ClientHello. Secure SV1 remains on SECURE_STRATUM_PORTS.
|
||||||
|
if (this.looksLikeTlsClientHello(firstChunk)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP on a stratum port is not supported in this branch.
|
||||||
|
if (this.looksLikeHttpRequest(firstChunk)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plaintext that is not JSON-RPC is not a valid SV2 Noise Act 1. This
|
||||||
|
// catches PROXY-protocol lines, SSH banners, and malformed SV1 clients
|
||||||
|
// before they get misrouted into the SV2 decrypt path.
|
||||||
|
if (this.looksLikePlaintext(firstChunk)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'v2';
|
||||||
|
}
|
||||||
|
|
||||||
|
private looksLikeJsonRpc(firstChunk: Buffer): boolean {
|
||||||
|
let firstNonWhitespace = -1;
|
||||||
|
for (let i = 0; i < firstChunk.length; i++) {
|
||||||
|
const byte = firstChunk[i];
|
||||||
|
if (byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
firstNonWhitespace = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstNonWhitespace < 0 || firstChunk[firstNonWhitespace] !== 0x7b) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonPrefix = firstChunk
|
||||||
|
.subarray(firstNonWhitespace, Math.min(firstChunk.length, firstNonWhitespace + 256))
|
||||||
|
.toString('utf8');
|
||||||
|
if (jsonPrefix.includes('"method"') || jsonPrefix.includes('"id"')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const byte of firstChunk) {
|
||||||
|
const isWhitespace = byte === 0x09 || byte === 0x0a || byte === 0x0d;
|
||||||
|
const isPrintableAscii = byte >= 0x20 && byte <= 0x7e;
|
||||||
|
if (!isWhitespace && !isPrintableAscii) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private looksLikeTlsClientHello(firstChunk: Buffer): boolean {
|
||||||
|
return firstChunk.length >= 3
|
||||||
|
&& firstChunk[0] === 0x16
|
||||||
|
&& firstChunk[1] === 0x03;
|
||||||
|
}
|
||||||
|
|
||||||
|
private looksLikeHttpRequest(firstChunk: Buffer): boolean {
|
||||||
|
const prefix = firstChunk.subarray(0, Math.min(firstChunk.length, 8)).toString('ascii').toUpperCase();
|
||||||
|
return prefix.startsWith('GET ')
|
||||||
|
|| prefix.startsWith('POST ')
|
||||||
|
|| prefix.startsWith('PUT ')
|
||||||
|
|| prefix.startsWith('PATCH ')
|
||||||
|
|| prefix.startsWith('HEAD ')
|
||||||
|
|| prefix.startsWith('OPTIONS ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private looksLikePlaintext(firstChunk: Buffer): boolean {
|
||||||
|
for (const byte of firstChunk) {
|
||||||
|
const isWhitespace = byte === 0x09 || byte === 0x0a || byte === 0x0d;
|
||||||
|
const isPrintableAscii = byte >= 0x20 && byte <= 0x7e;
|
||||||
|
if (!isWhitespace && !isPrintableAscii) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPositiveIntegerEnv(key: string, fallback: number) {
|
||||||
|
const configured = parseInt(process.env[key], 10);
|
||||||
|
if (Number.isFinite(configured) && configured > 0) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { Server, Socket } from 'net';
|
||||||
|
|
||||||
|
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 { StratumV2Client } from '../models/StratumV2Client';
|
||||||
|
import { encodeSv2AuthorityPublicKey } from '../models/sv2/sv2-authority-key';
|
||||||
|
import { Sv2ExtranonceManager } from '../models/sv2/sv2-extranonce-manager';
|
||||||
|
import {
|
||||||
|
createSignatureNoiseMessage,
|
||||||
|
generateServerKeypair,
|
||||||
|
Sv2NoiseConfig,
|
||||||
|
Sv2ServerKeypair,
|
||||||
|
xOnlyPubKeyFromPriv,
|
||||||
|
} from '../models/sv2/sv2-noise';
|
||||||
|
import { BitcoinRpcService } from './bitcoin-rpc.service';
|
||||||
|
import { NotificationService } from './notification.service';
|
||||||
|
import { StratumV1JobsService } from './stratum-v1-jobs.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StratumV2Service implements OnModuleInit {
|
||||||
|
private readonly servers: Server[] = [];
|
||||||
|
private authorityPrivKey: Buffer;
|
||||||
|
private authorityPublicKeyXOnly: Buffer;
|
||||||
|
private authorityKeyConfigured = false;
|
||||||
|
private serverKeypair: Sv2ServerKeypair;
|
||||||
|
private noiseConfig: Sv2NoiseConfig;
|
||||||
|
private channelIdCounter = 1;
|
||||||
|
private readonly extranonceManager = new Sv2ExtranonceManager();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
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 stratumV1JobsService: StratumV1JobsService,
|
||||||
|
private readonly addressSettingsService: AddressSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async onModuleInit(): Promise<void> {
|
||||||
|
if (process.env.MASTER === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ports = this.getPorts();
|
||||||
|
if (ports.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.ensureInitialized();
|
||||||
|
ports.forEach(port => this.startSocketServer(port));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ensureInitialized(): Promise<void> {
|
||||||
|
if (this.noiseConfig != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.initializeNoiseConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
public createClient(socket: Socket, firstChunk: Buffer): StratumV2Client {
|
||||||
|
if (this.noiseConfig == null) {
|
||||||
|
throw new Error('Stratum V2 service is not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StratumV2Client(
|
||||||
|
socket,
|
||||||
|
firstChunk,
|
||||||
|
this,
|
||||||
|
this.stratumV1JobsService,
|
||||||
|
this.bitcoinRpcService,
|
||||||
|
this.clientService,
|
||||||
|
this.clientStatisticsService,
|
||||||
|
this.notificationService,
|
||||||
|
this.blocksService,
|
||||||
|
this.configService,
|
||||||
|
this.addressSettingsService,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getNoiseConfig(): Sv2NoiseConfig {
|
||||||
|
return this.noiseConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPoolAuthorityPublicKey(): Promise<{ publicKey: string; configured: boolean }> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: encodeSv2AuthorityPublicKey(this.authorityPublicKeyXOnly),
|
||||||
|
configured: this.authorityKeyConfigured,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getNextChannelId(): number {
|
||||||
|
return this.channelIdCounter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateExtranoncePrefix(): Buffer {
|
||||||
|
const prefix = Buffer.alloc(4);
|
||||||
|
prefix.writeUInt16BE(this.channelIdCounter & 0xffff, 0);
|
||||||
|
crypto.randomBytes(2).copy(prefix, 2);
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
public allocateExtendedExtranoncePrefix(channelId: number): Buffer {
|
||||||
|
return this.extranonceManager.allocate(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public releaseExtendedExtranoncePrefix(channelId: number): void {
|
||||||
|
this.extranonceManager.release(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getExtendedMinerExtranonceSize(): number {
|
||||||
|
return this.extranonceManager.minerExtranonceSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeNoiseConfig(): Promise<void> {
|
||||||
|
const configuredAuthorityKey = this.configService.get<string>('SV2_AUTHORITY_PRIVKEY');
|
||||||
|
this.authorityKeyConfigured = configuredAuthorityKey?.length === 64;
|
||||||
|
this.authorityPrivKey = configuredAuthorityKey?.length === 64
|
||||||
|
? Buffer.from(configuredAuthorityKey, 'hex')
|
||||||
|
: crypto.randomBytes(32);
|
||||||
|
this.authorityPublicKeyXOnly = xOnlyPubKeyFromPriv(this.authorityPrivKey);
|
||||||
|
|
||||||
|
if (!configuredAuthorityKey) {
|
||||||
|
console.warn('SV2_AUTHORITY_PRIVKEY is not set; generated an ephemeral SV2 authority key');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serverKeypair = await generateServerKeypair();
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
this.noiseConfig = {
|
||||||
|
staticKeypair: this.serverKeypair,
|
||||||
|
certificateMessage: createSignatureNoiseMessage(
|
||||||
|
this.authorityPrivKey,
|
||||||
|
xOnlyPubKeyFromPriv(this.serverKeypair.privateKey),
|
||||||
|
now - 3600,
|
||||||
|
now + 86400,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPorts(): number[] {
|
||||||
|
const configuredPorts = this.configService.get<string>('STRATUM_V2_PORTS');
|
||||||
|
if (!configuredPorts?.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ports = configuredPorts
|
||||||
|
.split(',')
|
||||||
|
.map(port => parseInt(port.trim(), 10))
|
||||||
|
.filter(port => Number.isInteger(port) && port > 0 && port <= 65535);
|
||||||
|
|
||||||
|
return Array.from(new Set(ports));
|
||||||
|
}
|
||||||
|
|
||||||
|
private startSocketServer(port: number): void {
|
||||||
|
const server = new Server((socket: Socket) => {
|
||||||
|
socket.setTimeout(this.getSocketTimeoutMs());
|
||||||
|
socket.setNoDelay(true);
|
||||||
|
|
||||||
|
let client: StratumV2Client = null;
|
||||||
|
|
||||||
|
const closeSocket = () => {
|
||||||
|
if (client != null) {
|
||||||
|
void client.destroy();
|
||||||
|
}
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.once('data', (firstChunk: Buffer) => {
|
||||||
|
client = this.createClient(socket, firstChunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('timeout', closeSocket);
|
||||||
|
socket.on('error', (error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code !== 'ECONNRESET') {
|
||||||
|
console.error(`Stratum V2 socket error: ${error.message}`);
|
||||||
|
}
|
||||||
|
closeSocket();
|
||||||
|
});
|
||||||
|
socket.on('close', () => {
|
||||||
|
if (client != null) {
|
||||||
|
void client.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', (error) => {
|
||||||
|
console.error(`Stratum V2 server error on port ${port}: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`Stratum V2 server is listening on port ${port}`);
|
||||||
|
});
|
||||||
|
this.servers.push(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSocketTimeoutMs(): number {
|
||||||
|
const configured = parseInt(this.configService.get<string>('STRATUM_V2_SOCKET_TIMEOUT_MS') ?? '', 10);
|
||||||
|
return Number.isFinite(configured) && configured > 0 ? configured : 1000 * 60 * 15;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { TOTAL_EXTRANONCE_SIZE_BYTES } from '../models/stratum.constants';
|
||||||
|
|
||||||
|
export function patchCoinbasePrefixVarint(prefix: Buffer, totalExtranonceSize: number): Buffer {
|
||||||
|
if (totalExtranonceSize === TOTAL_EXTRANONCE_SIZE_BYTES) {
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
if (prefix.length < 42) {
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patched = Buffer.from(prefix);
|
||||||
|
const nextLength = patched[41] + (totalExtranonceSize - TOTAL_EXTRANONCE_SIZE_BYTES);
|
||||||
|
if (nextLength < 0 || nextLength > 0xfc) {
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
patched[41] = nextLength;
|
||||||
|
return patched;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||||
|
|
||||||
|
const TRUE_DIFF_ONE_BIGINT = BigInt(
|
||||||
|
'26959535291011309493156476344723991336010898738574164086137773096960',
|
||||||
|
);
|
||||||
|
const TWO_TO_256 = 1n << 256n;
|
||||||
|
const FRACTION_SCALE = 1_000_000_000_000_000n;
|
||||||
|
const FRACTION_SCALE_NUM = 1e15;
|
||||||
|
|
||||||
|
function bigIntRatioToDifficulty(divisor: bigint): number {
|
||||||
|
if (divisor === 0n) {
|
||||||
|
return Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scaled = (TRUE_DIFF_ONE_BIGINT * FRACTION_SCALE) / divisor;
|
||||||
|
return Number(scaled) / FRACTION_SCALE_NUM;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DifficultyUtils {
|
||||||
|
public static calculateDifficulty(header: Buffer): { submissionDifficulty: number; submissionHash: string; hashBuffer: Buffer } {
|
||||||
|
const hashResult = bitcoinjs.crypto.hash256(header);
|
||||||
|
const target = DifficultyUtils.le256ToBigInt(hashResult);
|
||||||
|
|
||||||
|
return {
|
||||||
|
submissionDifficulty: bigIntRatioToDifficulty(target),
|
||||||
|
submissionHash: hashResult.toString('hex'),
|
||||||
|
hashBuffer: hashResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static meetsTarget(hashBuffer: Buffer, target: Buffer): boolean {
|
||||||
|
return DifficultyUtils.le256ToBigInt(hashBuffer) <= DifficultyUtils.le256ToBigInt(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static difficultyToTarget(difficulty: number): Buffer {
|
||||||
|
if (!Number.isFinite(difficulty) || difficulty <= 0) {
|
||||||
|
return Buffer.alloc(32, 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = 1_000_000n;
|
||||||
|
const diffScaled = BigInt(Math.round(difficulty * Number(scale)));
|
||||||
|
if (diffScaled === 0n) {
|
||||||
|
return Buffer.alloc(32, 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DifficultyUtils.bigIntToLe256((TRUE_DIFF_ONE_BIGINT * scale) / diffScaled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static targetToDifficulty(target: Buffer): number {
|
||||||
|
if (target.length !== 32) {
|
||||||
|
throw new Error('Target must be 32 bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetBigInt = DifficultyUtils.le256ToBigInt(target);
|
||||||
|
return bigIntRatioToDifficulty(targetBigInt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static hashRateToDifficulty(hashRate: number, sharesPerMinute: number): number {
|
||||||
|
const target = DifficultyUtils.hashRateToTarget(hashRate, sharesPerMinute);
|
||||||
|
return DifficultyUtils.targetToDifficulty(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static clampDifficultyToMaxTarget(difficulty: number, maxTarget: Buffer): number {
|
||||||
|
if (maxTarget.length !== 32) {
|
||||||
|
return difficulty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxTargetBigInt = DifficultyUtils.le256ToBigInt(maxTarget);
|
||||||
|
if (maxTargetBigInt === 0n) {
|
||||||
|
return difficulty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const computedTargetBigInt = DifficultyUtils.le256ToBigInt(
|
||||||
|
DifficultyUtils.difficultyToTarget(difficulty),
|
||||||
|
);
|
||||||
|
if (computedTargetBigInt > maxTargetBigInt) {
|
||||||
|
const clamped = DifficultyUtils.targetToDifficulty(maxTarget);
|
||||||
|
return Number.isFinite(clamped) && clamped > 0 ? clamped : difficulty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return difficulty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static hashRateToTarget(hashRate: number, sharesPerMinute: number): Buffer {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(hashRate)
|
||||||
|
|| hashRate <= 0
|
||||||
|
|| !Number.isFinite(sharesPerMinute)
|
||||||
|
|| sharesPerMinute <= 0
|
||||||
|
) {
|
||||||
|
return Buffer.alloc(32, 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondsPerShare = 60 / sharesPerMinute;
|
||||||
|
const hashesPerShare = BigInt(Math.round(hashRate * secondsPerShare));
|
||||||
|
if (hashesPerShare === 0n) {
|
||||||
|
return Buffer.alloc(32, 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = (TWO_TO_256 - hashesPerShare) / (hashesPerShare + 1n);
|
||||||
|
return DifficultyUtils.bigIntToLe256(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bigIntToLe256(value: bigint): Buffer {
|
||||||
|
const buf = Buffer.alloc(32);
|
||||||
|
let remaining = value;
|
||||||
|
for (let i = 0; i < 32; i++) {
|
||||||
|
buf[i] = Number(remaining & 0xffn);
|
||||||
|
remaining >>= 8n;
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static le256ToBigInt(target: Buffer): bigint {
|
||||||
|
return target.reduceRight((acc, byte) => (acc << 8n) | BigInt(byte), 0n);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user