diff options
| author | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 11:44:16 +0000 |
|---|---|---|
| committer | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 11:44:16 +0000 |
| commit | 3ec75952d9142bb5aa58307fc5a07db264ecdb24 (patch) | |
| tree | 2b55df9a44d8243e55d889052ce4a9bd9f99ee13 | |
| parent | 9d6fcb2058d90e3ecfb011e2168e4eee04e3a90a (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.
| -rw-r--r-- | src/rpc.cpp | 164 |
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". | ||
| 3049 | static std::vector<CBlockIndex*> gPayoutTestBlockIndexes; | ||
| 3050 | static CBlockIndex* gPayoutTestChainTip = NULL; | ||
| 3051 | |||
| 3052 | static 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. | ||
| 3002 | static std::string PayoutTestAddWalletTx(const std::string& comment, | 3080 | static 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. |