upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/rpc.cpp
diff options
context:
space:
mode:
authorbitflash-maintainer <noreply@bitflash.invalid>2026-08-23 11:44:16 +0000
committerbitflash-maintainer <noreply@bitflash.invalid>2026-08-23 11:44:16 +0000
commit3ec75952d9142bb5aa58307fc5a07db264ecdb24 (patch)
tree2b55df9a44d8243e55d889052ce4a9bd9f99ee13 /src/rpc.cpp
parent9d6fcb2058d90e3ecfb011e2168e4eee04e3a90a (diff)
payout: require a confirmation before reading a pre-upgrade comment as payment
FindRecordedPoolPayout's legacy branch matched any wallet transaction carrying this height's payout comment and an output of exactly this amount to this address, and never asked whether that transaction was in the chain. A pre-upgrade payout that never confirmed is evidence of NON-payment, and it was being read as payment: the recipient is dropped from the in-flight list, a txid is recorded against the ledger entry, and the debt retires having never been settled. The miner is not paid and nothing on disk says they were owed. That is not hypothetical here. This tree has had a 280-block reorg (see the note in FlushMaturePayouts). A payout invalidated by one is still sitting in mapWallet with its comment intact, and the first boot after this upgrade -- the boot where the binary is swapped after an unclean shutdown with payments in flight -- is precisely when the legacy probe runs against it. The structured branch is deliberately left alone. This process sent those transactions moments earlier, and a send that failed leaves nothing behind: SendMoney calls RollbackTransactionSpent, which calls EraseFromWallet. Depth there would only reject payments that really were made. The check cannot be done inside the wallet scan. GetDepthInMainChain() reads mapBlockIndex and pindexBest, which belong to cs_main, and cs_main is taken before cs_mapWallet everywhere else in this tree -- CreateTransaction, ConnectBlock -- so testing it under cs_mapWallet would invert the lock order in the payout thread. Comment matches are collected in the scan and adjudicated afterwards under cs_main then cs_mapWallet, in that order. That also fixes a second thing: the old code stopped at the first comment match, so one unconfirmed transaction could hide a confirmed one behind it. A rejection logs the txid, height, amount and depth, and says the recipient will be paid, because this is the one decision where being wrong is unrecoverable in the direction that costs a miner money. Property: a payment is only ever inferred from a pre-upgrade comment when the transaction carrying it is in the main chain.
Diffstat (limited to 'src/rpc.cpp')
-rw-r--r--src/rpc.cpp164
1 files changed, 155 insertions, 9 deletions
diff --git a/src/rpc.cpp b/src/rpc.cpp
index 06de7f2..b0436ba 100644
--- a/src/rpc.cpp
+++ b/src/rpc.cpp
@@ -1339,7 +1339,16 @@ static PayoutProbeResult FindRecordedPoolPayout(const std::string& id,
1339 want << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; 1339 want << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG;
1340 const std::string comment = PayoutComment(blockHeight); 1340 const std::string comment = PayoutComment(blockHeight);
1341 1341
1342 std::string legacyTxid; 1342 // Candidate legacy matches, adjudicated after cs_mapWallet is released.
1343 //
1344 // The depth check cannot happen inside the scan: GetDepthInMainChain()
1345 // reads mapBlockIndex and pindexBest, which belong to cs_main, and cs_main
1346 // is taken BEFORE cs_mapWallet everywhere else in this tree
1347 // (CreateTransaction, ConnectBlock). Taking it the other way round here
1348 // would be a lock-order inversion in the payout thread. There is at most a
1349 // handful of these: a candidate has to carry this exact height's comment
1350 // and an output of exactly this amount to this address.
1351 std::vector<uint256> legacyCandidates;
1343 1352
1344 // One pass, not two: this scan runs under cs_mapWallet, which the whole 1353 // One pass, not two: this scan runs under cs_mapWallet, which the whole
1345 // node contends on, and the merge is already paying for it once per record. 1354 // node contends on, and the merge is already paying for it once per record.
@@ -1365,27 +1374,63 @@ static PayoutProbeResult FindRecordedPoolPayout(const std::string& id,
1365 return PAYOUT_PROBE_STRUCTURED; 1374 return PAYOUT_PROBE_STRUCTURED;
1366 } 1375 }
1367 1376
1368 if (!fHaveScript || !legacyTxid.empty()) 1377 if (!fHaveScript)
1369 continue; 1378 continue;
1370 // The comment is written only by the payout path, so its presence 1379 // The comment is written only by the payout path, so its presence
1371 // already identifies the transaction as one of ours. 1380 // already identifies the transaction as one of ours.
1372 map<string, string>::const_iterator ci = wtx.mapValue.find("comment"); 1381 map<string, string>::const_iterator ci = wtx.mapValue.find("comment");
1373 if (ci == wtx.mapValue.end() || ci->second != comment) 1382 if (ci == wtx.mapValue.end() || ci->second != comment)
1374 continue; 1383 continue;
1375 std::string txid = wtx.GetHash().GetHex(); 1384 if (excluded.count(wtx.GetHash().GetHex()))
1376 if (excluded.count(txid))
1377 continue; // already credited to a different debt 1385 continue; // already credited to a different debt
1378 foreach (const CTxOut& txout, wtx.vout) 1386 foreach (const CTxOut& txout, wtx.vout)
1379 if (txout.nValue == amount && txout.scriptPubKey == want) { 1387 if (txout.nValue == amount && txout.scriptPubKey == want) {
1380 legacyTxid = txid; 1388 legacyCandidates.push_back(it->first);
1381 break; 1389 break;
1382 } 1390 }
1383 } 1391 }
1384 } 1392 }
1385 1393
1386 if (!legacyTxid.empty()) { 1394 // A legacy match must have CONFIRMED.
1387 txidRet = legacyTxid; 1395 //
1388 return PAYOUT_PROBE_LEGACY; 1396 // The structured path does not need this and deliberately does not have
1397 // it: this process sent that transaction moments ago, and a send that
1398 // failed leaves nothing behind, because SendMoney calls
1399 // RollbackTransactionSpent, which calls EraseFromWallet.
1400 //
1401 // The legacy path is the opposite case in every respect. Those
1402 // transactions are days or weeks old, so whether they confirmed is a
1403 // settled fact rather than an open question, and a pre-upgrade payout that
1404 // never confirmed is evidence of NON-payment about to be read as payment.
1405 // This chain has had a 280-block reorg (see FlushMaturePayouts): a payout
1406 // invalidated by it is still sitting in mapWallet with its comment intact,
1407 // and the first boot after this upgrade is exactly when it gets matched.
1408 // The cost of being wrong here is a miner who is never paid and no record
1409 // that they were owed.
1410 CRITICAL_BLOCK(cs_main)
1411 CRITICAL_BLOCK(cs_mapWallet)
1412 {
1413 for (const uint256& hash : legacyCandidates) {
1414 map<uint256, CWalletTx>::const_iterator it = mapWallet.find(hash);
1415 if (it == mapWallet.end())
1416 continue; // erased under us; it is not evidence of anything
1417 int depth = it->second.GetDepthInMainChain();
1418 if (depth < 1) {
1419 // Loud: this is the difference between a miner having been
1420 // paid and a miner about to be paid twice, decided on a fact
1421 // that is easy to check and impossible to recover afterwards.
1422 LogPrint("payout", "[payout] wallet tx %s carries the payout"
1423 " comment for height %d and an output of exactly %s to"
1424 " %s, but it is NOT in the main chain (depth %d) --"
1425 " refusing to read it as payment; this recipient will"
1426 " be paid\n",
1427 hash.GetHex().c_str(), blockHeight,
1428 FormatMoney(amount).c_str(), address.c_str(), depth);
1429 continue;
1430 }
1431 txidRet = hash.GetHex();
1432 return PAYOUT_PROBE_LEGACY;
1433 }
1389 } 1434 }
1390 return PAYOUT_PROBE_NONE; 1435 return PAYOUT_PROBE_NONE;
1391} 1436}
@@ -2997,13 +3042,47 @@ static CScript PayoutTestScriptFor(const std::string& address)
2997 return sc; 3042 return sc;
2998} 3043}
2999 3044
3045// Block index entries fabricated by PayoutTestAddWalletTx, so they can be
3046// freed and unhooked when the test finishes. gPayoutTestChainTip is the end of
3047// that chain: blocks are linked onto it rather than each replacing the tip,
3048// because IsInMainChain() is "pnext is set, or I am the tip".
3049static std::vector<CBlockIndex*> gPayoutTestBlockIndexes;
3050static CBlockIndex* gPayoutTestChainTip = NULL;
3051
3052static void PayoutTestClearBlockIndex()
3053{
3054 CRITICAL_BLOCK(cs_main)
3055 {
3056 for (CBlockIndex* p : gPayoutTestBlockIndexes) {
3057 for (std::map<uint256, CBlockIndex*>::iterator it = mapBlockIndex.begin();
3058 it != mapBlockIndex.end(); ) {
3059 if (it->second == p) mapBlockIndex.erase(it++);
3060 else ++it;
3061 }
3062 delete p;
3063 }
3064 gPayoutTestBlockIndexes.clear();
3065 gPayoutTestChainTip = NULL;
3066 pindexBest = NULL;
3067 }
3068}
3069
3000// Put a transaction in the wallet map that looks exactly like one this pool 3070// Put a transaction in the wallet map that looks exactly like one this pool
3001// sent. `nonce` only exists to keep the hashes distinct. 3071// sent. `nonce` only exists to keep the hashes distinct.
3072//
3073// fConfirmed decides what GetDepthInMainChain() will answer for it, which is
3074// what the legacy probe now gates on. A confirmed one is given a block index
3075// entry of its own: an empty merkle branch hashes to the transaction itself,
3076// so a block whose merkle root IS that hash accepts it at index 0, and
3077// pointing pindexBest at that block puts it in the main chain one deep. An
3078// unconfirmed one keeps hashBlock 0, which is depth 0 -- the shape a payout
3079// left behind by a reorg has.
3002static std::string PayoutTestAddWalletTx(const std::string& comment, 3080static std::string PayoutTestAddWalletTx(const std::string& comment,
3003 const std::string& address, 3081 const std::string& address,
3004 int64 amount, 3082 int64 amount,
3005 int nonce, 3083 int nonce,
3006 const std::string& structuredId) 3084 const std::string& structuredId,
3085 bool fConfirmed = true)
3007{ 3086{
3008 CWalletTx wtx; 3087 CWalletTx wtx;
3009 wtx.vin.resize(1); 3088 wtx.vin.resize(1);
@@ -3020,6 +3099,32 @@ static std::string PayoutTestAddWalletTx(const std::string& comment,
3020 wtx.mapValue["pool_payout_amount"] = strprintf("%lld", amount); 3099 wtx.mapValue["pool_payout_amount"] = strprintf("%lld", amount);
3021 } 3100 }
3022 uint256 hash = wtx.GetHash(); 3101 uint256 hash = wtx.GetHash();
3102 if (fConfirmed) {
3103 // A block hash distinct from the transaction's own.
3104 uint256 blockHash = hash;
3105 ((unsigned char*)&blockHash)[0] ^= 0xff;
3106 CRITICAL_BLOCK(cs_main)
3107 {
3108 CBlockIndex* pindex = new CBlockIndex();
3109 pindex->hashMerkleRoot = hash;
3110 // Linked onto the end of the fabricated chain rather than simply
3111 // becoming the new tip: IsInMainChain() is "pnext is set, or I am
3112 // the tip", so a second confirmed transaction would otherwise take
3113 // the first one out of the chain again.
3114 pindex->pprev = gPayoutTestChainTip;
3115 pindex->nHeight = gPayoutTestChainTip ? gPayoutTestChainTip->nHeight + 1 : 1;
3116 if (gPayoutTestChainTip)
3117 gPayoutTestChainTip->pnext = pindex;
3118 gPayoutTestChainTip = pindex;
3119 std::map<uint256, CBlockIndex*>::iterator mi =
3120 mapBlockIndex.insert(std::make_pair(blockHash, pindex)).first;
3121 pindex->phashBlock = &mi->first;
3122 pindexBest = pindex;
3123 gPayoutTestBlockIndexes.push_back(pindex);
3124 }
3125 wtx.hashBlock = blockHash;
3126 wtx.nIndex = 0;
3127 }
3023 CRITICAL_BLOCK(cs_mapWallet) 3128 CRITICAL_BLOCK(cs_mapWallet)
3024 mapWallet[hash] = wtx; 3129 mapWallet[hash] = wtx;
3025 return hash.GetHex(); 3130 return hash.GetHex();
@@ -3497,6 +3602,46 @@ int RunPayoutLedgerSelfTest()
3497 check(FindRecordedPoolPayout(id, height, addrB, amount, none, found) 3602 check(FindRecordedPoolPayout(id, height, addrB, amount, none, found)
3498 == PAYOUT_PROBE_STRUCTURED && found == structuredTxid, 3603 == PAYOUT_PROBE_STRUCTURED && found == structuredTxid,
3499 "structured payout metadata is matched in preference to a comment"); 3604 "structured payout metadata is matched in preference to a comment");
3605
3606 // A comment-matching transaction that never made it into the chain is
3607 // evidence of NON-payment. This tree has seen a 280-block reorg; a
3608 // pre-upgrade payout invalidated by one is still in mapWallet with its
3609 // comment intact, and the first boot after the upgrade is exactly when
3610 // it would be matched.
3611 CRITICAL_BLOCK(cs_mapWallet)
3612 mapWallet.clear();
3613 PayoutTestClearBlockIndex();
3614 const std::string addrC = PayoutTestAddress(0x33);
3615 PayoutTestAddWalletTx(comment, addrC, amount, 4, "", false);
3616 check(FindRecordedPoolPayout(id, height, addrC, amount, none, found)
3617 == PAYOUT_PROBE_NONE,
3618 "a pre-upgrade payout that never confirmed is not read as"
3619 " payment");
3620
3621 // The same transaction, confirmed, is.
3622 CRITICAL_BLOCK(cs_mapWallet)
3623 mapWallet.clear();
3624 const std::string confirmedTxid =
3625 PayoutTestAddWalletTx(comment, addrC, amount, 4, "", true);
3626 check(FindRecordedPoolPayout(id, height, addrC, amount, none, found)
3627 == PAYOUT_PROBE_LEGACY && found == confirmedTxid,
3628 "the same payout, confirmed, is");
3629
3630 // And an unconfirmed one does not shadow a confirmed one that would
3631 // have matched: the probe used to stop at the first comment match.
3632 const std::string alsoConfirmed =
3633 PayoutTestAddWalletTx(comment, addrC, amount, 6, "", true);
3634 PayoutTestAddWalletTx(comment, addrC, amount, 5, "", false);
3635 std::set<std::string> spentFirst;
3636 spentFirst.insert(confirmedTxid);
3637 check(FindRecordedPoolPayout(id, height, addrC, amount, spentFirst, found)
3638 == PAYOUT_PROBE_LEGACY && found == alsoConfirmed,
3639 "an unconfirmed transaction does not hide a confirmed one behind"
3640 " it");
3641
3642 CRITICAL_BLOCK(cs_mapWallet)
3643 mapWallet.clear();
3644 PayoutTestClearBlockIndex();
3500 } 3645 }
3501 3646
3502 // -- 9. On-disk compatibility with the live production ledger ------------ 3647 // -- 9. On-disk compatibility with the live production ledger ------------
@@ -3908,6 +4053,7 @@ int RunPayoutLedgerSelfTest()
3908 PayoutTestResetState(); 4053 PayoutTestResetState();
3909 CRITICAL_BLOCK(cs_mapWallet) 4054 CRITICAL_BLOCK(cs_mapWallet)
3910 mapWallet.clear(); 4055 mapWallet.clear();
4056 PayoutTestClearBlockIndex();
3911 // Out of the scratch directory before removing it, and out of the key maps 4057 // Out of the scratch directory before removing it, and out of the key maps
3912 // too: the keys this test generated are process-local, but leaving them in 4058 // too: the keys this test generated are process-local, but leaving them in
3913 // the keystore would let a later test think a script was its own. 4059 // the keystore would let a later test think a script was its own.