mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 17:15:03 -07:00
Add payout modes and production switchover schema
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user