mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 09:05:06 -07:00
737 lines
28 KiB
TypeScript
737 lines
28 KiB
TypeScript
import { ConfigService } from '@nestjs/config';
|
|
import { DataSource } from 'typeorm';
|
|
|
|
import { AcceptedShareEntity } from '../src/ORM/accepted-share/accepted-share.entity';
|
|
import { UserAgentReportService } from '../src/ORM/_views/user-agent-report/user-agent-report.service';
|
|
import { UserAgentReportView } from '../src/ORM/_views/user-agent-report/user-agent-report.view';
|
|
import { ClientStatisticsService } from '../src/ORM/client-statistics/client-statistics.service';
|
|
import { ClientEntity } from '../src/ORM/client/client.entity';
|
|
import { PayoutSnapshotService } from '../src/ORM/payout-snapshot/payout-snapshot.service';
|
|
import { ShareAccountingService } from '../src/ORM/share-accounting/share-accounting.service';
|
|
import { ShareHighScoreService } from '../src/ORM/share-accounting/share-high-score.service';
|
|
import { RedisMessagingService } from '../src/services/redis-messaging.service';
|
|
|
|
describe('TimescaleDB and Redis integration', () => {
|
|
let dataSource: DataSource;
|
|
let redisMessagingService: RedisMessagingService;
|
|
|
|
beforeAll(async () => {
|
|
process.env.DB_HOST ??= '127.0.0.1';
|
|
process.env.DB_PORT ??= '15432';
|
|
process.env.DB_USERNAME ??= 'public_pool';
|
|
process.env.DB_PASSWORD ??= 'public_pool';
|
|
process.env.DB_DATABASE ??= 'public_pool_test';
|
|
process.env.REDIS_URL ??= 'redis://127.0.0.1:16379';
|
|
|
|
const imported = await import('../src/data-source');
|
|
dataSource = imported.AppDataSource;
|
|
if (!dataSource.isInitialized) {
|
|
await dataSource.initialize();
|
|
}
|
|
await dataSource.runMigrations();
|
|
|
|
redisMessagingService = new RedisMessagingService({
|
|
get: jest.fn((key: string) => key === 'REDIS_URL' ? process.env.REDIS_URL : null),
|
|
} as unknown as ConfigService);
|
|
await redisMessagingService.connect();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await redisMessagingService?.onModuleDestroy();
|
|
if (dataSource?.isInitialized) {
|
|
await dataSource.destroy();
|
|
}
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await dataSource.query(`DELETE FROM payout_history`);
|
|
await dataSource.query(`DELETE FROM payout_snapshot_entry`);
|
|
await dataSource.query(`DELETE FROM payout_snapshot`);
|
|
await dataSource.query(`DELETE FROM payout_balance`);
|
|
await dataSource.query(`DELETE FROM share_rollup_batch_summary`);
|
|
await dataSource.query(`DELETE FROM share_rollup_batch`);
|
|
await dataSource.query(`DELETE FROM accepted_share_high_score`);
|
|
await dataSource.query(`DELETE FROM accepted_share_entity`);
|
|
await dataSource.query(`DELETE FROM blocks_entity`);
|
|
await dataSource.query(`DELETE FROM client_entity`);
|
|
await dataSource.query(`REFRESH MATERIALIZED VIEW user_agent_report_view`);
|
|
await (redisMessagingService as any).publisher.del('json-cache:presence:user-agent-report');
|
|
});
|
|
|
|
it('should create Timescale extension, hypertable, continuous aggregates, and operational policies', async () => {
|
|
const extensions = await dataSource.query(`SELECT extname FROM pg_extension WHERE extname = 'timescaledb'`);
|
|
expect(extensions).toHaveLength(1);
|
|
|
|
const hypertables = await dataSource.query(`
|
|
SELECT hypertable_name, compression_enabled
|
|
FROM timescaledb_information.hypertables
|
|
WHERE hypertable_name IN ('accepted_share_entity', 'share_rollup_batch_summary')
|
|
ORDER BY hypertable_name
|
|
`);
|
|
expect(hypertables).toEqual([
|
|
expect.objectContaining({ hypertable_name: 'accepted_share_entity', compression_enabled: true }),
|
|
expect.objectContaining({ hypertable_name: 'share_rollup_batch_summary', compression_enabled: true }),
|
|
]);
|
|
|
|
const aggregates = await dataSource.query(`
|
|
SELECT view_name
|
|
FROM timescaledb_information.continuous_aggregates
|
|
WHERE view_name IN ('accepted_share_10m', 'accepted_share_1h', 'accepted_share_1d', 'accepted_share_block_10m')
|
|
ORDER BY view_name
|
|
`);
|
|
expect(aggregates.map(row => row.view_name)).toEqual([
|
|
'accepted_share_10m',
|
|
'accepted_share_1d',
|
|
'accepted_share_1h',
|
|
'accepted_share_block_10m',
|
|
]);
|
|
|
|
const legacyTables = await dataSource.query(`
|
|
SELECT table_name
|
|
FROM information_schema.tables
|
|
WHERE table_schema = 'public'
|
|
AND table_name IN ('client_statistics_entity', 'home_graph_entity')
|
|
`);
|
|
expect(legacyTables).toHaveLength(0);
|
|
|
|
const policies = await dataSource.query(`
|
|
SELECT proc_name, schedule_interval::text AS schedule_interval, hypertable_name
|
|
FROM timescaledb_information.jobs
|
|
WHERE hypertable_name = 'accepted_share_entity'
|
|
OR hypertable_name = 'share_rollup_batch_summary'
|
|
OR proc_name = 'prune_share_rollup_batches'
|
|
OR application_name LIKE 'Refresh Continuous Aggregate Policy%'
|
|
ORDER BY proc_name, schedule_interval
|
|
`);
|
|
expect(policies).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ proc_name: 'policy_compression', hypertable_name: 'accepted_share_entity' }),
|
|
expect.objectContaining({ proc_name: 'policy_compression', hypertable_name: 'share_rollup_batch_summary' }),
|
|
expect.objectContaining({ proc_name: 'policy_retention', hypertable_name: 'accepted_share_entity' }),
|
|
expect.objectContaining({ proc_name: 'prune_share_rollup_batches' }),
|
|
expect.objectContaining({ proc_name: 'policy_refresh_continuous_aggregate' }),
|
|
]));
|
|
|
|
const shareOrderObjects = await dataSource.query(`
|
|
SELECT to_regclass('public.accepted_share_index_seq') AS sequence_name,
|
|
to_regclass('public."IDX_accepted_share_mode_order"') AS index_name,
|
|
to_regclass('public."IDX_accepted_share_accounting_lookup"') AS dropped_accounting_lookup,
|
|
to_regclass('public."IDX_accepted_share_client_lookup"') AS dropped_client_lookup,
|
|
to_regclass('public."IDX_accepted_share_round_best"') AS dropped_round_best
|
|
`);
|
|
expect(shareOrderObjects[0]).toEqual({
|
|
sequence_name: 'accepted_share_index_seq',
|
|
index_name: '"IDX_accepted_share_mode_order"',
|
|
dropped_accounting_lookup: null,
|
|
dropped_client_lookup: null,
|
|
dropped_round_best: null,
|
|
});
|
|
|
|
const shareRollupObjects = await dataSource.query(`
|
|
SELECT
|
|
to_regclass('public.share_rollup_batch') AS batch_table,
|
|
to_regclass('public.share_rollup_batch_summary') AS summary_table,
|
|
to_regclass('public.accepted_share_high_score') AS high_score_table,
|
|
to_regclass('public."IDX_share_rollup_batch_finalized_end"') AS finalized_index,
|
|
to_regclass('public."IDX_share_rollup_summary_batch"') AS summary_batch_index,
|
|
to_regclass('public."UQ_accepted_share_high_score_scope"') AS high_score_scope_index,
|
|
to_regclass('public."IDX_share_rollup_summary_address_batch"') AS dropped_summary_address_index
|
|
`);
|
|
expect(shareRollupObjects[0]).toEqual({
|
|
batch_table: 'share_rollup_batch',
|
|
summary_table: 'share_rollup_batch_summary',
|
|
high_score_table: 'accepted_share_high_score',
|
|
finalized_index: '"IDX_share_rollup_batch_finalized_end"',
|
|
summary_batch_index: '"IDX_share_rollup_summary_batch"',
|
|
high_score_scope_index: '"UQ_accepted_share_high_score_scope"',
|
|
dropped_summary_address_index: null,
|
|
});
|
|
|
|
const payoutObjects = await dataSource.query(`
|
|
SELECT
|
|
to_regclass('public.payout_snapshot') AS snapshot_table,
|
|
to_regclass('public.payout_snapshot_entry') AS entry_table,
|
|
to_regclass('public.payout_balance') AS balance_table,
|
|
to_regclass('public.payout_history') AS history_table,
|
|
to_regclass('public."IDX_payout_snapshot_latest"') AS latest_index
|
|
`);
|
|
expect(payoutObjects[0]).toEqual({
|
|
snapshot_table: 'payout_snapshot',
|
|
entry_table: 'payout_snapshot_entry',
|
|
balance_table: 'payout_balance',
|
|
history_table: 'payout_history',
|
|
latest_index: '"IDX_payout_snapshot_latest"',
|
|
});
|
|
});
|
|
|
|
it('should persist accepted shares and refresh the 10 minute aggregate', async () => {
|
|
const client = await dataSource.getRepository(ClientEntity).save({
|
|
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
|
clientName: 'worker',
|
|
sessionId: '57a6f098',
|
|
userAgent: 'integration-test',
|
|
startTime: new Date(),
|
|
bestDifficulty: 0,
|
|
hashRate: 0,
|
|
});
|
|
const service = new ShareAccountingService(dataSource.getRepository(AcceptedShareEntity));
|
|
const acceptedAt = new Date('2026-06-07T12:10:00Z');
|
|
|
|
await service.recordAcceptedShare({
|
|
protocol: 'sv1',
|
|
payoutMode: 'pplns',
|
|
acceptedAt,
|
|
address: client.address,
|
|
clientName: client.clientName,
|
|
sessionId: client.sessionId,
|
|
clientId: client.id,
|
|
jobId: '1',
|
|
jobTemplateId: '1',
|
|
blockHeight: 900000,
|
|
creditedDifficulty: 64,
|
|
submissionDifficulty: 128,
|
|
networkDifficulty: 100000,
|
|
nonce: 'ed460d91',
|
|
ntime: '64b3f3ec',
|
|
version: '20000000',
|
|
extraNonce2: 'c708000000000000',
|
|
isBlockCandidate: false,
|
|
blockSubmissionResult: null,
|
|
});
|
|
await service.recordAcceptedShare({
|
|
protocol: 'sv1',
|
|
payoutMode: 'pplns',
|
|
acceptedAt: new Date(acceptedAt.getTime() + 1),
|
|
address: client.address,
|
|
clientName: client.clientName,
|
|
sessionId: client.sessionId,
|
|
clientId: client.id,
|
|
jobId: '2',
|
|
jobTemplateId: '1',
|
|
blockHeight: 900000,
|
|
creditedDifficulty: 32,
|
|
submissionDifficulty: 64,
|
|
networkDifficulty: 100000,
|
|
nonce: 'ed460d92',
|
|
ntime: '64b3f3ec',
|
|
version: '20000000',
|
|
extraNonce2: 'c708000000000001',
|
|
isBlockCandidate: false,
|
|
blockSubmissionResult: null,
|
|
});
|
|
|
|
const rows = await dataSource.query(`
|
|
SELECT COUNT(*)::int AS count, MIN("shareIndex")::bigint AS first, MAX("shareIndex")::bigint AS last
|
|
FROM accepted_share_entity
|
|
`);
|
|
expect(rows[0].count).toBeGreaterThanOrEqual(1);
|
|
expect(Number(rows[0].last)).toBeGreaterThan(Number(rows[0].first));
|
|
|
|
await dataSource.query(`CALL refresh_continuous_aggregate('accepted_share_10m', NULL, NULL)`);
|
|
await dataSource.query(`CALL refresh_continuous_aggregate('accepted_share_block_10m', NULL, NULL)`);
|
|
const aggregateRows = await dataSource.query(`
|
|
SELECT "shares"::float AS shares, "acceptedCount"::int AS "acceptedCount"
|
|
FROM accepted_share_10m
|
|
WHERE "address" = $1 AND "clientName" = $2
|
|
`, [client.address, client.clientName]);
|
|
|
|
expect(aggregateRows).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ shares: 96, acceptedCount: 2 }),
|
|
]));
|
|
|
|
const blockAggregateRows = await dataSource.query(`
|
|
SELECT
|
|
"shares"::float AS shares,
|
|
"acceptedCount"::int AS "acceptedCount",
|
|
"networkDifficulty"::float AS "networkDifficulty"
|
|
FROM accepted_share_block_10m
|
|
WHERE "blockHeight" = $1
|
|
`, [900000]);
|
|
|
|
expect(blockAggregateRows).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ shares: 96, acceptedCount: 2, networkDifficulty: 100000 }),
|
|
]));
|
|
});
|
|
|
|
it('should retain daily and all-time best share from completed rollup buckets', async () => {
|
|
const client = await dataSource.getRepository(ClientEntity).save({
|
|
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
|
clientName: 'high-score-worker',
|
|
sessionId: '67a6f099',
|
|
userAgent: 'integration-test',
|
|
startTime: new Date(),
|
|
bestDifficulty: 0,
|
|
hashRate: 0,
|
|
});
|
|
const accountingService = new ShareAccountingService(dataSource.getRepository(AcceptedShareEntity));
|
|
const highScoreService = new ShareHighScoreService(dataSource);
|
|
const bucketStartMs = Math.floor((Date.now() - 20 * 60 * 1000) / (10 * 60 * 1000)) * 10 * 60 * 1000;
|
|
const acceptedAt = new Date(bucketStartMs + 1000);
|
|
|
|
for (const [index, submissionDifficulty] of [4096, 1024].entries()) {
|
|
await accountingService.recordAcceptedShare({
|
|
protocol: index === 0 ? 'sv2' : 'sv1',
|
|
payoutMode: 'pplns',
|
|
acceptedAt: new Date(acceptedAt.getTime() + index),
|
|
address: client.address,
|
|
clientName: client.clientName,
|
|
sessionId: client.sessionId,
|
|
clientId: client.id,
|
|
jobId: `high-score-${index}`,
|
|
jobTemplateId: 'high-score-template',
|
|
blockHeight: 900100,
|
|
creditedDifficulty: 64,
|
|
submissionDifficulty,
|
|
networkDifficulty: 100000,
|
|
nonce: `high-score-nonce-${index}`,
|
|
ntime: '64b3f3ec',
|
|
version: '20000000',
|
|
extraNonce2: `c70800000000000${index}`,
|
|
isBlockCandidate: false,
|
|
blockSubmissionResult: null,
|
|
});
|
|
}
|
|
|
|
await dataSource.query(
|
|
`CALL refresh_continuous_aggregate('accepted_share_block_10m', $1::timestamptz, $2::timestamptz)`,
|
|
[new Date(bucketStartMs), new Date(bucketStartMs + 10 * 60 * 1000)],
|
|
);
|
|
|
|
await expect(highScoreService.refreshHighScores()).resolves.toEqual(expect.objectContaining({
|
|
processed: true,
|
|
}));
|
|
|
|
const rows = await dataSource.query(`
|
|
SELECT
|
|
"scope",
|
|
"payoutMode",
|
|
"submissionDifficulty"::float AS "submissionDifficulty",
|
|
"address",
|
|
"clientName",
|
|
"protocol"
|
|
FROM accepted_share_high_score
|
|
WHERE "payoutMode" IN ('all', 'pplns')
|
|
ORDER BY "payoutMode", "scope"
|
|
`);
|
|
|
|
expect(rows).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
scope: 'all_time',
|
|
payoutMode: 'all',
|
|
submissionDifficulty: 4096,
|
|
address: client.address,
|
|
clientName: client.clientName,
|
|
protocol: 'sv2',
|
|
}),
|
|
expect.objectContaining({
|
|
scope: 'all_time',
|
|
payoutMode: 'pplns',
|
|
submissionDifficulty: 4096,
|
|
address: client.address,
|
|
clientName: client.clientName,
|
|
protocol: 'sv2',
|
|
}),
|
|
expect.objectContaining({
|
|
scope: 'daily',
|
|
payoutMode: 'pplns',
|
|
submissionDifficulty: 4096,
|
|
address: client.address,
|
|
clientName: client.clientName,
|
|
protocol: 'sv2',
|
|
}),
|
|
]));
|
|
|
|
const summaryService = new ShareAccountingService(dataSource.getRepository(AcceptedShareEntity));
|
|
await expect(summaryService.refreshPoolSummary('pplns')).resolves.toEqual(expect.objectContaining({
|
|
bestSubmissionDifficulty: 4096,
|
|
}));
|
|
});
|
|
|
|
it('should finalize accepted shares into share rollup batches without mutating raw rows', async () => {
|
|
const client = await dataSource.getRepository(ClientEntity).save({
|
|
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
|
clientName: 'rollup-worker',
|
|
sessionId: '47a6f098',
|
|
userAgent: 'integration-test',
|
|
startTime: new Date(),
|
|
bestDifficulty: 0,
|
|
hashRate: 0,
|
|
});
|
|
const service = new ShareAccountingService(dataSource.getRepository(AcceptedShareEntity));
|
|
const acceptedAt = new Date('2026-06-07T12:20:00Z');
|
|
|
|
await service.recordAcceptedShare({
|
|
protocol: 'sv1',
|
|
payoutMode: 'pplns',
|
|
acceptedAt,
|
|
address: client.address,
|
|
clientName: client.clientName,
|
|
sessionId: client.sessionId,
|
|
clientId: client.id,
|
|
jobId: 'rollup-1',
|
|
jobTemplateId: 'rollup-template',
|
|
blockHeight: 900010,
|
|
creditedDifficulty: 64,
|
|
submissionDifficulty: 128,
|
|
networkDifficulty: 100000,
|
|
nonce: 'rollup-nonce-1',
|
|
ntime: '64b3f3ec',
|
|
version: '20000000',
|
|
extraNonce2: 'c708000000000001',
|
|
isBlockCandidate: false,
|
|
blockSubmissionResult: null,
|
|
});
|
|
await service.recordAcceptedShare({
|
|
protocol: 'sv2',
|
|
payoutMode: 'pplns',
|
|
acceptedAt: new Date(acceptedAt.getTime() + 1),
|
|
address: client.address,
|
|
clientName: client.clientName,
|
|
sessionId: client.sessionId,
|
|
clientId: client.id,
|
|
jobId: 'rollup-2',
|
|
jobTemplateId: 'rollup-template',
|
|
blockHeight: 900010,
|
|
creditedDifficulty: 32,
|
|
submissionDifficulty: 64,
|
|
networkDifficulty: 100000,
|
|
nonce: 'rollup-nonce-2',
|
|
ntime: '64b3f3ec',
|
|
version: '20000000',
|
|
extraNonce2: 'c708000000000002',
|
|
isBlockCandidate: false,
|
|
blockSubmissionResult: null,
|
|
});
|
|
|
|
await expect(service.processPendingShareRollupBatch()).resolves.toEqual(expect.objectContaining({
|
|
processed: true,
|
|
acceptedShareCount: 2,
|
|
creditedDifficulty: 96,
|
|
}));
|
|
await expect(service.processPendingShareRollupBatch()).resolves.toEqual({
|
|
processed: false,
|
|
reason: 'no-shares',
|
|
});
|
|
|
|
const batches = await dataSource.query(`
|
|
SELECT "acceptedShareCount"::int AS "acceptedShareCount", "creditedDifficulty"::float AS "creditedDifficulty"
|
|
FROM share_rollup_batch
|
|
`);
|
|
expect(batches).toEqual([{
|
|
acceptedShareCount: 2,
|
|
creditedDifficulty: 96,
|
|
}]);
|
|
|
|
const summaries = await dataSource.query(`
|
|
SELECT
|
|
"protocol",
|
|
"blockHeight",
|
|
"acceptedShareCount"::int AS "acceptedShareCount",
|
|
"creditedDifficulty"::float AS "creditedDifficulty",
|
|
"bestSubmissionDifficulty"::float AS "bestSubmissionDifficulty"
|
|
FROM share_rollup_batch_summary
|
|
WHERE "address" = $1 AND "clientName" = $2
|
|
ORDER BY "protocol"
|
|
`, [client.address, client.clientName]);
|
|
expect(summaries).toEqual([
|
|
{ protocol: 'sv1', blockHeight: 900010, acceptedShareCount: 1, creditedDifficulty: 64, bestSubmissionDifficulty: 128 },
|
|
{ protocol: 'sv2', blockHeight: 900010, acceptedShareCount: 1, creditedDifficulty: 32, bestSubmissionDifficulty: 64 },
|
|
]);
|
|
|
|
const rawRows = await dataSource.query(`SELECT COUNT(*)::int AS count FROM accepted_share_entity`);
|
|
expect(rawRows[0].count).toBe(2);
|
|
});
|
|
|
|
it('should create payout snapshots from finalized share rollup batches', async () => {
|
|
process.env.PAYOUT_SNAPSHOT_ENABLED = 'true';
|
|
process.env.PAYOUT_METHOD = 'pplns';
|
|
process.env.PAYOUT_WINDOW_FACTOR = '4';
|
|
process.env.PAYOUT_MAX_COINBASE_OUTPUTS = '10';
|
|
process.env.PAYOUT_MIN_OUTPUT_SATS = '0';
|
|
process.env.PAYOUT_FEE_PERCENT = '0';
|
|
process.env.PAYOUT_FEE_ADDRESS = '';
|
|
process.env.PAYOUT_COINBASE_WEIGHT_BUDGET = '50000';
|
|
const accountingService = new ShareAccountingService(dataSource.getRepository(AcceptedShareEntity));
|
|
const payoutSnapshotService = new PayoutSnapshotService(dataSource);
|
|
const acceptedAt = new Date('2026-06-07T12:30:00Z');
|
|
const clients = await Promise.all([
|
|
dataSource.getRepository(ClientEntity).save({
|
|
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
|
clientName: 'payout-worker-a',
|
|
sessionId: '57a6f098',
|
|
userAgent: 'integration-test',
|
|
startTime: new Date(),
|
|
bestDifficulty: 0,
|
|
hashRate: 0,
|
|
}),
|
|
dataSource.getRepository(ClientEntity).save({
|
|
address: 'tb1q99n3pu025yyu0jlywpmwzalyhm36tg5u37w20d',
|
|
clientName: 'payout-worker-b',
|
|
sessionId: '67a6f098',
|
|
userAgent: 'integration-test',
|
|
startTime: new Date(),
|
|
bestDifficulty: 0,
|
|
hashRate: 0,
|
|
}),
|
|
]);
|
|
|
|
for (const [index, client] of clients.entries()) {
|
|
await accountingService.recordAcceptedShare({
|
|
protocol: 'sv1',
|
|
payoutMode: 'pplns',
|
|
acceptedAt: new Date(acceptedAt.getTime() + index),
|
|
address: client.address,
|
|
clientName: client.clientName,
|
|
sessionId: client.sessionId,
|
|
clientId: client.id,
|
|
jobId: `payout-${index}`,
|
|
jobTemplateId: 'payout-template',
|
|
blockHeight: 900020,
|
|
creditedDifficulty: index === 0 ? 64 : 32,
|
|
submissionDifficulty: index === 0 ? 128 : 64,
|
|
networkDifficulty: 100000,
|
|
nonce: `payout-nonce-${index}`,
|
|
ntime: '64b3f3ec',
|
|
version: '20000000',
|
|
extraNonce2: `c70800000000000${index}`,
|
|
isBlockCandidate: false,
|
|
blockSubmissionResult: null,
|
|
});
|
|
}
|
|
|
|
await expect(accountingService.processPendingShareRollupBatch()).resolves.toEqual(expect.objectContaining({
|
|
processed: true,
|
|
acceptedShareCount: 2,
|
|
creditedDifficulty: 96,
|
|
}));
|
|
|
|
const snapshot = await payoutSnapshotService.createSnapshotForTemplate({
|
|
blockHeight: 900020,
|
|
coinbaseValueSats: 100000,
|
|
networkDifficulty: 100000,
|
|
});
|
|
|
|
expect(snapshot).toEqual(expect.objectContaining({
|
|
method: 'pplns',
|
|
blockHeight: 900020,
|
|
totalCreditedDifficulty: 96,
|
|
totalAcceptedShareCount: 2,
|
|
eligibleAddressCount: 2,
|
|
includedOutputCount: 2,
|
|
distributedSats: '100000',
|
|
}));
|
|
expect(snapshot.payoutOutputs).toEqual([
|
|
{ address: clients[0].address, amountSats: 66667, percent: 66.667 },
|
|
{ address: clients[1].address, amountSats: 33333, percent: 33.333 },
|
|
]);
|
|
|
|
await expect(payoutSnapshotService.finalizeSnapshotForBlock({
|
|
payoutSnapshotId: snapshot.id,
|
|
blockHeight: 900020,
|
|
blockSubmissionResult: 'SUCCESS!',
|
|
})).resolves.toEqual(expect.objectContaining({
|
|
finalized: true,
|
|
payoutSnapshotId: snapshot.id,
|
|
historyRows: 2,
|
|
balanceRows: 2,
|
|
}));
|
|
|
|
const historyRows = await dataSource.query(`
|
|
SELECT "address", "paidSats"::int AS "paidSats", "rowType"
|
|
FROM payout_history
|
|
WHERE "blockHeight" = $1
|
|
ORDER BY "paidSats" DESC
|
|
`, [900020]);
|
|
expect(historyRows).toEqual([
|
|
{ address: clients[0].address, paidSats: 66667, rowType: 'coinbase' },
|
|
{ address: clients[1].address, paidSats: 33333, rowType: 'coinbase' },
|
|
]);
|
|
|
|
await expect(payoutSnapshotService.finalizeSnapshotForBlock({
|
|
payoutSnapshotId: snapshot.id,
|
|
blockHeight: 900020,
|
|
blockSubmissionResult: 'SUCCESS!',
|
|
})).resolves.toEqual(expect.objectContaining({
|
|
finalized: false,
|
|
reason: 'already-finalized',
|
|
}));
|
|
});
|
|
|
|
it('should exclude soft-deleted clients from the user-agent report', async () => {
|
|
const userAgent = `integration-reconnect-${Date.now()}`;
|
|
const repository = dataSource.getRepository(ClientEntity);
|
|
|
|
const activeClient = await repository.save({
|
|
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
|
clientName: 'active-worker',
|
|
sessionId: 'a1b2c3d4',
|
|
userAgent,
|
|
startTime: new Date(),
|
|
bestDifficulty: 10,
|
|
hashRate: 100,
|
|
});
|
|
const staleClient = await repository.save({
|
|
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
|
clientName: 'stale-worker',
|
|
sessionId: 'd4c3b2a1',
|
|
userAgent,
|
|
startTime: new Date(),
|
|
bestDifficulty: 20,
|
|
hashRate: 200,
|
|
});
|
|
await repository.softDelete(staleClient.id);
|
|
|
|
await dataSource.query(`REFRESH MATERIALIZED VIEW user_agent_report_view`);
|
|
const rows = await dataSource.query(`
|
|
SELECT "count"::int AS "count", "bestDifficulty"::float AS "bestDifficulty", "totalHashRate"::float AS "totalHashRate"
|
|
FROM user_agent_report_view
|
|
WHERE "userAgent" = $1
|
|
`, [userAgent]);
|
|
|
|
expect(rows).toEqual([{
|
|
count: 1,
|
|
bestDifficulty: Number(activeClient.bestDifficulty),
|
|
totalHashRate: Number(activeClient.hashRate),
|
|
}]);
|
|
});
|
|
|
|
it('should serve live active user-agent reports without waiting for materialized refresh', async () => {
|
|
const userAgent = `integration-live-reconnect-${Date.now()}`;
|
|
const repository = dataSource.getRepository(ClientEntity);
|
|
const reportService = new UserAgentReportService(
|
|
dataSource.getRepository(UserAgentReportView),
|
|
repository,
|
|
redisMessagingService,
|
|
);
|
|
|
|
const activeClient = await repository.save({
|
|
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
|
clientName: 'active-live-worker',
|
|
sessionId: '91b2c3d4',
|
|
userAgent,
|
|
startTime: new Date(),
|
|
bestDifficulty: 11,
|
|
hashRate: 0,
|
|
});
|
|
const staleClient = await repository.save({
|
|
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
|
clientName: 'stale-live-worker',
|
|
sessionId: '92b2c3d4',
|
|
userAgent,
|
|
startTime: new Date(),
|
|
bestDifficulty: 22,
|
|
hashRate: 200,
|
|
});
|
|
await repository.softDelete(staleClient.id);
|
|
|
|
const rows = await reportService.getReport();
|
|
expect(rows).toEqual(expect.arrayContaining([{
|
|
userAgent,
|
|
count: '1',
|
|
bestDifficulty: Number(activeClient.bestDifficulty),
|
|
totalHashRate: '0',
|
|
}]));
|
|
});
|
|
|
|
it('should serve completed chart buckets and omit the current in-progress bucket', async () => {
|
|
const client = await dataSource.getRepository(ClientEntity).save({
|
|
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
|
clientName: 'worker',
|
|
sessionId: '8f3c5d2a',
|
|
userAgent: 'integration-test',
|
|
startTime: new Date(),
|
|
bestDifficulty: 0,
|
|
hashRate: 0,
|
|
});
|
|
const accountingService = new ShareAccountingService(dataSource.getRepository(AcceptedShareEntity));
|
|
const statisticsService = new ClientStatisticsService(dataSource);
|
|
const currentBucketStart = Math.floor(Date.now() / (10 * 60 * 1000)) * 10 * 60 * 1000;
|
|
const completedBucketStart = currentBucketStart - (10 * 60 * 1000);
|
|
const completedBucketEnd = currentBucketStart;
|
|
const acceptedAt = new Date(completedBucketStart + 1000);
|
|
|
|
for (const [index, difficulty] of [64, 32].entries()) {
|
|
await accountingService.recordAcceptedShare({
|
|
protocol: 'sv1',
|
|
acceptedAt: new Date(acceptedAt.getTime() + index),
|
|
address: client.address,
|
|
clientName: client.clientName,
|
|
sessionId: client.sessionId,
|
|
clientId: client.id,
|
|
jobId: `realtime-${index}`,
|
|
jobTemplateId: 'realtime-template',
|
|
blockHeight: 900001,
|
|
creditedDifficulty: difficulty,
|
|
submissionDifficulty: difficulty * 2,
|
|
networkDifficulty: 100000,
|
|
nonce: `nonce-${index}`,
|
|
ntime: '64b3f3ec',
|
|
version: '20000000',
|
|
extraNonce2: 'c708000000000000',
|
|
isBlockCandidate: false,
|
|
blockSubmissionResult: null,
|
|
});
|
|
}
|
|
await accountingService.recordAcceptedShare({
|
|
protocol: 'sv1',
|
|
acceptedAt: new Date(currentBucketStart + 1000),
|
|
address: client.address,
|
|
clientName: client.clientName,
|
|
sessionId: client.sessionId,
|
|
clientId: client.id,
|
|
jobId: 'current-bucket',
|
|
jobTemplateId: 'realtime-template',
|
|
blockHeight: 900001,
|
|
creditedDifficulty: 1024,
|
|
submissionDifficulty: 2048,
|
|
networkDifficulty: 100000,
|
|
nonce: 'nonce-current',
|
|
ntime: '64b3f3ec',
|
|
version: '20000000',
|
|
extraNonce2: 'c708000000000000',
|
|
isBlockCandidate: false,
|
|
blockSubmissionResult: null,
|
|
});
|
|
await dataSource.query(
|
|
`CALL refresh_continuous_aggregate('accepted_share_10m', $1::timestamptz, $2::timestamptz)`,
|
|
[new Date(completedBucketStart), new Date(completedBucketEnd)],
|
|
);
|
|
|
|
const expectedHashRate = (96 * 4294967296) / 600;
|
|
const expectedChartData = Math.round(expectedHashRate).toString();
|
|
const expectedLiveHashRate = ((96 + 1024) * 4294967296) / 600;
|
|
expect(await statisticsService.getHashRateForGroup(client.address, client.clientName))
|
|
.toBeCloseTo(expectedLiveHashRate);
|
|
|
|
const addressChart = await statisticsService.getChartDataForAddress(client.address);
|
|
const groupChart = await statisticsService.getChartDataForGroup(client.address, client.clientName);
|
|
const sessionChart = await statisticsService.getChartDataForSession(client.id);
|
|
const siteChart = await statisticsService.getChartDataForSite();
|
|
|
|
for (const chart of [addressChart, groupChart, sessionChart, siteChart]) {
|
|
expect(chart).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ data: expectedChartData }),
|
|
]));
|
|
expect(chart).not.toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ label: new Date(currentBucketStart).toISOString() }),
|
|
]));
|
|
}
|
|
});
|
|
|
|
it('should replay mining info and block templates through Redis', async () => {
|
|
await redisMessagingService.setLatestMiningInfo({ blocks: 900001 } as any);
|
|
await redisMessagingService.setBlockTemplate(900001, { height: 900001, transactions: [] } as any);
|
|
|
|
expect(await redisMessagingService.getLatestMiningInfo()).toEqual({ blocks: 900001 });
|
|
expect(await redisMessagingService.getBlockTemplate(900001)).toEqual({ height: 900001, transactions: [] });
|
|
|
|
const received: any[] = [];
|
|
await redisMessagingService.subscribeMiningInfoUpdates(async miningInfo => {
|
|
received.push(miningInfo);
|
|
});
|
|
await redisMessagingService.publishMiningInfoUpdate({ blocks: 900002 } as any);
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
expect(received).toEqual([{ blocks: 900002 }]);
|
|
});
|
|
});
|