Files
public-pool/src/models/StratumV2Client.spec.ts
T
2026-06-05 13:14:09 -04:00

207 lines
8.9 KiB
TypeScript

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 };
}
});