Add payout modes and production switchover schema

This commit is contained in:
Ben
2026-06-20 13:59:05 -04:00
parent a33c530268
commit a9d425f32f
61 changed files with 4164 additions and 1029 deletions
@@ -23,15 +23,14 @@ describe('buildPayoutDistribution', () => {
payoutSats: entry.payoutSats,
balanceAfterSats: entry.balanceAfterSats,
}))).toEqual([
{ address: ADDRESS_A, payoutSats: 1000, balanceAfterSats: -334 },
{ address: ADDRESS_A, payoutSats: 667, balanceAfterSats: 0 },
{ address: ADDRESS_B, payoutSats: 333, balanceAfterSats: 0 },
]);
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', () => {
it('should include small active miner payouts instead of reallocating their work', () => {
const result = buildPayoutDistribution({
addressWork: [
{ address: ADDRESS_A, creditedDifficulty: 999, acceptedShareCount: 999 },
@@ -48,17 +47,18 @@ describe('buildPayoutDistribution', () => {
const paid = result.entries.find(entry => entry.address === ADDRESS_A);
expect(pending).toEqual(expect.objectContaining({
includedInCoinbase: false,
includedInCoinbase: true,
grossPayoutSats: 1,
payoutSats: 0,
balanceAfterSats: 1,
payoutSats: 1,
balanceAfterSats: 0,
}));
expect(paid).toEqual(expect.objectContaining({
includedInCoinbase: true,
payoutSats: 1000,
balanceAfterSats: -1,
payoutSats: 999,
balanceAfterSats: 0,
}));
expect(result.distributedSats).toBe(1000);
expect(result.unallocatedRemainderSats).toBe(0);
});
it('should trim outputs that do not fit the coinbase weight budget', () => {
@@ -77,10 +77,16 @@ describe('buildPayoutDistribution', () => {
expect(result.payoutOutputs).toHaveLength(1);
expect(result.payoutOutputs[0].address).toBe(ADDRESS_A);
expect(result.payoutOutputs[0]).toEqual(expect.objectContaining({
payoutSats: 3000,
balanceAfterSats: -1889,
}));
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 }));
.toEqual(expect.objectContaining({ includedInCoinbase: false, balanceAfterSats: 889 }));
expect(result.distributedSats).toBe(3000);
expect(result.unallocatedRemainderSats).toBe(0);
});
it('should apply positive and negative carried balances to the next distribution', () => {
+154 -28
View File
@@ -73,6 +73,11 @@ interface ComputedAddress {
includedInCoinbase: boolean;
}
interface AllocationTarget<T> {
item: T;
weight: number;
}
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));
@@ -120,6 +125,15 @@ export function buildPayoutDistribution(input: PayoutDistributionInput): PayoutD
};
}
const fairShareSats = allocateIntegerSats(
[...workByAddress.entries()].map(([address, work]) => ({
item: address,
weight: work.creditedDifficulty,
})),
minerRewardSats,
totalCreditedDifficulty,
);
const computations = new Map<string, ComputedAddress>();
const considered = new Set<string>([...workByAddress.keys(), ...balanceByAddress.keys()]);
@@ -128,7 +142,7 @@ export function buildPayoutDistribution(input: PayoutDistributionInput): PayoutD
const creditedDifficulty = work?.creditedDifficulty ?? 0;
const acceptedShareCount = work?.acceptedShareCount ?? 0;
const payoutWeight = creditedDifficulty > 0 ? creditedDifficulty / totalCreditedDifficulty : 0;
const rawFairSats = Math.floor(payoutWeight * minerRewardSats);
const rawFairSats = fairShareSats.get(address) ?? 0;
const balanceBeforeSats = balanceByAddress.get(address) ?? 0;
const targetSats = rawFairSats + balanceBeforeSats;
computations.set(address, {
@@ -148,47 +162,31 @@ export function buildPayoutDistribution(input: PayoutDistributionInput): PayoutD
const feeWeight = feeSats > 0 ? outputWeightForAddress(feeAddress) : 0;
let usedWeight = COINBASE_BASE_WEIGHT + COINBASE_WITNESS_COMMITMENT_WEIGHT + feeWeight;
const effectiveBudget = coinbaseWeightBudget - BUDGET_SAFETY_MARGIN_WEIGHT;
allocatePayoutTargets(computations, minerRewardSats, totalCreditedDifficulty);
const eligible = [...computations.values()]
.filter(row => row.targetSats >= minOutputSats)
.sort((a, b) => b.targetSats - a.targetSats || a.address.localeCompare(b.address));
.filter(row => row.payoutSats > 0 && (row.creditedDifficulty > 0 || row.payoutSats >= minOutputSats))
.sort((a, b) => b.payoutSats - a.payoutSats || b.targetSats - a.targetSats || a.address.localeCompare(b.address));
for (const row of eligible) {
const outputWeight = outputWeightForAddress(row.address);
if (usedWeight + outputWeight > effectiveBudget) {
row.balanceAfterSats = row.targetSats;
continue;
}
row.includedInCoinbase = true;
row.payoutSats = row.targetSats;
row.balanceAfterSats = 0;
row.balanceAfterSats = row.targetSats - row.payoutSats;
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;
}
if (!row.includedInCoinbase && row.payoutSats > 0) {
row.payoutSats = 0;
row.balanceAfterSats = row.targetSats;
}
}
redistributeUnfitMinerPayouts(computations, minerRewardSats);
const feeEntry = feeSats > 0
? {
address: feeAddress,
@@ -243,6 +241,134 @@ export function buildPayoutDistribution(input: PayoutDistributionInput): PayoutD
};
}
function allocatePayoutTargets(
computations: Map<string, ComputedAddress>,
minerRewardSats: number,
totalCreditedDifficulty: number,
): void {
const positiveTargets = [...computations.values()]
.filter(row => row.targetSats > 0);
const totalPositiveTarget = positiveTargets.reduce((sum, row) => sum + row.targetSats, 0);
if (totalPositiveTarget <= 0) {
const activeRows = [...computations.values()]
.filter(row => row.creditedDifficulty > 0);
const activeAllocations = allocateIntegerSats(
activeRows.map(row => ({ item: row.address, weight: row.creditedDifficulty })),
minerRewardSats,
totalCreditedDifficulty,
);
for (const row of activeRows) {
row.payoutSats = activeAllocations.get(row.address) ?? 0;
row.balanceAfterSats = row.targetSats - row.payoutSats;
}
return;
}
if (totalPositiveTarget <= minerRewardSats) {
for (const row of positiveTargets) {
row.payoutSats = row.targetSats;
row.balanceAfterSats = 0;
}
const surplus = minerRewardSats - totalPositiveTarget;
if (surplus <= 0) {
return;
}
const activeRows = positiveTargets.filter(row => row.creditedDifficulty > 0);
const activeWeight = activeRows.reduce((sum, row) => sum + row.creditedDifficulty, 0);
const surplusAllocations = allocateIntegerSats(
activeRows.map(row => ({ item: row.address, weight: row.creditedDifficulty })),
surplus,
activeWeight,
);
for (const row of activeRows) {
const extra = surplusAllocations.get(row.address) ?? 0;
row.payoutSats += extra;
row.balanceAfterSats -= extra;
}
return;
}
const cappedAllocations = allocateIntegerSats(
positiveTargets.map(row => ({ item: row.address, weight: row.targetSats })),
minerRewardSats,
totalPositiveTarget,
);
for (const row of positiveTargets) {
row.payoutSats = cappedAllocations.get(row.address) ?? 0;
row.balanceAfterSats = row.targetSats - row.payoutSats;
}
}
function redistributeUnfitMinerPayouts(
computations: Map<string, ComputedAddress>,
minerRewardSats: number,
): void {
const distributedToMiners = [...computations.values()]
.filter(row => row.includedInCoinbase)
.reduce((sum, row) => sum + row.payoutSats, 0);
const remainder = minerRewardSats - distributedToMiners;
if (remainder <= 0) {
return;
}
const keptActive = [...computations.values()]
.filter(row => row.includedInCoinbase && row.creditedDifficulty > 0);
const keptDifficulty = keptActive.reduce((sum, row) => sum + row.creditedDifficulty, 0);
if (keptDifficulty <= 0) {
return;
}
const bonusAllocations = allocateIntegerSats(
keptActive.map(row => ({ item: row.address, weight: row.creditedDifficulty })),
remainder,
keptDifficulty,
);
for (const row of keptActive) {
const bonus = bonusAllocations.get(row.address) ?? 0;
row.payoutSats += bonus;
row.balanceAfterSats -= bonus;
}
}
function allocateIntegerSats<T extends string>(
targets: AllocationTarget<T>[],
amountSats: number,
totalWeight: number,
): Map<T, number> {
const result = new Map<T, number>();
if (amountSats <= 0 || totalWeight <= 0) {
return result;
}
let assigned = 0;
const fractions = targets
.filter(target => target.weight > 0)
.map(target => {
const exact = (target.weight * amountSats) / totalWeight;
const whole = Math.floor(exact);
assigned += whole;
result.set(target.item, whole);
return {
item: target.item,
weight: target.weight,
fraction: exact - whole,
};
})
.sort((a, b) => b.fraction - a.fraction || b.weight - a.weight || a.item.localeCompare(b.item));
let residual = amountSats - assigned;
for (const target of fractions) {
if (residual <= 0) {
break;
}
result.set(target.item, (result.get(target.item) ?? 0) + 1);
residual--;
}
return result;
}
function mergeFeeEntry(
feeEntry: PayoutDistributionEntry,
minerEntries: Omit<PayoutDistributionEntry, 'rank'>[],
@@ -1,12 +1,14 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { PayoutMode } from '../../types/payout-mode';
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('UQ_payout_history_block_address', ['payoutMode', 'blockHeight', 'address'], { unique: true })
@Index('IDX_payout_history_address_created', ['address', 'createdAt'])
export class PayoutHistoryEntity {
@PrimaryGeneratedColumn({ type: 'bigint' })
@@ -18,6 +20,9 @@ export class PayoutHistoryEntity {
@Column({ type: 'bigint' })
payoutSnapshotId: string;
@Column({ length: 16, type: 'varchar', default: 'pplns' })
payoutMode: PayoutMode;
@Column({ length: 62, type: 'varchar' })
address: string;
@@ -1,5 +1,6 @@
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { PayoutMode } from '../../types/payout-mode';
import { PayoutSnapshotEntity } from './payout-snapshot.entity';
@Entity({ name: 'payout_snapshot_entry' })
@@ -12,6 +13,9 @@ export class PayoutSnapshotEntryEntity {
@Column({ type: 'bigint' })
snapshotId: string;
@Column({ length: 16, type: 'varchar', default: 'pplns' })
payoutMode: PayoutMode;
@ManyToOne(() => PayoutSnapshotEntity, snapshot => snapshot.entries, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'snapshotId' })
snapshot: PayoutSnapshotEntity;
@@ -1,9 +1,10 @@
import { Column, Entity, Index, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { PayoutMode } from '../../types/payout-mode';
import { PayoutSnapshotEntryEntity } from './payout-snapshot-entry.entity';
@Entity({ name: 'payout_snapshot' })
@Index('IDX_payout_snapshot_latest', ['status', 'createdAt'])
@Index('IDX_payout_snapshot_latest', ['payoutMode', 'status', 'createdAt'])
@Index('IDX_payout_snapshot_window', ['windowEndShareIndex'])
export class PayoutSnapshotEntity {
@PrimaryGeneratedColumn({ type: 'bigint' })
@@ -12,6 +13,9 @@ export class PayoutSnapshotEntity {
@Column({ length: 32, type: 'varchar' })
method: string;
@Column({ length: 16, type: 'varchar', default: 'pplns' })
payoutMode: PayoutMode;
@Column({ length: 16, type: 'varchar', default: 'finalized' })
status: 'finalized';
@@ -16,6 +16,7 @@ describe('PayoutSnapshotService', () => {
process.env.PAYOUT_SNAPSHOT_ENABLED = 'true';
process.env.PAYOUT_METHOD = 'pplns';
process.env.PAYOUT_WINDOW_FACTOR = '4';
process.env.PAYOUT_BOOTSTRAP_WINDOW = 'false';
process.env.PAYOUT_MAX_COINBASE_OUTPUTS = '10';
process.env.PAYOUT_MIN_OUTPUT_SATS = '546';
process.env.PAYOUT_FEE_PERCENT = '0';
@@ -62,12 +63,13 @@ describe('PayoutSnapshotService', () => {
totalCreditedDifficulty: 100,
totalAcceptedShareCount: '5',
eligibleAddressCount: 2,
includedOutputCount: 1,
includedOutputCount: 2,
distributedSats: '1000',
unallocatedRemainderSats: '0',
}])
.mockResolvedValueOnce([
{ address: ADDRESS_A, payoutSats: '1000' },
{ address: ADDRESS_A, payoutSats: '600' },
{ address: ADDRESS_B, payoutSats: '400' },
]);
const snapshot = await service.createSnapshotForTemplate({
@@ -86,10 +88,67 @@ describe('PayoutSnapshotService', () => {
unallocatedRemainderSats: '0',
}));
expect(snapshot.payoutOutputs).toEqual([
{ address: ADDRESS_A, amountSats: 1000, percent: 100 },
{ address: ADDRESS_A, amountSats: 600, percent: 60 },
{ address: ADDRESS_B, amountSats: 400, percent: 40 },
]);
expect(manager.query).toHaveBeenCalledTimes(9);
expect(manager.query.mock.calls[0][1]).toEqual([100]);
expect(manager.query.mock.calls[0][1]).toEqual([100, 'pplns']);
});
it('should bootstrap the PPLNS window from paid block count up to the configured factor', async () => {
process.env.PAYOUT_BOOTSTRAP_WINDOW = 'true';
service = new PayoutSnapshotService(dataSource as unknown as DataSource);
manager.query
.mockResolvedValueOnce([{ paidBlockCount: 1 }])
.mockResolvedValueOnce([{
startBatchId: '10',
endBatchId: '12',
windowStartShareIndex: '1000',
windowEndShareIndex: '2000',
totalCreditedDifficulty: '50',
totalAcceptedShareCount: '3',
}])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ address: ADDRESS_A, creditedDifficulty: 30, acceptedShareCount: 2 },
{ address: ADDRESS_B, creditedDifficulty: 20, acceptedShareCount: 1 },
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: '56' }])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{
id: '56',
method: 'pplns',
blockHeight: 900001,
coinbaseValueSats: '1000',
windowStartShareIndex: '1000',
windowEndShareIndex: '2000',
totalCreditedDifficulty: 50,
totalAcceptedShareCount: '3',
eligibleAddressCount: 2,
includedOutputCount: 2,
distributedSats: '1000',
unallocatedRemainderSats: '0',
}])
.mockResolvedValueOnce([
{ address: ADDRESS_A, payoutSats: '600' },
{ address: ADDRESS_B, payoutSats: '400' },
]);
await expect(service.createSnapshotForTemplate({
blockHeight: 900001,
coinbaseValueSats: 1000,
networkDifficulty: 25,
})).resolves.toEqual(expect.objectContaining({
id: '56',
totalCreditedDifficulty: 50,
}));
expect(manager.query.mock.calls[0][0]).toContain('COUNT(DISTINCT "blockHeight")');
expect(manager.query.mock.calls[1][1]).toEqual([50, 'pplns']);
expect(manager.query.mock.calls[5][1][5]).toBe(50);
expect(manager.query.mock.calls[5][1][6]).toBe(2);
});
it('should not create a snapshot when there is no finalized rollup work', async () => {
@@ -131,12 +190,13 @@ describe('PayoutSnapshotService', () => {
totalCreditedDifficulty: 100,
totalAcceptedShareCount: '5',
eligibleAddressCount: 2,
includedOutputCount: 1,
includedOutputCount: 2,
distributedSats: '1000',
unallocatedRemainderSats: '0',
}])
.mockResolvedValueOnce([
{ address: ADDRESS_A, payoutSats: '1000' },
{ address: ADDRESS_A, payoutSats: '600' },
{ address: ADDRESS_B, payoutSats: '400' },
]);
const snapshot = await service.createSnapshotForTemplate({
@@ -147,7 +207,8 @@ describe('PayoutSnapshotService', () => {
expect(snapshot.id).toBe('55');
expect(snapshot.payoutOutputs).toEqual([
{ address: ADDRESS_A, amountSats: 1000, percent: 100 },
{ address: ADDRESS_A, amountSats: 600, percent: 60 },
{ address: ADDRESS_B, amountSats: 400, percent: 40 },
]);
expect(manager.query).toHaveBeenCalledTimes(4);
});
@@ -159,21 +220,21 @@ describe('PayoutSnapshotService', () => {
.mockResolvedValueOnce([
{
address: ADDRESS_A,
payoutSats: '1000',
payoutSats: '600',
balanceBeforeSats: '0',
balanceAfterSats: '-400',
balanceAfterSats: '0',
creditedDifficulty: 60,
includedInCoinbase: true,
rowType: 'coinbase',
},
{
address: ADDRESS_B,
payoutSats: '0',
payoutSats: '400',
balanceBeforeSats: '0',
balanceAfterSats: '400',
balanceAfterSats: '0',
creditedDifficulty: 40,
includedInCoinbase: false,
rowType: 'pending',
includedInCoinbase: true,
rowType: 'coinbase',
},
])
.mockResolvedValueOnce([])
@@ -210,4 +271,45 @@ describe('PayoutSnapshotService', () => {
});
expect(manager.query).toHaveBeenCalledTimes(2);
});
it('should return the latest current expected PPLNS payout for an address', async () => {
dataSource.query.mockResolvedValueOnce([{
snapshotId: '55',
blockHeight: 900000,
coinbaseValueSats: '1000',
distributedSats: '1000',
totalCreditedDifficulty: '100',
includedOutputCount: 2,
createdAt: new Date('2026-06-16T12:00:00.000Z'),
payoutMode: 'pplns',
payoutSats: '600',
grossPayoutSats: '600',
creditedDifficulty: '60',
payoutWeight: '0.6',
}]);
await expect(service.getLatestExpectedPayoutForAddress(ADDRESS_A)).resolves.toEqual({
snapshotId: '55',
blockHeight: 900000,
payoutMode: 'pplns',
payoutSats: 600,
grossPayoutSats: 600,
creditedDifficulty: 60,
payoutWeight: 0.6,
coinbaseValueSats: 1000,
distributedSats: 1000,
totalCreditedDifficulty: 100,
includedOutputCount: 2,
createdAt: new Date('2026-06-16T12:00:00.000Z'),
percent: 60,
});
expect(dataSource.query.mock.calls[0][1]).toEqual([ADDRESS_A, 'pplns']);
expect(dataSource.query.mock.calls[0][0]).toContain('WITH latest_snapshot AS');
});
it('should return null when the latest PPLNS snapshot does not pay the address', async () => {
dataSource.query.mockResolvedValueOnce([]);
await expect(service.getLatestExpectedPayoutForAddress(ADDRESS_A)).resolves.toBeNull();
});
});
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import type { AddressObject } from '../../models/MiningJob';
import { PayoutMode } from '../../types/payout-mode';
import {
buildPayoutDistribution,
PayoutAddressWork,
@@ -23,6 +24,7 @@ export interface PayoutSnapshotForTemplate {
distributedSats: string;
unallocatedRemainderSats: string;
payoutOutputs: AddressObject[];
payoutMode: PayoutMode;
}
export interface PayoutFinalizationResult {
@@ -33,17 +35,36 @@ export interface PayoutFinalizationResult {
balanceRows?: number;
}
export interface ExpectedPayout {
snapshotId: string;
blockHeight: number;
payoutMode: PayoutMode;
payoutSats: number;
grossPayoutSats: number;
creditedDifficulty: number;
payoutWeight: number;
coinbaseValueSats: number;
distributedSats: number;
totalCreditedDifficulty: number;
includedOutputCount: number;
createdAt: Date;
percent: 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;
const DEFAULT_PAYOUT_BOOTSTRAP_WINDOW = true;
const PPLNS_PAYOUT_MODE: PayoutMode = 'pplns';
@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 bootstrapWindow = this.readBoolean('PAYOUT_BOOTSTRAP_WINDOW', DEFAULT_PAYOUT_BOOTSTRAP_WINDOW);
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() ?? '';
@@ -64,7 +85,8 @@ export class PayoutSnapshotService {
}
return this.dataSource.transaction(async manager => {
const windowTargetDifficulty = input.networkDifficulty * this.windowFactor;
const effectiveWindowFactor = await this.getEffectiveWindowFactor(manager);
const windowTargetDifficulty = input.networkDifficulty * effectiveWindowFactor;
const window = await this.getPplnsWindow(manager, windowTargetDifficulty);
if (window == null || this.toNumber(window.totalCreditedDifficulty) <= 0) {
return null;
@@ -107,6 +129,7 @@ export class PayoutSnapshotService {
const [snapshotRow] = await manager.query(`
INSERT INTO "payout_snapshot" (
"method",
"payoutMode",
"status",
"blockHeight",
"coinbaseValueSats",
@@ -129,35 +152,37 @@ export class PayoutSnapshotService {
"unallocatedRemainderSats"
) VALUES (
$1,
$2,
'finalized',
$2::bigint,
$3::bigint,
$4::numeric,
$4::bigint,
$5::numeric,
$6::numeric,
NULLIF($7, ''),
$8::bigint,
$9::int,
$7::numeric,
NULLIF($8, ''),
$9::bigint,
$10::int,
$11::bigint,
$11::int,
$12::bigint,
$13::bigint,
$14::bigint,
$15::numeric,
$16::bigint,
$17::int,
$15::bigint,
$16::numeric,
$17::bigint,
$18::int,
$19::bigint,
$20::bigint
$19::int,
$20::bigint,
$21::bigint
)
RETURNING "id"::text AS "id"
`, [
this.method,
PPLNS_PAYOUT_MODE,
input.blockHeight,
input.coinbaseValueSats.toString(),
input.networkDifficulty,
windowTargetDifficulty,
this.windowFactor,
effectiveWindowFactor,
this.feeAddress,
distribution.feeSats.toString(),
this.minOutputSats,
@@ -182,14 +207,36 @@ export class PayoutSnapshotService {
});
}
private async getEffectiveWindowFactor(manager: EntityManager): Promise<number> {
if (!this.bootstrapWindow) {
return this.windowFactor;
}
const paidBlockCount = await this.getFinalizedPayoutBlockCount(manager);
return Math.min(this.windowFactor, Math.max(1, paidBlockCount + 1));
}
private async getFinalizedPayoutBlockCount(manager: EntityManager): Promise<number> {
const [row] = await manager.query(`
SELECT COUNT(DISTINCT "blockHeight")::int AS "paidBlockCount"
FROM "payout_history"
WHERE "payoutMode" = $1
`, [PPLNS_PAYOUT_MODE]);
return this.toNumber(row?.paidBlockCount);
}
public async finalizeSnapshotForBlock(input: {
payoutSnapshotId?: string | null;
blockHeight: number;
blockSubmissionResult?: string | null;
payoutMode?: PayoutMode;
}): Promise<PayoutFinalizationResult> {
if (!this.snapshotsEnabled) {
return { finalized: false, reason: 'disabled' };
}
if ((input.payoutMode ?? PPLNS_PAYOUT_MODE) !== PPLNS_PAYOUT_MODE) {
return { finalized: false, reason: 'missing-snapshot' };
}
if (!this.isSuccessfulBlockSubmission(input.blockSubmissionResult)) {
return { finalized: false, reason: 'missing-snapshot' };
}
@@ -202,8 +249,9 @@ export class PayoutSnapshotService {
SELECT "id"::text AS "id", "coinbaseValueSats"::text AS "coinbaseValueSats"
FROM "payout_snapshot"
WHERE "id" = $1::bigint
AND "payoutMode" = $2
LIMIT 1
`, [input.payoutSnapshotId]);
`, [input.payoutSnapshotId, PPLNS_PAYOUT_MODE]);
if (snapshot == null) {
return { finalized: false, reason: 'missing-snapshot' };
}
@@ -212,8 +260,9 @@ export class PayoutSnapshotService {
SELECT "id"::text AS "id"
FROM "payout_history"
WHERE "blockHeight" = $1::bigint
AND "payoutMode" = $2
LIMIT 1
`, [input.blockHeight]);
`, [input.blockHeight, PPLNS_PAYOUT_MODE]);
if (existing != null) {
return {
finalized: false,
@@ -233,8 +282,9 @@ export class PayoutSnapshotService {
"rowType"
FROM "payout_snapshot_entry"
WHERE "snapshotId" = $1::bigint
AND "payoutMode" = $2
ORDER BY "rank"
`, [input.payoutSnapshotId]);
`, [input.payoutSnapshotId, PPLNS_PAYOUT_MODE]);
if (entries.length === 0) {
return { finalized: false, reason: 'missing-snapshot' };
}
@@ -283,6 +333,7 @@ export class PayoutSnapshotService {
INSERT INTO "payout_history" (
"blockHeight",
"payoutSnapshotId",
"payoutMode",
"address",
"paidSats",
"percent",
@@ -293,16 +344,18 @@ export class PayoutSnapshotService {
$1::bigint,
$2::bigint,
$3,
$4::bigint,
$5::numeric,
$6::bigint,
$4,
$5::bigint,
$6::numeric,
$7::bigint,
$8
$8::bigint,
$9
)
ON CONFLICT ("blockHeight", "address") DO NOTHING
ON CONFLICT ("payoutMode", "blockHeight", "address") DO NOTHING
`, [
input.blockHeight,
input.payoutSnapshotId,
PPLNS_PAYOUT_MODE,
entry.address,
payoutSats.toString(),
coinbaseValue > 0 && isCoinbase ? (payoutSats / coinbaseValue) * 100 : 0,
@@ -336,15 +389,79 @@ export class PayoutSnapshotService {
SELECT "id"::text AS "id"
FROM "payout_snapshot"
WHERE "status" = 'finalized'
AND "payoutMode" = $1
ORDER BY "createdAt" DESC, "id" DESC
LIMIT 1
`);
`, [PPLNS_PAYOUT_MODE]);
if (snapshot?.id == null) {
return null;
}
return this.dataSource.transaction(manager => this.getSnapshotById(manager, snapshot.id));
}
public async getLatestExpectedPayoutForAddress(address: string): Promise<ExpectedPayout | null> {
const [row] = await this.dataSource.query(`
WITH latest_snapshot AS (
SELECT
"id",
"blockHeight",
"coinbaseValueSats",
"distributedSats",
"totalCreditedDifficulty",
"includedOutputCount",
"createdAt",
"payoutMode"
FROM "payout_snapshot"
WHERE "status" = 'finalized'
AND "payoutMode" = $2
ORDER BY "createdAt" DESC, "id" DESC
LIMIT 1
)
SELECT
s."id"::text AS "snapshotId",
s."blockHeight"::int AS "blockHeight",
s."coinbaseValueSats"::text AS "coinbaseValueSats",
s."distributedSats"::text AS "distributedSats",
s."totalCreditedDifficulty"::text AS "totalCreditedDifficulty",
s."includedOutputCount"::int AS "includedOutputCount",
s."createdAt" AS "createdAt",
e."payoutMode" AS "payoutMode",
e."payoutSats"::text AS "payoutSats",
e."grossPayoutSats"::text AS "grossPayoutSats",
e."creditedDifficulty"::text AS "creditedDifficulty",
e."payoutWeight"::text AS "payoutWeight"
FROM latest_snapshot s
JOIN "payout_snapshot_entry" e ON e."snapshotId" = s."id"
WHERE e."address" = $1
AND e."payoutMode" = $2
AND e."includedInCoinbase" = true
AND e."payoutSats" > 0
LIMIT 1
`, [address, PPLNS_PAYOUT_MODE]);
if (row == null) {
return null;
}
const payoutSats = this.toNumber(row.payoutSats);
const coinbaseValueSats = this.toNumber(row.coinbaseValueSats);
return {
snapshotId: row.snapshotId,
blockHeight: this.toNumber(row.blockHeight),
payoutMode: row.payoutMode,
payoutSats,
grossPayoutSats: this.toNumber(row.grossPayoutSats),
creditedDifficulty: this.toNumber(row.creditedDifficulty),
payoutWeight: this.toNumber(row.payoutWeight),
coinbaseValueSats,
distributedSats: this.toNumber(row.distributedSats),
totalCreditedDifficulty: this.toNumber(row.totalCreditedDifficulty),
includedOutputCount: this.toNumber(row.includedOutputCount),
createdAt: row.createdAt,
percent: coinbaseValueSats > 0 ? (payoutSats / coinbaseValueSats) * 100 : 0,
};
}
private async getPplnsWindow(manager: EntityManager, windowTargetDifficulty: number): Promise<{
startBatchId: string;
endBatchId: string;
@@ -370,6 +487,7 @@ export class PayoutSnapshotService {
) AS "difficultyAfter"
FROM "share_rollup_batch"
WHERE "status" = 'finalized'
AND "payoutMode" = $2
),
selected AS MATERIALIZED (
SELECT *
@@ -384,7 +502,7 @@ export class PayoutSnapshotService {
COALESCE(SUM("creditedDifficulty"), 0)::numeric AS "totalCreditedDifficulty",
COALESCE(SUM("acceptedShareCount"), 0)::bigint AS "totalAcceptedShareCount"
FROM selected
`, [windowTargetDifficulty]);
`, [windowTargetDifficulty, PPLNS_PAYOUT_MODE]);
if (window?.startBatchId == null || window?.windowEndShareIndex == null) {
return null;
@@ -404,6 +522,8 @@ export class PayoutSnapshotService {
FROM "share_rollup_batch_summary" s
JOIN "share_rollup_batch" b ON b."id" = s."batchId"
WHERE b."status" = 'finalized'
AND b."payoutMode" = $3
AND s."payoutMode" = $3
AND b."endShareIndex" >= $1::bigint
AND b."endShareIndex" <= $2::bigint
GROUP BY s."address"
@@ -411,6 +531,7 @@ export class PayoutSnapshotService {
`, [
window.windowStartShareIndex,
window.windowEndShareIndex,
PPLNS_PAYOUT_MODE,
]);
return rows.map(row => ({
address: row.address,
@@ -439,6 +560,7 @@ export class PayoutSnapshotService {
SELECT "id"::text AS "id"
FROM "payout_snapshot"
WHERE "method" = $1
AND "payoutMode" = $5
AND "status" = 'finalized'
AND "blockHeight" = $2::bigint
AND "coinbaseValueSats" = $3::bigint
@@ -450,6 +572,7 @@ export class PayoutSnapshotService {
input.blockHeight,
input.coinbaseValueSats.toString(),
input.windowEndShareIndex,
PPLNS_PAYOUT_MODE,
]);
if (snapshot?.id == null) {
@@ -463,6 +586,7 @@ export class PayoutSnapshotService {
SELECT
"id"::text AS "id",
"method",
"payoutMode",
"blockHeight"::int AS "blockHeight",
"coinbaseValueSats"::text AS "coinbaseValueSats",
"windowStartShareIndex"::text AS "windowStartShareIndex",
@@ -475,7 +599,8 @@ export class PayoutSnapshotService {
"unallocatedRemainderSats"::text AS "unallocatedRemainderSats"
FROM "payout_snapshot"
WHERE "id" = $1::bigint
`, [id]);
AND "payoutMode" = $2
`, [id, PPLNS_PAYOUT_MODE]);
if (snapshot == null) {
return null;
}
@@ -486,14 +611,16 @@ export class PayoutSnapshotService {
"payoutSats"::text AS "payoutSats"
FROM "payout_snapshot_entry"
WHERE "snapshotId" = $1::bigint
AND "payoutMode" = $2
AND "includedInCoinbase" = true
ORDER BY "rank"
`, [id]);
`, [id, PPLNS_PAYOUT_MODE]);
const coinbaseValue = Number(snapshot.coinbaseValueSats);
return {
id: snapshot.id,
method: snapshot.method,
payoutMode: snapshot.payoutMode,
blockHeight: this.toNumber(snapshot.blockHeight),
coinbaseValueSats: snapshot.coinbaseValueSats,
windowStartShareIndex: snapshot.windowStartShareIndex,
@@ -518,6 +645,7 @@ export class PayoutSnapshotService {
await manager.query(`
INSERT INTO "payout_snapshot_entry" (
"snapshotId",
"payoutMode",
"address",
"creditedDifficulty",
"acceptedShareCount",
@@ -532,19 +660,21 @@ export class PayoutSnapshotService {
) VALUES (
$1::bigint,
$2,
$3::numeric,
$4::bigint,
$5::numeric,
$6::bigint,
$3,
$4::numeric,
$5::bigint,
$6::numeric,
$7::bigint,
$8::bigint,
$9::bigint,
$10::boolean,
$11,
$12::int
$10::bigint,
$11::boolean,
$12,
$13::int
)
`, [
snapshotId,
PPLNS_PAYOUT_MODE,
entry.address,
entry.creditedDifficulty,
entry.acceptedShareCount,
@@ -561,7 +691,8 @@ export class PayoutSnapshotService {
private limitCoinbaseOutputs(entries: PayoutDistributionEntry[]): PayoutDistributionEntry[] {
let included = 0;
return entries.map(entry => {
let removedPayoutSats = 0;
const limitedEntries = entries.map(entry => {
if (!entry.includedInCoinbase) {
return entry;
}
@@ -569,6 +700,7 @@ export class PayoutSnapshotService {
if (included <= this.maxCoinbaseOutputs) {
return entry;
}
removedPayoutSats += entry.payoutSats;
return {
...entry,
payoutSats: 0,
@@ -576,6 +708,43 @@ export class PayoutSnapshotService {
includedInCoinbase: false,
};
});
if (removedPayoutSats <= 0) {
return limitedEntries;
}
const keptActive = limitedEntries
.filter(entry => entry.includedInCoinbase && entry.creditedDifficulty > 0);
const keptDifficulty = keptActive.reduce((sum, entry) => sum + entry.creditedDifficulty, 0);
if (keptDifficulty <= 0) {
return limitedEntries;
}
let assigned = 0;
const allocations = keptActive
.map(entry => {
const exact = (entry.creditedDifficulty * removedPayoutSats) / keptDifficulty;
const whole = Math.floor(exact);
assigned += whole;
return {
entry,
whole,
fraction: exact - whole,
};
})
.sort((a, b) => b.fraction - a.fraction || b.entry.creditedDifficulty - a.entry.creditedDifficulty || a.entry.address.localeCompare(b.entry.address));
let residual = removedPayoutSats - assigned;
for (const allocation of allocations) {
const extra = allocation.whole + (residual > 0 ? 1 : 0);
if (residual > 0) {
residual--;
}
allocation.entry.payoutSats += extra;
allocation.entry.balanceAfterSats -= extra;
}
return limitedEntries;
}
private isSuccessfulBlockSubmission(result?: string | null): boolean {