From cd4e86d431c3f5848183ca362175d1cb3edfa107 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 07:52:52 +0000 Subject: selftest: cover the payout ledger properties this series fixes A new `payout-ledger` self-test, 40 assertions, wired into RunSelfTest and into `make -C src tests`. It runs against a scratch data directory and an in-memory wallet map: it never opens wallet.dat, never touches a real datadir, and never sends anything. What it asserts, defect by defect: - a restart does not duplicate an in-flight record. A matured round is exploded, the process state is thrown away and reloaded from the files twice, and the in-flight list must still hold exactly one record per recipient. Both guards are checked separately -- once with the round-level set restored, and once with it deliberately emptied so only the per-recipient check against gPaying can save it. A paying file already containing the duplicate the old build wrote must collapse on load, with the attempt counts merged upwards rather than reset. - state updates address a slot, not a value. Two records equal in every field -- exactly what the old code produced -- and rescheduling or forgetting one must land on the one it was given. - the ledger file cannot be published from a stale snapshot. An older snapshot published after a newer one must not appear on disk, and four threads publishing 64 large images of the same file in different orders must leave it parseable and equal to the highest version, whatever order they finish in. - a payment with only a legacy comment is recognised as already paid, and the fallback's limits hold: it requires the right recipient, the exact amount and the right height; one legacy transaction cannot settle a second debt; a transaction carrying another round's structured metadata is never accepted as legacy evidence; and the structured match still wins where it applies. - grandfathered zero-hash rounds do not answer for each other, both as a direct test of the round comparison and end to end -- recording a txid against one zero-hash round must leave the other still owed. - the retry clock that is actually used survives a restart. - a forfeit is published rather than silently dropped: the ledger loses that recipient and only that recipient, and the round proof records it with a reason instead of going on advertising the address as owed. - on-disk compatibility, checked against the real shapes: the live production pending_payouts.json (amounts / blockHash / blockHeight / matureAtHeight and nothing else), the merged build's shape including the retryAfter this build no longer writes, and a round trip through this build. Each assertion was verified to fail when its defect is put back: seven mutations were applied one at a time to a scratch copy and every one was caught. Notably, re-introducing either half of the duplicate-record fix alone is caught by its own assertion while the other half still prevents the duplicate -- which is the defence in depth this series was asked for, checked rather than asserted. --- src/Makefile | 1 + src/rpc.cpp | 671 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/selftest.cpp | 5 +- 3 files changed, 676 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/Makefile b/src/Makefile index 1166daf..89ba559 100644 --- a/src/Makefile +++ b/src/Makefile @@ -192,6 +192,7 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=net-message ./bitflash-node -selftest=consensus-limits ./bitflash-node -selftest=pool-stratum + ./bitflash-node -selftest=payout-ledger ./bitflash-node -selftest=parse-money ./bitflash-node -selftest=socks5-proxy diff --git a/src/rpc.cpp b/src/rpc.cpp index 45130ff..cebc349 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -38,6 +38,7 @@ #include #include +#include #include #include #include @@ -2749,6 +2750,676 @@ static bool HandleLine(Miner* m, const std::string& rawLine, return true; } +// --------------------------------------------------------------------------- +// payout-ledger self-test +// +// Regression cover for the payout defects fixed in this series. Everything +// here runs against a scratch data directory and an in-memory wallet map; it +// never opens wallet.dat, never touches a real datadir, and never sends +// anything. +// --------------------------------------------------------------------------- + +static bool PayoutTestReadFile(const std::string& path, std::string& out) +{ + FILE* f = fopen(path.c_str(), "r"); + if (!f) return false; + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + out.assign((size_t)(sz > 0 ? sz : 0), '\0'); + if (sz > 0 && fread(&out[0], 1, (size_t)sz, f) != (size_t)sz) { + fclose(f); + return false; + } + fclose(f); + return true; +} + +static bool PayoutTestWriteFile(const std::string& path, const std::string& data) +{ + FILE* f = fopen(path.c_str(), "w"); + if (!f) return false; + bool ok = data.empty() || fwrite(data.c_str(), 1, data.size(), f) == data.size(); + return fclose(f) == 0 && ok; +} + +// Wipe every piece of payout state this test touches, so each section starts +// from the same place and a "restart" can be simulated by reloading from disk. +static void PayoutTestResetState() +{ + { + std::lock_guard lk(gPayoutLedgerMutex); + gPendingPayouts.clear(); + } + { + std::lock_guard lk(gPayoutExecMutex); + gPaying.clear(); + gQueuedPayouts.clear(); + } + { + std::lock_guard lk(gPoolRoundsMutex); + gPoolRounds.clear(); + } + { + std::lock_guard lk(gClaimedTxidMutex); + gLegacyClaimedTxids.clear(); + } +} + +// A BTF address whose hash160 is entirely under the test's control, so the +// output script the probe compares against can be built without a wallet. +static std::string PayoutTestAddress(unsigned char seed) +{ + uint160 h = 0; + for (int i = 0; i < 20; i++) + ((unsigned char*)&h)[i] = (unsigned char)(seed + i); + return Hash160ToAddress(h); +} + +static CScript PayoutTestScriptFor(const std::string& address) +{ + uint160 h160; + CScript sc; + if (AddressToHash160(address, h160)) + sc << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; + return sc; +} + +// Put a transaction in the wallet map that looks exactly like one this pool +// sent. `nonce` only exists to keep the hashes distinct. +static std::string PayoutTestAddWalletTx(const std::string& comment, + const std::string& address, + int64 amount, + int nonce, + const std::string& structuredId) +{ + CWalletTx wtx; + wtx.vin.resize(1); + wtx.vin[0].prevout.SetNull(); + wtx.vin[0].scriptSig << nonce; + wtx.vout.resize(1); + wtx.vout[0].nValue = amount; + wtx.vout[0].scriptPubKey = PayoutTestScriptFor(address); + if (!comment.empty()) + wtx.mapValue["comment"] = comment; + if (!structuredId.empty()) { + wtx.mapValue["pool_payout_id"] = structuredId; + wtx.mapValue["pool_payout_address"] = address; + wtx.mapValue["pool_payout_amount"] = strprintf("%lld", amount); + } + uint256 hash = wtx.GetHash(); + CRITICAL_BLOCK(cs_mapWallet) + mapWallet[hash] = wtx; + return hash.GetHex(); +} + +int RunPayoutLedgerSelfTest() +{ + int nFail = 0; + auto check = [&](bool ok, const char* msg) { + fprintf(stdout, " %s %s\n", ok ? "ok" : "FAIL", msg); + if (!ok) nFail++; + }; + + fprintf(stdout, "payout-ledger self-test\n"); + + const std::string savedDataDir = strSetDataDir; + const int savedBestHeight = nBestHeight; + + // Scratch data directory in the current working directory. GetAppDir() + // follows strSetDataDir, so this is what PayoutFilePath() and + // PayingFilePath() resolve against for the rest of the test. + std::string dir = strprintf("payout_selftest_%d", (int)GetTime()); +#ifdef _WIN32 + _mkdir(dir.c_str()); +#else + mkdir(dir.c_str(), 0700); +#endif + strSetDataDir = dir; + fprintf(stdout, " scratch datadir: %s\n", dir.c_str()); + + const std::string ledgerPath = PayoutFilePath(); + const std::string payingPath = PayingFilePath(); + const std::string addrA = PayoutTestAddress(0x11); + const std::string addrB = PayoutTestAddress(0x55); + + // -- 1. A restart must not duplicate an in-flight record ----------------- + // + // The ledger entry is grandfathered (zero hash) on purpose: it keeps + // PayoutBlockStillOnChain out of the picture, which needs a block index, + // and it exercises the height fallback at the same time. + { + PayoutTestResetState(); + remove(ledgerPath.c_str()); + remove(payingPath.c_str()); + + PendingPayout pp; + pp.blockHeight = 4242; + pp.blockHash = 0; + pp.matureAtHeight = 4242; + pp.amounts[addrA] = 10 * COIN; + pp.amounts[addrB] = 20 * COIN; + VersionedBlob snap; + { + std::lock_guard lk(gPayoutLedgerMutex); + gPendingPayouts.push_back(pp); + snap = SerializePendingPayoutsLocked(); + } + PublishPendingPayouts(snap); + + nBestHeight = 5000; + FlushMaturePayouts(); + size_t afterFirstFlush = 0; + { + std::lock_guard lk(gPayoutExecMutex); + afterFirstFlush = gPaying.size(); + } + check(afterFirstFlush == 2, + "a matured round explodes into one record per unpaid recipient"); + + // Restart: drop everything in memory and come back from the files. + for (int restart = 1; restart <= 2; restart++) { + PayoutTestResetState(); + LoadPendingPayouts(); + LoadPaying(); + ReconcilePayouts(); + + size_t queued = 0; + { + std::lock_guard lk(gPayoutExecMutex); + queued = gQueuedPayouts.size(); + } + if (restart == 1) + check(queued == 1, + "startup rebuilds the exploded-round set from the restored" + " in-flight list"); + + FlushMaturePayouts(); + size_t n = 0; + { + std::lock_guard lk(gPayoutExecMutex); + n = gPaying.size(); + } + check(n == 2, restart == 1 + ? "a restart does not duplicate an in-flight record" + : "a second restart does not duplicate one either"); + } + + // The other half of the guard: even with the round-level dedupe set + // deliberately emptied, the per-recipient check against gPaying must + // stop a second standing order for the same money. + { + std::lock_guard lk(gPayoutExecMutex); + gQueuedPayouts.clear(); + } + FlushMaturePayouts(); + size_t n = 0; + { + std::lock_guard lk(gPayoutExecMutex); + n = gPaying.size(); + } + check(n == 2, + "the flush refuses a second record for a (round, address) already" + " in flight even with the round set lost"); + } + + // -- 2. Duplicates already on disk are collapsed as they are read -------- + { + PayoutTestResetState(); + nlohmann::json arr = nlohmann::json::array(); + for (int i = 0; i < 2; i++) { + nlohmann::json o; + o["blockHeight"] = 4242; + o["blockHash"] = uint256(0).ToString(); + o["matureAtHeight"] = 4242; + o["address"] = addrA; + o["amount"] = (int64)(10 * COIN); + o["retries"] = i == 0 ? 7 : 0; + o["deferrals"] = i == 0 ? 0 : 3; + arr.push_back(o); + } + check(PayoutTestWriteFile(payingPath, arr.dump(2)), + "wrote a paying file containing the duplicate the old build made"); + LoadPaying(); + std::lock_guard lk(gPayoutExecMutex); + check(gPaying.size() == 1, + "loading collapses a duplicate in-flight record from disk"); + check(gPaying.size() == 1 && gPaying[0].retries == 7 && gPaying[0].deferrals == 3, + "collapsing merges the attempt counts upwards, not resetting them"); + } + + // -- 3. State updates address a slot, not a value ----------------------- + // + // Build the exact pathology the old code produced -- two records equal in + // every field -- and prove the update lands on the one asked for. Under + // value matching the first record absorbed everything and the second was + // untouchable. + { + PayoutTestResetState(); + PayingRecord a; + a.blockHeight = 4242; a.blockHash = 0; a.matureAtHeight = 4242; + a.address = addrA; a.amount = 10 * COIN; + PayingRecord b = a; + { + std::lock_guard lk(gPayoutExecMutex); + a.id = ++gPayingNextId; + b.id = ++gPayingNextId; + gPaying.push_back(a); + gPaying.push_back(b); + } + PayingRecord upd = b; + upd.retries = 5; + upd.retryAfter = 1234567; + ReschedulePayment(upd); + { + std::lock_guard lk(gPayoutExecMutex); + check(gPaying.size() == 2 && + gPaying[0].retries == 0 && gPaying[1].retries == 5, + "rescheduling updates the record it was given, not the first" + " one that looks like it"); + } + ForgetPayment(a); + { + std::lock_guard lk(gPayoutExecMutex); + check(gPaying.size() == 1 && gPaying[0].id == b.id, + "forgetting erases the record it was given, not the first" + " one that looks like it"); + } + } + + // -- 4. retryAfter survives a restart ------------------------------------ + { + PayoutTestResetState(); + remove(payingPath.c_str()); + PayingRecord r; + r.blockHeight = 77; r.blockHash = 0; r.matureAtHeight = 77; + r.address = addrA; r.amount = 3 * COIN; + r.retryAfter = 1700000000LL; + r.deferrals = 9; + VersionedBlob snap; + { + std::lock_guard lk(gPayoutExecMutex); + r.id = ++gPayingNextId; + gPaying.push_back(r); + snap = SerializePayingLocked(); + } + PublishPaying(snap); + PayoutTestResetState(); + LoadPaying(); + std::lock_guard lk(gPayoutExecMutex); + check(gPaying.size() == 1 && gPaying[0].retryAfter == 1700000000LL && + gPaying[0].deferrals == 9, + "the retry clock that is actually used survives a restart"); + } + + // -- 5. The ledger file cannot be published from a stale snapshot -------- + { + PayoutTestResetState(); + remove(ledgerPath.c_str()); + + PendingPayout one; + one.blockHeight = 100; one.blockHash = 0; one.matureAtHeight = 100; + one.amounts[addrA] = 1 * COIN; + VersionedBlob older, newer; + { + std::lock_guard lk(gPayoutLedgerMutex); + gPendingPayouts.push_back(one); + older = SerializePendingPayoutsLocked(); + PendingPayout two; + two.blockHeight = 200; two.blockHash = 0; two.matureAtHeight = 200; + two.amounts[addrB] = 2 * COIN; + gPendingPayouts.push_back(two); + newer = SerializePendingPayoutsLocked(); + } + check(newer.seq > older.seq, + "a later serialisation carries a strictly higher version"); + + // The order the race produced: the payout thread's older image + // arriving after the event loop's newer one. + PublishPendingPayouts(newer); + PublishPendingPayouts(older); + std::string onDisk; + check(PayoutTestReadFile(ledgerPath, onDisk) && onDisk == newer.blob, + "an older snapshot cannot overwrite a newer published ledger"); + + // And the ordinary direction still works. + VersionedBlob newest; + { + std::lock_guard lk(gPayoutLedgerMutex); + gPendingPayouts.clear(); + newest = SerializePendingPayoutsLocked(); + } + PublishPendingPayouts(newest); + check(PayoutTestReadFile(ledgerPath, onDisk) && onDisk == newest.blob, + "a newer snapshot does replace the published ledger"); + } + + // -- 6. Concurrent publishers cannot interleave a file ------------------- + // + // Two threads renaming the same ".tmp" over the same target was the + // original defect. The images are made big so a torn write shows up as + // unparseable JSON rather than by luck. + { + const std::string racePath = dir + "/publish_race_selftest.json"; + std::mutex raceMutex; + uint64 racePublished = 0; + + std::vector images; + for (int i = 0; i < 64; i++) { + nlohmann::json arr = nlohmann::json::array(); + for (int k = 0; k < 200; k++) { + nlohmann::json o; + o["image"] = i; + o["entry"] = k; + o["address"] = addrA; + o["amount"] = (int64)(k + 1) * COIN; + arr.push_back(o); + } + VersionedBlob v; + v.blob = arr.dump(2); + v.seq = (uint64)i + 1; + images.push_back(v); + } + + std::vector threads; + for (int t = 0; t < 4; t++) { + threads.push_back(std::thread([&, t]() { + for (size_t i = 0; i < images.size(); i++) { + size_t idx = (i * 7 + (size_t)t * 13) % images.size(); + PublishVersioned(racePath, images[idx], raceMutex, + racePublished, "publish race selftest"); + } + })); + } + for (std::thread& th : threads) + th.join(); + + std::string onDisk; + bool read = PayoutTestReadFile(racePath, onDisk); + bool parses = false; + if (read) { + try { parses = nlohmann::json::parse(onDisk).is_array(); } + catch (...) { parses = false; } + } + check(read && parses, + "four threads publishing the same file never leave it unparseable"); + check(read && onDisk == images.back().blob, + "the published file settles on the highest version, whatever the" + " completion order"); + remove(racePath.c_str()); + remove((racePath + ".tmp").c_str()); + } + + // -- 7. Grandfathered zero-hash rounds do not answer for each other ------ + { + uint256 h1 = 0, h2 = 0; + h1.SetHex("00000000000000000000000000000000000000000000000000000000000000aa"); + h2.SetHex("00000000000000000000000000000000000000000000000000000000000000bb"); + check(!SamePayoutRound(100, 0, 200, 0), + "two zero-hash entries at different heights are different rounds"); + check(SamePayoutRound(100, 0, 100, 0), + "two zero-hash entries at the same height are the same round"); + check(SamePayoutRound(100, h1, 200, h1), + "a real hash identifies the round on its own"); + check(!SamePayoutRound(100, h1, 100, h2), + "different hashes at one height are different rounds"); + check(!SamePayoutRound(100, h1, 100, 0), + "a grandfathered entry never matches a hash-keyed one"); + check(!PayoutRoundIdentifiable(0, 0) && PayoutRoundIdentifiable(0, h1) && + PayoutRoundIdentifiable(7, 0), + "an entry with neither hash nor height is not adjudicable"); + + // End to end: recording a payment against one grandfathered round must + // not settle the other's debt. + PayoutTestResetState(); + remove(ledgerPath.c_str()); + { + std::lock_guard lk(gPayoutLedgerMutex); + PendingPayout first; + first.blockHeight = 100; first.blockHash = 0; first.matureAtHeight = 100; + first.amounts[addrA] = 1 * COIN; + PendingPayout second; + second.blockHeight = 200; second.blockHash = 0; second.matureAtHeight = 200; + second.amounts[addrA] = 1 * COIN; + gPendingPayouts.push_back(first); + gPendingPayouts.push_back(second); + } + PayingRecord r; + r.blockHeight = 200; r.blockHash = 0; r.matureAtHeight = 200; + r.address = addrA; r.amount = 1 * COIN; + RecordPayoutTxid(r, "txid-for-the-second-round"); + { + std::lock_guard lk(gPayoutLedgerMutex); + // The second round was fully paid and retired; the first is + // untouched and still owed. + check(gPendingPayouts.size() == 1 && + gPendingPayouts[0].blockHeight == 100 && + gPendingPayouts[0].paidTxids.empty(), + "a txid recorded against one zero-hash round leaves the other" + " still owed"); + } + } + + // -- 8. A payment with only a legacy comment is recognised as paid ------- + { + PayoutTestResetState(); + const int height = 8080; + const int64 amount = 7 * COIN; + const std::string id = PayoutIdForBlock(height, 0); + const std::string comment = PayoutComment(height); + std::set none; + std::string found; + + check(FindRecordedPoolPayout(id, height, addrA, amount, none, found) + == PAYOUT_PROBE_NONE, + "an empty wallet is not evidence that anything was paid"); + + const std::string legacyTxid = + PayoutTestAddWalletTx(comment, addrA, amount, 1, ""); + check(FindRecordedPoolPayout(id, height, addrA, amount, none, found) + == PAYOUT_PROBE_LEGACY && found == legacyTxid, + "a payment carrying only a pre-upgrade comment is recognised as" + " already paid"); + + check(FindRecordedPoolPayout(id, height, addrB, amount, none, found) + == PAYOUT_PROBE_NONE, + "the legacy match requires the output to pay THIS recipient"); + check(FindRecordedPoolPayout(id, height, addrA, amount + COIN, none, found) + == PAYOUT_PROBE_NONE, + "the legacy match requires the output to pay exactly this amount"); + check(FindRecordedPoolPayout(id, height + 1, addrA, amount, none, found) + == PAYOUT_PROBE_NONE, + "the legacy match requires the comment for this block height"); + + std::set spent; + spent.insert(legacyTxid); + check(FindRecordedPoolPayout(id, height, addrA, amount, spent, found) + == PAYOUT_PROBE_NONE, + "one legacy transaction cannot settle a second debt"); + + // A transaction written by THIS build carries pool_payout_id, and must + // never be offered as legacy evidence for a different round -- that is + // exactly what the structured metadata exists to prevent. + const std::string otherId = PayoutIdForBlock(height, 0) + "-other"; + PayoutTestAddWalletTx(comment, addrB, amount, 2, otherId); + check(FindRecordedPoolPayout(id, height, addrB, amount, none, found) + == PAYOUT_PROBE_NONE, + "another round's structured payment is not legacy evidence for" + " this one"); + + // The structured path still wins where it applies. + const std::string structuredTxid = + PayoutTestAddWalletTx(comment, addrB, amount, 3, id); + check(FindRecordedPoolPayout(id, height, addrB, amount, none, found) + == PAYOUT_PROBE_STRUCTURED && found == structuredTxid, + "structured payout metadata is matched in preference to a comment"); + } + + // -- 9. On-disk compatibility with the live production ledger ------------ + { + PayoutTestResetState(); + // Exactly the shape of the file on the pool node: amounts, blockHash, + // blockHeight, matureAtHeight, and nothing else. + const char* live = + "[{\"amounts\":{\"%s\":1234500000}," + "\"blockHash\":\"00000000000000000000000000000000000000000000000000000000000000cd\"," + "\"blockHeight\":1000," + "\"matureAtHeight\":1120}]"; + check(PayoutTestWriteFile(ledgerPath, strprintf(live, addrA.c_str())), + "wrote a ledger file in the live production shape"); + LoadPendingPayouts(); + { + std::lock_guard lk(gPayoutLedgerMutex); + check(gPendingPayouts.size() == 1 && + gPendingPayouts[0].blockHeight == 1000 && + gPendingPayouts[0].matureAtHeight == 1120 && + gPendingPayouts[0].blockHash != 0 && + gPendingPayouts[0].amounts.size() == 1 && + gPendingPayouts[0].amounts[addrA] == 1234500000LL, + "the live production pending_payouts.json still loads"); + } + + // And the shape the merged build writes, retryAfter included, which + // this build no longer writes but must still read past. + PayoutTestResetState(); + const char* merged = + "[{\"amounts\":{\"%s\":100000000}," + "\"blockHash\":\"00000000000000000000000000000000000000000000000000000000000000ef\"," + "\"blockHeight\":2000,\"matureAtHeight\":2120,\"retryAfter\":0," + "\"paidTxids\":{\"%s\":\"abc123\"}}]"; + check(PayoutTestWriteFile(ledgerPath, + strprintf(merged, addrA.c_str(), addrA.c_str())), + "wrote a ledger file in the merged build's shape"); + LoadPendingPayouts(); + { + std::lock_guard lk(gPayoutLedgerMutex); + check(gPendingPayouts.size() == 1 && + gPendingPayouts[0].blockHeight == 2000 && + gPendingPayouts[0].paidTxids.size() == 1 && + gPendingPayouts[0].paidTxids[addrA] == "abc123", + "a ledger file carrying retryAfter and paidTxids still loads"); + } + + // A round trip through this build must not lose anything either. + VersionedBlob snap; + { + std::lock_guard lk(gPayoutLedgerMutex); + snap = SerializePendingPayoutsLocked(); + } + PublishPendingPayouts(snap); + PayoutTestResetState(); + LoadPendingPayouts(); + { + std::lock_guard lk(gPayoutLedgerMutex); + check(gPendingPayouts.size() == 1 && + gPendingPayouts[0].blockHeight == 2000 && + gPendingPayouts[0].matureAtHeight == 2120 && + gPendingPayouts[0].paidTxids[addrA] == "abc123", + "what this build writes, this build reads back unchanged"); + } + } + + // -- 10. A forfeit is published, not just dropped ------------------------ + { + PayoutTestResetState(); + remove(ledgerPath.c_str()); + const std::string roundsPath = dir + "/pool_rounds_selftest.json"; + const std::string savedRoundsFile = strPoolRoundsFile; + strPoolRoundsFile = roundsPath; + remove(roundsPath.c_str()); + + const int height = 606; + PoolRoundProof round; + round.id = PayoutIdForBlock(height, 0); + round.blockHeight = height; + round.matureAtHeight = height; + round.payouts[addrA] = 5 * COIN; + round.payouts[addrB] = 6 * COIN; + RecordPoolRound(round); + + { + std::lock_guard lk(gPayoutLedgerMutex); + PendingPayout pp; + pp.blockHeight = height; pp.blockHash = 0; pp.matureAtHeight = height; + pp.amounts[addrA] = 5 * COIN; + pp.amounts[addrB] = 6 * COIN; + gPendingPayouts.push_back(pp); + } + + PayingRecord r; + r.blockHeight = height; r.blockHash = 0; r.matureAtHeight = height; + r.address = addrA; r.amount = 5 * COIN; + ForfeitPayoutRecipient(r, "selftest reason"); + + { + std::lock_guard lk(gPayoutLedgerMutex); + check(gPendingPayouts.size() == 1 && + gPendingPayouts[0].amounts.count(addrA) == 0 && + gPendingPayouts[0].amounts.count(addrB) == 1, + "a forfeit removes only that recipient from the ledger entry"); + } + + std::string onDisk; + bool proofOk = false; + if (PayoutTestReadFile(roundsPath, onDisk)) { + try { + nlohmann::json root = nlohmann::json::parse(onDisk); + const nlohmann::json& p = root["rounds"][0]["payouts"]; + proofOk = p[addrA].value("forfeited", false) && + p[addrA].value("forfeitReason", "") == "selftest reason" && + p[addrA]["txid"].is_null() && + !p[addrB].contains("forfeited"); + } catch (...) { + proofOk = false; + } + } + check(proofOk, + "the published round proof records the forfeit instead of" + " advertising the address as still owed"); + + // And it survives a reload of the proof file. + { + std::lock_guard lk(gPoolRoundsMutex); + gPoolRounds.clear(); + } + LoadPoolRounds(); + { + std::lock_guard lk(gPoolRoundsMutex); + check(gPoolRounds.size() == 1 && + gPoolRounds[0].forfeited.count(addrA) == 1 && + gPoolRounds[0].forfeited[addrA] == "selftest reason", + "the forfeit is read back from pool_rounds.json"); + } + + remove(roundsPath.c_str()); + remove((roundsPath + ".tmp").c_str()); + strPoolRoundsFile = savedRoundsFile; + } + + // -- clean up ------------------------------------------------------------ + PayoutTestResetState(); + CRITICAL_BLOCK(cs_mapWallet) + mapWallet.clear(); + remove(ledgerPath.c_str()); + remove((ledgerPath + ".tmp").c_str()); + remove(payingPath.c_str()); + remove((payingPath + ".tmp").c_str()); + remove((dir + "/pool_status.json").c_str()); + remove((dir + "/pool_rounds.json").c_str()); +#ifdef _WIN32 + _rmdir(dir.c_str()); +#else + rmdir(dir.c_str()); +#endif + strSetDataDir = savedDataDir; + nBestHeight = savedBestHeight; + + fprintf(stdout, "%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + int RunPoolStratumSelfTest() { int nFail = 0; diff --git a/src/selftest.cpp b/src/selftest.cpp index eed6779..034e1f7 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -13,6 +13,7 @@ #include "walletcmd.h" extern int RunPoolStratumSelfTest(); +extern int RunPayoutLedgerSelfTest(); // Test results go to the terminal, not to debug.log. // @@ -1573,12 +1574,14 @@ int RunSelfTest(const std::string& name) return RunConsensusLimitsSelfTest(); if (name == "pool-stratum") return RunPoolStratumSelfTest(); + if (name == "payout-ledger") + return RunPayoutLedgerSelfTest(); if (name == "parse-money") return RunParseMoneySelfTest(); if (name == "socks5-proxy") return RunSocks5ProxySelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum, payout-ledger, parse-money, socks5-proxy\n"); return 1; } -- cgit v1.2.3