9 Commits
Author SHA1 Message Date
Ben fb367ac5f0 sv1-sv2 routing 2026-06-05 23:44:00 -04:00
Ben 3d6c8b3134 sv2 debug 2026-06-05 23:41:22 -04:00
Ben 2a37cb1f06 Stratum V2 initial commit 2026-06-05 13:14:09 -04:00
Ben f2d2283b47 fix possible dup work on diff change 2026-06-05 09:51:06 -04:00
Ben 4cab56a1a0 prevent half connections staying open 2026-05-30 19:24:34 -04:00
Ben c5169e105e add stratum worker backpressure 2026-05-07 23:56:55 -04:00
Ben 2d6d2afacb dead code 2026-05-07 23:52:56 -04:00
Ben 327be48ade update postgresql branch 2026-05-07 23:45:07 -04:00
Ben cc0ca3979e extranonce2 8 bytes 2026-05-06 18:18:34 -04:00
65 changed files with 16279 additions and 8501 deletions
+11
View File
@@ -15,10 +15,21 @@ BITCOIN_RPC_TIMEOUT=10000
# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000"
API_PORT=3334
# Plain TCP Stratum ports accept both SV1 JSON-RPC and SV2 Noise/binary traffic.
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
SECURE_STRATUM_PORTS=4333,4332,4331,4330
STRATUM_MAX_CONNECTIONS_PER_LISTENER=10000
STRATUM_TLS_HANDSHAKE_TIMEOUT_MS=10000
#optional telegram bot
#TELEGRAM_BOT_TOKEN=
+1
View File
@@ -0,0 +1 @@
omit=optional
+2 -2
View File
@@ -2,7 +2,7 @@
# 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
RUN apt-get update \
@@ -24,7 +24,7 @@ RUN npm i && npm run build
# 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 3333 3334 8332
+14
View File
@@ -45,6 +45,20 @@ Install pm2 (https://pm2.keymetrics.io/)
$ 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
Build container:
+7632 -8162
View File
File diff suppressed because it is too large Load Diff
+57 -44
View File
@@ -5,6 +5,9 @@
"author": "",
"private": true,
"license": "UNLICENSED",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
@@ -21,62 +24,62 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/axios": "^3.0.0",
"@nestjs/cache-manager": "^2.0.1",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.2",
"@nestjs/core": "^9.0.0",
"@nestjs/platform-fastify": "^9.4.2",
"@nestjs/schedule": "^3.0.1",
"@nestjs/typeorm": "^10.0.0",
"axios": "^1.4.0",
"big.js": "^6.2.1",
"bitcoin-address-validation": "^2.2.1",
"bitcoinjs-lib": "^6.1.3",
"bitcoinjs-message": "^2.2.0",
"@nestjs/axios": "^4.0.1",
"@nestjs/cache-manager": "^3.1.2",
"@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/platform-fastify": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/typeorm": "^11.0.1",
"@scure/btc-signer": "^2.2.0",
"axios": "^1.16.0",
"better-sqlite3": "^12.9.0",
"bitcoin-address-validation": "^2.2.3",
"bitcoinjs-lib": "^6.1.7",
"bs58": "^5.0.0",
"cache-manager": "^5.2.3",
"cache-manager": "^7.2.8",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"discord.js": "^14.11.0",
"class-validator": "^0.14.4",
"discord.js": "^14.26.4",
"merkle-lib": "^2.0.10",
"node-telegram-bot-api": "^0.61.0",
"pg": "^8.11.3",
"pg": "^8.20.0",
"pg-pubsub": "^0.8.1",
"reflect-metadata": "^0.1.13",
"rpc-bitcoin": "^2.0.0",
"rxjs": "^7.2.0",
"sqlite3": "^5.1.6",
"tiny-secp256k1": "^2.2.3",
"typeorm": "^0.3.17",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"tiny-secp256k1": "^2.2.4",
"typeorm": "^0.3.28",
"zeromq": "6.0.0-beta.19"
},
"devDependencies": {
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
"@types/big.js": "^6.1.6",
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19",
"@types/cron": "^2.0.1",
"@types/express": "^4.17.13",
"@types/jest": "29.5.1",
"@types/node": "^18.16.12",
"@types/node-telegram-bot-api": "^0.61.6",
"@types/supertest": "^2.0.11",
"@types/express": "^4.17.25",
"@types/jest": "^29.5.14",
"@types/node": "^18.19.130",
"@types/supertest": "^7.2.0",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "29.5.0",
"patch-package": "8.0.0",
"prettier": "^2.3.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"jest": "^29.7.0",
"patch-package": "^8.0.1",
"prettier": "^3.8.3",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "29.1.0",
"ts-loader": "^9.2.3",
"ts-node": "^10.0.0",
"supertest": "^7.2.2",
"ts-jest": "^29.4.9",
"ts-loader": "^9.5.7",
"ts-node": "^10.9.2",
"tsconfig-paths": "4.2.0",
"typescript": "^5.0.0"
"typescript": "^5.9.3"
},
"overrides": {
"@fastify/middie": "^9.3.2",
"fastify": "^5.8.5",
"path-to-regexp": "^8.4.2"
},
"jest": {
"moduleFileExtensions": [
@@ -87,8 +90,18 @@
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
"^.+\\.(t|j)s$": [
"ts-jest",
{
"tsconfig": {
"allowJs": true
}
}
]
},
"transformIgnorePatterns": [
"node_modules/(?!(@scure|@noble|micro-packed))"
],
"collectCoverageFrom": [
"**/*.(t|j)s"
],
-26
View File
@@ -1,26 +0,0 @@
diff --git a/node_modules/rpc-bitcoin/build/src/rpc.d.ts b/node_modules/rpc-bitcoin/build/src/rpc.d.ts
index d25d732..ce4eb22 100644
--- a/node_modules/rpc-bitcoin/build/src/rpc.d.ts
+++ b/node_modules/rpc-bitcoin/build/src/rpc.d.ts
@@ -6,7 +6,7 @@ export declare type RPCIniOptions = RESTIniOptions & {
fullResponse?: boolean;
};
export declare type JSONRPC = {
- jsonrpc?: string | number;
+ jsonrpc?: string;
id?: string | number;
method: string;
params?: object;
diff --git a/node_modules/rpc-bitcoin/build/src/rpc.js b/node_modules/rpc-bitcoin/build/src/rpc.js
index 7fec7aa..3bee5fc 100644
--- a/node_modules/rpc-bitcoin/build/src/rpc.js
+++ b/node_modules/rpc-bitcoin/build/src/rpc.js
@@ -12,7 +12,7 @@ class RPCClient extends rest_1.RESTClient {
}
async rpc(method, params = {}, wallet) {
const uri = typeof wallet === "undefined" ? "/" : "wallet/" + wallet;
- const body = { method, params, jsonrpc: 1.0, id: "rpc-bitcoin" };
+ const body = { method, params, jsonrpc: "1.0", id: "rpc-bitcoin" };
try {
const response = await this.batch(body, uri);
return this.fullResponse ? response : response.result;
@@ -31,6 +31,24 @@ export class AddressSettingsService {
return await this.addressSettingsRepository.update({ address }, { bestDifficulty, bestDifficultyUserAgent });
}
public async updateBestDifficultyIfHigher(address: string, bestDifficulty: number, bestDifficultyUserAgent: string) {
await this.addressSettingsRepository
.createQueryBuilder()
.insert()
.into(AddressSettingsEntity)
.values({ address })
.orIgnore()
.execute();
return await this.addressSettingsRepository
.createQueryBuilder()
.update(AddressSettingsEntity)
.set({ bestDifficulty, bestDifficultyUserAgent })
.where('address = :address', { address })
.andWhere('"bestDifficulty" < :bestDifficulty', { bestDifficulty })
.execute();
}
public async createNew(address: string) {
return await this.addressSettingsRepository.save({ address });
}
@@ -46,10 +64,14 @@ export class AddressSettingsService {
// }
public async resetBestDifficultyAndShares() {
return await this.addressSettingsRepository.update({}, {
return await this.addressSettingsRepository
.createQueryBuilder()
.update(AddressSettingsEntity)
.set({
shares: 0,
bestDifficulty: 0
});
})
.execute();
}
public async getHighScores() {
+3 -2
View File
@@ -1,11 +1,12 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { Column, Entity } from 'typeorm';
import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn';
import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Entity()
export class BlocksEntity extends TrackedEntity {
@PrimaryGeneratedColumn({type: 'bigint'})
@PrimaryGeneratedBigIntColumn()
id: number;
@Column()
@@ -1,6 +1,7 @@
import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { Column, Entity, Index, ManyToOne } from 'typeorm';
import { ClientEntity } from '../client/client.entity';
import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn';
import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Entity()
@@ -8,7 +9,7 @@ import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Index(["clientId", "time"])
export class ClientStatisticsEntity extends TrackedEntity {
@PrimaryGeneratedColumn({type: 'bigint'})
@PrimaryGeneratedBigIntColumn()
id: number;
@Column({ length: 62, type: 'varchar' })
@@ -259,6 +259,6 @@ export class ClientStatisticsService {
}
public async deleteAll() {
return await this.clientStatisticsRepository.delete({})
return await this.clientStatisticsRepository.clear()
}
}
+1 -2
View File
@@ -30,7 +30,7 @@ export class ClientEntity extends TrackedEntity {
userAgent: string;
@Column({ type: 'timestamp' })
@Column()
startTime: Date;
@Column({ type: 'decimal', default: 0 })
@@ -46,4 +46,3 @@ export class ClientEntity extends TrackedEntity {
statistics: ClientStatisticsEntity[]
}
+15 -1
View File
@@ -140,6 +140,17 @@ export class ClientService {
public async updateBestDifficulty(id: string, bestDifficulty: number) {
return await this.clientRepository.update({ id }, { bestDifficulty });
}
public async updateBestDifficultyIfHigher(id: string, bestDifficulty: number) {
return await this.clientRepository
.createQueryBuilder()
.update(ClientEntity)
.set({ bestDifficulty })
.where('id = :id', { id })
.andWhere('"bestDifficulty" < :bestDifficulty', { bestDifficulty })
.execute();
}
public async connectedClientCount(): Promise<number> {
return await this.clientRepository.count();
}
@@ -173,7 +184,10 @@ export class ClientService {
}
public async deleteAll() {
return await this.clientRepository.softDelete({})
return await this.clientRepository
.createQueryBuilder()
.softDelete()
.execute();
}
// public async getUserAgents() {
+4 -2
View File
@@ -1,9 +1,11 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { Column, Entity } from 'typeorm';
import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn';
@Entity()
export class HomeGraphEntity {
@PrimaryGeneratedColumn({type: 'bigint'})
@PrimaryGeneratedBigIntColumn()
id: number;
@Column({ type: 'bigint' })
@@ -1,11 +1,12 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { Column, Entity, Index } from 'typeorm';
import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn';
import { TrackedEntity } from '../utils/TrackedEntity.entity';
@Entity()
export class TelegramSubscriptionsEntity extends TrackedEntity {
@PrimaryGeneratedColumn({type: 'bigint'})
@PrimaryGeneratedBigIntColumn()
id: number;
@Index()
@@ -0,0 +1,5 @@
import { PrimaryGeneratedColumn } from 'typeorm';
export const PrimaryGeneratedBigIntColumn = () => PrimaryGeneratedColumn({
type: process.env.NODE_ENV === 'test' ? 'integer' : 'bigint'
});
+3 -3
View File
@@ -1,12 +1,12 @@
import { CreateDateColumn, DeleteDateColumn, UpdateDateColumn } from 'typeorm';
export abstract class TrackedEntity {
@DeleteDateColumn({ nullable: true, type: 'timestamp' })
@DeleteDateColumn({ nullable: true })
public deletedAt?: Date;
@CreateDateColumn({ type: 'timestamp' })
@CreateDateColumn()
public createdAt?: Date
@UpdateDateColumn({ type: 'timestamp' })
@UpdateDateColumn()
public updatedAt?: Date
}
+8 -1
View File
@@ -11,6 +11,7 @@ import { ClientService } from './ORM/client/client.service';
import { HomeGraphService } from './ORM/home-graph/home-graph.service';
import { BitcoinRpcService } from './services/bitcoin-rpc.service';
import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
import { StratumV2Service } from './services/stratum-v2.service';
@Controller()
export class AppController {
@@ -25,7 +26,8 @@ export class AppController {
private readonly bitcoinRpcService: BitcoinRpcService,
private readonly homeGraphService: HomeGraphService,
private readonly addressSettingsService: AddressSettingsService,
private readonly userAgentReportService: UserAgentReportService
private readonly userAgentReportService: UserAgentReportService,
private readonly stratumV2Service: StratumV2Service
) { }
@Get('info')
@@ -42,6 +44,7 @@ export class AppController {
const blockData = await this.blocksService.getFoundBlocks();
const highScores = await this.addressSettingsService.getHighScores();
const poolAuthority = await this.stratumV2Service.getPoolAuthorityPublicKey();
const other: {
count: number,
@@ -72,6 +75,10 @@ export class AppController {
blockData,
userAgents,
highScores,
sv2: {
poolAuthorityPublicKey: poolAuthority.publicKey,
authorityKeyConfigured: poolAuthority.configured
},
uptime: this.uptime
};
+2
View File
@@ -34,6 +34,7 @@ import { DiscordService } from './services/discord.service';
import { NotificationService } from './services/notification.service';
import { StratumV1JobsService } from './services/stratum-v1-jobs.service';
import { StratumV1Service } from './services/stratum-v1.service';
import { StratumV2Service } from './services/stratum-v2.service';
import { TelegramService } from './services/telegram.service';
@@ -100,6 +101,7 @@ const ORMModules = [
NotificationService,
BitcoinAddressValidator,
StratumV1JobsService,
StratumV2Service,
BTCPayService,
BraiinsService
],
+1 -12
View File
@@ -1,5 +1,4 @@
import { Controller, Patch } from '@nestjs/common';
import * as bitcoinMessage from 'bitcoinjs-message';
@Controller('address')
@@ -7,16 +6,6 @@ export class AddressController {
@Patch('settings')
async settings() {
const publicKey = '...'; // Public key corresponding to the private key used for signing
const message = '...'; // The message that was signed
const signature = '...'; // The signature of the message
const isValid: boolean = bitcoinMessage.verify(message, publicKey, signature);
if (isValid) {
console.log('Signature is valid!');
} else {
console.log('Signature is not valid!');
}
return;
}
}
@@ -13,8 +13,8 @@ describe('ClientController', () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: './DB/public-pool.test.sqlite',
type: 'better-sqlite3',
database: ':memory:',
synchronize: true,
autoLoadEntities: true,
cache: true,
+128
View File
@@ -0,0 +1,128 @@
import * as bitcoinjs from 'bitcoinjs-lib';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { MiningJob } from './MiningJob';
describe('MiningJob', () => {
let jobTemplate;
let job: MiningJob;
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
const blockTemplate$ = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
const bitcoinRpcService = {
newBlockTemplate$: blockTemplate$.asObservable(),
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height }
};
jest.spyOn(console, 'log').mockImplementation(() => undefined);
const jobsService = new StratumV1JobsService(bitcoinRpcService as any);
jobTemplate = await firstValueFrom(jobsService.newMiningJob$);
job = new MiningJob(
bitcoinjs.networks.testnet,
'1',
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
jobTemplate
);
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
it('should split coinbase around 12 bytes of extranonce space', () => {
const notify = JSON.parse(job.response(jobTemplate));
const coinbasePart1 = notify.params[2];
const coinbasePart2 = notify.params[3];
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const coinbase = bitcoinjs.Transaction.fromHex(`${coinbasePart1}${extraNonce1}${extraNonce2}${coinbasePart2}`);
expect(Buffer.byteLength(extraNonce1 + extraNonce2, 'hex')).toBe(12);
expect(coinbase.ins[0].script.toString('hex')).toContain(`${extraNonce1}${extraNonce2}`);
expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
});
it('should expose coinbase prefix and suffix buffers for SV2 extended jobs', () => {
const notify = JSON.parse(job.response(jobTemplate));
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const fromNotify = Buffer.from(`${notify.params[2]}${extraNonce1}${extraNonce2}${notify.params[3]}`, 'hex');
const fromBuffers = Buffer.concat([
job.getCoinbasePrefixBuffer(),
Buffer.from(`${extraNonce1}${extraNonce2}`, 'hex'),
job.getCoinbaseSuffixBuffer(),
]);
expect(fromBuffers).toEqual(fromNotify);
expect(bitcoinjs.Transaction.fromBuffer(fromBuffers).ins[0].script.toString('hex'))
.toContain(`${extraNonce1}${extraNonce2}`);
});
it('should update block nonce, timestamp, version mask, and coinbase script', () => {
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const timestamp = parseInt(MockRecording1.TIME, 16);
const originalMerkleRoot = Buffer.from(jobTemplate.block.merkleRoot);
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
parseInt('00002000', 16),
parseInt('ed460d91', 16),
extraNonce1,
extraNonce2,
timestamp
);
expect(updatedBlock.nonce).toBe(parseInt('ed460d91', 16));
expect(updatedBlock.timestamp).toBe(timestamp);
expect(updatedBlock.version).toBe(jobTemplate.block.version ^ parseInt('00002000', 16));
expect(updatedBlock.transactions[0].ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
expect(updatedBlock.merkleRoot.equals(originalMerkleRoot)).toBe(false);
});
it('should leave block version unchanged without a version mask', () => {
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
0,
parseInt('ed460d91', 16),
'57a6f098',
'c708000000000000',
parseInt(MockRecording1.TIME, 16)
);
expect(updatedBlock.version).toBe(jobTemplate.block.version);
});
it('should build the same header as the full block update path', () => {
const versionMask = parseInt('00002000', 16);
const nonce = parseInt('ed460d91', 16);
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const timestamp = parseInt(MockRecording1.TIME, 16);
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
versionMask,
nonce,
extraNonce1,
extraNonce2,
timestamp
);
const fastHeader = job.buildHeaderBuffer(
jobTemplate,
versionMask,
nonce,
extraNonce1,
extraNonce2,
timestamp
);
expect(fastHeader.equals(updatedBlock.toBuffer(true))).toBe(true);
});
});
+51 -7
View File
@@ -4,6 +4,7 @@ import * as bitcoinjs from 'bitcoinjs-lib';
import { IJobTemplate } from '../services/stratum-v1-jobs.service';
import { eResponseMethod } from './enums/eResponseMethod';
import { IMiningNotify } from './stratum-messages/IMiningNotify';
import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants';
interface AddressObject {
@@ -15,6 +16,9 @@ export class MiningJob {
private coinbaseTransaction: bitcoinjs.Transaction;
private coinbasePart1: string;
private coinbasePart2: string;
private coinbasePart1Buffer: Buffer;
private coinbasePart2Buffer: Buffer;
private merkleBranchBuffers: Buffer[];
public jobTemplateId: string;
public networkDifficulty: number;
@@ -29,6 +33,7 @@ export class MiningJob {
this.creation = new Date().getTime();
this.jobTemplateId = jobTemplate.blockData.id;
this.merkleBranchBuffers = jobTemplate.merkle_branch.map(branch => Buffer.from(branch, 'hex'));
this.coinbaseTransaction = this.createCoinbaseTransaction(payoutInformation, jobTemplate.blockData.coinbasevalue);
@@ -50,7 +55,7 @@ export class MiningJob {
const blockHeightLengthByte = Buffer.from([blockHeightEncoded.length]);
// generate padding and take length of encode blockHeight into account
const padding = Buffer.alloc(8 + (3 - blockHeightEncoded.length), 0)
const padding = Buffer.alloc(TOTAL_EXTRANONCE_SIZE_BYTES + (3 - blockHeightEncoded.length), 0)
// build the script
this.coinbaseTransaction.ins[0].script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, extra, padding])
@@ -65,12 +70,51 @@ export class MiningJob {
const partOneIndex = serializedCoinbaseTx.indexOf(inputScript) + inputScript.length;
this.coinbasePart1 = serializedCoinbaseTx.slice(0, partOneIndex - 16);
this.coinbasePart1 = serializedCoinbaseTx.slice(0, partOneIndex - (TOTAL_EXTRANONCE_SIZE_BYTES * 2));
this.coinbasePart2 = serializedCoinbaseTx.slice(partOneIndex);
this.coinbasePart1Buffer = Buffer.from(this.coinbasePart1, 'hex');
this.coinbasePart2Buffer = Buffer.from(this.coinbasePart2, 'hex');
}
public cloneCoinbaseTransaction(): bitcoinjs.Transaction {
return bitcoinjs.Transaction.fromBuffer(this.coinbaseTransaction.toBuffer());
}
public getCoinbasePrefixBuffer(): Buffer {
return Buffer.from(this.coinbasePart1Buffer);
}
public getCoinbaseSuffixBuffer(): Buffer {
return Buffer.from(this.coinbasePart2Buffer);
}
public buildHeaderBuffer(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): Buffer {
const coinbaseBuffer = Buffer.concat([
this.coinbasePart1Buffer,
Buffer.from(`${extraNonce}${extraNonce2}`, 'hex'),
this.coinbasePart2Buffer,
]);
const coinbaseHash = bitcoinjs.crypto.hash256(coinbaseBuffer);
const merkleRoot = this.calculateMerkleRootHash(coinbaseHash, this.merkleBranchBuffers);
let version = jobTemplate.block.version;
if (versionMask !== undefined && versionMask != 0) {
version = version ^ versionMask;
}
const header = Buffer.alloc(80);
header.writeInt32LE(version, 0);
jobTemplate.block.prevHash.copy(header, 4);
merkleRoot.copy(header, 36);
header.writeUInt32LE(timestamp, 68);
header.writeUInt32LE(jobTemplate.block.bits, 72);
header.writeUInt32LE(nonce, 76);
return header;
}
public copyAndUpdateBlock(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): bitcoinjs.Block {
const testBlock = Object.assign(new bitcoinjs.Block(), jobTemplate.block);
@@ -78,7 +122,7 @@ export class MiningJob {
return Object.assign(new bitcoinjs.Transaction(), tx);
});
testBlock.transactions[0] = this.coinbaseTransaction;
testBlock.transactions[0] = this.cloneCoinbaseTransaction();
testBlock.nonce = nonce;
@@ -90,10 +134,10 @@ export class MiningJob {
// set the nonces
const nonceScript = testBlock.transactions[0].ins[0].script.toString('hex');
testBlock.transactions[0].ins[0].script = Buffer.from(`${nonceScript.substring(0, nonceScript.length - 16)}${extraNonce}${extraNonce2}`, 'hex');
testBlock.transactions[0].ins[0].script = Buffer.from(`${nonceScript.substring(0, nonceScript.length - (TOTAL_EXTRANONCE_SIZE_BYTES * 2))}${extraNonce}${extraNonce2}`, 'hex');
//recompute the root since we updated the coinbase script with the nonces
testBlock.merkleRoot = this.calculateMerkleRootHash(testBlock.transactions[0].getHash(false), jobTemplate.merkle_branch);
testBlock.merkleRoot = this.calculateMerkleRootHash(testBlock.transactions[0].getHash(false), this.merkleBranchBuffers);
testBlock.timestamp = timestamp;
@@ -102,14 +146,14 @@ export class MiningJob {
}
private calculateMerkleRootHash(newRoot: Buffer, merkleBranches: string[]): Buffer {
private calculateMerkleRootHash(newRoot: Buffer, merkleBranches: Buffer[]): Buffer {
const bothMerkles = Buffer.alloc(64);
bothMerkles.set(newRoot);
for (let i = 0; i < merkleBranches.length; i++) {
bothMerkles.set(Buffer.from(merkleBranches[i], 'hex'), 32);
bothMerkles.set(merkleBranches[i], 32);
newRoot = bitcoinjs.crypto.hash256(bothMerkles);
bothMerkles.set(newRoot);
}
+366 -29
View File
@@ -8,6 +8,7 @@ import { DataSource } from 'typeorm';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { AddressSettingsModule } from '../ORM/address-settings/address-settings.module';
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
import { BlocksEntity } from '../ORM/blocks/blocks.entity';
import { BlocksService } from '../ORM/blocks/blocks.service';
import { ClientStatisticsEntity } from '../ORM/client-statistics/client-statistics.entity';
import { ClientStatisticsModule } from '../ORM/client-statistics/client-statistics.module';
@@ -18,8 +19,10 @@ import { ClientService } from '../ORM/client/client.service';
import { BitcoinRpcService as MockBitcoinRpcService } from '../services/bitcoin-rpc.service';
import { NotificationService } from '../services/notification.service';
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { IMiningInfo } from './bitcoin-rpc/IMiningInfo';
import { IBlockTemplate } from './bitcoin-rpc/IBlockTemplate';
import { MiningJob } from './MiningJob';
import { StratumV1Client } from './StratumV1Client';
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
@@ -50,8 +53,12 @@ describe('StratumV1Client', () => {
let client: StratumV1Client;
let socketEmitter: (...args: any[]) => void;
const emitMessage = (message: string) => socketEmitter(Buffer.from(`${message}\n`));
let consoleLogSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
let consoleWarnSpy: jest.SpyInstance;
let newBlockEmitter: BehaviorSubject<IMiningInfo> = new BehaviorSubject(null);
let newBlockEmitter: BehaviorSubject<IBlockTemplate> = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
let moduleRef: TestingModule;
@@ -59,12 +66,13 @@ describe('StratumV1Client', () => {
moduleRef = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: './DB/public-pool.test.sqlite',
type: 'better-sqlite3',
database: ':memory:',
synchronize: true,
autoLoadEntities: true,
cache: true,
logging: false
logging: false,
entities: [ClientEntity, ClientStatisticsEntity, BlocksEntity]
}),
ClientModule,
ClientStatisticsModule,
@@ -94,23 +102,41 @@ describe('StratumV1Client', () => {
beforeEach(async () => {
console.log('NEW TEST')
jest.useFakeTimers({ advanceTimers: true })
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
clientService = moduleRef.get<ClientService>(ClientService);
const dataSource = moduleRef.get<DataSource>(DataSource);
dataSource.getRepository(ClientEntity).delete({});
dataSource.getRepository(ClientStatisticsEntity).delete({});
await dataSource.getRepository(ClientStatisticsEntity).clear();
await dataSource.getRepository(ClientEntity).clear();
await dataSource.getRepository(BlocksEntity).clear();
clientStatisticsService = moduleRef.get<ClientStatisticsService>(ClientStatisticsService);
configService = moduleRef.get<ConfigService>(ConfigService);
(configService.get as jest.Mock).mockImplementation((key: string) => {
switch (key) {
case 'DEV_FEE_ADDRESS':
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
case 'NETWORK':
return 'testnet';
}
return null;
});
(StratumV1Client as any).blockedUserAgentLogState.clear();
(StratumV1Client as any).validationErrorLogState.clear();
bitcoinRpcService = new MockBitcoinRpcService(null);
jest.spyOn(bitcoinRpcService, 'getBlockTemplate').mockReturnValue(Promise.resolve(MockRecording1.BLOCK_TEMPLATE));
bitcoinRpcService.newBlock$ = newBlockEmitter.asObservable();
bitcoinRpcService = {
newBlockTemplate$: newBlockEmitter.asObservable(),
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height },
SUBMIT_BLOCK: jest.fn().mockResolvedValue(null)
} as any;
stratumV1JobsService = new StratumV1JobsService(bitcoinRpcService);
@@ -126,8 +152,15 @@ describe('StratumV1Client', () => {
});
socket.end = jest.fn();
jest.spyOn(socket, 'destroy').mockImplementation(() => socket);
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
notificationService = {
notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined)
} as any;
blocksService = {
save: jest.fn().mockResolvedValue(undefined)
} as any;
client = new StratumV1Client(
@@ -143,12 +176,15 @@ describe('StratumV1Client', () => {
);
client.extraNonceAndSessionId = MockRecording1.EXTRA_NONCE;
jest.spyOn(client as any, 'getRandomHexString').mockReturnValue(MockRecording1.EXTRA_NONCE);
jest.useFakeTimers({ advanceTimers: true })
});
afterEach(async () => {
client.destroy();
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
consoleWarnSpy.mockRestore();
jest.useRealTimers();
})
@@ -158,7 +194,7 @@ describe('StratumV1Client', () => {
});
it('should close socket on invalid JSON', () => {
socketEmitter(Buffer.from('INVALID'));
emitMessage('INVALID');
jest.spyOn(socket, 'destroy');
expect(socket.on).toHaveBeenCalled();
});
@@ -167,21 +203,88 @@ describe('StratumV1Client', () => {
jest.spyOn(socket, 'write').mockImplementation((data) => true);
expect(socket.on).toHaveBeenCalled();
socketEmitter(Buffer.from(MockRecording1.MINING_SUBSCRIBE));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
await new Promise((r) => setTimeout(r, 1));
expect(socket.write).toHaveBeenCalledWith(`{"id":1,"error":null,"result":[[["mining.notify","${client.extraNonceAndSessionId}"]],"${client.extraNonceAndSessionId}",4]}\n`, expect.any(Function));
expect(socket.write).toHaveBeenCalledWith(`{"id":1,"error":null,"result":[[["mining.notify","${client.extraNonceAndSessionId}"]],"${client.extraNonceAndSessionId}",8]}\n`, expect.any(Function));
});
it('should block non-compliant user agents on subscribe without allocating a session', async () => {
(configService.get as jest.Mock).mockImplementation((key: string) => {
switch (key) {
case 'NON_COMPLIANT_USER_AGENTS':
return 'NMMiner';
case 'DEV_FEE_ADDRESS':
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
case 'NETWORK':
return 'testnet';
}
return null;
});
jest.spyOn(socket, 'write').mockImplementation((data) => true);
emitMessage(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}`);
await new Promise((r) => setTimeout(r, 1));
expect(socket.destroy).toHaveBeenCalled();
expect(socket.write).not.toHaveBeenCalled();
expect((client as any).statistics).toBeUndefined();
expect(consoleLogSpy).toHaveBeenCalledWith('Blocked non-compliant connection from userAgent: NMMiner');
});
it('should throttle repeated non-compliant user agent logs', async () => {
(configService.get as jest.Mock).mockImplementation((key: string) => {
switch (key) {
case 'NON_COMPLIANT_USER_AGENTS':
return 'NMMiner';
case 'DEV_FEE_ADDRESS':
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
case 'NETWORK':
return 'testnet';
}
return null;
});
jest.spyOn(socket, 'write').mockImplementation((data) => true);
emitMessage(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}`);
await new Promise((r) => setTimeout(r, 1));
const secondSocket = new Socket();
jest.spyOn(secondSocket, 'on').mockImplementation((event: string, listener: (...args: any[]) => void) => {
socketEmitter = listener;
return secondSocket;
});
secondSocket.end = jest.fn();
jest.spyOn(secondSocket, 'destroy').mockImplementation(() => secondSocket);
const secondClient = new StratumV1Client(
secondSocket,
stratumV1JobsService,
bitcoinRpcService,
clientService,
clientStatisticsService,
notificationService,
blocksService,
configService,
moduleRef.get<AddressSettingsService>(AddressSettingsService)
);
socketEmitter(Buffer.from(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}\n`));
await new Promise((r) => setTimeout(r, 1));
expect(secondSocket.destroy).toHaveBeenCalled();
expect(consoleLogSpy.mock.calls.filter(call => call[0]?.startsWith('Blocked non-compliant connection'))).toHaveLength(1);
await secondClient.destroy();
});
it('should respond to mining.configure', async () => {
jest.spyOn(socket, 'write').mockImplementation((data) => true);
expect(socket.on).toHaveBeenCalled();
socketEmitter(Buffer.from(MockRecording1.MINING_CONFIGURE));
emitMessage(MockRecording1.MINING_CONFIGURE);
await new Promise((r) => setTimeout(r, 1));
expect(socket.write).toHaveBeenCalledWith(`{"id":2,"error":null,"result":{"version-rolling":true,"version-rolling.mask":"1fffe000"}}\n`, expect.any(Function));
});
@@ -191,7 +294,7 @@ describe('StratumV1Client', () => {
jest.spyOn(socket, 'write').mockImplementation((data) => true);
expect(socket.on).toHaveBeenCalled();
socketEmitter(Buffer.from(MockRecording1.MINING_AUTHORIZE));
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 1));
expect(socket.write).toHaveBeenCalledWith('{"id":3,"error":null,"result":true}\n', expect.any(Function));
});
@@ -200,7 +303,7 @@ describe('StratumV1Client', () => {
jest.spyOn(socket, 'write').mockImplementation((data) => true);
expect(socket.on).toHaveBeenCalled();
socketEmitter(Buffer.from(MockRecording1.MINING_SUGGEST_DIFFICULTY));
emitMessage(MockRecording1.MINING_SUGGEST_DIFFICULTY);
await new Promise((r) => setTimeout(r, 1));
expect(socket.write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[512]}\n`, expect.any(Function));
});
@@ -208,20 +311,22 @@ describe('StratumV1Client', () => {
it('should set difficulty', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
console.log('should set difficulty')
socketEmitter(Buffer.from(MockRecording1.MINING_SUBSCRIBE));
socketEmitter(Buffer.from(MockRecording1.MINING_AUTHORIZE));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[16384]}\n`);
expect((client as any).write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[100000]}\n`);
});
it('should save client', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
socketEmitter(Buffer.from(MockRecording1.MINING_SUBSCRIBE));
socketEmitter(Buffer.from(MockRecording1.MINING_AUTHORIZE));
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);
await new Promise((r) => setTimeout(r, 100));
const clientCount = await clientService.connectedClientCount();
@@ -244,9 +349,9 @@ describe('StratumV1Client', () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
socketEmitter(Buffer.from(MockRecording1.MINING_SUBSCRIBE));
socketEmitter(Buffer.from(MockRecording1.MINING_SUGGEST_DIFFICULTY));
socketEmitter(Buffer.from(MockRecording1.MINING_AUTHORIZE));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
@@ -255,10 +360,10 @@ describe('StratumV1Client', () => {
expect((client as any).write).lastCalledWith(`{"id":null,"method":"mining.notify","params":["1","171592f223740e92d223f6e68bff25279af7ac4f2246451e0000000200000000","02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff1903c943255c7075626c69632d706f6f6c5c","ffffffff037a90000000000000160014e6f22ca44dc800e9d049621a3b9a42c509f1c4bc3b0f250000000000160014e6f22ca44dc800e9d049621a3b9a42c509f1c4bc0000000000000000266a24aa21a9edbd3d1d916aa0b57326a2d88ebe1b68a1d7c48585f26d8335fe6a94b62755f64c00000000",["175335649d5e8746982969ec88f52e85ac9917106fba5468e699c8879ab974a1","d5644ab3e708c54cd68dc5aedc92b8d3037449687f92ec41ed6e37673d969d4a","5c9ec187517edc0698556cca5ce27e54c96acb014770599ed9df4d4937fbf2b0"],"20000000","192495f8","${MockRecording1.TIME}",false]}\n`);
expect((client as any).write).lastCalledWith(`{"id":null,"method":"mining.notify","params":["1","171592f223740e92d223f6e68bff25279af7ac4f2246451e0000000200000000","02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff1b03c943255075626c69632d506f6f6c","ffffffff02b59f250000000000160014e6f22ca44dc800e9d049621a3b9a42c509f1c4bc0000000000000000266a24aa21a9edbd3d1d916aa0b57326a2d88ebe1b68a1d7c48585f26d8335fe6a94b62755f64c00000000",["175335649d5e8746982969ec88f52e85ac9917106fba5468e699c8879ab974a1","d5644ab3e708c54cd68dc5aedc92b8d3037449687f92ec41ed6e37673d969d4a","5c9ec187517edc0698556cca5ce27e54c96acb014770599ed9df4d4937fbf2b0"],"20000000","192495f8","${MockRecording1.TIME}",true]}\n`);
socketEmitter(Buffer.from(MockRecording1.MINING_SUBMIT));
emitMessage(MockRecording1.MINING_SUBMIT);
jest.useRealTimers();
await new Promise((r) => setTimeout(r, 1000));
@@ -268,6 +373,238 @@ describe('StratumV1Client', () => {
});
it('should use the header-only fast path for non-block submissions', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
const buildHeaderSpy = jest.spyOn(MiningJob.prototype, 'buildHeaderBuffer');
const fullBlockSpy = jest.spyOn(MiningJob.prototype, 'copyAndUpdateBlock');
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(buildHeaderSpy).toHaveBeenCalled();
expect(fullBlockSpy).not.toHaveBeenCalled();
});
it('should write accepted response before share accounting finishes', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
let finishAccounting: () => void;
const accountingPromise = new Promise<void>((resolve) => {
finishAccounting = resolve;
});
jest.spyOn((client as any).statistics, 'addShares').mockReturnValue(accountingPromise);
emitMessage(MockRecording1.MINING_SUBMIT);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect((client as any).write).toHaveBeenCalledWith(`{"id":5,"error":null,"result":true}\n`);
finishAccounting();
jest.useRealTimers();
await new Promise((r) => setTimeout(r, 100));
});
it('should update address best difficulty through the atomic path', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
submissionDifficulty: 1024,
submissionHash: 'share'
});
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
const getSettingsSpy = jest.spyOn(addressSettings, 'getSettings');
const updateIfHigherSpy = jest.spyOn(addressSettings as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 });
const clientUpdateIfHigherSpy = jest.spyOn(clientService as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 });
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(updateIfHigherSpy).toHaveBeenCalledWith(
'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
1024,
expect.any(String)
);
expect(clientUpdateIfHigherSpy).toHaveBeenCalledWith(expect.any(String), 1024);
expect(getSettingsSpy).not.toHaveBeenCalled();
});
it('should reject duplicate submissions', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
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);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[22,"Duplicate share",""]}\n`);
});
it('should reject submissions for unknown jobs', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
const hashSpy = jest.spyOn(MiningSubmitMessage.prototype, 'hash');
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(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "ff", "c708000000000000", "64b3f3ec", "ed460d91", "00002000"]}`);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job not found",""]}\n`);
expect(await clientService.connectedClientCount()).toBe(0);
expect(hashSpy).not.toHaveBeenCalled();
});
it('should reject submissions when the job template has expired', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
stratumV1JobsService.blocks = {};
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job Template not found",""]}\n`);
});
it('should reject low difficulty shares', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_SUGGEST_DIFFICULTY);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[23,"Difficulty too low",""]}\n`);
expect(await clientService.connectedClientCount()).toBe(0);
});
it('should reject submissions with short extranonce2 values', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c7080000", "64b3f3ec", "ed460d91", "00002000"]}`);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(expect.stringContaining(`"error":[20,"Mining Submit validation error"`));
expect(socket.destroy).toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Mining Submit validation error: extraNonce2:isLength'));
});
it('should throttle repeated mining submit validation logs', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c7080000", "64b3f3ec", "ed460d91", "00002000"]}`);
await new Promise((r) => setTimeout(r, 100));
const secondSocket = new Socket();
jest.spyOn(secondSocket, 'on').mockImplementation((event: string, listener: (...args: any[]) => void) => {
socketEmitter = listener;
return secondSocket;
});
secondSocket.end = jest.fn();
jest.spyOn(secondSocket, 'destroy').mockImplementation(() => secondSocket);
const secondClient = new StratumV1Client(
secondSocket,
stratumV1JobsService,
bitcoinRpcService,
clientService,
clientStatisticsService,
notificationService,
blocksService,
configService,
moduleRef.get<AddressSettingsService>(AddressSettingsService)
);
jest.spyOn(secondClient as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(secondClient as any, 'getRandomHexString').mockReturnValue(MockRecording1.EXTRA_NONCE);
socketEmitter(Buffer.from(`${MockRecording1.MINING_SUBSCRIBE}\n`));
socketEmitter(Buffer.from(`${MockRecording1.MINING_AUTHORIZE}\n`));
await new Promise((r) => setTimeout(r, 100));
socketEmitter(Buffer.from(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c7080000", "64b3f3ec", "ed460d91", "00002000"]}\n`));
await new Promise((r) => setTimeout(r, 100));
expect(consoleWarnSpy.mock.calls.filter(call => call[0]?.startsWith('Mining Submit validation error'))).toHaveLength(1);
await secondClient.destroy();
});
it('should close socket when a submit arrives before stratum is initialized', async () => {
const endSpy = jest.spyOn(socket, 'end');
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect(endSpy).toHaveBeenCalled();
});
it('should submit and persist found blocks', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
submissionDifficulty: Number.MAX_SAFE_INTEGER,
submissionHash: 'block-share'
});
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined);
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).toHaveBeenCalledWith(expect.objectContaining({
height: MockRecording1.BLOCK_TEMPLATE.height,
minerAddress: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
worker: 'bitaxe3',
sessionId: MockRecording1.EXTRA_NONCE,
blockData: expect.any(String)
}));
expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalled();
expect(addressSettings.resetBestDifficultyAndShares).toHaveBeenCalled();
expect((client as any).write).lastCalledWith(`{"id":5,"error":null,"result":true}\n`);
});
});
+186 -72
View File
@@ -1,8 +1,7 @@
import { ConfigService } from '@nestjs/config';
import Big from 'big.js';
import * as bitcoinjs from 'bitcoinjs-lib';
import { plainToInstance } from 'class-transformer';
import { validate, ValidatorOptions } from 'class-validator';
import { validate, ValidationError, ValidatorOptions } from 'class-validator';
import * as crypto from 'crypto';
import { Socket } from 'net';
import { firstValueFrom, Subscription } from 'rxjs';
@@ -25,18 +24,24 @@ import { ConfigurationMessage } from './stratum-messages/ConfigurationMessage';
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
import { StratumErrorMessage } from './stratum-messages/StratumErrorMessage';
import { SubscriptionMessage } from './stratum-messages/SubscriptionMessage';
import { EXTRANONCE1_SIZE_BYTES } from './stratum.constants';
import { SuggestDifficulty } from './stratum-messages/SuggestDifficultyMessage';
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
const TRUE_DIFF_ONE = 2.695953529101131e67;
const BLOCKED_USER_AGENT_LOG_INTERVAL_MS = 60 * 1000;
const VALIDATION_ERROR_LOG_INTERVAL_MS = 60 * 1000;
export class StratumV1Client {
private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>();
private static validationErrorLogState = new Map<string, { nextLogAt: number, suppressed: number, sample: string }>();
public clientSubscription: SubscriptionMessage;
private clientConfiguration: ConfigurationMessage;
private clientAuthorization: AuthorizationMessage;
private clientSuggestedDifficulty: SuggestDifficulty;
private stratumSubscription: Subscription;
private backgroundWork: NodeJS.Timer[] = [];
private backgroundWork: NodeJS.Timeout[] = [];
private statistics: StratumV1ClientStatistics;
private stratumInitialized = false;
@@ -52,6 +57,8 @@ export class StratumV1Client {
//public hashRate: number = 0;
private buffer: string = '';
private connectionClosed = false;
private lastSentMiningJobTimestamp: number = null;
private miningSubmissionHashes = new Set<string>()
@@ -72,16 +79,19 @@ export class StratumV1Client {
let lines = this.buffer.split('\n');
this.buffer = lines.pop() || ''; // Save the last part of the data (incomplete line) to the buffer
lines
.filter(m => m.length > 0)
.forEach(async (m) => {
(async () => {
for (const m of lines.filter(l => l.length > 0)) {
if (this.connectionClosed || this.socket.destroyed || this.socket.writableEnded) {
break;
}
try {
await this.handleMessage(m);
} catch (e) {
await this.socket.end();
console.error(e);
}
});
}
})();
});
@@ -103,10 +113,8 @@ export class StratumV1Client {
}
private getRandomHexString() {
const randomBytes = crypto.randomBytes(4); // 4 bytes = 32 bits
const randomNumber = randomBytes.readUInt32BE(0); // Convert bytes to a 32-bit unsigned integer
const hexString = randomNumber.toString(16).padStart(8, '0'); // Convert to hex and pad with zeros
return hexString;
const randomBytes = crypto.randomBytes(EXTRANONCE1_SIZE_BYTES);
return randomBytes.toString('hex');
}
@@ -140,6 +148,11 @@ export class StratumV1Client {
const errors = await validate(subscriptionMessage, validatorOptions);
if (errors.length === 0) {
if (this.isBlockedUserAgent(subscriptionMessage.userAgent)) {
this.logBlockedUserAgent(subscriptionMessage.userAgent);
this.closeSocket();
return;
}
if (this.sessionStart == null) {
this.sessionStart = new Date();
@@ -318,17 +331,18 @@ export class StratumV1Client {
} else {
console.log('Mining Submit validation error');
this.logValidationError('Mining Submit validation error', errors);
const err = new StratumErrorMessage(
miningSubmitMessage.id,
eStratumErrorCode.OtherUnknown,
'Mining Submit validation error',
errors).response();
console.error(err);
const success = await this.write(err);
if (!success) {
return;
}
this.closeSocket();
return;
}
break;
}
@@ -353,9 +367,9 @@ export class StratumV1Client {
private async initStratum() {
this.stratumInitialized = true;
if (this.validateHeaderCompliance(this.clientSubscription.userAgent)) {
console.log(`Non compliant connection from userAgent: ${this.clientSubscription.userAgent}`);
await this.socket.end();
if (this.isBlockedUserAgent(this.clientSubscription.userAgent)) {
this.logBlockedUserAgent(this.clientSubscription.userAgent);
this.closeSocket();
return;
}
@@ -454,6 +468,7 @@ export class StratumV1Client {
if (!success) {
return;
}
this.lastSentMiningJobTimestamp = jobTemplate.block.timestamp;
//console.log(`Sent new job to ${this.clientAuthorization.worker}.${this.extraNonceAndSessionId}. (clearJobs: ${jobTemplate.blockData.clearJobs}, fee?: ${!this.noFee})`)
@@ -461,12 +476,13 @@ export class StratumV1Client {
}
private async handleMiningSubmission(submission: MiningSubmitMessage) {
private async ensureClientEntity() {
if (this.clientEntity != null) {
return;
}
if (this.clientEntity == null) {
if (this.creatingEntity == null) {
this.creatingEntity = new Promise(async (resolve, reject) => {
try {
this.creatingEntity = (async () => {
this.clientEntity = await this.clientService.insert({
sessionId: this.extraNonceAndSessionId,
address: this.clientAuthorization.address,
@@ -475,33 +491,14 @@ export class StratumV1Client {
startTime: new Date(),
bestDifficulty: 0
});
} catch (e) {
reject(e);
}
resolve();
});
await this.creatingEntity;
} else {
await this.creatingEntity;
}
})();
}
const submissionHash = submission.hash();
if(this.miningSubmissionHashes.has(submissionHash)){
const err = new StratumErrorMessage(
submission.id,
eStratumErrorCode.DuplicateShare,
'Duplicate share').response();
const success = await this.write(err);
if (!success) {
return false;
}
return false;
}else{
this.miningSubmissionHashes.add(submissionHash);
await this.creatingEntity;
}
private async handleMiningSubmission(submission: MiningSubmitMessage) {
const job = this.stratumV1JobsService.getJobById(submission.jobId);
// a miner may submit a job that doesn't exist anymore if it was removed by a new block notification (or expired, 5 min)
@@ -533,25 +530,60 @@ export class StratumV1Client {
return false;
}
const submissionHash = [
submission.jobId,
submission.extraNonce2,
submission.ntime,
submission.nonce,
submission.versionMask ?? ''
].join(':');
if (this.miningSubmissionHashes.has(submissionHash)) {
const err = new StratumErrorMessage(
submission.id,
eStratumErrorCode.DuplicateShare,
'Duplicate share').response();
const success = await this.write(err);
if (!success) {
return false;
}
return false;
} else {
this.miningSubmissionHashes.add(submissionHash);
}
const updatedJobBlock = job.copyAndUpdateBlock(
const versionMask = parseInt(submission.versionMask, 16);
const nonce = parseInt(submission.nonce, 16);
const timestamp = parseInt(submission.ntime, 16);
const header = job.buildHeaderBuffer(
jobTemplate,
parseInt(submission.versionMask, 16),
parseInt(submission.nonce, 16),
versionMask,
nonce,
this.extraNonceAndSessionId,
submission.extraNonce2,
parseInt(submission.ntime, 16)
timestamp
);
const header = updatedJobBlock.toBuffer(true);
const { submissionDifficulty } = this.calculateDifficulty(header);
//console.log(`DIFF: ${submissionDifficulty} of ${this.sessionDifficulty} from ${this.clientAuthorization.worker + '.' + this.extraNonceAndSessionId}`);
if (submissionDifficulty >= this.sessionDifficulty) {
const success = await this.write(JSON.stringify(submission.response()) + '\n');
if (!success) {
return false;
}
if (submissionDifficulty >= jobTemplate.blockData.networkDifficulty) {
console.log('!!! BLOCK FOUND !!!');
const updatedJobBlock = job.copyAndUpdateBlock(
jobTemplate,
versionMask,
nonce,
this.extraNonceAndSessionId,
submission.extraNonce2,
timestamp
);
const blockHex = updatedJobBlock.toHex(false);
const result = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
await this.blocksService.save({
@@ -568,12 +600,13 @@ export class StratumV1Client {
await this.addressSettingsService.resetBestDifficultyAndShares();
}
}
await this.ensureClientEntity();
try {
await this.statistics.addShares(this.clientEntity, this.sessionDifficulty);
const now = new Date();
// only update every minute
//if (this.clientEntity.updatedAt == null || now.getTime() - this.clientEntity.updatedAt.getTime() > 1000 * 60) {
await this.clientService.heartbeatBulkAsync(this.clientEntity.id, this.statistics.hashRate, now);
this.clientService.heartbeatBulkAsync(this.clientEntity.id, this.statistics.hashRate, now);
this.clientEntity.updatedAt = now;
//}
@@ -582,11 +615,9 @@ export class StratumV1Client {
}
if (submissionDifficulty > this.clientEntity.bestDifficulty) {
await this.clientService.updateBestDifficulty(this.clientEntity.id, submissionDifficulty);
await this.clientService.updateBestDifficultyIfHigher(this.clientEntity.id, submissionDifficulty);
this.clientEntity.bestDifficulty = submissionDifficulty;
if (submissionDifficulty > (await this.addressSettingsService.getSettings(this.clientAuthorization.address, true)).bestDifficulty) {
await this.addressSettingsService.updateBestDifficulty(this.clientAuthorization.address, submissionDifficulty, this.clientEntity.userAgent);
}
await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.clientEntity.userAgent);
}
@@ -606,7 +637,7 @@ export class StratumV1Client {
}
//await this.checkDifficulty();
return true;
return false;
}
@@ -630,9 +661,21 @@ export class StratumV1Client {
await this.socket.write(data);
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
jobTemplate.blockData.clearJobs = true;
await this.sendNewMiningJob(jobTemplate);
const nextTimestamp = Math.max(
jobTemplate.block.timestamp,
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);
}
}
@@ -641,34 +684,105 @@ export class StratumV1Client {
const hashResult = bitcoinjs.crypto.hash256(header);
let s64 = this.le256todouble(hashResult);
const truediffone = Big('26959535291011309493156476344723991336010898738574164086137773096960');
const difficulty = truediffone.div(s64.toString());
return { submissionDifficulty: difficulty.toNumber(), submissionHash: hashResult.toString('hex') };
const target = this.le256todouble(hashResult);
const submissionDifficulty = target === 0 ? Number.POSITIVE_INFINITY : TRUE_DIFF_ONE / target;
return { submissionDifficulty, submissionHash: hashResult.toString('hex') };
}
private le256todouble(target: Buffer): bigint {
private le256todouble(target: Buffer): number {
const number = target.reduceRight((acc, byte) => {
// Shift the number 8 bits to the left and OR with the current byte
return (acc << BigInt(8)) | BigInt(byte);
}, BigInt(0));
let number = 0;
for (let i = target.length - 1; i >= 0; i--) {
number = number * 256 + target[i];
}
return number;
}
private validateHeaderCompliance(userAgent: string): boolean {
const headerCompliance = this.configService.get<string>('COMPLIANT_HEADERS');
if (!headerCompliance || headerCompliance.trim() === '') {
private isBlockedUserAgent(userAgent: string): boolean {
const blockedUserAgents = this.configService.get<string>('NON_COMPLIANT_USER_AGENTS')
|| this.configService.get<string>('BLOCKED_USER_AGENTS')
|| this.configService.get<string>('COMPLIANT_HEADERS');
if (!blockedUserAgents || blockedUserAgents.trim() === '') {
return false;
}
const complianceList = headerCompliance.split(',').map(ua => ua.trim().toLowerCase());
const blockedList = blockedUserAgents.split(',').map(ua => ua.trim().toLowerCase());
const userAgentLower = userAgent.toLowerCase();
return complianceList.some(compliant => compliant.length > 0 && userAgentLower.includes(compliant));
return blockedList.some(blocked => blocked.length > 0 && userAgentLower.includes(blocked));
}
private logBlockedUserAgent(userAgent: string) {
const now = Date.now();
const logState = StratumV1Client.blockedUserAgentLogState.get(userAgent);
if (logState != null && now < logState.nextLogAt) {
logState.suppressed += 1;
return;
}
const suppressed = logState?.suppressed ?? 0;
const suffix = suppressed > 0 ? ` (${suppressed} similar connections suppressed)` : '';
console.log(`Blocked non-compliant connection from userAgent: ${userAgent}${suffix}`);
StratumV1Client.blockedUserAgentLogState.set(userAgent, {
nextLogAt: now + BLOCKED_USER_AGENT_LOG_INTERVAL_MS,
suppressed: 0
});
}
private logValidationError(label: string, errors: ValidationError[]) {
const now = Date.now();
const signature = this.getValidationErrorSignature(errors);
const sample = this.getValidationErrorSample(errors);
const key = `${label}:${signature}`;
const logState = StratumV1Client.validationErrorLogState.get(key);
if (logState != null && now < logState.nextLogAt) {
logState.suppressed += 1;
return;
}
const suppressed = logState?.suppressed ?? 0;
const suffix = suppressed > 0 ? ` (${suppressed} similar validation errors suppressed)` : '';
console.warn(`${label}: ${signature}${sample}${suffix}`);
StratumV1Client.validationErrorLogState.set(key, {
nextLogAt: now + VALIDATION_ERROR_LOG_INTERVAL_MS,
suppressed: 0,
sample
});
}
private getValidationErrorSignature(errors: ValidationError[]): string {
if (errors.length === 0) {
return 'unknown';
}
return errors.map(error => {
const constraints = Object.keys(error.constraints ?? {}).sort().join('|') || 'invalid';
return `${error.property}:${constraints}`;
}).join(';');
}
private getValidationErrorSample(errors: ValidationError[]): string {
const values = errors
.map(error => error.value)
.filter(value => value != null)
.map(value => String(value).replace(/[\r\n]/g, '').slice(0, 64));
if (values.length === 0) {
return '';
}
return ` sample=${values.join(',')}`;
}
private closeSocket() {
this.connectionClosed = true;
if (!this.socket.destroyed) {
this.socket.destroy();
}
}
private async write(message: string): Promise<boolean> {
@@ -0,0 +1,107 @@
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
describe('StratumV1ClientStatistics', () => {
const client = {
id: 'client-id',
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'bitaxe3',
sessionId: '57a6f098'
} as any;
let clientStatisticsService: {
insert: jest.Mock,
updateBulkAsync: jest.Mock
};
let statistics: StratumV1ClientStatistics;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-06T12:00:00Z'));
clientStatisticsService = {
insert: jest.fn().mockResolvedValue(undefined),
updateBulkAsync: jest.fn().mockResolvedValue(undefined)
};
statistics = new StratumV1ClientStatistics(clientStatisticsService as any);
});
afterEach(() => {
jest.useRealTimers();
});
it('should insert the first share bucket', async () => {
await statistics.addShares(client, 64);
expect(clientStatisticsService.insert).toHaveBeenCalledWith({
time: new Date('2026-05-06T12:00:00Z').getTime(),
clientId: client.id,
shares: 64,
acceptedCount: 1,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
});
});
it('should update the current share bucket for additional shares', async () => {
await statistics.addShares(client, 64);
await statistics.addShares(client, 32);
expect(clientStatisticsService.updateBulkAsync).toHaveBeenCalledWith({
time: new Date('2026-05-06T12:00:00Z').getTime(),
clientId: client.id,
shares: 96,
acceptedCount: 2
});
});
it('should create a new share bucket when the time slot changes', async () => {
await statistics.addShares(client, 64);
jest.setSystemTime(new Date('2026-05-06T12:10:00Z'));
await statistics.addShares(client, 32);
expect(clientStatisticsService.updateBulkAsync).toHaveBeenCalledWith({
time: new Date('2026-05-06T12:00:00Z').getTime(),
clientId: client.id,
shares: 64,
acceptedCount: 1
});
expect(clientStatisticsService.insert).toHaveBeenLastCalledWith({
time: new Date('2026-05-06T12:10:00Z').getTime(),
clientId: client.id,
shares: 32,
acceptedCount: 1,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
});
});
it('should not suggest a difficulty change before enough time or shares have passed', () => {
expect(statistics.getSuggestedDifficulty(64)).toBeNull();
});
it('should lower difficulty when a miner has not submitted shares for several minutes', () => {
jest.setSystemTime(new Date('2026-05-06T12:06:00Z'));
expect(statistics.getSuggestedDifficulty(64)).toBe(8);
});
it('should increase difficulty for rapid submissions', async () => {
for (let i = 0; i < 5; i++) {
jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 1000)));
await statistics.addShares(client, 64);
}
expect(statistics.getSuggestedDifficulty(64)).toBe(2048);
});
it('should decrease difficulty for slow submissions', async () => {
for (let i = 0; i < 5; i++) {
jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 150000)));
await statistics.addShares(client, 64);
}
expect(statistics.getSuggestedDifficulty(128)).toBe(16);
});
});
+206
View File
@@ -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
@@ -0,0 +1,40 @@
import { plainToInstance } from 'class-transformer';
import { AuthorizationMessage } from './AuthorizationMessage';
describe('AuthorizationMessage', () => {
it('should parse address, worker, and starting difficulty', () => {
const message = plainToInstance(
AuthorizationMessage,
JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker1","x,d=2048"]}')
);
expect(message.address).toBe('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4');
expect(message.worker).toBe('worker1');
expect(message.password).toBe('x,d=2048');
expect(message.startingDiff).toBe(2048);
});
it('should default worker name when one is not provided', () => {
const message = plainToInstance(
AuthorizationMessage,
JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4","x"]}')
);
expect(message.worker).toBe('worker');
expect(message.startingDiff).toBeNull();
});
it('should build successful authorization responses', () => {
const message = plainToInstance(
AuthorizationMessage,
JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker1","x"]}')
);
expect(message.response()).toEqual({
id: 3,
error: null,
result: true
});
});
});
@@ -1,4 +1,5 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { MiningSubmitMessage } from './MiningSubmitMessage';
@@ -13,7 +14,7 @@ describe('MiningSubmitMessage', () => {
describe('test message parsing', () => {
const MINING_SUBMIT_MESSAGE = ' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "99020000", "64b1f10f", "2402812d", "00006000"]}'
const MINING_SUBMIT_MESSAGE = ' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "9902000000000000", "64b1f10f", "2402812d", "00006000"]}'
const message = plainToInstance(
MiningSubmitMessage,
@@ -24,11 +25,39 @@ describe('MiningSubmitMessage', () => {
expect(message.id).toEqual(5);
expect(message.userId).toEqual('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3');
expect(message.jobId).toEqual('1');
expect(message.extraNonce2).toEqual('99020000');
expect(message.extraNonce2).toEqual('9902000000000000');
expect(message.ntime).toEqual('64b1f10f');
expect(message.nonce).toEqual('2402812d');
expect(message.versionMask).toEqual('00006000');
});
it('should validate 8-byte extranonce2 submissions', async () => {
const errors = await validate(message);
expect(errors).toEqual([]);
});
it('should reject short extranonce2 submissions', async () => {
const shortMessage = plainToInstance(
MiningSubmitMessage,
JSON.parse(' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "99020000", "64b1f10f", "2402812d", "00006000"]}'),
);
const errors = await validate(shortMessage);
expect(errors.some(error => error.property === 'extraNonce2')).toBe(true);
});
it('should reject long extranonce2 submissions', async () => {
const longMessage = plainToInstance(
MiningSubmitMessage,
JSON.parse(' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "990200000000000000", "64b1f10f", "2402812d", "00006000"]}'),
);
const errors = await validate(longMessage);
expect(errors.some(error => error.property === 'extraNonce2')).toBe(true);
});
});
@@ -1,7 +1,8 @@
import { Expose, Transform } from 'class-transformer';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, Length } from 'class-validator';
import { eRequestMethod } from '../enums/eRequestMethod';
import { EXTRANONCE2_SIZE_BYTES } from '../stratum.constants';
import { StratumBaseMessage } from './StratumBaseMessage';
import * as bitcoinjs from 'bitcoinjs-lib';
@@ -27,6 +28,7 @@ export class MiningSubmitMessage extends StratumBaseMessage {
public jobId: string;
@Expose()
@IsString()
@Length(EXTRANONCE2_SIZE_BYTES * 2, EXTRANONCE2_SIZE_BYTES * 2)
@Transform(({ value, key, obj, type }) => {
return obj.params[2];
})
@@ -0,0 +1,45 @@
import { plainToInstance } from 'class-transformer';
import { SubscriptionMessage } from './SubscriptionMessage';
describe('SubscriptionMessage', () => {
it('should parse and refine known user agents', () => {
const bosminer = plainToInstance(
SubscriptionMessage,
JSON.parse('{"id":1,"method":"mining.subscribe","params":["bosminer/23.08"]}')
);
const cpuminer = plainToInstance(
SubscriptionMessage,
JSON.parse('{"id":1,"method":"mining.subscribe","params":["cpuminer-opt/1.0"]}')
);
expect(bosminer.userAgent).toBe('Braiins OS');
expect(cpuminer.userAgent).toBe('cpuminer');
});
it('should default missing user agents to unknown', () => {
const message = plainToInstance(
SubscriptionMessage,
JSON.parse('{"id":1,"method":"mining.subscribe","params":[]}')
);
expect(message.userAgent).toBe('unknown');
});
it('should respond with extranonce2 size of 8 bytes', () => {
const message = plainToInstance(
SubscriptionMessage,
JSON.parse('{"id":1,"method":"mining.subscribe","params":["bitaxe v2.2"]}')
);
expect(message.response('57a6f098')).toEqual({
id: 1,
error: null,
result: [
[['mining.notify', '57a6f098']],
'57a6f098',
8
]
});
});
});
@@ -2,6 +2,7 @@ import { Expose, Transform } from 'class-transformer';
import { IsArray, IsString, MaxLength } from 'class-validator';
import { eRequestMethod } from '../enums/eRequestMethod';
import { EXTRANONCE2_SIZE_BYTES } from '../stratum.constants';
import { StratumBaseMessage } from './StratumBaseMessage';
export class SubscriptionMessage extends StratumBaseMessage {
@@ -32,7 +33,7 @@ export class SubscriptionMessage extends StratumBaseMessage {
['mining.notify', clientId]
],
clientId, //Extranonce1 - Hex-encoded, per-connection unique string which will be used for coinbase serialization later. Keep it safe!
4 //Extranonce2_size - Represents expected length of extranonce2 which will be generated by the miner.
EXTRANONCE2_SIZE_BYTES //Extranonce2_size - Represents expected length of extranonce2 which will be generated by the miner.
]
}
+3
View File
@@ -0,0 +1,3 @@
export const EXTRANONCE1_SIZE_BYTES = 4;
export const EXTRANONCE2_SIZE_BYTES = 8;
export const TOTAL_EXTRANONCE_SIZE_BYTES = EXTRANONCE1_SIZE_BYTES + EXTRANONCE2_SIZE_BYTES;
+19
View File
@@ -0,0 +1,19 @@
import { encodeSv2AuthorityPublicKey } from './sv2-authority-key';
describe('SV2 authority key encoding', () => {
it('matches the protocol security test vector', () => {
const rawPublicKey = Buffer.from([
118, 99, 112, 0, 151, 156, 28, 17,
175, 12, 48, 11, 205, 140, 127, 228,
134, 16, 252, 233, 185, 193, 30, 61,
174, 227, 90, 224, 176, 138, 116, 85,
]);
expect(encodeSv2AuthorityPublicKey(rawPublicKey))
.toBe('9bXiEd8boQVhq7WddEcERUL5tyyJVFYdU8th3HfbNXK3Yw6GRXh');
});
it('rejects non-x-only public keys', () => {
expect(() => encodeSv2AuthorityPublicKey(Buffer.alloc(33))).toThrow(RangeError);
});
});
+19
View File
@@ -0,0 +1,19 @@
import * as bs58 from 'bs58';
import * as crypto from 'crypto';
const AUTHORITY_KEY_VERSION_PREFIX = Buffer.from([1, 0]);
export function encodeSv2AuthorityPublicKey(publicKey: Buffer): string {
if (publicKey.length !== 32) {
throw new RangeError(`SV2 authority public key must be 32 bytes, got ${publicKey.length}`);
}
const versionedKey = Buffer.concat([AUTHORITY_KEY_VERSION_PREFIX, publicKey]);
const checksum = crypto
.createHash('sha256')
.update(crypto.createHash('sha256').update(versionedKey).digest())
.digest()
.subarray(0, 4);
return bs58.encode(Buffer.concat([versionedKey, checksum]));
}
+249
View File
@@ -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);
});
});
});
+301
View File
@@ -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));
}
}
+138
View File
@@ -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);
});
});
+184
View File
@@ -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);
});
});
+113
View File
@@ -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;
}
}
+243
View File
@@ -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');
});
});
});
+232
View File
@@ -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);
}
}
+312
View File
@@ -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);
});
});
+309
View File
@@ -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(),
};
}
+250
View File
@@ -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);
});
});
+478
View File
@@ -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(),
};
}
+500
View File
@@ -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);
});
});
});
+601
View File
@@ -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;
}
}
+107
View File
@@ -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);
});
});
});
+154
View File
@@ -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;
}
+226
View File
@@ -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
});
});
+196
View File
@@ -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(),
};
}
+43 -13
View File
@@ -1,8 +1,8 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RPCClient } from 'rpc-bitcoin';
import axios, { AxiosInstance } from 'axios';
import { asyncScheduler, BehaviorSubject, delay, filter, from, interval, scheduled, shareReplay, startWith, Subject, switchMap } from 'rxjs';
import { RpcBlockService } from 'src/ORM/rpc-block/rpc-block.service';
import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service';
import * as zmq from 'zeromq';
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
@@ -13,10 +13,11 @@ import * as PGPubsub from 'pg-pubsub';
export class BitcoinRpcService implements OnModuleInit {
private client: RPCClient;
private client: AxiosInstance;
private _newBlockTemplate$: BehaviorSubject<IBlockTemplate> = new BehaviorSubject(undefined);
private pubsubInstance: PGPubsub;
private resetTemplateInterval$ = new Subject<void>();
private rpcRequestId = 0;
public miningInfo: IMiningInfo;
public newBlockTemplate$ = this._newBlockTemplate$.pipe(filter(block => block != null), shareReplay({ refCount: true, bufferSize: 1 }));
@@ -39,9 +40,17 @@ export class BitcoinRpcService implements OnModuleInit {
const port = parseInt(this.configService.get('BITCOIN_RPC_PORT'));
const timeout = parseInt(this.configService.get('BITCOIN_RPC_TIMEOUT'));
this.client = new RPCClient({ url, port, timeout, user, pass });
const baseURL = this.buildRpcUrl(url, port);
this.client = axios.create({
baseURL,
timeout,
auth: {
username: user,
password: pass
}
});
this.client.getrpcinfo().then((res) => {
this.callRpc('getrpcinfo').then((res) => {
console.log('Bitcoin RPC connected');
}, () => {
console.error('Could not reach RPC host');
@@ -109,13 +118,13 @@ export class BitcoinRpcService implements OnModuleInit {
let blockTemplate: IBlockTemplate;
while (blockTemplate == null) {
blockTemplate = await this.client.getblocktemplate({
template_request: {
blockTemplate = await this.callRpc<IBlockTemplate>('getblocktemplate', [
{
rules: ['segwit'],
mode: 'template',
capabilities: ['serverlist', 'proposal']
}
});
]);
}
try {
@@ -131,7 +140,7 @@ export class BitcoinRpcService implements OnModuleInit {
public async getMiningInfo(): Promise<IMiningInfo> {
try {
return await this.client.getmininginfo();
return await this.callRpc<IMiningInfo>('getmininginfo');
} catch (e) {
console.error('Error getmininginfo', e.message);
return null;
@@ -142,9 +151,7 @@ export class BitcoinRpcService implements OnModuleInit {
public async SUBMIT_BLOCK(hexdata: string): Promise<string> {
let response: string = 'unknown';
try {
response = await this.client.submitblock({
hexdata
});
response = await this.callRpc<string>('submitblock', [hexdata]);
if (response == null) {
response = 'SUCCESS!';
}
@@ -158,5 +165,28 @@ export class BitcoinRpcService implements OnModuleInit {
return response;
}
}
private async callRpc<T>(method: string, params: unknown[] = []): Promise<T> {
const response = await this.client.post('', {
jsonrpc: '1.0',
id: ++this.rpcRequestId,
method,
params
});
if (response.data.error != null) {
throw response.data.error;
}
return response.data.result;
}
private buildRpcUrl(url: string, port: number): string {
const normalizedUrl = /^https?:\/\//i.test(url) ? url : `http://${url}`;
const rpcUrl = new URL(normalizedUrl);
if (Number.isFinite(port) && port > 0) {
rpcUrl.port = port.toString();
}
return rpcUrl.toString();
}
}
@@ -0,0 +1,122 @@
import { BehaviorSubject, firstValueFrom, skip } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
import { StratumV1JobsService } from './stratum-v1-jobs.service';
describe('StratumV1JobsService', () => {
let blockTemplate$: BehaviorSubject<IBlockTemplate>;
let bitcoinRpcService: { newBlockTemplate$: any, miningInfo: { blocks: number } };
let service: StratumV1JobsService;
let consoleLogSpy: jest.SpyInstance;
const createTemplate = (height = MockRecording1.BLOCK_TEMPLATE.height): IBlockTemplate => ({
...MockRecording1.BLOCK_TEMPLATE,
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(tx => ({ ...tx })),
height
});
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
blockTemplate$ = new BehaviorSubject(createTemplate());
bitcoinRpcService = {
newBlockTemplate$: blockTemplate$.asObservable(),
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height }
};
service = new StratumV1JobsService(bitcoinRpcService as any);
});
afterEach(() => {
consoleLogSpy.mockRestore();
jest.useRealTimers();
});
it('should create job templates from block templates', async () => {
const jobTemplate = await firstValueFrom(service.newMiningJob$);
expect(jobTemplate.blockData).toEqual(expect.objectContaining({
id: '1',
height: MockRecording1.BLOCK_TEMPLATE.height,
clearJobs: true,
coinbasevalue: MockRecording1.BLOCK_TEMPLATE.coinbasevalue
}));
expect(jobTemplate.merkle_branch.length).toBeGreaterThan(0);
expect(jobTemplate.block.transactions[0].ins[0].witness[0]).toHaveLength(32);
expect(service.getJobTemplateById('1')).toBe(jobTemplate);
});
it('should clear jobs when the block height changes', async () => {
const firstTemplate = await firstValueFrom(service.newMiningJob$);
service.addJob({ jobId: 'old-job', creation: Date.now() } as any);
bitcoinRpcService.miningInfo.blocks = MockRecording1.BLOCK_TEMPLATE.height + 1;
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
blockTemplate$.next(createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1));
const jobTemplate = await nextTemplate;
expect(jobTemplate.blockData.clearJobs).toBe(true);
expect(service.getJobById('old-job')).toBeUndefined();
expect(service.getJobTemplateById(firstTemplate.blockData.id)).toBeUndefined();
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
});
it('should skip identical non-clean template refreshes', async () => {
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);
service.jobs['old-job'] = { jobId: 'old-job', creation: oldCreation } as any;
service.blocks['old-template'] = {
blockData: { creation: oldCreation }
} as any;
bitcoinRpcService.miningInfo.blocks = MockRecording1.BLOCK_TEMPLATE.height;
jest.setSystemTime(new Date(Date.now() + 1000));
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
blockTemplate$.next(createTemplate());
const jobTemplate = await nextTemplate;
expect(jobTemplate.blockData.clearJobs).toBe(false);
expect(service.getJobById('old-job')).toBeUndefined();
expect(service.getJobTemplateById('old-template')).toBeUndefined();
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
});
it('should increment job ids when jobs are added', () => {
expect(service.getNextId()).toBe('1');
service.addJob({ jobId: '1', creation: Date.now() } as any);
expect(service.getNextId()).toBe('2');
expect(service.getJobById('1')).toEqual(expect.objectContaining({ jobId: '1' }));
});
});
+18 -2
View File
@@ -31,6 +31,7 @@ export class StratumV1JobsService {
public blocks: { [id: number]: IJobTemplate } = {};
private lastBlockHeight = 0;
private lastWorkSignature: string;
constructor(
private readonly bitcoinRpcService: BitcoinRpcService
@@ -53,13 +54,29 @@ export class StratumV1JobsService {
}
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 {
version: blockTemplate.version,
bits: parseInt(blockTemplate.bits, 16),
prevHash: this.convertToLittleEndian(blockTemplate.previousblockhash),
transactions: blockTemplate.transactions.map(t => bitcoinjs.Transaction.fromHex(t.data)),
coinbasevalue: blockTemplate.coinbasevalue,
timestamp: blockTemplate.mintime > currentTime ? blockTemplate.mintime : currentTime,
timestamp,
networkDifficulty: this.calculateNetworkDifficulty(parseInt(blockTemplate.bits, 16)),
clearJobs,
height: blockTemplate.height
@@ -178,5 +195,4 @@ export class StratumV1JobsService {
return this.latestJobId.toString(16);
}
}
+203
View File
@@ -0,0 +1,203 @@
import { StratumV1Service } from './stratum-v1.service';
describe('StratumV1Service', () => {
const originalMaster = process.env.MASTER;
const originalStratumPorts = process.env.STRATUM_PORTS;
const originalStratumSecure = process.env.STRATUM_SECURE;
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 clientService;
let stratumV2Service;
let consoleLogSpy: jest.SpyInstance;
let consoleWarnSpy: jest.SpyInstance;
beforeEach(() => {
jest.useFakeTimers();
clientService = {
deleteAll: jest.fn().mockResolvedValue(undefined)
};
stratumV2Service = {
ensureInitialized: jest.fn().mockResolvedValue(undefined),
createClient: jest.fn()
};
service = new StratumV1Service(
{} as any,
clientService,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
stratumV2Service as any
);
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
restoreEnv('MASTER', originalMaster);
restoreEnv('STRATUM_PORTS', originalStratumPorts);
restoreEnv('STRATUM_SECURE', originalStratumSecure);
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();
consoleWarnSpy.mockRestore();
jest.useRealTimers();
});
it('should skip Stratum listeners in the master process', async () => {
process.env.MASTER = 'true';
const startSocketServerSpy = jest.spyOn(service as any, 'startSocketServer');
const startSecureSocketServerSpy = jest.spyOn(service as any, 'startSecureSocketServer');
await service.onModuleInit();
jest.runOnlyPendingTimers();
expect(clientService.deleteAll).toHaveBeenCalled();
expect(startSocketServerSpy).not.toHaveBeenCalled();
expect(startSecureSocketServerSpy).not.toHaveBeenCalled();
expect(consoleLogSpy).toHaveBeenCalledWith('Master process skipping Stratum socket listeners');
});
it('should start Stratum listeners in worker processes', async () => {
process.env.MASTER = 'false';
process.env.STRATUM_PORTS = '3333,3334';
process.env.STRATUM_SECURE = 'true';
process.env.SECURE_STRATUM_PORTS = '4333';
const startSocketServerSpy = jest.spyOn(service as any, 'startSocketServer').mockImplementation(() => undefined);
const startSecureSocketServerSpy = jest.spyOn(service as any, 'startSecureSocketServer').mockImplementation(() => undefined);
await service.onModuleInit();
jest.advanceTimersByTime(10000);
expect(clientService.deleteAll).not.toHaveBeenCalled();
expect(startSocketServerSpy).toHaveBeenCalledWith(3333);
expect(startSocketServerSpy).toHaveBeenCalledWith(3334);
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) {
if (value == null) {
delete process.env[key];
return;
}
process.env[key] = value;
}
});
+337 -36
View File
@@ -1,8 +1,10 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Server, Socket } from 'net';
import { monitorEventLoopDelay } from 'perf_hooks';
import { StratumV1Client } from '../models/StratumV1Client';
import { StratumV2Client } from '../models/StratumV2Client';
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';
@@ -10,11 +12,28 @@ import { ClientService } from '../ORM/client/client.service';
import { BitcoinRpcService } from './bitcoin-rpc.service';
import { NotificationService } from './notification.service';
import { StratumV1JobsService } from './stratum-v1-jobs.service';
import { StratumV2Service } from './stratum-v2.service';
import { readFileSync } from 'fs';
import { TlsOptions, TLSSocket, createServer } from 'tls';
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()
@@ -24,6 +43,10 @@ export class StratumV1Service implements OnModuleInit {
private emptySocket = 0;
private normalClosure = 0;
private errorClosure = 0;
private readonly listeners: StratumListenerState[] = [];
private readonly eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
private backpressureMonitor: NodeJS.Timeout | null = null;
private healthyBackpressureChecks = 0;
constructor(
private readonly bitcoinRpcService: BitcoinRpcService,
@@ -33,7 +56,8 @@ export class StratumV1Service implements OnModuleInit {
private readonly blocksService: BlocksService,
private readonly configService: ConfigService,
private readonly stratumV1JobsService: StratumV1JobsService,
private readonly addressSettingsService: AddressSettingsService
private readonly addressSettingsService: AddressSettingsService,
private readonly stratumV2Service: StratumV2Service
) {
}
@@ -42,6 +66,8 @@ export class StratumV1Service implements OnModuleInit {
if (process.env.MASTER == 'true') {
await this.clientService.deleteAll();
console.log('Master process skipping Stratum socket listeners');
return;
}
// wait for all the other processes to init for an even connection distribution
@@ -64,28 +90,32 @@ export class StratumV1Service implements OnModuleInit {
this.errorClosure = 0;
}, 1000 * 60);
this.startBackpressureMonitor();
}
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) => {
// Set 15-minute timeout
socket.setTimeout(1000 * 60 * 15);
const client = new StratumV1Client(
socket,
this.stratumV1JobsService,
this.bitcoinRpcService,
this.clientService,
this.clientStatisticsService,
this.notificationService,
this.blocksService,
this.configService,
this.addressSettingsService
);
let client: StratumV1Client | StratumV2Client = null;
let protocol: 'v1' | 'v2' | null = null;
// Unified cleanup function
const cleanup = async (reason: string) => {
if (client.extraNonceAndSessionId != null) {
if (client != null && (protocol === 'v2' || (client as StratumV1Client).extraNonceAndSessionId != null)) {
await client.destroy();
if (reason == 'Error') {
this.errorClosure++;
@@ -119,8 +149,30 @@ export class StratumV1Service implements OnModuleInit {
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');
}
});
});
@@ -128,29 +180,13 @@ export class StratumV1Service implements OnModuleInit {
server.on('error', (err) => {
console.error(`Server error: ${err.message}`);
});
this.configureConnectionLimit(server);
server.listen(port, () => {
console.log(`Stratum server is listening on port ${port}`);
});
return server;
}
private startSecureSocketServer(port: number) {
const currentDirectory = process.cwd();
const keyPath = path.join(currentDirectory, 'secrets', 'key.pem');
const certPath = path.join(currentDirectory, 'secrets', 'cert.pem');
const tlsOptions: TlsOptions = {
key: readFileSync(keyPath),
cert: readFileSync(certPath),
};
const server = createServer(tlsOptions, async (socket: TLSSocket) => {
// Set 15-minute timeout
socket.setTimeout(1000 * 60 * 15);
const client = new StratumV1Client(
private createV1Client(socket: Socket): StratumV1Client {
return new StratumV1Client(
socket,
this.stratumV1JobsService,
this.bitcoinRpcService,
@@ -161,6 +197,36 @@ export class StratumV1Service implements OnModuleInit {
this.configService,
this.addressSettingsService
);
}
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 keyPath = path.join(currentDirectory, 'secrets', 'key.pem');
const certPath = path.join(currentDirectory, 'secrets', 'cert.pem');
const tlsOptions: TlsOptions = {
key: readFileSync(keyPath),
cert: readFileSync(certPath),
handshakeTimeout: this.getTlsHandshakeTimeoutMs()
};
const server = createServer(tlsOptions, async (socket: TLSSocket) => {
// Set 15-minute timeout
socket.setTimeout(1000 * 60 * 15);
const client = this.createV1Client(socket);
const cleanup = async (reason: string) => {
if (client.extraNonceAndSessionId != null) {
@@ -200,10 +266,245 @@ export class StratumV1Service implements OnModuleInit {
server.on('error', (err) => {
console.error(`Server error: ${err.message}`);
});
this.configureConnectionLimit(server);
server.listen(port, () => {
console.log(`Stratum TLS server is listening on port ${port}`);
return server;
}
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;
}
+211
View File
@@ -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;
}
}
+63 -22
View File
@@ -1,8 +1,8 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
import { validate } from 'bitcoin-address-validation';
import { Block } from 'bitcoinjs-lib';
import * as TelegramBot from 'node-telegram-bot-api';
import { TelegramSubscriptionsService } from '../ORM/telegram-subscriptions/telegram-subscriptions.service';
@@ -10,7 +10,9 @@ import { TelegramSubscriptionsService } from '../ORM/telegram-subscriptions/tele
@Injectable()
export class TelegramService implements OnModuleInit {
private bot: TelegramBot;
private bot: AxiosInstance;
private updateOffset = 0;
private pollingTimer: NodeJS.Timeout;
constructor(
private readonly configService: ConfigService,
@@ -20,7 +22,10 @@ export class TelegramService implements OnModuleInit {
if (token == null || token.length < 1) {
return;
}
this.bot = new TelegramBot(token, { polling: true });
this.bot = axios.create({
baseURL: `https://api.telegram.org/bot${token}/`,
timeout: 10000
});
console.log('Telegram bot init');
@@ -32,23 +37,10 @@ export class TelegramService implements OnModuleInit {
return;
}
this.bot.onText(/\/subscribe/, async (msg) => {
const address = msg.text.split('/subscribe ')[1];
if (validate(address) == false) {
this.bot.sendMessage(msg.chat.id, "Invalid address.");
return;
}
await this.telegramSubscriptionsService.saveSubscription(msg.chat.id, address);
this.bot.sendMessage(msg.chat.id, "Subscribed!");
});
this.bot.onText(/\/start/, (msg) => {
this.bot.sendMessage(msg.chat.id, "Welcome to the public-pool bot. /subscribe <address> to get notified.");
});
this.bot.on('message', (msg) => {
console.log(msg);
});
await this.pollUpdates();
this.pollingTimer = setInterval(async () => {
await this.pollUpdates();
}, 2000);
}
public async notifySubscribersBlockFound(address: string, height: number, block: Block, message: string) {
@@ -57,8 +49,57 @@ export class TelegramService implements OnModuleInit {
}
const subscribers = await this.telegramSubscriptionsService.getSubscriptions(address);
subscribers.forEach(subscriber => {
this.bot.sendMessage(subscriber.telegramChatId, `Block Found! Result: ${message}, Height: ${height}`);
await Promise.all(subscribers.map(subscriber => {
return this.sendMessage(subscriber.telegramChatId, `Block Found! Result: ${message}, Height: ${height}`);
}));
}
private async pollUpdates() {
try {
const response = await this.bot.get('getUpdates', {
params: {
offset: this.updateOffset,
timeout: 0
}
});
for (const update of response.data.result ?? []) {
this.updateOffset = update.update_id + 1;
await this.handleMessage(update.message);
}
} catch (e) {
console.error('Telegram polling failed', e.message);
}
}
private async handleMessage(msg: any) {
if (msg?.text == null) {
return;
}
if (msg.text.startsWith('/subscribe')) {
const address = msg.text.split('/subscribe ')[1];
if (validate(address) == false) {
await this.sendMessage(msg.chat.id, 'Invalid address.');
return;
}
await this.telegramSubscriptionsService.saveSubscription(msg.chat.id, address);
await this.sendMessage(msg.chat.id, 'Subscribed!');
return;
}
if (msg.text.startsWith('/start')) {
await this.sendMessage(msg.chat.id, 'Welcome to the public-pool bot. /subscribe <address> to get notified.');
return;
}
console.log(msg);
}
private async sendMessage(chatId: number | string, text: string) {
await this.bot.post('sendMessage', {
chat_id: chatId,
text
});
}
}
+19
View File
@@ -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;
}
+117
View File
@@ -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);
}
}
+1 -1
View File
@@ -7,6 +7,6 @@ export class MockRecording1 {
public static MINING_AUTHORIZE = `{"id": 3, "method": "mining.authorize", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "x"]}`;
public static MINING_SUGGEST_DIFFICULTY = `{"id": 4, "method": "mining.suggest_difficulty", "params": [512]}`;
public static BLOCK_TEMPLATE: IBlockTemplate = { "capabilities": ["proposal"], "version": 536870912, "rules": ["csv", "!segwit", "taproot"], "vbavailable": {}, "vbrequired": 0, "previousblockhash": "00000000000000022246451e9af7ac4f8bff2527d223f6e623740e92171592f2", "transactions": [{ "data": "02000000000101c3efa41c94ee24dd6c6aaccab0f27e9d2baf92e5112bff15cc463b600e4e3a3c0100000000fdffffff024ea319000000000017a914a5b8d32a6388f02204a1445bd623150ee3b851d18765d67b000000000017a914a316e3a4eaeee9cbee9c254c3292830281a8c9c38702473044022065fbcd2918e8b52c3af8c4ae21b3646d7a12f66ddcaa73a68273f5806527c0da0220291f555dd120bb9eadb144ccf4203db659b78d79bffcd37a29016da068f3abb9012102baab4d464b57771f5108f40ffc31b7242c5b430a61d4bfead024d715c441f3f3c7432500", "txid": "a174b99a87c899e66854ba6f101799ac852ef588ec69299846875e9d64355317", "hash": "748f4db5e45dab6a9903f50e7e760470ce80ac219c637d61418667a0ccb6c536", "depends": [], "fee": 14300, "sigops": 1, "weight": 569 }, { "data": "01000000018985719caa78472c8fad7efa88fc109c6faf6df3dc3a690656479e53c6b10fe9030000006b483045022100e7abb2f2f7e6cfad680efe10a7fc218d685a9cce929b1a4e62fcb726228ac9b50220538f3d2eb36d277bcadf00f3ac2abbaf80395c389e1f8048dc95fdf6204cf1840121037435c194e9b01b3d7f7a2802d6684a3af68d05bbf4ec8f17021980d777691f1dfdffffff040000000000000000536a4c5054325b0b15aaff5c59aab47ce39829055c3007922e2934d3b7c9f51238f8d4a98718da4f9c99a6692adacd0e324b7775eebe151c4f3ad37fcc5f9303eed491a02638b8002543c70002002543a7000a4c10270000000000001976a914000000000000000000000000000000000000000088ac10270000000000001976a914000000000000000000000000000000000000000088aca99f6412000000001976a914ba27f99e007c7f605a8305e318c1abde3cd220ac88ac00000000", "txid": "c34ac3f822a0b49f298be1a25fc9e9dd375c23985ed2656f7cf7d87b67fee1c0", "hash": "c34ac3f822a0b49f298be1a25fc9e9dd375c23985ed2656f7cf7d87b67fee1c0", "depends": [], "fee": 9153, "sigops": 12, "weight": 1408 }, { "data": "020000000001013f8e5ebf5620f2c62c9c4e4f77bb4eb903699620da372ad44f666a552e5b9fc801000000171600148442ee1fe3742153a6b40623d363a22ceed677c3ffffffff02bc0200000000000017a91470b93cf7ead31ef24b71159388c2e8e147f755b987aa0415000000000017a9145506e665bcf63e1225100d7c8613a640699fd5d087024730440220215cd74141ebcde3aee383f232dfd2c6b011645012f35714781d28d4a6959829022017ea40a15f4116bbf0a6f86dc0af08758d9a74673498a61164ed13e9d1c712e501210294184e8093958861740c4ce32e724a24b7df7eeb04702cae274652e86b01f83a00000000", "txid": "5aaf2fa707da25c24374b87d89c420c9e4c51022a4bbed4cc91614831f359366", "hash": "2c19268e65cc223c5fac9666883ecc1d10e680c54a4fdbbb3e4775f886b0feec", "depends": [], "fee": 491, "sigops": 1, "weight": 661 }, { "data": "020000000001012c53f5b0a12cadb57602466e703e60272183b75f3366073f4841dac8e616f2740000000000feffffff02e80300000000000016001496e381bd3fe4bae631adc5a5a420fb9124e5480af0a8100000000000160014cd06fc96fea44c6c5e074ba16dd20b222a0a9b32024730440220440ba2252ea6f70f1b8106897984330a997ef504f7f0dbb89679e44309026f2b02207074ad7dbd9a2bd37a41352527b7f3e68612956fe2085a0eb0b41f97f6a3599d012103b3ecf0f44126019a94a818245e35ecff1582c2900a38487a306fcd91bdb28003c8432500", "txid": "69770cf296b64329e784a5666fb0eab034fbb1780bf067f4e1606874c214f01d", "hash": "fadcaa8de64274e63c093592ed6d8c6b7584bb92c0e8d9c49f02a4df11f6f69d", "depends": [], "fee": 141, "sigops": 1, "weight": 561 }, { "data": "01000000019e03cd78895f6876932501b66eab1e44611d48b4c5029d250b7d2d6073b44b68000000006b48304502210088dfbeb842d54893987e5afb87b70fe70e7b91ebad740782515c44464b19a74202205a6bc47ad6c925f85aa29b2be1a174efcc30a4ad8ed6cfe6236b66d7a4f8d96f01210259530ee281106e237ac3aa801d1f7699ada05501186401da78b7d88849bbda8bffffffff0204ce3900000000001976a91419803f0d83a1b58348a4617c743638bbf9ec235588ac44ce5c01000000001976a9149e38aebe02be7cecacdb521f514cffe445c5533788ac00000000", "txid": "2137ca428d6233e680cea25c428b984391314d872a1a0f5f7b8dc8cfc4474c44", "hash": "2137ca428d6233e680cea25c428b984391314d872a1a0f5f7b8dc8cfc4474c44", "depends": [], "fee": 226, "sigops": 8, "weight": 904 }], "coinbaseaux": {}, "coinbasevalue": 2465717, "longpollid": "00000000000000022246451e9af7ac4f8bff2527d223f6e623740e92171592f29370", "target": "000000000000002495f800000000000000000000000000000000000000000000", "mintime": 1689512405, "mutable": ["time", "transactions", "prevblock"], "noncerange": "00000000ffffffff", "sigoplimit": 80000, "sizelimit": 4000000, "weightlimit": 4000000, "curtime": 1689514989, "bits": "192495f8", "height": 2442185, "default_witness_commitment": "6a24aa21a9edbd3d1d916aa0b57326a2d88ebe1b68a1d7c48585f26d8335fe6a94b62755f64c" };
public static MINING_SUBMIT = `{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c7080000", "64b3f3ec", "ed460d91", "00002000"]}`;
public static MINING_SUBMIT = `{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c708000000000000", "64b3f3ec", "ed460d91", "00002000"]}`;
public static TIME = '64b3f3ec';
}