upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorbitflash-maintainer <noreply@bitflash.invalid>2026-09-02 08:59:21 +0000
committerbitflash-maintainer <noreply@bitflash.invalid>2026-09-02 08:59:40 +0000
commitd8eb3aeec198e9e6753b89a53d25f4f91f8ab3d6 (patch)
tree602a3399828d2dc542a3cab7e7f85f92a6ca3518
parenta2e1f27b32442bcea92eeda3ac09432f6c5ec2c5 (diff)
payout: refuse comment-only evidence for a round that has a proof
A pre-upgrade payout comment names only the height, so when a height is won, orphaned, and won again, the old transaction matches the new round's (height, address, amount) exactly and the miner is never paid. Round proofs decide the ambiguity where it is decidable: a proof is written when a round is created, and structured payout metadata (upstream 0b01d6c) predates round proofs (upstream b1eb8b6), so a round with a proof was created by a build whose every payment names its round -- a comment-only match can only be another round's money. Skipping the fallback cannot double-pay: any real payment for such a round is already found by the structured probe. Proofless rounds keep the documented bias.
-rw-r--r--src/rpc.cpp161
1 files changed, 139 insertions, 22 deletions
diff --git a/src/rpc.cpp b/src/rpc.cpp
index dbbf06d..310c0d4 100644
--- a/src/rpc.cpp
+++ b/src/rpc.cpp
@@ -1369,6 +1369,24 @@ static void MarkPoolRoundForfeited(const std::string& id,
1369 PublishPoolRounds(snap); 1369 PublishPoolRounds(snap);
1370} 1370}
1371 1371
1372// Does this round have a published round proof?
1373//
1374// Existence answers a temporal question the legacy fallback below needs:
1375// which builds could have paid this round. RecordPoolRound writes the proof
1376// when the round is created, and structured payout metadata (upstream
1377// cbdd06d) is an ancestor of round proofs (upstream 83ba822) -- no build
1378// able to write a proof has ever sent a payment that only a comment can
1379// identify. A round with a proof therefore cannot have a comment-only
1380// payment; one that matches it is another round's, colliding on the height.
1381static bool PoolRoundHasProof(const std::string& id)
1382{
1383 std::lock_guard<std::mutex> lk(gPoolRoundsMutex);
1384 for (const PoolRoundProof& r : gPoolRounds)
1385 if (r.id == id)
1386 return true;
1387 return false;
1388}
1389
1372// Which wallet transaction, if any, already paid this? 1390// Which wallet transaction, if any, already paid this?
1373// 1391//
1374// Two kinds of evidence, in order of quality. 1392// Two kinds of evidence, in order of quality.
@@ -1404,11 +1422,22 @@ static void MarkPoolRoundForfeited(const std::string& id,
1404// (round, address) pair -- from the ledger's paidTxids, which is durable, 1422// (round, address) pair -- from the ledger's paidTxids, which is durable,
1405// and from what this process has already claimed. One legacy transaction 1423// and from what this process has already claimed. One legacy transaction
1406// can therefore settle at most one debt, so N transactions can never 1424// can therefore settle at most one debt, so N transactions can never
1407// satisfy more than N obligations. 1425// satisfy more than N obligations;
1426//
1427// 4. it is not consulted at all for a round that has a round proof
1428// (fAllowLegacy, decided by the caller from PoolRoundHasProof). Proofs
1429// are written when a round is created, and only by builds whose every
1430// payment names its round exactly -- so a comment-only transaction
1431// cannot be a payment for such a round, only a same-height collision
1432// from an older one, and skipping the fallback cannot hide a real
1433// payment: the structured probe above already found any that exist.
1408// 1434//
1409// What is left unresolved by construction is two pre-upgrade rounds at the 1435// What is left unresolved by construction is a comment-only transaction and
1410// SAME height paying the same address the same amount, since the comment 1436// a PROOFLESS round at the same height paying the same address the same
1411// carries only the height. That needs a reorg. 1437// amount: two grandfathered zero-hash twins, or a twin pair from the builds
1438// that keyed the ledger by hash before any metadata reached the wallet. The
1439// comment carries only the height, so nothing ties the transaction to one
1440// twin rather than the other. That needs a reorg.
1412// 1441//
1413// This used to say the losing twin's ledger entry is voided by the orphan 1442// This used to say the losing twin's ledger entry is voided by the orphan
1414// check before it can be paid. That is wrong: PayoutBlockStillOnChain() 1443// check before it can be paid. That is wrong: PayoutBlockStillOnChain()
@@ -1445,6 +1474,7 @@ static PayoutProbeResult FindRecordedPoolPayout(const std::string& id,
1445 const uint256& blockHash, 1474 const uint256& blockHash,
1446 const std::string& address, 1475 const std::string& address,
1447 int64 amount, 1476 int64 amount,
1477 bool fAllowLegacy,
1448 const std::set<std::string>& excluded, 1478 const std::set<std::string>& excluded,
1449 std::string& txidRet) 1479 std::string& txidRet)
1450{ 1480{
@@ -1505,6 +1535,8 @@ static PayoutProbeResult FindRecordedPoolPayout(const std::string& id,
1505 return PAYOUT_PROBE_STRUCTURED; 1535 return PAYOUT_PROBE_STRUCTURED;
1506 } 1536 }
1507 1537
1538 if (!fAllowLegacy)
1539 continue; // comment-only evidence is inadmissible: rule 4
1508 if (!fHaveScript) 1540 if (!fHaveScript)
1509 continue; 1541 continue;
1510 // The comment is written only by the payout path, so its presence 1542 // The comment is written only by the payout path, so its presence
@@ -2142,10 +2174,15 @@ static void PayoutThreadFn(void*)
2142 std::set<std::string> excluded; 2174 std::set<std::string> excluded;
2143 CollectForeignPayoutTxids(r.blockHeight, r.blockHash, r.address, 2175 CollectForeignPayoutTxids(r.blockHeight, r.blockHash, r.address,
2144 claimKey, excluded); 2176 claimKey, excluded);
2177 // A round with a published proof was created by a build whose
2178 // every payment names its round exactly, so only structured
2179 // evidence can settle it -- a comment match would be another
2180 // round's payment colliding on the height.
2181 const bool fAllowLegacy = !PoolRoundHasProof(id);
2145 r.fProbeWallet = false; 2182 r.fProbeWallet = false;
2146 PayoutProbeResult probe = FindRecordedPoolPayout( 2183 PayoutProbeResult probe = FindRecordedPoolPayout(
2147 id, r.blockHeight, r.blockHash, r.address, r.amount, 2184 id, r.blockHeight, r.blockHash, r.address, r.amount,
2148 excluded, recovered); 2185 fAllowLegacy, excluded, recovered);
2149 if (probe != PAYOUT_PROBE_NONE) { 2186 if (probe != PAYOUT_PROBE_NONE) {
2150 if (probe == PAYOUT_PROBE_LEGACY) { 2187 if (probe == PAYOUT_PROBE_LEGACY) {
2151 // Loud on purpose: this is the upgrade path, where the 2188 // Loud on purpose: this is the upgrade path, where the
@@ -3880,31 +3917,34 @@ int RunPayoutLedgerSelfTest()
3880 const std::string comment = LegacyPayoutComment(height); 3917 const std::string comment = LegacyPayoutComment(height);
3881 std::set<std::string> none; 3918 std::set<std::string> none;
3882 std::string found; 3919 std::string found;
3920 // No round proof exists for these rounds, so legacy evidence is
3921 // admissible -- computed exactly as the payout thread computes it.
3922 const bool fAllowLegacy = !PoolRoundHasProof(id);
3883 3923
3884 check(FindRecordedPoolPayout(id, height, 0, addrA, amount, none, found) 3924 check(FindRecordedPoolPayout(id, height, 0, addrA, amount, fAllowLegacy, none, found)
3885 == PAYOUT_PROBE_NONE, 3925 == PAYOUT_PROBE_NONE,
3886 "an empty wallet is not evidence that anything was paid"); 3926 "an empty wallet is not evidence that anything was paid");
3887 3927
3888 const std::string legacyTxid = 3928 const std::string legacyTxid =
3889 PayoutTestAddWalletTx(comment, addrA, amount, 1, ""); 3929 PayoutTestAddWalletTx(comment, addrA, amount, 1, "");
3890 check(FindRecordedPoolPayout(id, height, 0, addrA, amount, none, found) 3930 check(FindRecordedPoolPayout(id, height, 0, addrA, amount, fAllowLegacy, none, found)
3891 == PAYOUT_PROBE_LEGACY && found == legacyTxid, 3931 == PAYOUT_PROBE_LEGACY && found == legacyTxid,
3892 "a payment carrying only a pre-upgrade comment is recognised as" 3932 "a payment carrying only a pre-upgrade comment is recognised as"
3893 " already paid"); 3933 " already paid");
3894 3934
3895 check(FindRecordedPoolPayout(id, height, 0, addrB, amount, none, found) 3935 check(FindRecordedPoolPayout(id, height, 0, addrB, amount, fAllowLegacy, none, found)
3896 == PAYOUT_PROBE_NONE, 3936 == PAYOUT_PROBE_NONE,
3897 "the legacy match requires the output to pay THIS recipient"); 3937 "the legacy match requires the output to pay THIS recipient");
3898 check(FindRecordedPoolPayout(id, height, 0, addrA, amount + COIN, none, found) 3938 check(FindRecordedPoolPayout(id, height, 0, addrA, amount + COIN, fAllowLegacy, none, found)
3899 == PAYOUT_PROBE_NONE, 3939 == PAYOUT_PROBE_NONE,
3900 "the legacy match requires the output to pay exactly this amount"); 3940 "the legacy match requires the output to pay exactly this amount");
3901 check(FindRecordedPoolPayout(id, height + 1, 0, addrA, amount, none, found) 3941 check(FindRecordedPoolPayout(id, height + 1, 0, addrA, amount, fAllowLegacy, none, found)
3902 == PAYOUT_PROBE_NONE, 3942 == PAYOUT_PROBE_NONE,
3903 "the legacy match requires the comment for this block height"); 3943 "the legacy match requires the comment for this block height");
3904 3944
3905 std::set<std::string> spent; 3945 std::set<std::string> spent;
3906 spent.insert(legacyTxid); 3946 spent.insert(legacyTxid);
3907 check(FindRecordedPoolPayout(id, height, 0, addrA, amount, spent, found) 3947 check(FindRecordedPoolPayout(id, height, 0, addrA, amount, fAllowLegacy, spent, found)
3908 == PAYOUT_PROBE_NONE, 3948 == PAYOUT_PROBE_NONE,
3909 "one legacy transaction cannot settle a second debt"); 3949 "one legacy transaction cannot settle a second debt");
3910 3950
@@ -3913,7 +3953,7 @@ int RunPayoutLedgerSelfTest()
3913 // exactly what the structured metadata exists to prevent. 3953 // exactly what the structured metadata exists to prevent.
3914 const std::string otherId = PayoutIdForBlock(height, 0) + "-other"; 3954 const std::string otherId = PayoutIdForBlock(height, 0) + "-other";
3915 PayoutTestAddWalletTx(comment, addrB, amount, 2, otherId); 3955 PayoutTestAddWalletTx(comment, addrB, amount, 2, otherId);
3916 check(FindRecordedPoolPayout(id, height, 0, addrB, amount, none, found) 3956 check(FindRecordedPoolPayout(id, height, 0, addrB, amount, fAllowLegacy, none, found)
3917 == PAYOUT_PROBE_NONE, 3957 == PAYOUT_PROBE_NONE,
3918 "another round's structured payment is not legacy evidence for" 3958 "another round's structured payment is not legacy evidence for"
3919 " this one"); 3959 " this one");
@@ -3921,7 +3961,7 @@ int RunPayoutLedgerSelfTest()
3921 // The structured path still wins where it applies. 3961 // The structured path still wins where it applies.
3922 const std::string structuredTxid = 3962 const std::string structuredTxid =
3923 PayoutTestAddWalletTx(comment, addrB, amount, 3, id); 3963 PayoutTestAddWalletTx(comment, addrB, amount, 3, id);
3924 check(FindRecordedPoolPayout(id, height, 0, addrB, amount, none, found) 3964 check(FindRecordedPoolPayout(id, height, 0, addrB, amount, fAllowLegacy, none, found)
3925 == PAYOUT_PROBE_STRUCTURED && found == structuredTxid, 3965 == PAYOUT_PROBE_STRUCTURED && found == structuredTxid,
3926 "structured payout metadata is matched in preference to a comment"); 3966 "structured payout metadata is matched in preference to a comment");
3927 3967
@@ -3935,7 +3975,7 @@ int RunPayoutLedgerSelfTest()
3935 PayoutTestClearBlockIndex(); 3975 PayoutTestClearBlockIndex();
3936 const std::string addrC = PayoutTestAddress(0x33); 3976 const std::string addrC = PayoutTestAddress(0x33);
3937 PayoutTestAddWalletTx(comment, addrC, amount, 4, "", false); 3977 PayoutTestAddWalletTx(comment, addrC, amount, 4, "", false);
3938 check(FindRecordedPoolPayout(id, height, 0, addrC, amount, none, found) 3978 check(FindRecordedPoolPayout(id, height, 0, addrC, amount, fAllowLegacy, none, found)
3939 == PAYOUT_PROBE_NONE, 3979 == PAYOUT_PROBE_NONE,
3940 "a pre-upgrade payout that never confirmed is not read as" 3980 "a pre-upgrade payout that never confirmed is not read as"
3941 " payment"); 3981 " payment");
@@ -3945,7 +3985,7 @@ int RunPayoutLedgerSelfTest()
3945 mapWallet.clear(); 3985 mapWallet.clear();
3946 const std::string confirmedTxid = 3986 const std::string confirmedTxid =
3947 PayoutTestAddWalletTx(comment, addrC, amount, 4, "", true); 3987 PayoutTestAddWalletTx(comment, addrC, amount, 4, "", true);
3948 check(FindRecordedPoolPayout(id, height, 0, addrC, amount, none, found) 3988 check(FindRecordedPoolPayout(id, height, 0, addrC, amount, fAllowLegacy, none, found)
3949 == PAYOUT_PROBE_LEGACY && found == confirmedTxid, 3989 == PAYOUT_PROBE_LEGACY && found == confirmedTxid,
3950 "the same payout, confirmed, is"); 3990 "the same payout, confirmed, is");
3951 3991
@@ -3956,7 +3996,7 @@ int RunPayoutLedgerSelfTest()
3956 PayoutTestAddWalletTx(comment, addrC, amount, 5, "", false); 3996 PayoutTestAddWalletTx(comment, addrC, amount, 5, "", false);
3957 std::set<std::string> spentFirst; 3997 std::set<std::string> spentFirst;
3958 spentFirst.insert(confirmedTxid); 3998 spentFirst.insert(confirmedTxid);
3959 check(FindRecordedPoolPayout(id, height, 0, addrC, amount, spentFirst, found) 3999 check(FindRecordedPoolPayout(id, height, 0, addrC, amount, fAllowLegacy, spentFirst, found)
3960 == PAYOUT_PROBE_LEGACY && found == alsoConfirmed, 4000 == PAYOUT_PROBE_LEGACY && found == alsoConfirmed,
3961 "an unconfirmed transaction does not hide a confirmed one behind" 4001 "an unconfirmed transaction does not hide a confirmed one behind"
3962 " it"); 4002 " it");
@@ -4511,11 +4551,14 @@ int RunPayoutLedgerSelfTest()
4511 const std::string idB = PayoutIdForBlock(height, hashB); 4551 const std::string idB = PayoutIdForBlock(height, hashB);
4512 std::set<std::string> none; 4552 std::set<std::string> none;
4513 std::string found; 4553 std::string found;
4554 // Neither twin has a round proof here (section 15 covers the gate),
4555 // and their ids collide, so one answer serves both.
4556 const bool fAllowLegacy = !PoolRoundHasProof(idA);
4514 PayoutTestAddWalletTx(PayoutComment(height, hashA), addrA, amount, 1, ""); 4557 PayoutTestAddWalletTx(PayoutComment(height, hashA), addrA, amount, 1, "");
4515 check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, none, found) 4558 check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, fAllowLegacy, none, found)
4516 == PAYOUT_PROBE_NONE, 4559 == PAYOUT_PROBE_NONE,
4517 "a new-format comment is never legacy evidence for a twin round"); 4560 "a new-format comment is never legacy evidence for a twin round");
4518 check(FindRecordedPoolPayout(idA, height, hashA, addrA, amount, none, found) 4561 check(FindRecordedPoolPayout(idA, height, hashA, addrA, amount, fAllowLegacy, none, found)
4519 == PAYOUT_PROBE_NONE, 4562 == PAYOUT_PROBE_NONE,
4520 "nor a match channel for its own round -- structured metadata is"); 4563 "nor a match channel for its own round -- structured metadata is");
4521 4564
@@ -4527,7 +4570,7 @@ int RunPayoutLedgerSelfTest()
4527 const std::string paidA = 4570 const std::string paidA =
4528 PayoutTestAddWalletTx(PayoutComment(height, hashA), addrA, amount, 4571 PayoutTestAddWalletTx(PayoutComment(height, hashA), addrA, amount,
4529 2, idA); 4572 2, idA);
4530 check(FindRecordedPoolPayout(idA, height, hashA, addrA, amount, none, found) 4573 check(FindRecordedPoolPayout(idA, height, hashA, addrA, amount, fAllowLegacy, none, found)
4531 == PAYOUT_PROBE_STRUCTURED && found == paidA, 4574 == PAYOUT_PROBE_STRUCTURED && found == paidA,
4532 "the winning twin's own payment is found by its structured id"); 4575 "the winning twin's own payment is found by its structured id");
4533 4576
@@ -4539,7 +4582,7 @@ int RunPayoutLedgerSelfTest()
4539 check(idA == idB, 4582 check(idA == idB,
4540 "twin hashes differing only past the sixteenth hex digit collide" 4583 "twin hashes differing only past the sixteenth hex digit collide"
4541 " in id -- the reason the full hash must decide"); 4584 " in id -- the reason the full hash must decide");
4542 check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, none, found) 4585 check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, fAllowLegacy, none, found)
4543 == PAYOUT_PROBE_STRUCTURED, 4586 == PAYOUT_PROBE_STRUCTURED,
4544 "a payment without the block key answers for a colliding twin --" 4587 "a payment without the block key answers for a colliding twin --"
4545 " the accepted residual for pre-key transactions"); 4588 " the accepted residual for pre-key transactions");
@@ -4552,10 +4595,10 @@ int RunPayoutLedgerSelfTest()
4552 const std::string keyedA = 4595 const std::string keyedA =
4553 PayoutTestAddWalletTx(PayoutComment(height, hashA), addrA, amount, 4596 PayoutTestAddWalletTx(PayoutComment(height, hashA), addrA, amount,
4554 3, idA, true, hashA.ToString()); 4597 3, idA, true, hashA.ToString());
4555 check(FindRecordedPoolPayout(idA, height, hashA, addrA, amount, none, found) 4598 check(FindRecordedPoolPayout(idA, height, hashA, addrA, amount, fAllowLegacy, none, found)
4556 == PAYOUT_PROBE_STRUCTURED && found == keyedA, 4599 == PAYOUT_PROBE_STRUCTURED && found == keyedA,
4557 "a payment carrying the full block hash settles its own round"); 4600 "a payment carrying the full block hash settles its own round");
4558 check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, none, found) 4601 check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, fAllowLegacy, none, found)
4559 == PAYOUT_PROBE_NONE, 4602 == PAYOUT_PROBE_NONE,
4560 "and is no evidence for the twin, so the re-won height is paid"); 4603 "and is no evidence for the twin, so the re-won height is paid");
4561 4604
@@ -4564,6 +4607,80 @@ int RunPayoutLedgerSelfTest()
4564 PayoutTestClearBlockIndex(); 4607 PayoutTestClearBlockIndex();
4565 } 4608 }
4566 4609
4610 // -- 15. A proof-bearing round rejects comment-only evidence -------------
4611 //
4612 // The surviving half of the same-height collision: a pre-upgrade
4613 // transaction carries only "Pool payout height H", so if H is won again
4614 // after a reorg, it matches the new round's (height, address, amount)
4615 // exactly and would settle a debt it never paid. The new round has a
4616 // round proof -- written at creation, and only by builds whose payments
4617 // all name their round -- so for it, comment-only evidence is
4618 // inadmissible and the miner is paid. A round without a proof keeps
4619 // today's bias: the evidence is admissible and doubt resolves to
4620 // "already paid".
4621 {
4622 PayoutTestResetState();
4623 CRITICAL_BLOCK(cs_mapWallet)
4624 mapWallet.clear();
4625 PayoutTestClearBlockIndex();
4626 const std::string roundsPath = dir + "/pool_rounds_gate_selftest.json";
4627 const std::string savedRoundsFile = strPoolRoundsFile;
4628 strPoolRoundsFile = roundsPath;
4629 remove(roundsPath.c_str());
4630
4631 const int height = 7171;
4632 const int64 amount = 4 * COIN;
4633 uint256 hashB = 0;
4634 hashB.SetHex("00000000000000000000000000000000000000000000000000000000000000cc");
4635 const std::string idB = PayoutIdForBlock(height, hashB);
4636 std::set<std::string> none;
4637 std::string found;
4638
4639 // The colliding pre-upgrade payment: confirmed, comment-only, same
4640 // height, same address, same amount -- the strongest wrong evidence
4641 // there is.
4642 PayoutTestAddWalletTx(LegacyPayoutComment(height), addrA, amount, 1, "");
4643
4644 check(!PoolRoundHasProof(idB),
4645 "a round with no recorded proof does not claim one");
4646 check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount,
4647 !PoolRoundHasProof(idB), none, found)
4648 == PAYOUT_PROBE_LEGACY,
4649 "without a proof the doubt is genuine and still resolves to"
4650 " already-paid");
4651
4652 PoolRoundProof round;
4653 round.id = idB;
4654 round.blockHeight = height;
4655 round.matureAtHeight = height + COINBASE_SPENDABLE_DEPTH;
4656 round.payouts[addrA] = amount;
4657 RecordPoolRound(round);
4658 check(PoolRoundHasProof(idB),
4659 "recording the round proof marks the round as this lineage's");
4660 check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount,
4661 !PoolRoundHasProof(idB), none, found)
4662 == PAYOUT_PROBE_NONE,
4663 "with a proof, a same-height comment-only transaction is not"
4664 " evidence, and the re-won height is paid");
4665
4666 // Structured evidence still is: the round's own payment, recovered.
4667 const std::string paidB =
4668 PayoutTestAddWalletTx(PayoutComment(height, hashB), addrA, amount,
4669 2, idB, true, hashB.ToString());
4670 check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount,
4671 !PoolRoundHasProof(idB), none, found)
4672 == PAYOUT_PROBE_STRUCTURED && found == paidB,
4673 "the gate does not blind the probe to the round's own structured"
4674 " payment");
4675
4676 CRITICAL_BLOCK(cs_mapWallet)
4677 mapWallet.clear();
4678 PayoutTestClearBlockIndex();
4679 remove(roundsPath.c_str());
4680 remove((roundsPath + ".tmp").c_str());
4681 strPoolRoundsFile = savedRoundsFile;
4682 }
4683
4567 // -- clean up ------------------------------------------------------------ 4684 // -- clean up ------------------------------------------------------------
4568 PayoutTestResetState(); 4685 PayoutTestResetState();
4569 CRITICAL_BLOCK(cs_mapWallet) 4686 CRITICAL_BLOCK(cs_mapWallet)