mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 17:15:03 -07:00
Fix DATUM PPLNS coinbaser snapshots
This commit is contained in:
@@ -95,6 +95,63 @@ describe('PayoutSnapshotService', () => {
|
||||
expect(manager.query.mock.calls[0][1]).toEqual([100, 'pplns']);
|
||||
});
|
||||
|
||||
it('should allow protocol-specific snapshot methods and coinbase limits', 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: '57' }])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{
|
||||
id: '57',
|
||||
method: 'pplns-datum',
|
||||
blockHeight: 900002,
|
||||
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: 900002,
|
||||
coinbaseValueSats: 1000,
|
||||
networkDifficulty: 25,
|
||||
method: 'pplns-datum',
|
||||
maxCoinbaseOutputs: 1,
|
||||
coinbaseWeightBudget: 2000,
|
||||
});
|
||||
|
||||
expect(snapshot).toEqual(expect.objectContaining({
|
||||
id: '57',
|
||||
method: 'pplns-datum',
|
||||
includedOutputCount: 1,
|
||||
}));
|
||||
expect(manager.query.mock.calls[1][1][0]).toBe('pplns-datum');
|
||||
expect(manager.query.mock.calls[4][1][0]).toBe('pplns-datum');
|
||||
expect(manager.query.mock.calls[4][1][10]).toBe(2000);
|
||||
expect(manager.query.mock.calls[4][1][18]).toBe(1);
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -303,7 +360,7 @@ describe('PayoutSnapshotService', () => {
|
||||
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][1]).toEqual([ADDRESS_A, 'pplns', 'pplns']);
|
||||
expect(dataSource.query.mock.calls[0][0]).toContain('WITH latest_snapshot AS');
|
||||
});
|
||||
|
||||
|
||||
@@ -51,6 +51,15 @@ export interface ExpectedPayout {
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface CreatePayoutSnapshotInput {
|
||||
blockHeight: number;
|
||||
coinbaseValueSats: number;
|
||||
networkDifficulty: number;
|
||||
method?: string;
|
||||
maxCoinbaseOutputs?: number;
|
||||
coinbaseWeightBudget?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_COINBASE_OUTPUTS = 10;
|
||||
const DEFAULT_MIN_OUTPUT_SATS = 546;
|
||||
const DEFAULT_PAYOUT_METHOD = 'pplns';
|
||||
@@ -75,15 +84,15 @@ export class PayoutSnapshotService {
|
||||
private readonly dataSource: DataSource,
|
||||
) { }
|
||||
|
||||
public async createSnapshotForTemplate(input: {
|
||||
blockHeight: number;
|
||||
coinbaseValueSats: number;
|
||||
networkDifficulty: number;
|
||||
}): Promise<PayoutSnapshotForTemplate | null> {
|
||||
public async createSnapshotForTemplate(input: CreatePayoutSnapshotInput): Promise<PayoutSnapshotForTemplate | null> {
|
||||
if (!this.snapshotsEnabled || input.coinbaseValueSats <= 0 || input.networkDifficulty <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const method = this.normalizeMethod(input.method);
|
||||
const maxCoinbaseOutputs = this.resolvePositiveInt(input.maxCoinbaseOutputs, this.maxCoinbaseOutputs);
|
||||
const coinbaseWeightBudget = this.resolvePositiveInt(input.coinbaseWeightBudget, this.coinbaseWeightBudget);
|
||||
|
||||
return this.dataSource.transaction(async manager => {
|
||||
const effectiveWindowFactor = await this.getEffectiveWindowFactor(manager);
|
||||
const windowTargetDifficulty = input.networkDifficulty * effectiveWindowFactor;
|
||||
@@ -93,6 +102,7 @@ export class PayoutSnapshotService {
|
||||
}
|
||||
|
||||
const existing = await this.getExistingSnapshot(manager, {
|
||||
method,
|
||||
blockHeight: input.blockHeight,
|
||||
coinbaseValueSats: input.coinbaseValueSats,
|
||||
windowEndShareIndex: window.windowEndShareIndex,
|
||||
@@ -114,9 +124,9 @@ export class PayoutSnapshotService {
|
||||
feeAddress: this.feeAddress,
|
||||
feePercent: this.feePercent,
|
||||
minOutputSats: this.minOutputSats,
|
||||
coinbaseWeightBudget: this.coinbaseWeightBudget,
|
||||
coinbaseWeightBudget,
|
||||
});
|
||||
const entries = this.limitCoinbaseOutputs(distribution.entries);
|
||||
const entries = this.limitCoinbaseOutputs(distribution.entries, maxCoinbaseOutputs);
|
||||
if (entries.every(entry => !entry.includedInCoinbase)) {
|
||||
return null;
|
||||
}
|
||||
@@ -176,7 +186,7 @@ export class PayoutSnapshotService {
|
||||
)
|
||||
RETURNING "id"::text AS "id"
|
||||
`, [
|
||||
this.method,
|
||||
method,
|
||||
PPLNS_PAYOUT_MODE,
|
||||
input.blockHeight,
|
||||
input.coinbaseValueSats.toString(),
|
||||
@@ -186,7 +196,7 @@ export class PayoutSnapshotService {
|
||||
this.feeAddress,
|
||||
distribution.feeSats.toString(),
|
||||
this.minOutputSats,
|
||||
this.coinbaseWeightBudget,
|
||||
coinbaseWeightBudget,
|
||||
window.startBatchId,
|
||||
window.endBatchId,
|
||||
window.windowStartShareIndex,
|
||||
@@ -390,9 +400,10 @@ export class PayoutSnapshotService {
|
||||
FROM "payout_snapshot"
|
||||
WHERE "status" = 'finalized'
|
||||
AND "payoutMode" = $1
|
||||
AND "method" = $2
|
||||
ORDER BY "createdAt" DESC, "id" DESC
|
||||
LIMIT 1
|
||||
`, [PPLNS_PAYOUT_MODE]);
|
||||
`, [PPLNS_PAYOUT_MODE, this.method]);
|
||||
if (snapshot?.id == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -414,6 +425,7 @@ export class PayoutSnapshotService {
|
||||
FROM "payout_snapshot"
|
||||
WHERE "status" = 'finalized'
|
||||
AND "payoutMode" = $2
|
||||
AND "method" = $3
|
||||
ORDER BY "createdAt" DESC, "id" DESC
|
||||
LIMIT 1
|
||||
)
|
||||
@@ -437,7 +449,7 @@ export class PayoutSnapshotService {
|
||||
AND e."includedInCoinbase" = true
|
||||
AND e."payoutSats" > 0
|
||||
LIMIT 1
|
||||
`, [address, PPLNS_PAYOUT_MODE]);
|
||||
`, [address, PPLNS_PAYOUT_MODE, this.method]);
|
||||
|
||||
if (row == null) {
|
||||
return null;
|
||||
@@ -554,7 +566,7 @@ export class PayoutSnapshotService {
|
||||
|
||||
private async getExistingSnapshot(
|
||||
manager: EntityManager,
|
||||
input: { blockHeight: number; coinbaseValueSats: number; windowEndShareIndex: string },
|
||||
input: { method: string; blockHeight: number; coinbaseValueSats: number; windowEndShareIndex: string },
|
||||
): Promise<PayoutSnapshotForTemplate | null> {
|
||||
const [snapshot] = await manager.query(`
|
||||
SELECT "id"::text AS "id"
|
||||
@@ -568,7 +580,7 @@ export class PayoutSnapshotService {
|
||||
ORDER BY "id" DESC
|
||||
LIMIT 1
|
||||
`, [
|
||||
this.method,
|
||||
input.method,
|
||||
input.blockHeight,
|
||||
input.coinbaseValueSats.toString(),
|
||||
input.windowEndShareIndex,
|
||||
@@ -689,7 +701,7 @@ export class PayoutSnapshotService {
|
||||
]);
|
||||
}
|
||||
|
||||
private limitCoinbaseOutputs(entries: PayoutDistributionEntry[]): PayoutDistributionEntry[] {
|
||||
private limitCoinbaseOutputs(entries: PayoutDistributionEntry[], maxCoinbaseOutputs: number): PayoutDistributionEntry[] {
|
||||
let included = 0;
|
||||
let removedPayoutSats = 0;
|
||||
const limitedEntries = entries.map(entry => {
|
||||
@@ -697,7 +709,7 @@ export class PayoutSnapshotService {
|
||||
return entry;
|
||||
}
|
||||
included++;
|
||||
if (included <= this.maxCoinbaseOutputs) {
|
||||
if (included <= maxCoinbaseOutputs) {
|
||||
return entry;
|
||||
}
|
||||
removedPayoutSats += entry.payoutSats;
|
||||
@@ -761,6 +773,17 @@ export class PayoutSnapshotService {
|
||||
return Number.isInteger(value) && value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private resolvePositiveInt(value: number | undefined, defaultValue: number): number {
|
||||
return Number.isInteger(value) && value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private normalizeMethod(method?: string): string {
|
||||
const normalized = method?.trim();
|
||||
return normalized == null || normalized.length === 0
|
||||
? this.method
|
||||
: normalized.slice(0, 32);
|
||||
}
|
||||
|
||||
private readNonNegativeInt(name: string, defaultValue: number): number {
|
||||
const value = Number(process.env[name]);
|
||||
return Number.isInteger(value) && value >= 0 ? value : defaultValue;
|
||||
|
||||
Reference in New Issue
Block a user