Add SV2 DATUM payout hardening

This commit is contained in:
Ben
2026-06-12 23:54:50 -04:00
parent bcd6bb0aa7
commit a33c530268
59 changed files with 7852 additions and 81 deletions
@@ -0,0 +1,24 @@
import { Column, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm';
const bigintTransformer = {
from: (value: string | number | null): number => Number(value ?? 0),
to: (value: number): number => Math.trunc(value ?? 0),
};
@Entity({ name: 'payout_balance' })
export class PayoutBalanceEntity {
@PrimaryColumn({ length: 62, type: 'varchar' })
address: string;
@Column({ type: 'bigint', default: 0, transformer: bigintTransformer })
balanceSats: number;
@Column({ type: 'bigint', default: 0, transformer: bigintTransformer })
totalPaidSats: number;
@Column({ type: 'timestamptz', nullable: true })
lastAcceptedShareAt: Date | null;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,135 @@
import { buildPayoutDistribution } from './payout-distribution';
const ADDRESS_A = 'bc1qs29kyaqqc0fkvj897ke9e5xa9utljjey0y5jjn';
const ADDRESS_B = 'bc1q99n3pu025yyu0jlywpmwzalyhm36tg5u37w20d';
const ADDRESS_C = 'bc1q7yx4d3xspf4xvssmsm4prf4mg2slkv8jtkc5l6';
describe('buildPayoutDistribution', () => {
it('should conserve the full coinbase reward across outputs', () => {
const result = buildPayoutDistribution({
addressWork: [
{ address: ADDRESS_A, creditedDifficulty: 2, acceptedShareCount: 2 },
{ address: ADDRESS_B, creditedDifficulty: 1, acceptedShareCount: 1 },
],
balances: [],
coinbaseValueSats: 1000,
feePercent: 0,
minOutputSats: 546,
coinbaseWeightBudget: 50_000,
});
expect(result.payoutOutputs.map(entry => ({
address: entry.address,
payoutSats: entry.payoutSats,
balanceAfterSats: entry.balanceAfterSats,
}))).toEqual([
{ address: ADDRESS_A, payoutSats: 1000, balanceAfterSats: -334 },
]);
expect(result.entries.find(entry => entry.address === ADDRESS_B))
.toEqual(expect.objectContaining({ includedInCoinbase: false, balanceAfterSats: 333 }));
expect(result.distributedSats).toBe(1000);
expect(result.unallocatedRemainderSats).toBe(0);
});
it('should carry sub-dust work forward as signed credit', () => {
const result = buildPayoutDistribution({
addressWork: [
{ address: ADDRESS_A, creditedDifficulty: 999, acceptedShareCount: 999 },
{ address: ADDRESS_B, creditedDifficulty: 1, acceptedShareCount: 1 },
],
balances: [],
coinbaseValueSats: 1000,
feePercent: 0,
minOutputSats: 546,
coinbaseWeightBudget: 50_000,
});
const pending = result.entries.find(entry => entry.address === ADDRESS_B);
const paid = result.entries.find(entry => entry.address === ADDRESS_A);
expect(pending).toEqual(expect.objectContaining({
includedInCoinbase: false,
grossPayoutSats: 1,
payoutSats: 0,
balanceAfterSats: 1,
}));
expect(paid).toEqual(expect.objectContaining({
includedInCoinbase: true,
payoutSats: 1000,
balanceAfterSats: -1,
}));
expect(result.distributedSats).toBe(1000);
});
it('should trim outputs that do not fit the coinbase weight budget', () => {
const result = buildPayoutDistribution({
addressWork: [
{ address: ADDRESS_A, creditedDifficulty: 10, acceptedShareCount: 1 },
{ address: ADDRESS_B, creditedDifficulty: 9, acceptedShareCount: 1 },
{ address: ADDRESS_C, creditedDifficulty: 8, acceptedShareCount: 1 },
],
balances: [],
coinbaseValueSats: 3000,
feePercent: 0,
minOutputSats: 546,
coinbaseWeightBudget: 850,
});
expect(result.payoutOutputs).toHaveLength(1);
expect(result.payoutOutputs[0].address).toBe(ADDRESS_A);
expect(result.entries.find(entry => entry.address === ADDRESS_B))
.toEqual(expect.objectContaining({ includedInCoinbase: false, balanceAfterSats: 1000 }));
expect(result.entries.find(entry => entry.address === ADDRESS_C))
.toEqual(expect.objectContaining({ includedInCoinbase: false, balanceAfterSats: 888 }));
});
it('should apply positive and negative carried balances to the next distribution', () => {
const result = buildPayoutDistribution({
addressWork: [
{ address: ADDRESS_A, creditedDifficulty: 10, acceptedShareCount: 1 },
{ address: ADDRESS_B, creditedDifficulty: 10, acceptedShareCount: 1 },
],
balances: [
{ address: ADDRESS_A, balanceSats: 100 },
{ address: ADDRESS_B, balanceSats: -100 },
],
coinbaseValueSats: 2000,
feePercent: 0,
minOutputSats: 546,
coinbaseWeightBudget: 50_000,
});
expect(result.payoutOutputs.map(entry => ({
address: entry.address,
payoutSats: entry.payoutSats,
balanceAfterSats: entry.balanceAfterSats,
}))).toEqual([
{ address: ADDRESS_A, payoutSats: 1100, balanceAfterSats: 0 },
{ address: ADDRESS_B, payoutSats: 900, balanceAfterSats: 0 },
]);
expect(result.unallocatedRemainderSats).toBe(0);
});
it('should reserve a configured fee output before miner distribution', () => {
const result = buildPayoutDistribution({
addressWork: [
{ address: ADDRESS_A, creditedDifficulty: 1, acceptedShareCount: 1 },
],
balances: [],
coinbaseValueSats: 100000,
feeAddress: ADDRESS_C,
feePercent: 2,
minOutputSats: 546,
coinbaseWeightBudget: 50_000,
});
expect(result.payoutOutputs[0]).toEqual(expect.objectContaining({
address: ADDRESS_C,
payoutSats: 2000,
}));
expect(result.payoutOutputs[1]).toEqual(expect.objectContaining({
address: ADDRESS_A,
payoutSats: 98000,
}));
});
});
@@ -0,0 +1,276 @@
import { AddressType, getAddressInfo } from 'bitcoin-address-validation';
export interface PayoutAddressWork {
address: string;
creditedDifficulty: number;
acceptedShareCount: number;
}
export interface PayoutBalanceInput {
address: string;
balanceSats: number;
}
export interface PayoutDistributionEntry {
address: string;
creditedDifficulty: number;
acceptedShareCount: number;
payoutWeight: number;
grossPayoutSats: number;
payoutSats: number;
balanceBeforeSats: number;
balanceAfterSats: number;
includedInCoinbase: boolean;
rank: number;
}
export interface PayoutDistributionResult {
entries: PayoutDistributionEntry[];
payoutOutputs: PayoutDistributionEntry[];
consideredAddressCount: number;
distributedSats: number;
unallocatedRemainderSats: number;
feeSats: number;
}
export interface PayoutDistributionInput {
addressWork: PayoutAddressWork[];
balances: PayoutBalanceInput[];
coinbaseValueSats: number;
feeAddress?: string;
feePercent: number;
minOutputSats: number;
coinbaseWeightBudget: number;
}
const DEFAULT_COINBASE_WEIGHT_BUDGET = 50_000;
const DUST_LIMIT_SATS = 546;
const COINBASE_BASE_WEIGHT = 328;
const COINBASE_WITNESS_COMMITMENT_WEIGHT = 188;
const COINBASE_OUTPUT_WEIGHT = 172;
const BUDGET_SAFETY_MARGIN_WEIGHT = 200;
const OUTPUT_WEIGHT_BY_TYPE: Record<string, number> = {
[AddressType.p2wpkh]: 124,
[AddressType.p2sh]: 128,
[AddressType.p2pkh]: 136,
[AddressType.p2wsh]: 172,
[AddressType.p2tr]: 172,
};
const outputWeightCache = new Map<string, number>();
interface ComputedAddress {
address: string;
creditedDifficulty: number;
acceptedShareCount: number;
payoutWeight: number;
rawFairSats: number;
balanceBeforeSats: number;
targetSats: number;
payoutSats: number;
balanceAfterSats: number;
includedInCoinbase: boolean;
}
export function buildPayoutDistribution(input: PayoutDistributionInput): PayoutDistributionResult {
const coinbaseValueSats = Math.max(0, Math.floor(input.coinbaseValueSats));
const minOutputSats = Math.max(DUST_LIMIT_SATS, Math.floor(input.minOutputSats));
const coinbaseWeightBudget = input.coinbaseWeightBudget > 0
? input.coinbaseWeightBudget
: DEFAULT_COINBASE_WEIGHT_BUDGET;
const feePercent = Number.isFinite(input.feePercent) && input.feePercent > 0
? input.feePercent
: 0;
const feeAddress = input.feeAddress?.trim() ?? '';
const wantedFeeSats = Math.floor((feePercent / 100) * coinbaseValueSats);
const feeSats = feeAddress.length > 0 && wantedFeeSats >= minOutputSats ? wantedFeeSats : 0;
const minerRewardSats = coinbaseValueSats - feeSats;
const workByAddress = new Map<string, { creditedDifficulty: number; acceptedShareCount: number }>();
for (const work of input.addressWork) {
if (!work.address || !Number.isFinite(work.creditedDifficulty) || work.creditedDifficulty <= 0) {
continue;
}
const existing = workByAddress.get(work.address) ?? { creditedDifficulty: 0, acceptedShareCount: 0 };
existing.creditedDifficulty += work.creditedDifficulty;
existing.acceptedShareCount += work.acceptedShareCount;
workByAddress.set(work.address, existing);
}
const balanceByAddress = new Map<string, number>();
for (const balance of input.balances) {
if (balance.address && Number.isFinite(balance.balanceSats) && balance.balanceSats !== 0) {
balanceByAddress.set(balance.address, Math.trunc(balance.balanceSats));
}
}
let totalCreditedDifficulty = 0;
for (const work of workByAddress.values()) {
totalCreditedDifficulty += work.creditedDifficulty;
}
if (coinbaseValueSats <= 0 || totalCreditedDifficulty <= 0) {
return {
entries: [],
payoutOutputs: [],
consideredAddressCount: 0,
distributedSats: 0,
unallocatedRemainderSats: coinbaseValueSats,
feeSats,
};
}
const computations = new Map<string, ComputedAddress>();
const considered = new Set<string>([...workByAddress.keys(), ...balanceByAddress.keys()]);
for (const address of considered) {
const work = workByAddress.get(address);
const creditedDifficulty = work?.creditedDifficulty ?? 0;
const acceptedShareCount = work?.acceptedShareCount ?? 0;
const payoutWeight = creditedDifficulty > 0 ? creditedDifficulty / totalCreditedDifficulty : 0;
const rawFairSats = Math.floor(payoutWeight * minerRewardSats);
const balanceBeforeSats = balanceByAddress.get(address) ?? 0;
const targetSats = rawFairSats + balanceBeforeSats;
computations.set(address, {
address,
creditedDifficulty,
acceptedShareCount,
payoutWeight,
rawFairSats,
balanceBeforeSats,
targetSats,
payoutSats: 0,
balanceAfterSats: targetSats,
includedInCoinbase: false,
});
}
const feeWeight = feeSats > 0 ? outputWeightForAddress(feeAddress) : 0;
let usedWeight = COINBASE_BASE_WEIGHT + COINBASE_WITNESS_COMMITMENT_WEIGHT + feeWeight;
const effectiveBudget = coinbaseWeightBudget - BUDGET_SAFETY_MARGIN_WEIGHT;
const eligible = [...computations.values()]
.filter(row => row.targetSats >= minOutputSats)
.sort((a, b) => b.targetSats - a.targetSats || a.address.localeCompare(b.address));
for (const row of eligible) {
const outputWeight = outputWeightForAddress(row.address);
if (usedWeight + outputWeight > effectiveBudget) {
continue;
}
row.includedInCoinbase = true;
row.payoutSats = row.targetSats;
row.balanceAfterSats = 0;
usedWeight += outputWeight;
}
let distributedToMiners = 0;
for (const row of computations.values()) {
distributedToMiners += row.payoutSats;
}
const remainder = minerRewardSats - distributedToMiners;
if (remainder > 0) {
const keptActive = [...computations.values()]
.filter(row => row.includedInCoinbase && row.creditedDifficulty > 0)
.sort((a, b) => b.creditedDifficulty - a.creditedDifficulty || a.address.localeCompare(b.address));
if (keptActive.length > 0) {
let assigned = 0;
for (const row of keptActive) {
const bonus = Math.floor((remainder * row.creditedDifficulty) / totalCreditedDifficulty);
row.payoutSats += bonus;
row.balanceAfterSats -= bonus;
assigned += bonus;
}
const residual = remainder - assigned;
if (residual > 0) {
keptActive[0].payoutSats += residual;
keptActive[0].balanceAfterSats -= residual;
}
}
}
const feeEntry = feeSats > 0
? {
address: feeAddress,
creditedDifficulty: 0,
acceptedShareCount: 0,
payoutWeight: 0,
grossPayoutSats: feeSats,
payoutSats: feeSats,
balanceBeforeSats: 0,
balanceAfterSats: 0,
includedInCoinbase: true,
rank: 1,
}
: null;
const minerEntries = [...computations.values()]
.filter(row => row.includedInCoinbase || row.balanceBeforeSats !== row.balanceAfterSats || row.balanceAfterSats !== 0)
.sort((a, b) => {
if (a.includedInCoinbase !== b.includedInCoinbase) {
return a.includedInCoinbase ? -1 : 1;
}
return b.payoutSats - a.payoutSats || b.targetSats - a.targetSats || a.address.localeCompare(b.address);
})
.map(row => ({
address: row.address,
creditedDifficulty: row.creditedDifficulty,
acceptedShareCount: row.acceptedShareCount,
payoutWeight: row.payoutWeight,
grossPayoutSats: row.rawFairSats,
payoutSats: row.payoutSats,
balanceBeforeSats: row.balanceBeforeSats,
balanceAfterSats: row.balanceAfterSats,
includedInCoinbase: row.includedInCoinbase,
rank: 0,
}));
const mergedEntries = feeEntry == null
? minerEntries
: mergeFeeEntry(feeEntry, minerEntries);
const entries = mergedEntries
.map((entry, index) => ({ ...entry, rank: index + 1 }));
const payoutOutputs = entries.filter(entry => entry.includedInCoinbase);
const distributedSats = payoutOutputs.reduce((sum, entry) => sum + entry.payoutSats, 0);
return {
entries,
payoutOutputs,
consideredAddressCount: considered.size,
distributedSats,
unallocatedRemainderSats: coinbaseValueSats - distributedSats,
feeSats,
};
}
function mergeFeeEntry(
feeEntry: PayoutDistributionEntry,
minerEntries: Omit<PayoutDistributionEntry, 'rank'>[],
): Omit<PayoutDistributionEntry, 'rank'>[] {
const existing = minerEntries.find(entry => entry.address === feeEntry.address && entry.includedInCoinbase);
if (existing == null) {
return [feeEntry, ...minerEntries];
}
existing.payoutSats += feeEntry.payoutSats;
existing.grossPayoutSats += feeEntry.grossPayoutSats;
return minerEntries;
}
export function outputWeightForAddress(address: string): number {
if (!address) {
return COINBASE_OUTPUT_WEIGHT;
}
const cached = outputWeightCache.get(address);
if (cached != null) {
return cached;
}
let weight = COINBASE_OUTPUT_WEIGHT;
try {
const info = getAddressInfo(address);
weight = OUTPUT_WEIGHT_BY_TYPE[info.type] ?? COINBASE_OUTPUT_WEIGHT;
} catch {
weight = COINBASE_OUTPUT_WEIGHT;
}
outputWeightCache.set(address, weight);
return weight;
}
@@ -0,0 +1,41 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
const bigintTransformer = {
from: (value: string | number | null): number => Number(value ?? 0),
to: (value: number): number => Math.trunc(value ?? 0),
};
@Entity({ name: 'payout_history' })
@Index('UQ_payout_history_block_address', ['blockHeight', 'address'], { unique: true })
@Index('IDX_payout_history_address_created', ['address', 'createdAt'])
export class PayoutHistoryEntity {
@PrimaryGeneratedColumn({ type: 'bigint' })
id: string;
@Column({ type: 'bigint' })
blockHeight: number;
@Column({ type: 'bigint' })
payoutSnapshotId: string;
@Column({ length: 62, type: 'varchar' })
address: string;
@Column({ type: 'bigint', default: 0, transformer: bigintTransformer })
paidSats: number;
@Column({ type: 'decimal', default: 0 })
percent: number;
@Column({ type: 'bigint', default: 0, transformer: bigintTransformer })
balanceBeforeSats: number;
@Column({ type: 'bigint', default: 0, transformer: bigintTransformer })
balanceAfterSats: number;
@Column({ length: 16, type: 'varchar', default: 'coinbase' })
rowType: 'coinbase' | 'pending';
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,54 @@
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { PayoutSnapshotEntity } from './payout-snapshot.entity';
@Entity({ name: 'payout_snapshot_entry' })
@Index('IDX_payout_snapshot_entry_snapshot_rank', ['snapshotId', 'rank'])
@Index('IDX_payout_snapshot_entry_address', ['address'])
export class PayoutSnapshotEntryEntity {
@PrimaryGeneratedColumn({ type: 'bigint' })
id: string;
@Column({ type: 'bigint' })
snapshotId: string;
@ManyToOne(() => PayoutSnapshotEntity, snapshot => snapshot.entries, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'snapshotId' })
snapshot: PayoutSnapshotEntity;
@Column({ length: 62, type: 'varchar' })
address: string;
@Column({ type: 'decimal' })
creditedDifficulty: number;
@Column({ type: 'bigint' })
acceptedShareCount: string;
@Column({ type: 'decimal' })
payoutWeight: number;
@Column({ type: 'bigint' })
grossPayoutSats: string;
@Column({ type: 'bigint' })
payoutSats: string;
@Column({ type: 'bigint', default: 0 })
balanceBeforeSats: string;
@Column({ type: 'bigint', default: 0 })
balanceAfterSats: string;
@Column({ type: 'boolean', default: true })
includedInCoinbase: boolean;
@Column({ length: 16, type: 'varchar', default: 'coinbase' })
rowType: 'coinbase' | 'pending';
@Column({ type: 'integer' })
rank: number;
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
}
@@ -0,0 +1,80 @@
import { Column, Entity, Index, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { PayoutSnapshotEntryEntity } from './payout-snapshot-entry.entity';
@Entity({ name: 'payout_snapshot' })
@Index('IDX_payout_snapshot_latest', ['status', 'createdAt'])
@Index('IDX_payout_snapshot_window', ['windowEndShareIndex'])
export class PayoutSnapshotEntity {
@PrimaryGeneratedColumn({ type: 'bigint' })
id: string;
@Column({ length: 32, type: 'varchar' })
method: string;
@Column({ length: 16, type: 'varchar', default: 'finalized' })
status: 'finalized';
@Column({ type: 'bigint' })
blockHeight: number;
@Column({ type: 'bigint' })
coinbaseValueSats: string;
@Column({ type: 'decimal' })
networkDifficulty: number;
@Column({ type: 'decimal', default: 0 })
windowTargetDifficulty: number;
@Column({ type: 'decimal', default: 4 })
windowFactor: number;
@Column({ length: 62, type: 'varchar', nullable: true })
feeAddress?: string | null;
@Column({ type: 'bigint', default: 0 })
feeSats: string;
@Column({ type: 'integer', default: 546 })
minPayoutSats: number;
@Column({ type: 'integer', default: 50000 })
coinbaseWeightBudget: number;
@Column({ type: 'bigint' })
startBatchId: string;
@Column({ type: 'bigint' })
endBatchId: string;
@Column({ type: 'bigint' })
windowStartShareIndex: string;
@Column({ type: 'bigint' })
windowEndShareIndex: string;
@Column({ type: 'decimal' })
totalCreditedDifficulty: number;
@Column({ type: 'bigint' })
totalAcceptedShareCount: string;
@Column({ type: 'integer' })
eligibleAddressCount: number;
@Column({ type: 'integer' })
includedOutputCount: number;
@Column({ type: 'bigint' })
distributedSats: string;
@Column({ type: 'bigint' })
unallocatedRemainderSats: string;
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
@OneToMany(() => PayoutSnapshotEntryEntity, entry => entry.snapshot)
entries: PayoutSnapshotEntryEntity[];
}
@@ -0,0 +1,21 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PayoutBalanceEntity } from './payout-balance.entity';
import { PayoutHistoryEntity } from './payout-history.entity';
import { PayoutSnapshotEntryEntity } from './payout-snapshot-entry.entity';
import { PayoutSnapshotEntity } from './payout-snapshot.entity';
import { PayoutSnapshotService } from './payout-snapshot.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([
PayoutBalanceEntity,
PayoutHistoryEntity,
PayoutSnapshotEntity,
PayoutSnapshotEntryEntity,
])],
providers: [PayoutSnapshotService],
exports: [TypeOrmModule, PayoutSnapshotService],
})
export class PayoutSnapshotModule { }
@@ -0,0 +1,213 @@
import { DataSource, EntityManager } from 'typeorm';
import { PayoutSnapshotService } from './payout-snapshot.service';
const ADDRESS_A = 'bc1qs29kyaqqc0fkvj897ke9e5xa9utljjey0y5jjn';
const ADDRESS_B = 'bc1q99n3pu025yyu0jlywpmwzalyhm36tg5u37w20d';
describe('PayoutSnapshotService', () => {
let originalEnv: NodeJS.ProcessEnv;
let manager: { query: jest.Mock };
let dataSource: { transaction: jest.Mock; query: jest.Mock };
let service: PayoutSnapshotService;
beforeEach(() => {
originalEnv = { ...process.env };
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 = '546';
process.env.PAYOUT_FEE_PERCENT = '0';
process.env.PAYOUT_FEE_ADDRESS = '';
process.env.PAYOUT_COINBASE_WEIGHT_BUDGET = '50000';
manager = { query: jest.fn() };
dataSource = {
transaction: jest.fn(async callback => callback(manager as unknown as EntityManager)),
query: jest.fn(),
};
service = new PayoutSnapshotService(dataSource as unknown as DataSource);
});
afterEach(() => {
process.env = originalEnv;
});
it('should create a PPLNS payout snapshot from finalized rollup batches', async () => {
manager.query
.mockResolvedValueOnce([{
startBatchId: '10',
endBatchId: '12',
windowStartShareIndex: '1000',
windowEndShareIndex: '2000',
totalCreditedDifficulty: '100',
totalAcceptedShareCount: '5',
}])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ address: ADDRESS_A, creditedDifficulty: 60, acceptedShareCount: 3 },
{ address: ADDRESS_B, creditedDifficulty: 40, acceptedShareCount: 2 },
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: '55' }])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{
id: '55',
method: 'pplns',
blockHeight: 900000,
coinbaseValueSats: '1000',
windowStartShareIndex: '1000',
windowEndShareIndex: '2000',
totalCreditedDifficulty: 100,
totalAcceptedShareCount: '5',
eligibleAddressCount: 2,
includedOutputCount: 1,
distributedSats: '1000',
unallocatedRemainderSats: '0',
}])
.mockResolvedValueOnce([
{ address: ADDRESS_A, payoutSats: '1000' },
]);
const snapshot = await service.createSnapshotForTemplate({
blockHeight: 900000,
coinbaseValueSats: 1000,
networkDifficulty: 25,
});
expect(snapshot).toEqual(expect.objectContaining({
id: '55',
method: 'pplns',
blockHeight: 900000,
totalCreditedDifficulty: 100,
totalAcceptedShareCount: 5,
distributedSats: '1000',
unallocatedRemainderSats: '0',
}));
expect(snapshot.payoutOutputs).toEqual([
{ address: ADDRESS_A, amountSats: 1000, percent: 100 },
]);
expect(manager.query).toHaveBeenCalledTimes(9);
expect(manager.query.mock.calls[0][1]).toEqual([100]);
});
it('should not create a snapshot when there is no finalized rollup work', async () => {
manager.query.mockResolvedValueOnce([{
startBatchId: null,
endBatchId: null,
windowStartShareIndex: null,
windowEndShareIndex: null,
totalCreditedDifficulty: '0',
totalAcceptedShareCount: '0',
}]);
await expect(service.createSnapshotForTemplate({
blockHeight: 900000,
coinbaseValueSats: 1000,
networkDifficulty: 25,
})).resolves.toBeNull();
expect(manager.query).toHaveBeenCalledTimes(1);
});
it('should reuse an existing snapshot for the same template and rollup window', async () => {
manager.query
.mockResolvedValueOnce([{
startBatchId: '10',
endBatchId: '12',
windowStartShareIndex: '1000',
windowEndShareIndex: '2000',
totalCreditedDifficulty: '100',
totalAcceptedShareCount: '5',
}])
.mockResolvedValueOnce([{ id: '55' }])
.mockResolvedValueOnce([{
id: '55',
method: 'pplns',
blockHeight: 900000,
coinbaseValueSats: '1000',
windowStartShareIndex: '1000',
windowEndShareIndex: '2000',
totalCreditedDifficulty: 100,
totalAcceptedShareCount: '5',
eligibleAddressCount: 2,
includedOutputCount: 1,
distributedSats: '1000',
unallocatedRemainderSats: '0',
}])
.mockResolvedValueOnce([
{ address: ADDRESS_A, payoutSats: '1000' },
]);
const snapshot = await service.createSnapshotForTemplate({
blockHeight: 900000,
coinbaseValueSats: 1000,
networkDifficulty: 25,
});
expect(snapshot.id).toBe('55');
expect(snapshot.payoutOutputs).toEqual([
{ address: ADDRESS_A, amountSats: 1000, percent: 100 },
]);
expect(manager.query).toHaveBeenCalledTimes(4);
});
it('should finalize snapshot balances and payout history for a found block', async () => {
manager.query
.mockResolvedValueOnce([{ id: '55', coinbaseValueSats: '1000' }])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
address: ADDRESS_A,
payoutSats: '1000',
balanceBeforeSats: '0',
balanceAfterSats: '-400',
creditedDifficulty: 60,
includedInCoinbase: true,
rowType: 'coinbase',
},
{
address: ADDRESS_B,
payoutSats: '0',
balanceBeforeSats: '0',
balanceAfterSats: '400',
creditedDifficulty: 40,
includedInCoinbase: false,
rowType: 'pending',
},
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await expect(service.finalizeSnapshotForBlock({
payoutSnapshotId: '55',
blockHeight: 900000,
blockSubmissionResult: 'SUCCESS!',
})).resolves.toEqual({
finalized: true,
payoutSnapshotId: '55',
historyRows: 2,
balanceRows: 2,
});
expect(manager.query).toHaveBeenCalledTimes(7);
});
it('should skip finalization when a block was already processed', async () => {
manager.query
.mockResolvedValueOnce([{ id: '55', coinbaseValueSats: '1000' }])
.mockResolvedValueOnce([{ id: '99' }]);
await expect(service.finalizeSnapshotForBlock({
payoutSnapshotId: '55',
blockHeight: 900000,
blockSubmissionResult: 'SUCCESS!',
})).resolves.toEqual({
finalized: false,
reason: 'already-finalized',
payoutSnapshotId: '55',
});
expect(manager.query).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,617 @@
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import type { AddressObject } from '../../models/MiningJob';
import {
buildPayoutDistribution,
PayoutAddressWork,
PayoutBalanceInput,
PayoutDistributionEntry,
} from './payout-distribution';
export interface PayoutSnapshotForTemplate {
id: string;
method: string;
blockHeight: number;
coinbaseValueSats: string;
windowStartShareIndex: string;
windowEndShareIndex: string;
totalCreditedDifficulty: number;
totalAcceptedShareCount: number;
eligibleAddressCount: number;
includedOutputCount: number;
distributedSats: string;
unallocatedRemainderSats: string;
payoutOutputs: AddressObject[];
}
export interface PayoutFinalizationResult {
finalized: boolean;
reason?: 'disabled' | 'missing-snapshot' | 'already-finalized' | 'duplicate';
payoutSnapshotId?: string;
historyRows?: number;
balanceRows?: number;
}
const DEFAULT_MAX_COINBASE_OUTPUTS = 10;
const DEFAULT_MIN_OUTPUT_SATS = 546;
const DEFAULT_PAYOUT_METHOD = 'pplns';
const DEFAULT_PAYOUT_WINDOW_FACTOR = 4;
const DEFAULT_COINBASE_WEIGHT_BUDGET = 50_000;
@Injectable()
export class PayoutSnapshotService {
private readonly snapshotsEnabled = this.readBoolean('PAYOUT_SNAPSHOT_ENABLED', true);
private readonly method = process.env.PAYOUT_METHOD ?? DEFAULT_PAYOUT_METHOD;
private readonly windowFactor = this.readPositiveNumber('PAYOUT_WINDOW_FACTOR', DEFAULT_PAYOUT_WINDOW_FACTOR);
private readonly maxCoinbaseOutputs = this.readPositiveInt('PAYOUT_MAX_COINBASE_OUTPUTS', DEFAULT_MAX_COINBASE_OUTPUTS);
private readonly minOutputSats = this.readNonNegativeInt('PAYOUT_MIN_OUTPUT_SATS', DEFAULT_MIN_OUTPUT_SATS);
private readonly feeAddress = process.env.PAYOUT_FEE_ADDRESS?.trim() ?? '';
private readonly feePercent = this.readNonNegativeNumber('PAYOUT_FEE_PERCENT', 0);
private readonly coinbaseWeightBudget = this.readPositiveInt('PAYOUT_COINBASE_WEIGHT_BUDGET', DEFAULT_COINBASE_WEIGHT_BUDGET);
constructor(
private readonly dataSource: DataSource,
) { }
public async createSnapshotForTemplate(input: {
blockHeight: number;
coinbaseValueSats: number;
networkDifficulty: number;
}): Promise<PayoutSnapshotForTemplate | null> {
if (!this.snapshotsEnabled || input.coinbaseValueSats <= 0 || input.networkDifficulty <= 0) {
return null;
}
return this.dataSource.transaction(async manager => {
const windowTargetDifficulty = input.networkDifficulty * this.windowFactor;
const window = await this.getPplnsWindow(manager, windowTargetDifficulty);
if (window == null || this.toNumber(window.totalCreditedDifficulty) <= 0) {
return null;
}
const existing = await this.getExistingSnapshot(manager, {
blockHeight: input.blockHeight,
coinbaseValueSats: input.coinbaseValueSats,
windowEndShareIndex: window.windowEndShareIndex,
});
if (existing != null) {
return existing;
}
const addressWork = await this.getAddressWork(manager, window);
const balances = await this.getOpenBalances(manager);
if (addressWork.length === 0) {
return null;
}
const distribution = buildPayoutDistribution({
addressWork,
balances,
coinbaseValueSats: input.coinbaseValueSats,
feeAddress: this.feeAddress,
feePercent: this.feePercent,
minOutputSats: this.minOutputSats,
coinbaseWeightBudget: this.coinbaseWeightBudget,
});
const entries = this.limitCoinbaseOutputs(distribution.entries);
if (entries.every(entry => !entry.includedInCoinbase)) {
return null;
}
const includedOutputCount = entries.filter(entry => entry.includedInCoinbase).length;
const distributedSats = entries
.filter(entry => entry.includedInCoinbase)
.reduce((sum, entry) => sum + entry.payoutSats, 0);
const unallocatedRemainderSats = input.coinbaseValueSats - distributedSats;
const [snapshotRow] = await manager.query(`
INSERT INTO "payout_snapshot" (
"method",
"status",
"blockHeight",
"coinbaseValueSats",
"networkDifficulty",
"windowTargetDifficulty",
"windowFactor",
"feeAddress",
"feeSats",
"minPayoutSats",
"coinbaseWeightBudget",
"startBatchId",
"endBatchId",
"windowStartShareIndex",
"windowEndShareIndex",
"totalCreditedDifficulty",
"totalAcceptedShareCount",
"eligibleAddressCount",
"includedOutputCount",
"distributedSats",
"unallocatedRemainderSats"
) VALUES (
$1,
'finalized',
$2::bigint,
$3::bigint,
$4::numeric,
$5::numeric,
$6::numeric,
NULLIF($7, ''),
$8::bigint,
$9::int,
$10::int,
$11::bigint,
$12::bigint,
$13::bigint,
$14::bigint,
$15::numeric,
$16::bigint,
$17::int,
$18::int,
$19::bigint,
$20::bigint
)
RETURNING "id"::text AS "id"
`, [
this.method,
input.blockHeight,
input.coinbaseValueSats.toString(),
input.networkDifficulty,
windowTargetDifficulty,
this.windowFactor,
this.feeAddress,
distribution.feeSats.toString(),
this.minOutputSats,
this.coinbaseWeightBudget,
window.startBatchId,
window.endBatchId,
window.windowStartShareIndex,
window.windowEndShareIndex,
window.totalCreditedDifficulty,
window.totalAcceptedShareCount,
distribution.consideredAddressCount,
includedOutputCount,
distributedSats.toString(),
unallocatedRemainderSats.toString(),
]);
for (const entry of entries) {
await this.insertSnapshotEntry(manager, snapshotRow.id, entry);
}
return this.getSnapshotById(manager, snapshotRow.id);
});
}
public async finalizeSnapshotForBlock(input: {
payoutSnapshotId?: string | null;
blockHeight: number;
blockSubmissionResult?: string | null;
}): Promise<PayoutFinalizationResult> {
if (!this.snapshotsEnabled) {
return { finalized: false, reason: 'disabled' };
}
if (!this.isSuccessfulBlockSubmission(input.blockSubmissionResult)) {
return { finalized: false, reason: 'missing-snapshot' };
}
if (input.payoutSnapshotId == null) {
return { finalized: false, reason: 'missing-snapshot' };
}
return this.dataSource.transaction<PayoutFinalizationResult>(async manager => {
const [snapshot] = await manager.query(`
SELECT "id"::text AS "id", "coinbaseValueSats"::text AS "coinbaseValueSats"
FROM "payout_snapshot"
WHERE "id" = $1::bigint
LIMIT 1
`, [input.payoutSnapshotId]);
if (snapshot == null) {
return { finalized: false, reason: 'missing-snapshot' };
}
const [existing] = await manager.query(`
SELECT "id"::text AS "id"
FROM "payout_history"
WHERE "blockHeight" = $1::bigint
LIMIT 1
`, [input.blockHeight]);
if (existing != null) {
return {
finalized: false,
reason: 'already-finalized',
payoutSnapshotId: input.payoutSnapshotId,
};
}
const entries = await manager.query(`
SELECT
"address",
"payoutSats"::text AS "payoutSats",
"balanceBeforeSats"::text AS "balanceBeforeSats",
"balanceAfterSats"::text AS "balanceAfterSats",
"creditedDifficulty"::float AS "creditedDifficulty",
"includedInCoinbase",
"rowType"
FROM "payout_snapshot_entry"
WHERE "snapshotId" = $1::bigint
ORDER BY "rank"
`, [input.payoutSnapshotId]);
if (entries.length === 0) {
return { finalized: false, reason: 'missing-snapshot' };
}
let balanceRows = 0;
let historyRows = 0;
const coinbaseValue = this.toNumber(snapshot.coinbaseValueSats);
for (const entry of entries) {
const payoutSats = this.toNumber(entry.payoutSats);
const balanceBeforeSats = this.toNumber(entry.balanceBeforeSats);
const balanceAfterSats = this.toNumber(entry.balanceAfterSats);
const isCoinbase = entry.includedInCoinbase === true;
const wasActive = this.toNumber(entry.creditedDifficulty) > 0;
await manager.query(`
INSERT INTO "payout_balance" (
"address",
"balanceSats",
"totalPaidSats",
"lastAcceptedShareAt",
"updatedAt"
) VALUES (
$1,
$2::bigint,
$3::bigint,
CASE WHEN $4::boolean THEN NOW() ELSE NULL END,
NOW()
)
ON CONFLICT ("address") DO UPDATE SET
"balanceSats" = EXCLUDED."balanceSats",
"totalPaidSats" = "payout_balance"."totalPaidSats" + EXCLUDED."totalPaidSats",
"lastAcceptedShareAt" = CASE
WHEN $4::boolean THEN NOW()
ELSE "payout_balance"."lastAcceptedShareAt"
END,
"updatedAt" = NOW()
`, [
entry.address,
balanceAfterSats.toString(),
isCoinbase ? payoutSats.toString() : '0',
wasActive,
]);
balanceRows++;
await manager.query(`
INSERT INTO "payout_history" (
"blockHeight",
"payoutSnapshotId",
"address",
"paidSats",
"percent",
"balanceBeforeSats",
"balanceAfterSats",
"rowType"
) VALUES (
$1::bigint,
$2::bigint,
$3,
$4::bigint,
$5::numeric,
$6::bigint,
$7::bigint,
$8
)
ON CONFLICT ("blockHeight", "address") DO NOTHING
`, [
input.blockHeight,
input.payoutSnapshotId,
entry.address,
payoutSats.toString(),
coinbaseValue > 0 && isCoinbase ? (payoutSats / coinbaseValue) * 100 : 0,
balanceBeforeSats.toString(),
balanceAfterSats.toString(),
isCoinbase ? 'coinbase' : 'pending',
]);
historyRows++;
}
return {
finalized: true,
payoutSnapshotId: input.payoutSnapshotId,
historyRows,
balanceRows,
};
}).catch(error => {
if (error?.code === '23505') {
return {
finalized: false,
reason: 'duplicate',
payoutSnapshotId: input.payoutSnapshotId ?? undefined,
};
}
throw error;
});
}
public async getLatestSnapshot(): Promise<PayoutSnapshotForTemplate | null> {
const [snapshot] = await this.dataSource.query(`
SELECT "id"::text AS "id"
FROM "payout_snapshot"
WHERE "status" = 'finalized'
ORDER BY "createdAt" DESC, "id" DESC
LIMIT 1
`);
if (snapshot?.id == null) {
return null;
}
return this.dataSource.transaction(manager => this.getSnapshotById(manager, snapshot.id));
}
private async getPplnsWindow(manager: EntityManager, windowTargetDifficulty: number): Promise<{
startBatchId: string;
endBatchId: string;
windowStartShareIndex: string;
windowEndShareIndex: string;
totalCreditedDifficulty: string;
totalAcceptedShareCount: string;
} | null> {
const [window] = await manager.query(`
WITH ordered AS MATERIALIZED (
SELECT
"id",
"startShareIndex",
"endShareIndex",
"acceptedShareCount",
"creditedDifficulty",
COALESCE(
SUM("creditedDifficulty") OVER (
ORDER BY "endShareIndex" DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
),
0
) AS "difficultyAfter"
FROM "share_rollup_batch"
WHERE "status" = 'finalized'
),
selected AS MATERIALIZED (
SELECT *
FROM ordered
WHERE "difficultyAfter" < $1::numeric
)
SELECT
MIN("id")::text AS "startBatchId",
MAX("id")::text AS "endBatchId",
MIN("startShareIndex")::text AS "windowStartShareIndex",
MAX("endShareIndex")::text AS "windowEndShareIndex",
COALESCE(SUM("creditedDifficulty"), 0)::numeric AS "totalCreditedDifficulty",
COALESCE(SUM("acceptedShareCount"), 0)::bigint AS "totalAcceptedShareCount"
FROM selected
`, [windowTargetDifficulty]);
if (window?.startBatchId == null || window?.windowEndShareIndex == null) {
return null;
}
return window;
}
private async getAddressWork(
manager: EntityManager,
window: { windowStartShareIndex: string; windowEndShareIndex: string },
): Promise<PayoutAddressWork[]> {
const rows = await manager.query(`
SELECT
s."address",
COALESCE(SUM(s."creditedDifficulty"), 0)::float AS "creditedDifficulty",
COALESCE(SUM(s."acceptedShareCount"), 0)::int AS "acceptedShareCount"
FROM "share_rollup_batch_summary" s
JOIN "share_rollup_batch" b ON b."id" = s."batchId"
WHERE b."status" = 'finalized'
AND b."endShareIndex" >= $1::bigint
AND b."endShareIndex" <= $2::bigint
GROUP BY s."address"
HAVING COALESCE(SUM(s."creditedDifficulty"), 0) > 0
`, [
window.windowStartShareIndex,
window.windowEndShareIndex,
]);
return rows.map(row => ({
address: row.address,
creditedDifficulty: this.toNumber(row.creditedDifficulty),
acceptedShareCount: this.toNumber(row.acceptedShareCount),
}));
}
private async getOpenBalances(manager: EntityManager): Promise<PayoutBalanceInput[]> {
const rows = await manager.query(`
SELECT "address", "balanceSats"::text AS "balanceSats"
FROM "payout_balance"
WHERE "balanceSats" != 0
`);
return rows.map(row => ({
address: row.address,
balanceSats: this.toNumber(row.balanceSats),
}));
}
private async getExistingSnapshot(
manager: EntityManager,
input: { blockHeight: number; coinbaseValueSats: number; windowEndShareIndex: string },
): Promise<PayoutSnapshotForTemplate | null> {
const [snapshot] = await manager.query(`
SELECT "id"::text AS "id"
FROM "payout_snapshot"
WHERE "method" = $1
AND "status" = 'finalized'
AND "blockHeight" = $2::bigint
AND "coinbaseValueSats" = $3::bigint
AND "windowEndShareIndex" = $4::bigint
ORDER BY "id" DESC
LIMIT 1
`, [
this.method,
input.blockHeight,
input.coinbaseValueSats.toString(),
input.windowEndShareIndex,
]);
if (snapshot?.id == null) {
return null;
}
return this.getSnapshotById(manager, snapshot.id);
}
private async getSnapshotById(manager: EntityManager, id: string): Promise<PayoutSnapshotForTemplate | null> {
const [snapshot] = await manager.query(`
SELECT
"id"::text AS "id",
"method",
"blockHeight"::int AS "blockHeight",
"coinbaseValueSats"::text AS "coinbaseValueSats",
"windowStartShareIndex"::text AS "windowStartShareIndex",
"windowEndShareIndex"::text AS "windowEndShareIndex",
"totalCreditedDifficulty"::float AS "totalCreditedDifficulty",
"totalAcceptedShareCount"::bigint AS "totalAcceptedShareCount",
"eligibleAddressCount",
"includedOutputCount",
"distributedSats"::text AS "distributedSats",
"unallocatedRemainderSats"::text AS "unallocatedRemainderSats"
FROM "payout_snapshot"
WHERE "id" = $1::bigint
`, [id]);
if (snapshot == null) {
return null;
}
const entries = await manager.query(`
SELECT
"address",
"payoutSats"::text AS "payoutSats"
FROM "payout_snapshot_entry"
WHERE "snapshotId" = $1::bigint
AND "includedInCoinbase" = true
ORDER BY "rank"
`, [id]);
const coinbaseValue = Number(snapshot.coinbaseValueSats);
return {
id: snapshot.id,
method: snapshot.method,
blockHeight: this.toNumber(snapshot.blockHeight),
coinbaseValueSats: snapshot.coinbaseValueSats,
windowStartShareIndex: snapshot.windowStartShareIndex,
windowEndShareIndex: snapshot.windowEndShareIndex,
totalCreditedDifficulty: this.toNumber(snapshot.totalCreditedDifficulty),
totalAcceptedShareCount: this.toNumber(snapshot.totalAcceptedShareCount),
eligibleAddressCount: this.toNumber(snapshot.eligibleAddressCount),
includedOutputCount: this.toNumber(snapshot.includedOutputCount),
distributedSats: snapshot.distributedSats,
unallocatedRemainderSats: snapshot.unallocatedRemainderSats,
payoutOutputs: entries.map(entry => ({
address: entry.address,
amountSats: this.toNumber(entry.payoutSats),
percent: coinbaseValue > 0
? (this.toNumber(entry.payoutSats) / coinbaseValue) * 100
: 0,
})),
};
}
private async insertSnapshotEntry(manager: EntityManager, snapshotId: string, entry: PayoutDistributionEntry): Promise<void> {
await manager.query(`
INSERT INTO "payout_snapshot_entry" (
"snapshotId",
"address",
"creditedDifficulty",
"acceptedShareCount",
"payoutWeight",
"grossPayoutSats",
"payoutSats",
"balanceBeforeSats",
"balanceAfterSats",
"includedInCoinbase",
"rowType",
"rank"
) VALUES (
$1::bigint,
$2,
$3::numeric,
$4::bigint,
$5::numeric,
$6::bigint,
$7::bigint,
$8::bigint,
$9::bigint,
$10::boolean,
$11,
$12::int
)
`, [
snapshotId,
entry.address,
entry.creditedDifficulty,
entry.acceptedShareCount,
entry.payoutWeight,
entry.grossPayoutSats,
entry.payoutSats,
entry.balanceBeforeSats,
entry.balanceAfterSats,
entry.includedInCoinbase,
entry.includedInCoinbase ? 'coinbase' : 'pending',
entry.rank,
]);
}
private limitCoinbaseOutputs(entries: PayoutDistributionEntry[]): PayoutDistributionEntry[] {
let included = 0;
return entries.map(entry => {
if (!entry.includedInCoinbase) {
return entry;
}
included++;
if (included <= this.maxCoinbaseOutputs) {
return entry;
}
return {
...entry,
payoutSats: 0,
balanceAfterSats: entry.balanceAfterSats + entry.payoutSats,
includedInCoinbase: false,
};
});
}
private isSuccessfulBlockSubmission(result?: string | null): boolean {
return result == null || result === 'SUCCESS!';
}
private toNumber(value: unknown): number {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
}
private readPositiveInt(name: string, defaultValue: number): number {
const value = Number(process.env[name]);
return Number.isInteger(value) && value > 0 ? value : defaultValue;
}
private readNonNegativeInt(name: string, defaultValue: number): number {
const value = Number(process.env[name]);
return Number.isInteger(value) && value >= 0 ? value : defaultValue;
}
private readPositiveNumber(name: string, defaultValue: number): number {
const value = Number(process.env[name]);
return Number.isFinite(value) && value > 0 ? value : defaultValue;
}
private readNonNegativeNumber(name: string, defaultValue: number): number {
const value = Number(process.env[name]);
return Number.isFinite(value) && value >= 0 ? value : defaultValue;
}
private readBoolean(name: string, defaultValue: boolean): boolean {
const value = process.env[name]?.toLowerCase();
if (value == null || value.length === 0) {
return defaultValue;
}
return value === 'true' || value === '1' || value === 'yes';
}
}