diff options
| author | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 07:40:33 +0000 |
|---|---|---|
| committer | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 07:40:33 +0000 |
| commit | c4754fb505be75b8eedee3ce230a8991762e18c5 (patch) | |
| tree | 4d9e0007b1b56f6fac69fe143f40d9c58ef18c8d | |
| parent | 489be141ee3be1c355650e1429c3f830eb8e442d (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.
| -rw-r--r-- | src/rpc.cpp | 182 |
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 | ||
| 1135 | static 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. | ||
| 1174 | enum 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 | |||
| 1180 | static 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. | ||
| 1253 | static std::map<std::string, std::string> gLegacyClaimedTxids; | ||
| 1254 | static 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. | ||
| 1259 | static 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 | |||
| 1282 | static 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; |