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:
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user