mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 09:05:06 -07:00
344 lines
14 KiB
TypeScript
344 lines
14 KiB
TypeScript
import { ShareAccountingService } from './share-accounting.service';
|
|
|
|
describe('ShareAccountingService', () => {
|
|
const originalEnv = process.env;
|
|
|
|
beforeEach(() => {
|
|
jest.useRealTimers();
|
|
jest.restoreAllMocks();
|
|
process.env = { ...originalEnv };
|
|
});
|
|
|
|
afterAll(() => {
|
|
process.env = originalEnv;
|
|
});
|
|
|
|
it('should normalize and save accepted share records', async () => {
|
|
const repository = {
|
|
create: jest.fn(value => value),
|
|
insert: jest.fn().mockResolvedValue({}),
|
|
};
|
|
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '1';
|
|
const service = new ShareAccountingService(repository as any);
|
|
const acceptedAt = new Date('2026-06-07T12:00:00Z');
|
|
|
|
await expect(service.recordAcceptedShare({
|
|
protocol: 'sv1',
|
|
acceptedAt,
|
|
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
|
clientName: 'worker',
|
|
sessionId: '57a6f098',
|
|
clientId: '6df7beea-921a-46cb-b7dc-e8075f916232',
|
|
jobId: '1',
|
|
jobTemplateId: '2',
|
|
blockHeight: 900000,
|
|
creditedDifficulty: 1024,
|
|
submissionDifficulty: 2048,
|
|
networkDifficulty: 100000,
|
|
nonce: 123,
|
|
ntime: 456,
|
|
version: 536870912,
|
|
extraNonce2: 'c708000000000000',
|
|
isBlockCandidate: false,
|
|
blockSubmissionResult: null,
|
|
})).resolves.toEqual(expect.objectContaining({
|
|
protocol: 'sv1',
|
|
creditedDifficulty: 1024,
|
|
}));
|
|
|
|
expect(repository.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
acceptedAt,
|
|
nonce: '123',
|
|
ntime: '456',
|
|
version: '536870912',
|
|
blockSubmissionResult: null,
|
|
}));
|
|
expect(repository.insert).toHaveBeenCalledWith([expect.objectContaining({
|
|
protocol: 'sv1',
|
|
creditedDifficulty: 1024,
|
|
})]);
|
|
});
|
|
|
|
it('should batch accepted share inserts until the batch size is reached', async () => {
|
|
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '2';
|
|
process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '1000';
|
|
|
|
const repository = {
|
|
create: jest.fn(value => value),
|
|
insert: jest.fn().mockResolvedValue({}),
|
|
};
|
|
const service = new ShareAccountingService(repository as any);
|
|
|
|
const first = service.recordAcceptedShare(buildRecord('1'));
|
|
expect(repository.insert).not.toHaveBeenCalled();
|
|
|
|
const second = service.recordAcceptedShare(buildRecord('2'));
|
|
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
|
|
|
|
expect(repository.insert).toHaveBeenCalledTimes(1);
|
|
expect(repository.insert).toHaveBeenCalledWith([
|
|
expect.objectContaining({ jobId: '1' }),
|
|
expect.objectContaining({ jobId: '2' }),
|
|
]);
|
|
});
|
|
|
|
it('should flush partial batches on the flush interval', async () => {
|
|
jest.useFakeTimers();
|
|
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '10';
|
|
process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '25';
|
|
|
|
const repository = {
|
|
create: jest.fn(value => value),
|
|
insert: jest.fn().mockResolvedValue({}),
|
|
};
|
|
const service = new ShareAccountingService(repository as any);
|
|
|
|
const pending = service.recordAcceptedShare(buildRecord('interval'));
|
|
expect(repository.insert).not.toHaveBeenCalled();
|
|
|
|
jest.advanceTimersByTime(25);
|
|
await expect(pending).resolves.toEqual(expect.objectContaining({ jobId: 'interval' }));
|
|
expect(repository.insert).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should reject accepted shares when the accounting queue is full', async () => {
|
|
process.env.SHARE_ACCOUNTING_BATCH_SIZE = '10';
|
|
process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '1000';
|
|
process.env.SHARE_ACCOUNTING_MAX_QUEUE_SIZE = '1';
|
|
|
|
const repository = {
|
|
create: jest.fn(value => value),
|
|
insert: jest.fn().mockResolvedValue({}),
|
|
};
|
|
const service = new ShareAccountingService(repository as any);
|
|
|
|
const queued = service.recordAcceptedShare(buildRecord('queued'));
|
|
|
|
await expect(service.recordAcceptedShare(buildRecord('overflow')))
|
|
.rejects
|
|
.toThrow('Share accounting queue is full');
|
|
await service.flushPendingShares();
|
|
await expect(queued).resolves.toEqual(expect.objectContaining({ jobId: 'queued' }));
|
|
});
|
|
|
|
it('should return numeric accounting summaries from the share rollup', async () => {
|
|
const repository = {
|
|
query: jest.fn()
|
|
.mockResolvedValueOnce([{
|
|
totalAcceptedShares: '3',
|
|
totalCreditedDifficulty: '96',
|
|
acceptedSharesLast10Minutes: '2',
|
|
creditedDifficultyLast10Minutes: '64',
|
|
acceptedSharesLastHour: '3',
|
|
creditedDifficultyLastHour: '96',
|
|
acceptedSharesLastDay: '3',
|
|
creditedDifficultyLastDay: '96',
|
|
hashRateLast10Minutes: '458129844.9',
|
|
hashRateLastHour: '114532461.2',
|
|
latestShareAt: new Date('2026-06-07T12:10:00Z'),
|
|
}]),
|
|
};
|
|
const service = new ShareAccountingService(repository as any);
|
|
|
|
await expect(service.getAddressSummary('bc1qtest')).resolves.toEqual({
|
|
totalAcceptedShares: 3,
|
|
totalCreditedDifficulty: 96,
|
|
acceptedSharesLast10Minutes: 2,
|
|
creditedDifficultyLast10Minutes: 64,
|
|
acceptedSharesLastHour: 3,
|
|
creditedDifficultyLastHour: 96,
|
|
acceptedSharesLastDay: 3,
|
|
creditedDifficultyLastDay: 96,
|
|
hashRateLast10Minutes: 458129844.9,
|
|
hashRateLastHour: 114532461.2,
|
|
bestSubmissionDifficulty: 0,
|
|
bestSubmissionDifficultyAt: null,
|
|
blockCandidateCount: 0,
|
|
latestShareAt: '2026-06-07T12:10:00.000Z',
|
|
protocolBreakdown: [],
|
|
});
|
|
expect(repository.query).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.stringContaining('"accepted_share_10m"'),
|
|
['bc1qtest'],
|
|
);
|
|
expect(repository.query).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should serve API-only address accounting from the share rollup instead of empty summaries', async () => {
|
|
process.env.API_ONLY = 'true';
|
|
const repository = {
|
|
query: jest.fn()
|
|
.mockResolvedValueOnce([{
|
|
totalAcceptedShares: '12',
|
|
totalCreditedDifficulty: '384',
|
|
acceptedSharesLast10Minutes: '4',
|
|
creditedDifficultyLast10Minutes: '128',
|
|
acceptedSharesLastHour: '10',
|
|
creditedDifficultyLastHour: '320',
|
|
acceptedSharesLastDay: '12',
|
|
creditedDifficultyLastDay: '384',
|
|
hashRateLast10Minutes: '916259689.8',
|
|
hashRateLastHour: '381774870.2',
|
|
latestShareAt: new Date('2026-06-07T12:30:00Z'),
|
|
}]),
|
|
};
|
|
const service = new ShareAccountingService(repository as any);
|
|
|
|
await expect(service.getAddressSummary('bc1qapi')).resolves.toEqual(expect.objectContaining({
|
|
totalAcceptedShares: 12,
|
|
totalCreditedDifficulty: 384,
|
|
acceptedSharesLast10Minutes: 4,
|
|
creditedDifficultyLast10Minutes: 128,
|
|
hashRateLast10Minutes: 916259689.8,
|
|
latestShareAt: '2026-06-07T12:30:00.000Z',
|
|
}));
|
|
expect(repository.query).toHaveBeenCalledWith(
|
|
expect.stringContaining('FROM "accepted_share_10m"'),
|
|
['bc1qapi'],
|
|
);
|
|
expect(repository.query).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('FROM "accepted_share_entity"'),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it('should use Redis cache for share accounting summaries across API workers', async () => {
|
|
process.env.SHARE_ACCOUNTING_SUMMARY_CACHE_MS = '0';
|
|
const repository = {
|
|
query: jest.fn()
|
|
.mockResolvedValueOnce([{
|
|
totalAcceptedShares: '1',
|
|
totalCreditedDifficulty: '32',
|
|
acceptedSharesLast10Minutes: '1',
|
|
creditedDifficultyLast10Minutes: '32',
|
|
acceptedSharesLastHour: '1',
|
|
creditedDifficultyLastHour: '32',
|
|
acceptedSharesLastDay: '1',
|
|
creditedDifficultyLastDay: '32',
|
|
hashRateLast10Minutes: '1',
|
|
hashRateLastHour: '1',
|
|
latestShareAt: null,
|
|
}]),
|
|
};
|
|
const redis = {
|
|
getJsonCache: jest.fn()
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({
|
|
...new ShareAccountingService(repository as any).emptySummary(),
|
|
totalAcceptedShares: 1,
|
|
}),
|
|
setJsonCache: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
const service = new ShareAccountingService(repository as any, redis as any);
|
|
|
|
await service.getAddressSummary('bc1qcached');
|
|
await expect(service.getAddressSummary('bc1qcached')).resolves.toEqual(expect.objectContaining({
|
|
totalAcceptedShares: 1,
|
|
}));
|
|
|
|
expect(repository.query).toHaveBeenCalledTimes(1);
|
|
expect(redis.setJsonCache).toHaveBeenCalledWith(
|
|
expect.stringContaining('accounting:summary:'),
|
|
expect.objectContaining({ totalAcceptedShares: 1 }),
|
|
30000,
|
|
);
|
|
});
|
|
|
|
it('should overlay live pool data and best share from the current round', async () => {
|
|
const redis = {
|
|
getJsonCache: jest.fn().mockResolvedValue(null),
|
|
setJsonCache: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
const repository = {
|
|
query: jest.fn()
|
|
.mockResolvedValueOnce([{
|
|
totalAcceptedShares: '3',
|
|
totalCreditedDifficulty: '96',
|
|
acceptedSharesLast10Minutes: '0',
|
|
creditedDifficultyLast10Minutes: '0',
|
|
acceptedSharesLastHour: '3',
|
|
creditedDifficultyLastHour: '96',
|
|
acceptedSharesLastDay: '3',
|
|
creditedDifficultyLastDay: '96',
|
|
hashRateLast10Minutes: '0',
|
|
hashRateLastHour: '114532461.2',
|
|
latestShareAt: new Date('2026-06-07T12:10:00Z'),
|
|
}])
|
|
.mockResolvedValueOnce([{
|
|
acceptedSharesLast10Minutes: '7',
|
|
creditedDifficultyLast10Minutes: '224',
|
|
hashRateLast10Minutes: '1603451170.77',
|
|
latestShareAt: new Date('2026-06-07T12:20:00Z'),
|
|
}])
|
|
.mockResolvedValueOnce([{
|
|
bestSubmissionDifficulty: '4096',
|
|
bestSubmissionDifficultyAt: new Date('2026-06-07T12:19:00Z'),
|
|
}]),
|
|
};
|
|
const service = new ShareAccountingService(repository as any, redis as any);
|
|
|
|
await expect(service.refreshPoolSummary()).resolves.toEqual(expect.objectContaining({
|
|
acceptedSharesLast10Minutes: 7,
|
|
creditedDifficultyLast10Minutes: 224,
|
|
hashRateLast10Minutes: 1603451170.77,
|
|
bestSubmissionDifficulty: 4096,
|
|
bestSubmissionDifficultyAt: '2026-06-07T12:19:00.000Z',
|
|
latestShareAt: '2026-06-07T12:20:00.000Z',
|
|
}));
|
|
expect(repository.query).toHaveBeenNthCalledWith(
|
|
3,
|
|
expect.stringContaining('WHERE "blockHeight" > latest_found_block."height"'),
|
|
);
|
|
});
|
|
|
|
it('should cache accounting summaries briefly to protect hot dashboard endpoints', async () => {
|
|
process.env.SHARE_ACCOUNTING_SUMMARY_CACHE_MS = '1000';
|
|
const repository = {
|
|
query: jest.fn()
|
|
.mockResolvedValueOnce([{
|
|
totalAcceptedShares: '1',
|
|
totalCreditedDifficulty: '32',
|
|
acceptedSharesLast10Minutes: '1',
|
|
creditedDifficultyLast10Minutes: '32',
|
|
acceptedSharesLastHour: '1',
|
|
creditedDifficultyLastHour: '32',
|
|
acceptedSharesLastDay: '1',
|
|
creditedDifficultyLastDay: '32',
|
|
hashRateLast10Minutes: '1',
|
|
hashRateLastHour: '1',
|
|
latestShareAt: null,
|
|
}]),
|
|
};
|
|
const service = new ShareAccountingService(repository as any);
|
|
|
|
await service.getPoolSummary();
|
|
await service.getPoolSummary();
|
|
|
|
expect(repository.query).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
function buildRecord(jobId: string) {
|
|
return {
|
|
protocol: 'sv1' as const,
|
|
acceptedAt: new Date('2026-06-07T12:00:00Z'),
|
|
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
|
clientName: 'worker',
|
|
sessionId: '57a6f098',
|
|
clientId: '6df7beea-921a-46cb-b7dc-e8075f916232',
|
|
jobId,
|
|
jobTemplateId: 'template',
|
|
blockHeight: 900000,
|
|
creditedDifficulty: 1024,
|
|
submissionDifficulty: 2048,
|
|
networkDifficulty: 100000,
|
|
nonce: 123,
|
|
ntime: 456,
|
|
version: 536870912,
|
|
extraNonce2: 'c708000000000000',
|
|
isBlockCandidate: false,
|
|
blockSubmissionResult: null,
|
|
};
|
|
}
|