mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 17:15:03 -07:00
Add SV2 DATUM payout hardening
This commit is contained in:
@@ -24,4 +24,13 @@ export class BlocksEntity extends TrackedEntity {
|
||||
@Column()
|
||||
blockData: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
blockHash?: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
blockSubmissionResult?: string;
|
||||
|
||||
@Column({ type: 'bigint', nullable: true })
|
||||
payoutSnapshotId?: string;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { BlocksService } from './blocks.service';
|
||||
|
||||
describe('BlocksService', () => {
|
||||
it('computes block hash metadata when saving found blocks', async () => {
|
||||
const repository = createRepository();
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
const service = new BlocksService({} as any, repository as any);
|
||||
const blockHex = createBlockHex();
|
||||
const blockHash = bitcoinjs.Block.fromHex(blockHex).getId();
|
||||
|
||||
await service.save({
|
||||
height: 123,
|
||||
minerAddress: 'tb1qexample',
|
||||
worker: 'worker',
|
||||
sessionId: 'abcd1234',
|
||||
blockData: blockHex,
|
||||
blockSubmissionResult: 'SUCCESS!',
|
||||
});
|
||||
|
||||
expect(repository.save).toHaveBeenCalledWith(expect.objectContaining({
|
||||
blockHash,
|
||||
blockSubmissionResult: 'SUCCESS!',
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not downgrade an existing successful block on duplicate rejected submissions', async () => {
|
||||
const repository = createRepository();
|
||||
repository.findOne.mockResolvedValue({
|
||||
id: 9,
|
||||
blockSubmissionResult: 'SUCCESS!',
|
||||
payoutSnapshotId: '7',
|
||||
});
|
||||
const service = new BlocksService({} as any, repository as any);
|
||||
|
||||
await service.save({
|
||||
blockData: createBlockHex(),
|
||||
blockSubmissionResult: 'duplicate',
|
||||
payoutSnapshotId: '8',
|
||||
});
|
||||
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
expect(repository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns one public found-block row per block hash or legacy height', async () => {
|
||||
const repository = createRepository();
|
||||
repository.find.mockResolvedValue([
|
||||
{ height: 124, minerAddress: 'addr2', worker: 'b', sessionId: '2222', blockHash: 'hash-b' },
|
||||
{ height: 124, minerAddress: 'addr2', worker: 'b-dup', sessionId: '3333', blockHash: 'hash-b' },
|
||||
{ height: 123, minerAddress: 'addr1', worker: 'a', sessionId: '1111', blockHash: null },
|
||||
{ height: 123, minerAddress: 'addr1', worker: 'a-dup', sessionId: '4444', blockHash: null },
|
||||
]);
|
||||
const service = new BlocksService({} as any, repository as any);
|
||||
|
||||
await expect(service.getFoundBlocks()).resolves.toEqual([
|
||||
{ height: 124, minerAddress: 'addr2', worker: 'b', sessionId: '2222', blockHash: 'hash-b' },
|
||||
{ height: 123, minerAddress: 'addr1', worker: 'a', sessionId: '1111', blockHash: null },
|
||||
]);
|
||||
});
|
||||
|
||||
function createRepository() {
|
||||
return {
|
||||
save: jest.fn().mockResolvedValue(undefined),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function createBlockHex(): string {
|
||||
const tx = new bitcoinjs.Transaction();
|
||||
tx.version = 1;
|
||||
tx.addInput(Buffer.alloc(32), 0xffffffff, 0xffffffff, Buffer.from([0x01]));
|
||||
tx.addOutput(Buffer.from('6a', 'hex'), 0);
|
||||
|
||||
const block = new bitcoinjs.Block();
|
||||
block.version = 1;
|
||||
block.prevHash = Buffer.alloc(32);
|
||||
block.timestamp = 1;
|
||||
block.bits = 0x207fffff;
|
||||
block.nonce = 1;
|
||||
block.transactions = [tx];
|
||||
block.merkleRoot = bitcoinjs.Block.calculateMerkleRoot(block.transactions, false);
|
||||
return block.toHex(false);
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { DataSource, IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { BlocksEntity } from './blocks.entity';
|
||||
|
||||
@@ -19,31 +20,106 @@ export class BlocksService {
|
||||
|
||||
|
||||
public async save(block: Partial<BlocksEntity>) {
|
||||
await this.blocksRepository.save(block);
|
||||
const blockHash = block.blockHash ?? this.getBlockHash(block.blockData);
|
||||
const existing = blockHash == null
|
||||
? null
|
||||
: await this.blocksRepository.findOne({
|
||||
select: { id: true, blockSubmissionResult: true, payoutSnapshotId: true },
|
||||
where: { blockHash },
|
||||
});
|
||||
if (existing != null) {
|
||||
if (this.isSuccessfulBlockSubmission(block.blockSubmissionResult)) {
|
||||
await this.blocksRepository.update(existing.id, {
|
||||
blockSubmissionResult: 'SUCCESS!',
|
||||
payoutSnapshotId: block.payoutSnapshotId ?? existing.payoutSnapshotId,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.blocksRepository.save({
|
||||
...block,
|
||||
blockHash,
|
||||
});
|
||||
}
|
||||
|
||||
public async getFoundBlocks() {
|
||||
return await this.blocksRepository.find({
|
||||
const rows = await this.blocksRepository.find({
|
||||
select: {
|
||||
height: true,
|
||||
minerAddress: true,
|
||||
worker: true,
|
||||
sessionId: true
|
||||
}
|
||||
sessionId: true,
|
||||
blockHash: true,
|
||||
blockSubmissionResult: true,
|
||||
createdAt: true,
|
||||
},
|
||||
where: [
|
||||
{ blockSubmissionResult: 'SUCCESS!' },
|
||||
{ blockSubmissionResult: IsNull() },
|
||||
],
|
||||
order: {
|
||||
height: 'DESC',
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
});
|
||||
return this.uniqueFoundBlocks(rows);
|
||||
}
|
||||
|
||||
public async getFoundBlocksByAddress(address: string) {
|
||||
return await this.blocksRepository.find({
|
||||
const rows = await this.blocksRepository.find({
|
||||
select: {
|
||||
height: true,
|
||||
minerAddress: true,
|
||||
worker: true,
|
||||
sessionId: true
|
||||
sessionId: true,
|
||||
blockHash: true,
|
||||
blockSubmissionResult: true,
|
||||
createdAt: true,
|
||||
},
|
||||
where: {
|
||||
minerAddress: address
|
||||
where: [
|
||||
{ minerAddress: address, blockSubmissionResult: 'SUCCESS!' },
|
||||
{ minerAddress: address, blockSubmissionResult: IsNull() },
|
||||
],
|
||||
order: {
|
||||
height: 'DESC',
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
});
|
||||
return this.uniqueFoundBlocks(rows);
|
||||
}
|
||||
|
||||
private uniqueFoundBlocks(rows: Partial<BlocksEntity>[]): Partial<BlocksEntity>[] {
|
||||
const seenBlocks = new Set<string>();
|
||||
const seenHeights = new Set<number>();
|
||||
return rows.filter(row => {
|
||||
if (row.height != null && seenHeights.has(row.height)) {
|
||||
return false;
|
||||
}
|
||||
const key = row.blockHash || `height:${row.height}`;
|
||||
if (seenBlocks.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seenBlocks.add(key);
|
||||
if (row.height != null) {
|
||||
seenHeights.add(row.height);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private getBlockHash(blockData?: string): string | undefined {
|
||||
if (blockData == null || blockData.length < 160) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return bitcoinjs.Block.fromHex(blockData).getId();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private isSuccessfulBlockSubmission(result?: string | null): boolean {
|
||||
return result == null || result === 'SUCCESS!';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user