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 07:40:33 +0000
committerbitflash-maintainer <noreply@bitflash.invalid>2026-08-23 07:40:33 +0000
commitc4754fb505be75b8eedee3ce230a8991762e18c5 (patch)
tree4d9e0007b1b56f6fac69fe143f40d9c58ef18c8d /src/rpc.cpp
parent489be141ee3be1c355650e1429c3f830eb8e442d (diff)
payout: recognise a payment a pre-upgrade build already made
FindRecordedPoolPayout matches on pool_payout_id / pool_payout_address / pool_payout_amount. That metadata is written only by this build. Every payout the pool has actually sent, in the wallet right now, carries nothing but a "comment". So the first boot after this upgrade is the dangerous one: swap the binary after an unclean shutdown with payments in flight, and the recovery probe finds no evidence for any of them and pays every one of those recipients a second time. The deleted WalletAlreadyPaid() matched on what the payment WAS -- the comment plus an output paying exactly that amount to exactly that script -- and that evidence is still sitting in the wallet. It just stopped being read. The probe now answers with which kind of evidence it found, and the legacy form is restored as a fallback under three constraints that keep it from answering for a round that is not the one being asked about: 1. it is consulted only when the structured lookup found nothing, so the exact path stays primary and strictly better going forward; 2. any transaction carrying pool_payout_id is excluded from it outright. That key means this build wrote the transaction, and this build's transactions are adjudicated exactly -- so a newer round's payment can never be offered as proof for an older one. Only pre-upgrade transactions, which is the entire population the fallback exists for, are eligible; 3. a txid already credited to a DIFFERENT (round, address) is excluded -- from the ledger's paidTxids, which is durable across restarts, and from what this process has already claimed. One legacy transaction therefore settles at most one debt, so N transactions can never discharge more than N obligations. Two debts with one transaction pays the second, which is the correct reading of the evidence. What cannot be resolved from a comment alone is two PRE-UPGRADE rounds at the same height paying the same address the same amount, because the comment carries only the height. That requires a reorg, and the losing round's ledger entry is voided by the orphan check before it can be paid, so the two cannot both be live. Where the evidence genuinely runs out the fallback does not match and the payment is made: paying a debt that cannot be proven settled is the correct default for a pool, and it is the direction that does not keep a miner's money. Recovery on legacy evidence logs loudly and says exactly what it matched on, because it is a judgement made about a binary that is no longer running. The two lookups share one pass over mapWallet. The structured path used to scan alone; adding a second scan would have doubled a linear walk taken under cs_mapWallet, which the whole node contends on. No on-disk format change.
Diffstat (limited to 'src/rpc.cpp')
-rw-r--r--src/rpc.cpp182
1 files changed, 164 insertions, 18 deletions
diff --git a/src/rpc.cpp b/src/rpc.cpp
index 7badb7c..b71457c 100644
--- a/src/rpc.cpp
+++ b/src/rpc.cpp
@@ -1132,11 +1132,71 @@ static void MarkPoolRoundPaid(const std::string& id,
1132 PublishPoolRounds(snap); 1132 PublishPoolRounds(snap);
1133} 1133}
1134 1134
1135static bool FindRecordedPoolPayout(const std::string& id, 1135// Which wallet transaction, if any, already paid this?
1136 const std::string& address, 1136//
1137 int64 amount, 1137// Two kinds of evidence, in order of quality.
1138 std::string& txidRet) 1138//
1139// Structured (pool_payout_id / pool_payout_address / pool_payout_amount) is
1140// upstream's, and it is exact: the id is derived from the block hash, so it
1141// names one round and one round only. Every transaction this build sends
1142// carries it.
1143//
1144// But NO transaction this pool has ever sent carries it. Every payout in the
1145// existing wallet has only a "comment". So on the first boot after this
1146// upgrade -- precisely the boot where the binary is swapped after an unclean
1147// shutdown with payments in flight -- a structured-only probe finds nothing
1148// and every one of those recipients is paid a second time. The deleted
1149// WalletAlreadyPaid() matched on what the payment WAS, and that evidence is
1150// still there; it just has to be read.
1151//
1152// The legacy fallback is therefore restored, with three constraints that
1153// together stop it answering for a round that is not this one:
1154//
1155// 1. it is only consulted when the structured lookup found nothing;
1156// 2. it ignores any transaction carrying pool_payout_id. That key means this
1157// build wrote the transaction, and this build's transactions are
1158// adjudicated by the exact path -- so a NEW round's payment can never be
1159// offered as evidence for an old one. Only pre-upgrade transactions,
1160// which is exactly the population the fallback exists for, are eligible;
1161// 3. `excluded` holds every txid already credited to some OTHER
1162// (round, address) pair -- from the ledger's paidTxids, which is durable,
1163// and from what this process has already claimed. One legacy transaction
1164// can therefore settle at most one debt, so N transactions can never
1165// satisfy more than N obligations.
1166//
1167// What is left unresolved by construction is two pre-upgrade rounds at the
1168// SAME height paying the same address the same amount, since the comment
1169// carries only the height. That needs a reorg, and the losing round's ledger
1170// entry is voided by the orphan check before it can be paid, so the pair
1171// cannot both be live. Where the evidence genuinely runs out the fallback
1172// simply does not match, and the payment is made -- paying a debt we cannot
1173// prove was settled is the correct default for a pool.
1174enum PayoutProbeResult {
1175 PAYOUT_PROBE_NONE = 0, // the wallet holds no record of this payment
1176 PAYOUT_PROBE_STRUCTURED, // matched this build's pool_payout_* metadata
1177 PAYOUT_PROBE_LEGACY // matched a pre-upgrade comment and output
1178};
1179
1180static PayoutProbeResult FindRecordedPoolPayout(const std::string& id,
1181 int blockHeight,
1182 const std::string& address,
1183 int64 amount,
1184 const std::set<std::string>& excluded,
1185 std::string& txidRet)
1139{ 1186{
1187 // Built before cs_mapWallet is taken: an invalid address has no script to
1188 // compare against, and the caller has already rejected those.
1189 uint160 h160;
1190 bool fHaveScript = AddressToHash160(address, h160);
1191 CScript want;
1192 if (fHaveScript)
1193 want << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG;
1194 const std::string comment = PayoutComment(blockHeight);
1195
1196 std::string legacyTxid;
1197
1198 // One pass, not two: this scan runs under cs_mapWallet, which the whole
1199 // node contends on, and the merge is already paying for it once per record.
1140 CRITICAL_BLOCK(cs_mapWallet) 1200 CRITICAL_BLOCK(cs_mapWallet)
1141 { 1201 {
1142 for (map<uint256, CWalletTx>::const_iterator it = mapWallet.begin(); 1202 for (map<uint256, CWalletTx>::const_iterator it = mapWallet.begin();
@@ -1144,19 +1204,85 @@ static bool FindRecordedPoolPayout(const std::string& id,
1144 { 1204 {
1145 const CWalletTx& wtx = it->second; 1205 const CWalletTx& wtx = it->second;
1146 map<string, string>::const_iterator idIt = wtx.mapValue.find("pool_payout_id"); 1206 map<string, string>::const_iterator idIt = wtx.mapValue.find("pool_payout_id");
1147 if (idIt == wtx.mapValue.end() || idIt->second != id) 1207 if (idIt != wtx.mapValue.end()) {
1148 continue; 1208 // Written by this build. Adjudicated exactly, and never
1149 map<string, string>::const_iterator addrIt = wtx.mapValue.find("pool_payout_address"); 1209 // offered to the fallback below.
1150 if (addrIt == wtx.mapValue.end() || addrIt->second != address) 1210 if (idIt->second != id)
1211 continue;
1212 map<string, string>::const_iterator addrIt = wtx.mapValue.find("pool_payout_address");
1213 if (addrIt == wtx.mapValue.end() || addrIt->second != address)
1214 continue;
1215 map<string, string>::const_iterator amtIt = wtx.mapValue.find("pool_payout_amount");
1216 if (amtIt == wtx.mapValue.end() || amtIt->second != strprintf("%lld", amount))
1217 continue;
1218 txidRet = wtx.GetHash().GetHex();
1219 return PAYOUT_PROBE_STRUCTURED;
1220 }
1221
1222 if (!fHaveScript || !legacyTxid.empty())
1151 continue; 1223 continue;
1152 map<string, string>::const_iterator amtIt = wtx.mapValue.find("pool_payout_amount"); 1224 // The comment is written only by the payout path, so its presence
1153 if (amtIt == wtx.mapValue.end() || amtIt->second != strprintf("%lld", amount)) 1225 // already identifies the transaction as one of ours.
1226 map<string, string>::const_iterator ci = wtx.mapValue.find("comment");
1227 if (ci == wtx.mapValue.end() || ci->second != comment)
1154 continue; 1228 continue;
1155 txidRet = wtx.GetHash().GetHex(); 1229 std::string txid = wtx.GetHash().GetHex();
1156 return true; 1230 if (excluded.count(txid))
1231 continue; // already credited to a different debt
1232 foreach (const CTxOut& txout, wtx.vout)
1233 if (txout.nValue == amount && txout.scriptPubKey == want) {
1234 legacyTxid = txid;
1235 break;
1236 }
1157 } 1237 }
1158 } 1238 }
1159 return false; 1239
1240 if (!legacyTxid.empty()) {
1241 txidRet = legacyTxid;
1242 return PAYOUT_PROBE_LEGACY;
1243 }
1244 return PAYOUT_PROBE_NONE;
1245}
1246
1247// txid -> "<round id>|<address>", for payments this run has already credited
1248// to a debt on legacy evidence. Stops one pre-upgrade transaction being read
1249// as proof that two different obligations were met. The ledger's paidTxids
1250// covers the same ground durably; this covers the window before the recovered
1251// txid reaches the ledger, and the case where the ledger entry has already
1252// been retired.
1253static std::map<std::string, std::string> gLegacyClaimedTxids;
1254static std::mutex gClaimedTxidMutex;
1255
1256// Every txid already spoken for by some payment OTHER than this one. Collected
1257// before cs_mapWallet is taken: the ledger mutex must not be acquired under a
1258// wallet lock.
1259static void CollectForeignPayoutTxids(int blockHeight, const uint256& blockHash,
1260 const std::string& address,
1261 const std::string& claimKey,
1262 std::set<std::string>& out)
1263{
1264 {
1265 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
1266 for (const PendingPayout& pp : gPendingPayouts) {
1267 bool sameRound = SamePayoutRound(pp.blockHeight, pp.blockHash,
1268 blockHeight, blockHash);
1269 for (const auto& kv : pp.paidTxids)
1270 if (!(sameRound && kv.first == address))
1271 out.insert(kv.second);
1272 }
1273 }
1274 {
1275 std::lock_guard<std::mutex> lk(gClaimedTxidMutex);
1276 for (const auto& kv : gLegacyClaimedTxids)
1277 if (kv.second != claimKey)
1278 out.insert(kv.first);
1279 }
1280}
1281
1282static void ClaimLegacyPayoutTxid(const std::string& txid, const std::string& claimKey)
1283{
1284 std::lock_guard<std::mutex> lk(gClaimedTxidMutex);
1285 gLegacyClaimedTxids[txid] = claimKey;
1160} 1286}
1161 1287
1162// blockHash stays a uint256 and not upstream's display string: it is the key 1288// blockHash stays a uint256 and not upstream's display string: it is the key
@@ -1603,12 +1729,32 @@ static void PayoutThreadFn(void*)
1603 // Only asked once per record -- see PayingRecord::fProbeWallet. 1729 // Only asked once per record -- see PayingRecord::fProbeWallet.
1604 if (r.fProbeWallet) { 1730 if (r.fProbeWallet) {
1605 std::string recovered; 1731 std::string recovered;
1732 const std::string claimKey = id + "|" + r.address;
1733 std::set<std::string> excluded;
1734 CollectForeignPayoutTxids(r.blockHeight, r.blockHash, r.address,
1735 claimKey, excluded);
1606 r.fProbeWallet = false; 1736 r.fProbeWallet = false;
1607 if (FindRecordedPoolPayout(id, r.address, r.amount, recovered)) { 1737 PayoutProbeResult probe = FindRecordedPoolPayout(
1608 LogPrint("payout", "[payout] recovered recorded payout %s to %s" 1738 id, r.blockHeight, r.address, r.amount, excluded, recovered);
1609 " from wallet tx %s -- not resending\n", 1739 if (probe != PAYOUT_PROBE_NONE) {
1610 FormatMoney(r.amount).c_str(), r.address.c_str(), 1740 if (probe == PAYOUT_PROBE_LEGACY) {
1611 recovered.c_str()); 1741 // Loud on purpose: this is the upgrade path, where the
1742 // only evidence a payment went out is a comment written
1743 // by a binary that is no longer running.
1744 LogPrint("payout", "[payout] %s to %s was already paid by"
1745 " a pre-upgrade build (wallet tx %s carries the"
1746 " payout comment for height %d and an output of"
1747 " exactly this amount, and no structured payout"
1748 " metadata) -- not resending\n",
1749 FormatMoney(r.amount).c_str(), r.address.c_str(),
1750 recovered.c_str(), r.blockHeight);
1751 ClaimLegacyPayoutTxid(recovered, claimKey);
1752 } else {
1753 LogPrint("payout", "[payout] recovered recorded payout %s to %s"
1754 " from wallet tx %s -- not resending\n",
1755 FormatMoney(r.amount).c_str(), r.address.c_str(),
1756 recovered.c_str());
1757 }
1612 RecordPayoutTxid(r, recovered); 1758 RecordPayoutTxid(r, recovered);
1613 ForgetPayment(r); 1759 ForgetPayment(r);
1614 continue; 1760 continue;