diff --git a/.env.example b/.env.example index 9e08035..78a13d3 100644 --- a/.env.example +++ b/.env.example @@ -15,8 +15,17 @@ 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 diff --git a/package-lock.json b/package-lock.json index 8a8d020..3745f1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,6 @@ "version": "0.0.1", "hasInstallScript": true, "license": "UNLICENSED", - "engines": { - "node": ">=22.12.0" - }, "dependencies": { "@nestjs/axios": "^4.0.1", "@nestjs/cache-manager": "^3.1.2", @@ -21,6 +18,7 @@ "@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", @@ -63,6 +61,9 @@ "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.12.0" } }, "node_modules/@aminya/node-gyp-build": { @@ -2517,6 +2518,33 @@ "typeorm": "^0.3.0 || ^1.0.0-dev" } }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", @@ -2679,6 +2707,42 @@ "npm": ">=7.0.0" } }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/btc-signer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.2.0.tgz", + "integrity": "sha512-ZXZ08sZqSZKEcOuEQnxTF66ouHtl6+UA6U/QfQM06K9WiOlEkXF4LviZCaSgkdiFh9cyMt9+xdup7JtEv3p0fw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~2.2.0", + "@noble/hashes": "~2.2.0", + "@scure/base": "~2.2.0", + "micro-packed": "~0.9.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/btc-signer/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -8271,6 +8335,21 @@ "node": ">= 0.6" } }, + "node_modules/micro-packed": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.9.0.tgz", + "integrity": "sha512-gFdaWTxEXOwtSOcpxulO4AuXVtp3HWIRmB8eq8+3m1Zku0ubgva0UGpi03YhcvsTJasHngG9gTIUK5kHNKdesg==", + "license": "MIT", + "dependencies": { + "@scure/base": "~2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -13598,6 +13677,21 @@ "integrity": "sha512-8rw/nKT0S+L+MkzgE9F2/mox7mAgsPlwfzmW9gsESN1lmQtIrVEfiiBwC2O8+guS1jBfQehJIdcdUj2OAp4VUQ==", "requires": {} }, + "@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "requires": { + "@noble/hashes": "2.2.0" + }, + "dependencies": { + "@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==" + } + } + }, "@noble/hashes": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", @@ -13704,6 +13798,29 @@ "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==" }, + "@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==" + }, + "@scure/btc-signer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.2.0.tgz", + "integrity": "sha512-ZXZ08sZqSZKEcOuEQnxTF66ouHtl6+UA6U/QfQM06K9WiOlEkXF4LviZCaSgkdiFh9cyMt9+xdup7JtEv3p0fw==", + "requires": { + "@noble/curves": "~2.2.0", + "@noble/hashes": "~2.2.0", + "@scure/base": "~2.2.0", + "micro-packed": "~0.9.0" + }, + "dependencies": { + "@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==" + } + } + }, "@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -17605,6 +17722,14 @@ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "dev": true }, + "micro-packed": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.9.0.tgz", + "integrity": "sha512-gFdaWTxEXOwtSOcpxulO4AuXVtp3HWIRmB8eq8+3m1Zku0ubgva0UGpi03YhcvsTJasHngG9gTIUK5kHNKdesg==", + "requires": { + "@scure/base": "~2.2.0" + } + }, "micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", diff --git a/package.json b/package.json index ba194dd..43f8ee3 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@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", @@ -89,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" ], diff --git a/src/app.controller.ts b/src/app.controller.ts index d69b8f7..8e0d215 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -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 }; diff --git a/src/app.module.ts b/src/app.module.ts index c10e772..1fed5b1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 ], diff --git a/src/models/MiningJob.spec.ts b/src/models/MiningJob.spec.ts index 85ec83f..4df1e87 100644 --- a/src/models/MiningJob.spec.ts +++ b/src/models/MiningJob.spec.ts @@ -48,6 +48,22 @@ describe('MiningJob', () => { 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'; diff --git a/src/models/MiningJob.ts b/src/models/MiningJob.ts index 6955910..5024b0e 100644 --- a/src/models/MiningJob.ts +++ b/src/models/MiningJob.ts @@ -82,6 +82,14 @@ export class MiningJob { return bitcoinjs.Transaction.fromBuffer(this.coinbaseTransaction.toBuffer()); } + public getCoinbasePrefixBuffer(): Buffer { + return Buffer.from(this.coinbasePart1Buffer); + } + + public getCoinbaseSuffixBuffer(): Buffer { + return Buffer.from(this.coinbasePart2Buffer); + } + public buildHeaderBuffer(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): Buffer { const coinbaseBuffer = Buffer.concat([ this.coinbasePart1Buffer, diff --git a/src/models/StratumV2Client.spec.ts b/src/models/StratumV2Client.spec.ts new file mode 100644 index 0000000..0efcd5e --- /dev/null +++ b/src/models/StratumV2Client.spec.ts @@ -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 }; + } +}); diff --git a/src/models/StratumV2Client.ts b/src/models/StratumV2Client.ts new file mode 100644 index 0000000..b613bab --- /dev/null +++ b/src/models/StratumV2Client.ts @@ -0,0 +1,1161 @@ +import { ConfigService } from '@nestjs/config'; +import { getAddressInfo } from 'bitcoin-address-validation'; +import * as bitcoinjs from 'bitcoinjs-lib'; +import * as crypto from 'crypto'; +import { Socket } from 'net'; +import { firstValueFrom, Subscription } from 'rxjs'; + +import { AddressSettingsService } from '../ORM/address-settings/address-settings.service'; +import { BlocksService } from '../ORM/blocks/blocks.service'; +import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service'; +import { ClientEntity } from '../ORM/client/client.entity'; +import { ClientService } from '../ORM/client/client.service'; +import { BitcoinRpcService } from '../services/bitcoin-rpc.service'; +import { NotificationService } from '../services/notification.service'; +import { StratumV2Service } from '../services/stratum-v2.service'; +import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service'; +import { patchCoinbasePrefixVarint } from '../utils/coinbase-prefix.utils'; +import { DifficultyUtils } from '../utils/difficulty.utils'; +import { MiningJob } from './MiningJob'; +import { StratumV1ClientStatistics } from './StratumV1ClientStatistics'; +import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants'; +import { BufferReader } from './sv2/sv2-binary-codec'; +import { + SV2_CHANNEL_MSG_FLAG, + SV2_NOISE_ACT1_SIZE, + Sv2MiningSetupFlags, + Sv2MiningSetupSuccessFlags, + Sv2MsgType, + Sv2Protocol, +} from './sv2/sv2-constants'; +import { Sv2FrameReader, Sv2FrameWriter } from './sv2/sv2-frame'; +import { + deserializeCloseChannel, + deserializeOpenStandardMiningChannel, + deserializeRequestExtensions, + deserializeSetupConnection, + deserializeSubmitSharesStandard, + deserializeUpdateChannel, + serializeOpenMiningChannelError, + serializeOpenStandardMiningChannelSuccess, + serializeNewMiningJob, + serializeRequestExtensionsSuccess, + serializeSetNewPrevHash, + serializeSetTarget, + serializeSetupConnectionError, + serializeSetupConnectionSuccess, + serializeSubmitSharesError, + serializeSubmitSharesSuccess, +} from './sv2/sv2-messages'; +import { + deserializeOpenExtendedMiningChannel, + deserializeSubmitSharesExtended, + serializeNewExtendedMiningJob, + serializeOpenExtendedMiningChannelSuccess, + Sv2SubmitSharesExtended, +} from './sv2/sv2-extended-messages'; +import { Sv2NoiseSession } from './sv2/sv2-noise'; + +const DEFAULT_START_DIFFICULTY = 100000; +const DEFAULT_TARGET_SHARES_PER_MINUTE = 2; +const DEFAULT_DIFFICULTY_CHECK_INTERVAL_MS = 60 * 1000; +const FIXED_STANDARD_EXTRANONCE2 = '0000000000000000'; +const RETIRED_EXTENDED_JOB_RETENTION_MS = 5 * 60 * 1000; + +interface ExtendedJobData { + coinbasePrefix: Buffer; + coinbaseSuffix: Buffer; + merklePath: Buffer[]; + prevHash: Buffer; + nBits: number; + minNtime: number; + jobTemplate: IJobTemplate; + miningJob: MiningJob; + retiredAt?: number; + creation: number; +} + +interface ChannelState { + channelId: number; + channelType: 'standard' | 'extended'; + extranoncePrefix: Buffer; + extranonceSize: number; + sessionDifficulty: number; + declaredMaxTarget: Buffer; + jobIdToDifficulty: Map; + jobIdToMerkleRoot: Map; + extendedJobs: Map; + latestExtendedPrevHash: Buffer; + latestExtendedNBits: number; + latestExtendedMinNtime: number; + miningSubmissionHashes: Set; + acceptedShareCount: number; +} + +export class StratumV2Client { + private readonly sessionId = crypto.randomBytes(4).toString('hex'); + private readonly noiseSession: Sv2NoiseSession; + private readonly frameReader = new Sv2FrameReader(null); + private readonly frameWriter = new Sv2FrameWriter(null); + private readonly statistics: StratumV1ClientStatistics; + private readonly network: bitcoinjs.networks.Network; + private readonly targetSharesPerMinute: number; + private readonly difficultyCheckIntervalMs: number; + + private handshakeBuffer = Buffer.alloc(0); + private handshakeComplete = false; + private processingHandshake = false; + private destroyed = false; + private stratumSubscription: Subscription = null; + private difficultyTimer: NodeJS.Timeout = null; + private channels = new Map(); + private primaryChannelId: number = null; + private address: string = null; + private workerName = 'default'; + private userAgent = 'unknown/sv2'; + private sessionDifficulty: number; + private clientEntity: ClientEntity = null; + private creatingEntity: Promise = null; + + constructor( + private readonly socket: Socket, + firstChunk: Buffer, + private readonly stratumV2Service: StratumV2Service, + private readonly stratumV1JobsService: StratumV1JobsService, + private readonly bitcoinRpcService: BitcoinRpcService, + private readonly clientService: ClientService, + private readonly clientStatisticsService: ClientStatisticsService, + private readonly notificationService: NotificationService, + private readonly blocksService: BlocksService, + private readonly configService: ConfigService, + private readonly addressSettingsService: AddressSettingsService, + ) { + this.noiseSession = new Sv2NoiseSession(this.stratumV2Service.getNoiseConfig()); + this.sessionDifficulty = this.getInitialDifficulty(); + this.targetSharesPerMinute = this.getTargetSharesPerMinute(); + this.difficultyCheckIntervalMs = this.getDifficultyCheckIntervalMs(); + this.statistics = new StratumV1ClientStatistics(this.clientStatisticsService); + this.statistics.targetSubmitShareEveryNSeconds = 60 / this.targetSharesPerMinute; + this.network = this.getNetwork(); + + this.socket.on('data', (data: Buffer) => { + void this.handleSocketData(data); + }); + + void this.handleSocketData(firstChunk); + } + + public async destroy(): Promise { + if (this.destroyed) { + return; + } + this.destroyed = true; + + if (this.stratumSubscription != null) { + this.stratumSubscription.unsubscribe(); + this.stratumSubscription = null; + } + if (this.difficultyTimer != null) { + clearInterval(this.difficultyTimer); + this.difficultyTimer = null; + } + for (const channel of this.channels.values()) { + if (channel.channelType === 'extended') { + this.stratumV2Service.releaseExtendedExtranoncePrefix(channel.channelId); + } + } + this.channels.clear(); + if (this.clientEntity?.id != null) { + await this.clientService.delete(this.clientEntity.id); + } + } + + private async handleSocketData(data: Buffer): Promise { + if (this.destroyed) { + return; + } + + try { + if (!this.handshakeComplete) { + await this.handleHandshakeData(data); + } else { + await this.handleEncryptedData(data); + } + } catch (error) { + console.error(`[SV2 ${this.sessionId}] ${error.message}`); + this.closeSocket(); + } + } + + private async handleHandshakeData(data: Buffer): Promise { + this.handshakeBuffer = Buffer.concat([this.handshakeBuffer, data]); + if (this.processingHandshake || this.handshakeBuffer.length < SV2_NOISE_ACT1_SIZE) { + return; + } + + this.processingHandshake = true; + const act1 = this.handshakeBuffer.subarray(0, SV2_NOISE_ACT1_SIZE); + const remainder = Buffer.from(this.handshakeBuffer.subarray(SV2_NOISE_ACT1_SIZE)); + this.handshakeBuffer = Buffer.alloc(0); + + const act2 = await this.noiseSession.processAct1(Buffer.from(act1)); + await this.writeRaw(act2); + + this.frameReader.setDecryptFn(ciphertext => this.noiseSession.decrypt(ciphertext)); + this.frameWriter.setEncryptFn(plaintext => this.noiseSession.encrypt(plaintext)); + this.handshakeComplete = true; + this.processingHandshake = false; + + if (remainder.length > 0) { + await this.handleEncryptedData(remainder); + } + } + + private async handleEncryptedData(data: Buffer): Promise { + const frames = this.frameReader.feed(data); + for (const frame of frames) { + await this.handleFrame(frame.header.msgType, frame.header.extensionType, frame.payload); + } + } + + private async handleFrame(msgType: number, extensionType: number, payload: Buffer): Promise { + if ((extensionType & ~SV2_CHANNEL_MSG_FLAG) === 0x0001 && msgType === 0x00) { + const request = deserializeRequestExtensions(new BufferReader(payload)); + await this.sendFrame( + 0x01, + serializeRequestExtensionsSuccess({ + requestId: request.requestId, + supportedExtensions: [], + }), + 0x0001, + ); + return; + } + + switch (msgType) { + case Sv2MsgType.SETUP_CONNECTION: + await this.handleSetupConnection(payload); + break; + case Sv2MsgType.OPEN_STANDARD_MINING_CHANNEL: + await this.handleOpenStandardMiningChannel(payload); + break; + case Sv2MsgType.OPEN_EXTENDED_MINING_CHANNEL: + await this.handleOpenExtendedMiningChannel(payload); + break; + case Sv2MsgType.SUBMIT_SHARES_STANDARD: + await this.handleSubmitSharesStandard(payload); + break; + case Sv2MsgType.SUBMIT_SHARES_EXTENDED: + await this.handleSubmitSharesExtended(payload); + break; + case Sv2MsgType.UPDATE_CHANNEL: + await this.handleUpdateChannel(payload); + break; + case Sv2MsgType.CLOSE_CHANNEL: + this.handleCloseChannel(payload); + break; + default: + console.warn(`[SV2 ${this.sessionId}] Ignoring unsupported message type 0x${msgType.toString(16)}`); + break; + } + } + + private async handleSetupConnection(payload: Buffer): Promise { + const message = deserializeSetupConnection(new BufferReader(payload)); + this.userAgent = `${message.vendor || 'unknown'}/sv2`; + + if (message.protocol !== Sv2Protocol.MINING) { + await this.sendFrame( + Sv2MsgType.SETUP_CONNECTION_ERROR, + serializeSetupConnectionError({ + flags: 0, + errorCode: 'unsupported-protocol', + }), + ); + this.closeSocket(); + return; + } + + if (message.minVersion > 2 || message.maxVersion < 2) { + await this.sendFrame( + Sv2MsgType.SETUP_CONNECTION_ERROR, + serializeSetupConnectionError({ + flags: 0, + errorCode: 'protocol-version-mismatch', + }), + ); + this.closeSocket(); + return; + } + + const supportedFlags = Sv2MiningSetupFlags.REQUIRES_STANDARD_JOBS + | Sv2MiningSetupFlags.REQUIRES_VERSION_ROLLING; + const unsupportedFlags = message.flags & ~supportedFlags; + if (unsupportedFlags !== 0) { + await this.sendFrame( + Sv2MsgType.SETUP_CONNECTION_ERROR, + serializeSetupConnectionError({ + flags: unsupportedFlags, + errorCode: 'unsupported-feature-flags', + }), + ); + this.closeSocket(); + return; + } + + const versionRolling = (message.flags & Sv2MiningSetupFlags.REQUIRES_VERSION_ROLLING) !== 0; + const successFlags = versionRolling ? 0 : Sv2MiningSetupSuccessFlags.REQUIRES_FIXED_VERSION; + await this.sendFrame( + Sv2MsgType.SETUP_CONNECTION_SUCCESS, + serializeSetupConnectionSuccess({ + usedVersion: 2, + flags: successFlags, + }), + ); + } + + private async handleOpenStandardMiningChannel(payload: Buffer): Promise { + const message = deserializeOpenStandardMiningChannel(new BufferReader(payload)); + const { address, workerName } = this.parseUserIdentity(message.user_identity); + + if (!this.isValidAddress(address)) { + await this.sendOpenChannelError(message.requestId, 'unknown-user'); + this.closeSocket(); + return; + } + + if (this.address != null && this.address !== address) { + await this.sendOpenChannelError(message.requestId, 'unknown-user'); + return; + } + + if (message.maxTarget.length !== 32 || message.maxTarget.every(byte => byte === 0)) { + await this.sendOpenChannelError(message.requestId, 'max-target-out-of-range'); + return; + } + + const isFirstChannel = this.channels.size === 0; + if (isFirstChannel) { + this.address = address; + this.workerName = workerName; + } + + const channelId = this.stratumV2Service.getNextChannelId(); + const extranoncePrefix = this.stratumV2Service.generateExtranoncePrefix(); + let channelDifficulty = this.sessionDifficulty; + if (Number.isFinite(message.nominalHashRate) && message.nominalHashRate > 0) { + const calculatedDifficulty = DifficultyUtils.hashRateToDifficulty( + message.nominalHashRate, + this.targetSharesPerMinute, + ); + if (Number.isFinite(calculatedDifficulty) && calculatedDifficulty > 0) { + channelDifficulty = calculatedDifficulty; + } + } + channelDifficulty = DifficultyUtils.clampDifficultyToMaxTarget(channelDifficulty, message.maxTarget); + this.sessionDifficulty = channelDifficulty; + + const channel: ChannelState = { + channelId, + channelType: 'standard', + extranoncePrefix, + extranonceSize: Buffer.byteLength(FIXED_STANDARD_EXTRANONCE2, 'hex'), + sessionDifficulty: channelDifficulty, + declaredMaxTarget: Buffer.from(message.maxTarget), + jobIdToDifficulty: new Map(), + jobIdToMerkleRoot: new Map(), + extendedJobs: new Map(), + latestExtendedPrevHash: Buffer.alloc(32), + latestExtendedNBits: 0, + latestExtendedMinNtime: 0, + miningSubmissionHashes: new Set(), + acceptedShareCount: 0, + }; + this.channels.set(channelId, channel); + if (this.primaryChannelId == null) { + this.primaryChannelId = channelId; + } + + await this.ensureClientEntity(); + await this.sendFrame( + Sv2MsgType.OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + serializeOpenStandardMiningChannelSuccess({ + requestId: message.requestId, + channelId, + target: DifficultyUtils.difficultyToTarget(channel.sessionDifficulty), + extranonce_prefix: channel.extranoncePrefix, + groupChannelId: 0, + }), + ); + + const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$); + await this.sendNewMiningJob(channel, jobTemplate, true); + + if (isFirstChannel) { + this.subscribeToJobs(); + this.startDifficultyTimer(); + } + } + + private async handleOpenExtendedMiningChannel(payload: Buffer): Promise { + const message = deserializeOpenExtendedMiningChannel(new BufferReader(payload)); + const { address, workerName } = this.parseUserIdentity(message.userIdentity); + + if (!this.isValidAddress(address)) { + await this.sendOpenChannelError(message.requestId, 'unknown-user'); + this.closeSocket(); + return; + } + + if (this.address != null && this.address !== address) { + await this.sendOpenChannelError(message.requestId, 'unknown-user'); + return; + } + + if (message.maxTarget.length !== 32 || message.maxTarget.every(byte => byte === 0)) { + await this.sendOpenChannelError(message.requestId, 'max-target-out-of-range'); + return; + } + + const isFirstChannel = this.channels.size === 0; + if (isFirstChannel) { + this.address = address; + this.workerName = workerName; + } + + const channelId = this.stratumV2Service.getNextChannelId(); + const extranoncePrefix = this.stratumV2Service.allocateExtendedExtranoncePrefix(channelId); + const maxMinerExtranonceSize = Math.max(0, TOTAL_EXTRANONCE_SIZE_BYTES - extranoncePrefix.length); + const defaultMinerExtranonceSize = Math.min( + this.stratumV2Service.getExtendedMinerExtranonceSize(), + maxMinerExtranonceSize, + ); + const extranonceSize = message.minExtranonceSize > 0 + ? Math.min(message.minExtranonceSize, maxMinerExtranonceSize) + : defaultMinerExtranonceSize; + + let channelDifficulty = this.sessionDifficulty; + if (Number.isFinite(message.nominalHashRate) && message.nominalHashRate > 0) { + const calculatedDifficulty = DifficultyUtils.hashRateToDifficulty( + message.nominalHashRate, + this.targetSharesPerMinute, + ); + if (Number.isFinite(calculatedDifficulty) && calculatedDifficulty > 0) { + channelDifficulty = calculatedDifficulty; + } + } + channelDifficulty = DifficultyUtils.clampDifficultyToMaxTarget(channelDifficulty, message.maxTarget); + this.sessionDifficulty = channelDifficulty; + + const channel: ChannelState = { + channelId, + channelType: 'extended', + extranoncePrefix, + extranonceSize, + sessionDifficulty: channelDifficulty, + declaredMaxTarget: Buffer.from(message.maxTarget), + jobIdToDifficulty: new Map(), + jobIdToMerkleRoot: new Map(), + extendedJobs: new Map(), + latestExtendedPrevHash: Buffer.alloc(32), + latestExtendedNBits: 0, + latestExtendedMinNtime: 0, + miningSubmissionHashes: new Set(), + acceptedShareCount: 0, + }; + this.channels.set(channelId, channel); + if (this.primaryChannelId == null) { + this.primaryChannelId = channelId; + } + + await this.ensureClientEntity(); + await this.sendFrame( + Sv2MsgType.OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + serializeOpenExtendedMiningChannelSuccess({ + requestId: message.requestId, + channelId, + target: DifficultyUtils.difficultyToTarget(channel.sessionDifficulty), + extranonceSize, + extranoncePrefix, + groupChannelId: 0, + }), + ); + + const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$); + await this.sendNewExtendedMiningJob(channel, jobTemplate, true); + + if (isFirstChannel) { + this.subscribeToJobs(); + this.startDifficultyTimer(); + } + } + + private async handleSubmitSharesStandard(payload: Buffer): Promise { + const submission = deserializeSubmitSharesStandard(new BufferReader(payload)); + const channel = this.channels.get(submission.channelId); + if (channel == null) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-channel-id'); + return; + } + + const submissionKey = [ + submission.jobId, + submission.nonce, + submission.ntime, + submission.version, + ].join(':'); + if (channel.miningSubmissionHashes.has(submissionKey)) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share'); + return; + } + channel.miningSubmissionHashes.add(submissionKey); + if (channel.miningSubmissionHashes.size > 10000) { + channel.miningSubmissionHashes.clear(); + } + + const jobIdHex = submission.jobId.toString(16); + const job = this.stratumV1JobsService.getJobById(jobIdHex); + const sentMerkleRoot = channel.jobIdToMerkleRoot.get(submission.jobId); + if (job == null || sentMerkleRoot == null) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-job-id'); + return; + } + + const jobTemplate = this.stratumV1JobsService.getJobTemplateById(job.jobTemplateId); + if (jobTemplate == null) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-job-id'); + return; + } + + const header = this.buildHeader(jobTemplate, sentMerkleRoot, submission.version, submission.ntime, submission.nonce); + const { submissionDifficulty, hashBuffer } = DifficultyUtils.calculateDifficulty(header); + const jobDifficulty = channel.jobIdToDifficulty.get(submission.jobId) ?? channel.sessionDifficulty; + const target = DifficultyUtils.difficultyToTarget(jobDifficulty); + + if (!DifficultyUtils.meetsTarget(hashBuffer, target)) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'difficulty-too-low'); + return; + } + + await this.sendFrame( + Sv2MsgType.SUBMIT_SHARES_SUCCESS, + serializeSubmitSharesSuccess({ + channelId: submission.channelId, + lastSequenceNumber: submission.sequenceNumber, + newSubmitsAcceptedCount: 1, + newSharesSum: BigInt(Math.round(jobDifficulty)), + }), + SV2_CHANNEL_MSG_FLAG, + ); + + channel.acceptedShareCount++; + await this.handleAcceptedShare(submission, channel, job, jobTemplate, submissionDifficulty, jobDifficulty); + } + + private async handleSubmitSharesExtended(payload: Buffer): Promise { + const submission = deserializeSubmitSharesExtended(new BufferReader(payload)); + const channel = this.channels.get(submission.channelId); + if (channel == null || channel.channelType !== 'extended') { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-channel-id'); + return; + } + + const submissionKey = [ + submission.jobId, + submission.nonce, + submission.ntime, + submission.version, + submission.extranonce.toString('hex'), + ].join(':'); + if (channel.miningSubmissionHashes.has(submissionKey)) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share'); + return; + } + channel.miningSubmissionHashes.add(submissionKey); + if (channel.miningSubmissionHashes.size > 10000) { + channel.miningSubmissionHashes.clear(); + } + + const extendedJob = channel.extendedJobs.get(submission.jobId); + const jobDifficulty = channel.jobIdToDifficulty.get(submission.jobId) ?? channel.sessionDifficulty; + if (extendedJob == null) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-job-id'); + return; + } + if (extendedJob.retiredAt != null) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share'); + return; + } + if (submission.extranonce.length !== channel.extranonceSize) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-extranonce-size'); + return; + } + + const coinbaseTxBytes = Buffer.concat([ + extendedJob.coinbasePrefix, + channel.extranoncePrefix, + submission.extranonce, + extendedJob.coinbaseSuffix, + ]); + let merkleRoot = bitcoinjs.crypto.hash256(coinbaseTxBytes); + const merklePair = Buffer.alloc(64); + for (const sibling of extendedJob.merklePath) { + merklePair.set(merkleRoot, 0); + merklePair.set(sibling, 32); + merkleRoot = bitcoinjs.crypto.hash256(merklePair); + } + + const header = this.buildHeader( + { + ...extendedJob.jobTemplate, + block: Object.assign(new bitcoinjs.Block(), extendedJob.jobTemplate.block, { + prevHash: extendedJob.prevHash, + bits: extendedJob.nBits, + }), + }, + merkleRoot, + submission.version, + submission.ntime, + submission.nonce, + ); + const { submissionDifficulty, hashBuffer } = DifficultyUtils.calculateDifficulty(header); + const target = DifficultyUtils.difficultyToTarget(jobDifficulty); + + if (!DifficultyUtils.meetsTarget(hashBuffer, target)) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'difficulty-too-low'); + return; + } + + await this.sendFrame( + Sv2MsgType.SUBMIT_SHARES_SUCCESS, + serializeSubmitSharesSuccess({ + channelId: submission.channelId, + lastSequenceNumber: submission.sequenceNumber, + newSubmitsAcceptedCount: 1, + newSharesSum: BigInt(Math.round(jobDifficulty)), + }), + SV2_CHANNEL_MSG_FLAG, + ); + + channel.acceptedShareCount++; + let updatedJobBlock: bitcoinjs.Block = null; + if (submissionDifficulty >= extendedJob.jobTemplate.blockData.networkDifficulty) { + updatedJobBlock = this.reconstructExtendedBlock(extendedJob, submission, merkleRoot, channel.extranoncePrefix); + } + await this.recordAcceptedShare(submissionDifficulty, jobDifficulty, extendedJob.jobTemplate, updatedJobBlock); + } + + private async handleAcceptedShare( + submission: { nonce: number; ntime: number; version: number }, + channel: ChannelState, + job: MiningJob, + jobTemplate: IJobTemplate, + submissionDifficulty: number, + jobDifficulty: number, + ): Promise { + let updatedJobBlock: bitcoinjs.Block = null; + if (submissionDifficulty >= jobTemplate.blockData.networkDifficulty) { + const versionMask = submission.version ^ jobTemplate.block.version; + updatedJobBlock = job.copyAndUpdateBlock( + jobTemplate, + versionMask, + submission.nonce, + channel.extranoncePrefix.toString('hex'), + FIXED_STANDARD_EXTRANONCE2, + submission.ntime, + ); + } + + await this.recordAcceptedShare(submissionDifficulty, jobDifficulty, jobTemplate, updatedJobBlock); + } + + private async recordAcceptedShare( + submissionDifficulty: number, + jobDifficulty: number, + jobTemplate: IJobTemplate, + updatedJobBlock: bitcoinjs.Block | null, + ): Promise { + await this.ensureClientEntity(); + + if (updatedJobBlock != null) { + console.log(`[SV2 ${this.sessionId}] BLOCK FOUND at height ${jobTemplate.blockData.height}`); + const blockHex = updatedJobBlock.toHex(false); + const result = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex); + await this.blocksService.save({ + height: jobTemplate.blockData.height, + minerAddress: this.address, + worker: this.workerName, + sessionId: this.sessionId, + blockData: blockHex, + }); + await this.notificationService.notifySubscribersBlockFound( + this.address, + jobTemplate.blockData.height, + updatedJobBlock, + result, + ); + if (result == null) { + await this.addressSettingsService.resetBestDifficultyAndShares(); + } + } + + await this.statistics.addShares(this.clientEntity, jobDifficulty); + const now = new Date(); + this.clientService.heartbeatBulkAsync(this.clientEntity.id, this.statistics.hashRate, now); + this.clientEntity.updatedAt = now; + + if (submissionDifficulty > this.clientEntity.bestDifficulty) { + await this.clientService.updateBestDifficultyIfHigher(this.clientEntity.id, submissionDifficulty); + this.clientEntity.bestDifficulty = submissionDifficulty; + await this.addressSettingsService.updateBestDifficultyIfHigher( + this.address, + submissionDifficulty, + this.userAgent, + ); + } + } + + private reconstructExtendedBlock( + extendedJob: ExtendedJobData, + submission: Sv2SubmitSharesExtended, + merkleRoot: Buffer, + extranoncePrefix: Buffer, + ): bitcoinjs.Block { + const jobTemplate = extendedJob.jobTemplate; + const testBlock = Object.assign(new bitcoinjs.Block(), jobTemplate.block); + testBlock.transactions = jobTemplate.block.transactions.map(tx => Object.assign(new bitcoinjs.Transaction(), tx)); + + const coinbaseTx = extendedJob.miningJob.cloneCoinbaseTransaction(); + const script = coinbaseTx.ins[0].script; + coinbaseTx.ins[0].script = Buffer.concat([ + script.subarray(0, script.length - TOTAL_EXTRANONCE_SIZE_BYTES), + extranoncePrefix, + submission.extranonce, + ]); + testBlock.transactions[0] = coinbaseTx; + testBlock.version = submission.version; + testBlock.nonce = submission.nonce; + testBlock.timestamp = submission.ntime; + testBlock.merkleRoot = merkleRoot; + return testBlock; + } + + private async handleUpdateChannel(payload: Buffer): Promise { + const message = deserializeUpdateChannel(new BufferReader(payload)); + const channel = this.channels.get(message.channelId); + if (channel == null) { + return; + } + + channel.declaredMaxTarget = Buffer.from(message.maximumTarget); + if (Number.isFinite(message.nominalHashRate) && message.nominalHashRate > 0) { + const nextDifficulty = DifficultyUtils.hashRateToDifficulty( + message.nominalHashRate, + this.targetSharesPerMinute, + ); + if (Number.isFinite(nextDifficulty) && nextDifficulty > 0) { + channel.sessionDifficulty = DifficultyUtils.clampDifficultyToMaxTarget( + nextDifficulty, + channel.declaredMaxTarget, + ); + await this.sendSetTarget(channel); + } + } + } + + private handleCloseChannel(payload: Buffer): void { + const message = deserializeCloseChannel(new BufferReader(payload)); + const channel = this.channels.get(message.channelId); + if (channel?.channelType === 'extended') { + this.stratumV2Service.releaseExtendedExtranoncePrefix(message.channelId); + } + this.channels.delete(message.channelId); + if (this.primaryChannelId === message.channelId) { + this.primaryChannelId = this.channels.keys().next().value ?? null; + } + if (this.channels.size === 0) { + this.closeSocket(); + } + } + + private subscribeToJobs(): void { + if (this.stratumSubscription != null) { + return; + } + + this.stratumSubscription = this.stratumV1JobsService.newMiningJob$.subscribe({ + next: async (jobTemplate) => { + try { + if (jobTemplate.blockData.clearJobs) { + this.channels.forEach(channel => { + if (channel.channelType === 'extended') { + const now = Date.now(); + channel.extendedJobs.forEach(job => { + if (job.retiredAt == null) { + job.retiredAt = now; + } + }); + this.cleanupRetiredExtendedJobs(channel); + } else { + channel.jobIdToDifficulty.clear(); + channel.jobIdToMerkleRoot.clear(); + } + channel.miningSubmissionHashes.clear(); + }); + } + for (const channel of this.channels.values()) { + if (channel.channelType === 'extended') { + await this.sendNewExtendedMiningJob(channel, jobTemplate, jobTemplate.blockData.clearJobs); + } else { + await this.sendNewMiningJob(channel, jobTemplate, jobTemplate.blockData.clearJobs); + } + } + } catch (error) { + console.error(`[SV2 ${this.sessionId}] Failed to send mining job: ${error.message}`); + this.closeSocket(); + } + }, + error: error => { + console.error(`[SV2 ${this.sessionId}] Job subscription failed: ${error.message}`); + this.closeSocket(); + }, + }); + } + + private startDifficultyTimer(): void { + if (this.difficultyTimer != null) { + return; + } + + this.difficultyTimer = setInterval(() => { + void this.checkDifficulty(); + }, this.difficultyCheckIntervalMs); + } + + private async checkDifficulty(): Promise { + const targetDiff = this.statistics.getSuggestedDifficulty(this.sessionDifficulty); + if (targetDiff == null || targetDiff === this.sessionDifficulty || !Number.isFinite(targetDiff)) { + return; + } + + this.sessionDifficulty = targetDiff; + for (const channel of this.channels.values()) { + channel.sessionDifficulty = DifficultyUtils.clampDifficultyToMaxTarget( + targetDiff, + channel.declaredMaxTarget, + ); + await this.sendSetTarget(channel); + } + + const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$); + const refreshedTemplate: IJobTemplate = { + ...jobTemplate, + block: Object.assign(new bitcoinjs.Block(), jobTemplate.block, { + timestamp: Math.max(jobTemplate.block.timestamp, Math.floor(Date.now() / 1000)), + }), + blockData: { ...jobTemplate.blockData, clearJobs: true }, + }; + for (const channel of this.channels.values()) { + if (channel.channelType === 'extended') { + await this.sendNewExtendedMiningJob(channel, refreshedTemplate, true); + } else { + await this.sendNewMiningJob(channel, refreshedTemplate, true); + } + } + } + + private async sendNewMiningJob(channel: ChannelState, jobTemplate: IJobTemplate, sendPrevHash: boolean): Promise { + if (this.address == null) { + return; + } + + const jobIdHex = this.stratumV1JobsService.getNextId(); + const jobId = parseInt(jobIdHex, 16); + const job = new MiningJob( + this.network, + jobIdHex, + [{ address: this.address, percent: 100 }], + jobTemplate, + ); + this.stratumV1JobsService.addJob(job); + + const jobSideBlock = job.copyAndUpdateBlock( + jobTemplate, + 0, + 0, + channel.extranoncePrefix.toString('hex'), + FIXED_STANDARD_EXTRANONCE2, + jobTemplate.block.timestamp, + ); + const merkleRoot = Buffer.from(jobSideBlock.merkleRoot); + channel.jobIdToDifficulty.set(jobId, channel.sessionDifficulty); + channel.jobIdToMerkleRoot.set(jobId, merkleRoot); + + await this.sendFrame( + Sv2MsgType.NEW_MINING_JOB, + serializeNewMiningJob({ + channelId: channel.channelId, + jobId, + minNtime: sendPrevHash ? null : jobTemplate.block.timestamp, + version: jobTemplate.block.version, + merkleRoot, + }), + SV2_CHANNEL_MSG_FLAG, + ); + + if (sendPrevHash) { + await this.sendFrame( + Sv2MsgType.SET_NEW_PREV_HASH, + serializeSetNewPrevHash({ + channelId: channel.channelId, + jobId, + prevHash: Buffer.from(jobTemplate.block.prevHash), + minNtime: jobTemplate.block.timestamp, + nBits: jobTemplate.block.bits, + }), + SV2_CHANNEL_MSG_FLAG, + ); + } + } + + private async sendNewExtendedMiningJob(channel: ChannelState, jobTemplate: IJobTemplate, sendPrevHash: boolean): Promise { + if (this.address == null) { + return; + } + + const jobIdHex = this.stratumV1JobsService.getNextId(); + const jobId = parseInt(jobIdHex, 16); + const job = new MiningJob( + this.network, + jobIdHex, + [{ address: this.address, percent: 100 }], + jobTemplate, + ); + this.stratumV1JobsService.addJob(job); + + const merklePath = jobTemplate.merkle_branch.map(branch => Buffer.from(branch, 'hex')); + const totalExtranonceSize = channel.extranoncePrefix.length + channel.extranonceSize; + const coinbasePrefix = patchCoinbasePrefixVarint(job.getCoinbasePrefixBuffer(), totalExtranonceSize); + const coinbaseSuffix = job.getCoinbaseSuffixBuffer(); + + await this.sendFrame( + Sv2MsgType.NEW_EXTENDED_MINING_JOB, + serializeNewExtendedMiningJob({ + channelId: channel.channelId, + jobId, + minNtime: sendPrevHash ? null : jobTemplate.block.timestamp, + version: jobTemplate.block.version, + versionRollingAllowed: true, + merklePath, + coinbasePrefix, + coinbaseSuffix, + }), + SV2_CHANNEL_MSG_FLAG, + ); + + const prevHash = sendPrevHash + ? Buffer.from(jobTemplate.block.prevHash) + : Buffer.from(channel.latestExtendedPrevHash); + const nBits = sendPrevHash ? jobTemplate.block.bits : channel.latestExtendedNBits; + const minNtime = sendPrevHash ? jobTemplate.block.timestamp : channel.latestExtendedMinNtime; + + channel.jobIdToDifficulty.set(jobId, channel.sessionDifficulty); + channel.extendedJobs.set(jobId, { + coinbasePrefix, + coinbaseSuffix, + merklePath, + prevHash, + nBits, + minNtime, + jobTemplate, + miningJob: job, + creation: Date.now(), + }); + + if (sendPrevHash) { + channel.latestExtendedPrevHash = prevHash; + channel.latestExtendedNBits = nBits; + channel.latestExtendedMinNtime = minNtime; + await this.sendFrame( + Sv2MsgType.SET_NEW_PREV_HASH, + serializeSetNewPrevHash({ + channelId: channel.channelId, + jobId, + prevHash, + minNtime, + nBits, + }), + SV2_CHANNEL_MSG_FLAG, + ); + } + } + + private cleanupRetiredExtendedJobs(channel: ChannelState): void { + const cutoff = Date.now() - RETIRED_EXTENDED_JOB_RETENTION_MS; + for (const [jobId, job] of channel.extendedJobs.entries()) { + if (job.retiredAt != null && job.retiredAt < cutoff) { + channel.extendedJobs.delete(jobId); + channel.jobIdToDifficulty.delete(jobId); + } + } + } + + private buildHeader( + jobTemplate: IJobTemplate, + merkleRoot: Buffer, + version: number, + timestamp: number, + nonce: number, + ): Buffer { + const header = Buffer.alloc(80); + header.writeInt32LE(version, 0); + jobTemplate.block.prevHash.copy(header, 4); + merkleRoot.copy(header, 36); + header.writeUInt32LE(timestamp >>> 0, 68); + header.writeUInt32LE(jobTemplate.block.bits >>> 0, 72); + header.writeUInt32LE(nonce >>> 0, 76); + return header; + } + + private async sendSetTarget(channel: ChannelState): Promise { + await this.sendFrame( + Sv2MsgType.SET_TARGET, + serializeSetTarget({ + channelId: channel.channelId, + maxTarget: DifficultyUtils.difficultyToTarget(channel.sessionDifficulty), + }), + SV2_CHANNEL_MSG_FLAG, + ); + } + + private async sendOpenChannelError(requestId: number, errorCode: string): Promise { + await this.sendFrame( + Sv2MsgType.OPEN_STANDARD_MINING_CHANNEL_ERROR, + serializeOpenMiningChannelError({ requestId, errorCode }), + ); + } + + private async sendShareError(channelId: number, sequenceNumber: number, errorCode: string): Promise { + await this.sendFrame( + Sv2MsgType.SUBMIT_SHARES_ERROR, + serializeSubmitSharesError({ + channelId, + sequenceNumber, + errorCode, + }), + SV2_CHANNEL_MSG_FLAG, + ); + } + + private async sendFrame(msgType: number, payload: Buffer, extensionType = 0): Promise { + const data = this.frameWriter.writeFrame({ + extensionType, + msgType, + msgLength: payload.length, + }, payload); + await this.writeRaw(data); + } + + private async writeRaw(data: Buffer): Promise { + if (this.socket.destroyed || this.socket.writableEnded) { + return; + } + await new Promise((resolve, reject) => { + this.socket.write(data, error => error ? reject(error) : resolve()); + }); + } + + private async ensureClientEntity(): Promise { + if (this.clientEntity != null) { + return; + } + + if (this.creatingEntity == null) { + this.creatingEntity = (async () => { + this.clientEntity = await this.clientService.insert({ + sessionId: this.sessionId, + address: this.address, + clientName: this.workerName, + userAgent: this.userAgent, + startTime: new Date(), + bestDifficulty: 0, + }); + })(); + } + + await this.creatingEntity; + } + + private parseUserIdentity(userIdentity: string): { address: string; workerName: string } { + const parts = userIdentity.split('.'); + const rawAddress = parts[0] ?? ''; + return { + address: this.normalizeAddress(rawAddress), + workerName: parts.length > 1 ? parts.slice(1).join('.') : 'default', + }; + } + + private normalizeAddress(address: string): string { + if ( + address.startsWith('bc1') + || address.startsWith('BC1') + || address.startsWith('tb1') + || address.startsWith('TB1') + || address.startsWith('bcrt1') + || address.startsWith('BCRT1') + ) { + return address.toLowerCase(); + } + + return address; + } + + private isValidAddress(address: string): boolean { + try { + getAddressInfo(address); + return true; + } catch { + return false; + } + } + + private getInitialDifficulty(): number { + const configured = parseFloat( + this.configService.get('SV2_START_DIFFICULTY') + ?? this.configService.get('STRATUM_START_DIFFICULTY') + ?? '', + ); + return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_START_DIFFICULTY; + } + + private getTargetSharesPerMinute(): number { + const configured = parseFloat(this.configService.get('SV2_TARGET_SHARES_PER_MINUTE') ?? ''); + return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_TARGET_SHARES_PER_MINUTE; + } + + private getDifficultyCheckIntervalMs(): number { + const configured = parseInt(this.configService.get('SV2_DIFFICULTY_CHECK_INTERVAL_MS') ?? '', 10); + return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_DIFFICULTY_CHECK_INTERVAL_MS; + } + + private getNetwork(): bitcoinjs.networks.Network { + const networkConfig = this.configService.get('NETWORK'); + if (networkConfig === 'mainnet') { + return bitcoinjs.networks.bitcoin; + } + if (networkConfig === 'testnet') { + return bitcoinjs.networks.testnet; + } + if (networkConfig === 'regtest') { + return bitcoinjs.networks.regtest; + } + throw new Error('Invalid network configuration'); + } + + private closeSocket(): void { + if (!this.socket.destroyed) { + this.socket.destroy(); + } + void this.destroy(); + } +} diff --git a/src/models/sv2/sv2-authority-key.spec.ts b/src/models/sv2/sv2-authority-key.spec.ts new file mode 100644 index 0000000..ecb3f76 --- /dev/null +++ b/src/models/sv2/sv2-authority-key.spec.ts @@ -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); + }); +}); diff --git a/src/models/sv2/sv2-authority-key.ts b/src/models/sv2/sv2-authority-key.ts new file mode 100644 index 0000000..37f6d0e --- /dev/null +++ b/src/models/sv2/sv2-authority-key.ts @@ -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])); +} diff --git a/src/models/sv2/sv2-binary-codec.spec.ts b/src/models/sv2/sv2-binary-codec.spec.ts new file mode 100644 index 0000000..e03163c --- /dev/null +++ b/src/models/sv2/sv2-binary-codec.spec.ts @@ -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); + }); + }); +}); diff --git a/src/models/sv2/sv2-binary-codec.ts b/src/models/sv2/sv2-binary-codec.ts new file mode 100644 index 0000000..ebb1703 --- /dev/null +++ b/src/models/sv2/sv2-binary-codec.ts @@ -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: 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(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(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: 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(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(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)); + } +} diff --git a/src/models/sv2/sv2-constants.ts b/src/models/sv2/sv2-constants.ts new file mode 100644 index 0000000..f1b54b9 --- /dev/null +++ b/src/models/sv2/sv2-constants.ts @@ -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, +} diff --git a/src/models/sv2/sv2-extended-messages.spec.ts b/src/models/sv2/sv2-extended-messages.spec.ts new file mode 100644 index 0000000..6b9f3f7 --- /dev/null +++ b/src/models/sv2/sv2-extended-messages.spec.ts @@ -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( + 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); + }); +}); diff --git a/src/models/sv2/sv2-extended-messages.ts b/src/models/sv2/sv2-extended-messages.ts new file mode 100644 index 0000000..eb5f0fd --- /dev/null +++ b/src/models/sv2/sv2-extended-messages.ts @@ -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 - null = future job (activated by SetNewPrevHash), Some(ts) = mine immediately + version: number; // U32 + versionRollingAllowed: boolean; // BOOL + merklePath: Buffer[]; // SEQ0_255 + 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()), + }; +} diff --git a/src/models/sv2/sv2-extranonce-manager.spec.ts b/src/models/sv2/sv2-extranonce-manager.spec.ts new file mode 100644 index 0000000..c920718 --- /dev/null +++ b/src/models/sv2/sv2-extranonce-manager.spec.ts @@ -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(); + + 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); + }); + +}); diff --git a/src/models/sv2/sv2-extranonce-manager.ts b/src/models/sv2/sv2-extranonce-manager.ts new file mode 100644 index 0000000..22e9179 --- /dev/null +++ b/src/models/sv2/sv2-extranonce-manager.ts @@ -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(); // channelId → prefix + private usedPrefixes = new Set(); + 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; + } +} diff --git a/src/models/sv2/sv2-frame.spec.ts b/src/models/sv2/sv2-frame.spec.ts new file mode 100644 index 0000000..a02c8fe --- /dev/null +++ b/src/models/sv2/sv2-frame.spec.ts @@ -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'); + }); + }); +}); diff --git a/src/models/sv2/sv2-frame.ts b/src/models/sv2/sv2-frame.ts new file mode 100644 index 0000000..263e330 --- /dev/null +++ b/src/models/sv2/sv2-frame.ts @@ -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); + } +} diff --git a/src/models/sv2/sv2-jdp-messages.spec.ts b/src/models/sv2/sv2-jdp-messages.spec.ts new file mode 100644 index 0000000..aa4f818 --- /dev/null +++ b/src/models/sv2/sv2-jdp-messages.spec.ts @@ -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( + 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); + }); +}); diff --git a/src/models/sv2/sv2-jdp-messages.ts b/src/models/sv2/sv2-jdp-messages.ts new file mode 100644 index 0000000..81cb65b --- /dev/null +++ b/src/models/sv2/sv2-jdp-messages.ts @@ -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 + 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 +} + +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 +} + +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 +} + +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(), + }; +} diff --git a/src/models/sv2/sv2-messages.spec.ts b/src/models/sv2/sv2-messages.spec.ts new file mode 100644 index 0000000..a845406 --- /dev/null +++ b/src/models/sv2/sv2-messages.spec.ts @@ -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( + 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: 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: 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); + }); +}); diff --git a/src/models/sv2/sv2-messages.ts b/src/models/sv2/sv2-messages.ts new file mode 100644 index 0000000..88bdb63 --- /dev/null +++ b/src/models/sv2/sv2-messages.ts @@ -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 - 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(), + }; +} diff --git a/src/models/sv2/sv2-noise.spec.ts b/src/models/sv2/sv2-noise.spec.ts new file mode 100644 index 0000000..2fb8a38 --- /dev/null +++ b/src/models/sv2/sv2-noise.spec.ts @@ -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); + }); + }); +}); diff --git a/src/models/sv2/sv2-noise.ts b/src/models/sv2/sv2-noise.ts new file mode 100644 index 0000000..bac91a3 --- /dev/null +++ b/src/models/sv2/sv2-noise.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/models/sv2/sv2-siphash.spec.ts b/src/models/sv2/sv2-siphash.spec.ts new file mode 100644 index 0000000..a1499c8 --- /dev/null +++ b/src/models/sv2/sv2-siphash.spec.ts @@ -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); + }); + }); +}); diff --git a/src/models/sv2/sv2-siphash.ts b/src/models/sv2/sv2-siphash.ts new file mode 100644 index 0000000..94759e8 --- /dev/null +++ b/src/models/sv2/sv2-siphash.ts @@ -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; +} diff --git a/src/models/sv2/sv2-tdp-messages.spec.ts b/src/models/sv2/sv2-tdp-messages.spec.ts new file mode 100644 index 0000000..71c2adc --- /dev/null +++ b/src/models/sv2/sv2-tdp-messages.spec.ts @@ -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( + 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 + }); +}); diff --git a/src/models/sv2/sv2-tdp-messages.ts b/src/models/sv2/sv2-tdp-messages.ts new file mode 100644 index 0000000..59a63b5 --- /dev/null +++ b/src/models/sv2/sv2-tdp-messages.ts @@ -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 +} + +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 +} + +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(), + }; +} diff --git a/src/services/stratum-v1.service.spec.ts b/src/services/stratum-v1.service.spec.ts index aca4cb4..fb9e879 100644 --- a/src/services/stratum-v1.service.spec.ts +++ b/src/services/stratum-v1.service.spec.ts @@ -11,6 +11,7 @@ describe('StratumV1Service', () => { let service: StratumV1Service; let clientService; + let stratumV2Service; let consoleLogSpy: jest.SpyInstance; let consoleWarnSpy: jest.SpyInstance; @@ -19,6 +20,10 @@ describe('StratumV1Service', () => { clientService = { deleteAll: jest.fn().mockResolvedValue(undefined) }; + stratumV2Service = { + ensureInitialized: jest.fn().mockResolvedValue(undefined), + createClient: jest.fn() + }; service = new StratumV1Service( {} as any, clientService, @@ -27,7 +32,8 @@ describe('StratumV1Service', () => { {} 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); @@ -145,6 +151,29 @@ describe('StratumV1Service', () => { 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 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'); + }); + function restoreEnv(key: string, value: string | undefined) { if (value == null) { delete process.env[key]; diff --git a/src/services/stratum-v1.service.ts b/src/services/stratum-v1.service.ts index 434f071..793ba37 100644 --- a/src/services/stratum-v1.service.ts +++ b/src/services/stratum-v1.service.ts @@ -4,6 +4,7 @@ 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'; @@ -11,6 +12,7 @@ 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'; @@ -54,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 ) { } @@ -107,21 +110,12 @@ export class StratumV1Service implements OnModuleInit { // 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++; @@ -155,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'); + } + }); }); @@ -169,6 +185,20 @@ export class StratumV1Service implements OnModuleInit { return server; } + private createV1Client(socket: Socket): StratumV1Client { + return new StratumV1Client( + socket, + this.stratumV1JobsService, + this.bitcoinRpcService, + this.clientService, + this.clientStatisticsService, + this.notificationService, + this.blocksService, + this.configService, + this.addressSettingsService + ); + } + private startSecureSocketServer(port: number) { const listener: StratumListenerState = { port, @@ -196,17 +226,7 @@ export class StratumV1Service implements OnModuleInit { // 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 - ); + const client = this.createV1Client(socket); const cleanup = async (reason: string) => { if (client.extraNonceAndSessionId != null) { @@ -389,6 +409,70 @@ export class StratumV1Service implements OnModuleInit { 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; + } + + 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; + } + + 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 getPositiveIntegerEnv(key: string, fallback: number) { const configured = parseInt(process.env[key], 10); if (Number.isFinite(configured) && configured > 0) { diff --git a/src/services/stratum-v2.service.ts b/src/services/stratum-v2.service.ts new file mode 100644 index 0000000..7b9bd4d --- /dev/null +++ b/src/services/stratum-v2.service.ts @@ -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 { + 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 { + 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 { + const configuredAuthorityKey = this.configService.get('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('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('STRATUM_V2_SOCKET_TIMEOUT_MS') ?? '', 10); + return Number.isFinite(configured) && configured > 0 ? configured : 1000 * 60 * 15; + } +} diff --git a/src/utils/coinbase-prefix.utils.ts b/src/utils/coinbase-prefix.utils.ts new file mode 100644 index 0000000..d670869 --- /dev/null +++ b/src/utils/coinbase-prefix.utils.ts @@ -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; +} diff --git a/src/utils/difficulty.utils.ts b/src/utils/difficulty.utils.ts new file mode 100644 index 0000000..3fc31cc --- /dev/null +++ b/src/utils/difficulty.utils.ts @@ -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); + } +}