diff options
| author | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 11:50:12 +0000 |
|---|---|---|
| committer | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 11:50:12 +0000 |
| commit | 46864cdf3c90d15d5d21a330bb54f43696aa7d3b (patch) | |
| tree | 213ce88d42cbc207448c14335c3263a140e40902 | |
| parent | e960802fa7149c9f997074ec7c1a7af66fb47e0f (diff) | |
payout: say why a grandfathered pair is safe, since it is not the orphan check
The comment on FindRecordedPoolPayout justified the legacy fallback by
saying that where two pre-upgrade rounds share a height, the losing
one's ledger entry is voided by the orphan check before it can be paid.
That check is PayoutBlockStillOnChain(), which returns true for a zero
hash -- and every pre-upgrade entry has a zero hash, because they were
written before payouts recorded one. So it is a no-op for exactly the
population the fallback exists to serve. The stated reason for a
safety property was not the reason.
The property does hold, by a different mechanism. SamePayoutRound()
treats two zero-hash entries at the same height as the same round, so
the pair is indistinguishable to every consumer, not just to this one:
FlushMaturePayouts explodes one and skips the other as already queued,
PaymentInFlightLocked refuses a second record for that (round, address),
and RecordPayoutTxid settles against the first match. The pair presents
as one debt and can be paid at most once. The cost of that -- only one
of a genuine same-height pair would ever be paid -- is now written down
too, rather than left as an implication.
Both halves are asserted rather than reasoned about: the self-test
checks that the orphan check passes a zero hash, and that two identical
zero-hash entries at one height produce exactly one in-flight record.
Property: the comment describes the mechanism that is actually
load-bearing, and a test fails if either half of it stops being true.
| -rw-r--r-- | src/rpc.cpp | 58 |
1 files changed, 53 insertions, 5 deletions
diff --git a/src/rpc.cpp b/src/rpc.cpp index 7b45fea..7ea0597 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp | |||
| @@ -1379,11 +1379,32 @@ static void MarkPoolRoundForfeited(const std::string& id, | |||
| 1379 | // | 1379 | // |
| 1380 | // What is left unresolved by construction is two pre-upgrade rounds at the | 1380 | // What is left unresolved by construction is two pre-upgrade rounds at the |
| 1381 | // SAME height paying the same address the same amount, since the comment | 1381 | // SAME height paying the same address the same amount, since the comment |
| 1382 | // carries only the height. That needs a reorg, and the losing round's ledger | 1382 | // carries only the height. That needs a reorg. |
| 1383 | // entry is voided by the orphan check before it can be paid, so the pair | 1383 | // |
| 1384 | // cannot both be live. Where the evidence genuinely runs out the fallback | 1384 | // This used to say the losing twin's ledger entry is voided by the orphan |
| 1385 | // simply does not match, and the payment is made -- paying a debt we cannot | 1385 | // check before it can be paid. That is wrong: PayoutBlockStillOnChain() |
| 1386 | // prove was settled is the correct default for a pool. | 1386 | // returns true for a zero hash, and EVERY pre-upgrade entry has a zero hash, |
| 1387 | // so the orphan check is a no-op for exactly the population this fallback | ||
| 1388 | // serves. | ||
| 1389 | // | ||
| 1390 | // The outcome is still safe, by a different mechanism. SamePayoutRound() | ||
| 1391 | // treats two zero-hash entries at the same height as the SAME round, so the | ||
| 1392 | // pair is indistinguishable everywhere, not only here: FlushMaturePayouts | ||
| 1393 | // explodes one of them and skips the other as already queued, | ||
| 1394 | // PaymentInFlightLocked refuses a second record for that (round, address), and | ||
| 1395 | // RecordPayoutTxid settles against the first match. The pair therefore | ||
| 1396 | // presents as ONE debt and can be paid at most once -- which is the direction | ||
| 1397 | // that matters for a fallback whose failure mode is treating one payment as | ||
| 1398 | // proof of two. | ||
| 1399 | // | ||
| 1400 | // The cost of that mechanism, stated plainly: only one of a genuine | ||
| 1401 | // same-height pair would ever be paid. It takes a reorg AND both rounds | ||
| 1402 | // predating hash keying, and no file this tree has written since can produce | ||
| 1403 | // one, so it is a property of history rather than a live risk. | ||
| 1404 | // | ||
| 1405 | // Where the evidence genuinely runs out the fallback simply does not match, | ||
| 1406 | // and the payment is made -- paying a debt we cannot prove was settled is the | ||
| 1407 | // correct default for a pool. | ||
| 1387 | enum PayoutProbeResult { | 1408 | enum PayoutProbeResult { |
| 1388 | PAYOUT_PROBE_NONE = 0, // the wallet holds no record of this payment | 1409 | PAYOUT_PROBE_NONE = 0, // the wallet holds no record of this payment |
| 1389 | PAYOUT_PROBE_STRUCTURED, // matched this build's pool_payout_* metadata | 1410 | PAYOUT_PROBE_STRUCTURED, // matched this build's pool_payout_* metadata |
| @@ -3594,6 +3615,33 @@ int RunPayoutLedgerSelfTest() | |||
| 3594 | PayoutRoundIdentifiable(7, 0), | 3615 | PayoutRoundIdentifiable(7, 0), |
| 3595 | "an entry with neither hash nor height is not adjudicable"); | 3616 | "an entry with neither hash nor height is not adjudicable"); |
| 3596 | 3617 | ||
| 3618 | // What actually makes a grandfathered same-height pair safe, since the | ||
| 3619 | // comment on FindRecordedPoolPayout used to claim it was the orphan | ||
| 3620 | // check. It is not: the orphan check passes every zero-hash entry, and | ||
| 3621 | // every pre-upgrade entry has a zero hash. | ||
| 3622 | check(PayoutBlockStillOnChain(0), | ||
| 3623 | "the orphan check passes every grandfathered entry, so it cannot" | ||
| 3624 | " be what voids a losing twin"); | ||
| 3625 | PayoutTestResetState(); | ||
| 3626 | remove(ledgerPath.c_str()); | ||
| 3627 | remove(payingPath.c_str()); | ||
| 3628 | { | ||
| 3629 | std::lock_guard<std::mutex> lk(gPayoutLedgerMutex); | ||
| 3630 | PendingPayout twin; | ||
| 3631 | twin.blockHeight = 300; twin.blockHash = 0; twin.matureAtHeight = 300; | ||
| 3632 | twin.amounts[addrA] = 1 * COIN; | ||
| 3633 | gPendingPayouts.push_back(twin); | ||
| 3634 | gPendingPayouts.push_back(twin); // the losing side of a reorg | ||
| 3635 | } | ||
| 3636 | nBestHeight = 5000; | ||
| 3637 | FlushMaturePayouts(); | ||
| 3638 | { | ||
| 3639 | std::lock_guard<std::mutex> lk(gPayoutExecMutex); | ||
| 3640 | check(gPaying.size() == 1, | ||
| 3641 | "two grandfathered rounds at one height are one round, so the" | ||
| 3642 | " pair presents as one debt and is paid at most once"); | ||
| 3643 | } | ||
| 3644 | |||
| 3597 | // End to end: recording a payment against one grandfathered round must | 3645 | // End to end: recording a payment against one grandfathered round must |
| 3598 | // not settle the other's debt. | 3646 | // not settle the other's debt. |
| 3599 | PayoutTestResetState(); | 3647 | PayoutTestResetState(); |