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:52:52 +0000
committerbitflash-maintainer <noreply@bitflash.invalid>2026-08-23 07:52:52 +0000
commitcd4e86d431c3f5848183ca362175d1cb3edfa107 (patch)
treea1ddd2b3011a9e71f55085f7752624a924558c59 /src/rpc.cpp
parent86b39b952d81c8aa6db56beb293e08befdca296e (diff)
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.
Diffstat (limited to 'src/rpc.cpp')
-rw-r--r--src/rpc.cpp671
1 files changed, 671 insertions, 0 deletions
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 @@
38 38
39#include <atomic> 39#include <atomic>
40#include <mutex> 40#include <mutex>
41#include <thread>
41#include <map> 42#include <map>
42#include <set> 43#include <set>
43#include <deque> 44#include <deque>
@@ -2749,6 +2750,676 @@ static bool HandleLine(Miner* m, const std::string& rawLine,
2749 return true; 2750 return true;
2750} 2751}
2751 2752
2753// ---------------------------------------------------------------------------
2754// payout-ledger self-test
2755//
2756// Regression cover for the payout defects fixed in this series. Everything
2757// here runs against a scratch data directory and an in-memory wallet map; it
2758// never opens wallet.dat, never touches a real datadir, and never sends
2759// anything.
2760// ---------------------------------------------------------------------------
2761
2762static bool PayoutTestReadFile(const std::string& path, std::string& out)
2763{
2764 FILE* f = fopen(path.c_str(), "r");
2765 if (!f) return false;
2766 fseek(f, 0, SEEK_END);
2767 long sz = ftell(f);
2768 fseek(f, 0, SEEK_SET);
2769 out.assign((size_t)(sz > 0 ? sz : 0), '\0');
2770 if (sz > 0 && fread(&out[0], 1, (size_t)sz, f) != (size_t)sz) {
2771 fclose(f);
2772 return false;
2773 }
2774 fclose(f);
2775 return true;
2776}
2777
2778static bool PayoutTestWriteFile(const std::string& path, const std::string& data)
2779{
2780 FILE* f = fopen(path.c_str(), "w");
2781 if (!f) return false;
2782 bool ok = data.empty() || fwrite(data.c_str(), 1, data.size(), f) == data.size();
2783 return fclose(f) == 0 && ok;
2784}
2785
2786// Wipe every piece of payout state this test touches, so each section starts
2787// from the same place and a "restart" can be simulated by reloading from disk.
2788static void PayoutTestResetState()
2789{
2790 {
2791 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
2792 gPendingPayouts.clear();
2793 }
2794 {
2795 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
2796 gPaying.clear();
2797 gQueuedPayouts.clear();
2798 }
2799 {
2800 std::lock_guard<std::mutex> lk(gPoolRoundsMutex);
2801 gPoolRounds.clear();
2802 }
2803 {
2804 std::lock_guard<std::mutex> lk(gClaimedTxidMutex);
2805 gLegacyClaimedTxids.clear();
2806 }
2807}
2808
2809// A BTF address whose hash160 is entirely under the test's control, so the
2810// output script the probe compares against can be built without a wallet.
2811static std::string PayoutTestAddress(unsigned char seed)
2812{
2813 uint160 h = 0;
2814 for (int i = 0; i < 20; i++)
2815 ((unsigned char*)&h)[i] = (unsigned char)(seed + i);
2816 return Hash160ToAddress(h);
2817}
2818
2819static CScript PayoutTestScriptFor(const std::string& address)
2820{
2821 uint160 h160;
2822 CScript sc;
2823 if (AddressToHash160(address, h160))
2824 sc << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG;
2825 return sc;
2826}
2827
2828// Put a transaction in the wallet map that looks exactly like one this pool
2829// sent. `nonce` only exists to keep the hashes distinct.
2830static std::string PayoutTestAddWalletTx(const std::string& comment,
2831 const std::string& address,
2832 int64 amount,
2833 int nonce,
2834 const std::string& structuredId)
2835{
2836 CWalletTx wtx;
2837 wtx.vin.resize(1);
2838 wtx.vin[0].prevout.SetNull();
2839 wtx.vin[0].scriptSig << nonce;
2840 wtx.vout.resize(1);
2841 wtx.vout[0].nValue = amount;
2842 wtx.vout[0].scriptPubKey = PayoutTestScriptFor(address);
2843 if (!comment.empty())
2844 wtx.mapValue["comment"] = comment;
2845 if (!structuredId.empty()) {
2846 wtx.mapValue["pool_payout_id"] = structuredId;
2847 wtx.mapValue["pool_payout_address"] = address;
2848 wtx.mapValue["pool_payout_amount"] = strprintf("%lld", amount);
2849 }
2850 uint256 hash = wtx.GetHash();
2851 CRITICAL_BLOCK(cs_mapWallet)
2852 mapWallet[hash] = wtx;
2853 return hash.GetHex();
2854}
2855
2856int RunPayoutLedgerSelfTest()
2857{
2858 int nFail = 0;
2859 auto check = [&](bool ok, const char* msg) {
2860 fprintf(stdout, " %s %s\n", ok ? "ok" : "FAIL", msg);
2861 if (!ok) nFail++;
2862 };
2863
2864 fprintf(stdout, "payout-ledger self-test\n");
2865
2866 const std::string savedDataDir = strSetDataDir;
2867 const int savedBestHeight = nBestHeight;
2868
2869 // Scratch data directory in the current working directory. GetAppDir()
2870 // follows strSetDataDir, so this is what PayoutFilePath() and
2871 // PayingFilePath() resolve against for the rest of the test.
2872 std::string dir = strprintf("payout_selftest_%d", (int)GetTime());
2873#ifdef _WIN32
2874 _mkdir(dir.c_str());
2875#else
2876 mkdir(dir.c_str(), 0700);
2877#endif
2878 strSetDataDir = dir;
2879 fprintf(stdout, " scratch datadir: %s\n", dir.c_str());
2880
2881 const std::string ledgerPath = PayoutFilePath();
2882 const std::string payingPath = PayingFilePath();
2883 const std::string addrA = PayoutTestAddress(0x11);
2884 const std::string addrB = PayoutTestAddress(0x55);
2885
2886 // -- 1. A restart must not duplicate an in-flight record -----------------
2887 //
2888 // The ledger entry is grandfathered (zero hash) on purpose: it keeps
2889 // PayoutBlockStillOnChain out of the picture, which needs a block index,
2890 // and it exercises the height fallback at the same time.
2891 {
2892 PayoutTestResetState();
2893 remove(ledgerPath.c_str());
2894 remove(payingPath.c_str());
2895
2896 PendingPayout pp;
2897 pp.blockHeight = 4242;
2898 pp.blockHash = 0;
2899 pp.matureAtHeight = 4242;
2900 pp.amounts[addrA] = 10 * COIN;
2901 pp.amounts[addrB] = 20 * COIN;
2902 VersionedBlob snap;
2903 {
2904 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
2905 gPendingPayouts.push_back(pp);
2906 snap = SerializePendingPayoutsLocked();
2907 }
2908 PublishPendingPayouts(snap);
2909
2910 nBestHeight = 5000;
2911 FlushMaturePayouts();
2912 size_t afterFirstFlush = 0;
2913 {
2914 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
2915 afterFirstFlush = gPaying.size();
2916 }
2917 check(afterFirstFlush == 2,
2918 "a matured round explodes into one record per unpaid recipient");
2919
2920 // Restart: drop everything in memory and come back from the files.
2921 for (int restart = 1; restart <= 2; restart++) {
2922 PayoutTestResetState();
2923 LoadPendingPayouts();
2924 LoadPaying();
2925 ReconcilePayouts();
2926
2927 size_t queued = 0;
2928 {
2929 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
2930 queued = gQueuedPayouts.size();
2931 }
2932 if (restart == 1)
2933 check(queued == 1,
2934 "startup rebuilds the exploded-round set from the restored"
2935 " in-flight list");
2936
2937 FlushMaturePayouts();
2938 size_t n = 0;
2939 {
2940 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
2941 n = gPaying.size();
2942 }
2943 check(n == 2, restart == 1
2944 ? "a restart does not duplicate an in-flight record"
2945 : "a second restart does not duplicate one either");
2946 }
2947
2948 // The other half of the guard: even with the round-level dedupe set
2949 // deliberately emptied, the per-recipient check against gPaying must
2950 // stop a second standing order for the same money.
2951 {
2952 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
2953 gQueuedPayouts.clear();
2954 }
2955 FlushMaturePayouts();
2956 size_t n = 0;
2957 {
2958 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
2959 n = gPaying.size();
2960 }
2961 check(n == 2,
2962 "the flush refuses a second record for a (round, address) already"
2963 " in flight even with the round set lost");
2964 }
2965
2966 // -- 2. Duplicates already on disk are collapsed as they are read --------
2967 {
2968 PayoutTestResetState();
2969 nlohmann::json arr = nlohmann::json::array();
2970 for (int i = 0; i < 2; i++) {
2971 nlohmann::json o;
2972 o["blockHeight"] = 4242;
2973 o["blockHash"] = uint256(0).ToString();
2974 o["matureAtHeight"] = 4242;
2975 o["address"] = addrA;
2976 o["amount"] = (int64)(10 * COIN);
2977 o["retries"] = i == 0 ? 7 : 0;
2978 o["deferrals"] = i == 0 ? 0 : 3;
2979 arr.push_back(o);
2980 }
2981 check(PayoutTestWriteFile(payingPath, arr.dump(2)),
2982 "wrote a paying file containing the duplicate the old build made");
2983 LoadPaying();
2984 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
2985 check(gPaying.size() == 1,
2986 "loading collapses a duplicate in-flight record from disk");
2987 check(gPaying.size() == 1 && gPaying[0].retries == 7 && gPaying[0].deferrals == 3,
2988 "collapsing merges the attempt counts upwards, not resetting them");
2989 }
2990
2991 // -- 3. State updates address a slot, not a value -----------------------
2992 //
2993 // Build the exact pathology the old code produced -- two records equal in
2994 // every field -- and prove the update lands on the one asked for. Under
2995 // value matching the first record absorbed everything and the second was
2996 // untouchable.
2997 {
2998 PayoutTestResetState();
2999 PayingRecord a;
3000 a.blockHeight = 4242; a.blockHash = 0; a.matureAtHeight = 4242;
3001 a.address = addrA; a.amount = 10 * COIN;
3002 PayingRecord b = a;
3003 {
3004 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
3005 a.id = ++gPayingNextId;
3006 b.id = ++gPayingNextId;
3007 gPaying.push_back(a);
3008 gPaying.push_back(b);
3009 }
3010 PayingRecord upd = b;
3011 upd.retries = 5;
3012 upd.retryAfter = 1234567;
3013 ReschedulePayment(upd);
3014 {
3015 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
3016 check(gPaying.size() == 2 &&
3017 gPaying[0].retries == 0 && gPaying[1].retries == 5,
3018 "rescheduling updates the record it was given, not the first"
3019 " one that looks like it");
3020 }
3021 ForgetPayment(a);
3022 {
3023 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
3024 check(gPaying.size() == 1 && gPaying[0].id == b.id,
3025 "forgetting erases the record it was given, not the first"
3026 " one that looks like it");
3027 }
3028 }
3029
3030 // -- 4. retryAfter survives a restart ------------------------------------
3031 {
3032 PayoutTestResetState();
3033 remove(payingPath.c_str());
3034 PayingRecord r;
3035 r.blockHeight = 77; r.blockHash = 0; r.matureAtHeight = 77;
3036 r.address = addrA; r.amount = 3 * COIN;
3037 r.retryAfter = 1700000000LL;
3038 r.deferrals = 9;
3039 VersionedBlob snap;
3040 {
3041 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
3042 r.id = ++gPayingNextId;
3043 gPaying.push_back(r);
3044 snap = SerializePayingLocked();
3045 }
3046 PublishPaying(snap);
3047 PayoutTestResetState();
3048 LoadPaying();
3049 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
3050 check(gPaying.size() == 1 && gPaying[0].retryAfter == 1700000000LL &&
3051 gPaying[0].deferrals == 9,
3052 "the retry clock that is actually used survives a restart");
3053 }
3054
3055 // -- 5. The ledger file cannot be published from a stale snapshot --------
3056 {
3057 PayoutTestResetState();
3058 remove(ledgerPath.c_str());
3059
3060 PendingPayout one;
3061 one.blockHeight = 100; one.blockHash = 0; one.matureAtHeight = 100;
3062 one.amounts[addrA] = 1 * COIN;
3063 VersionedBlob older, newer;
3064 {
3065 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
3066 gPendingPayouts.push_back(one);
3067 older = SerializePendingPayoutsLocked();
3068 PendingPayout two;
3069 two.blockHeight = 200; two.blockHash = 0; two.matureAtHeight = 200;
3070 two.amounts[addrB] = 2 * COIN;
3071 gPendingPayouts.push_back(two);
3072 newer = SerializePendingPayoutsLocked();
3073 }
3074 check(newer.seq > older.seq,
3075 "a later serialisation carries a strictly higher version");
3076
3077 // The order the race produced: the payout thread's older image
3078 // arriving after the event loop's newer one.
3079 PublishPendingPayouts(newer);
3080 PublishPendingPayouts(older);
3081 std::string onDisk;
3082 check(PayoutTestReadFile(ledgerPath, onDisk) && onDisk == newer.blob,
3083 "an older snapshot cannot overwrite a newer published ledger");
3084
3085 // And the ordinary direction still works.
3086 VersionedBlob newest;
3087 {
3088 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
3089 gPendingPayouts.clear();
3090 newest = SerializePendingPayoutsLocked();
3091 }
3092 PublishPendingPayouts(newest);
3093 check(PayoutTestReadFile(ledgerPath, onDisk) && onDisk == newest.blob,
3094 "a newer snapshot does replace the published ledger");
3095 }
3096
3097 // -- 6. Concurrent publishers cannot interleave a file -------------------
3098 //
3099 // Two threads renaming the same "<path>.tmp" over the same target was the
3100 // original defect. The images are made big so a torn write shows up as
3101 // unparseable JSON rather than by luck.
3102 {
3103 const std::string racePath = dir + "/publish_race_selftest.json";
3104 std::mutex raceMutex;
3105 uint64 racePublished = 0;
3106
3107 std::vector<VersionedBlob> images;
3108 for (int i = 0; i < 64; i++) {
3109 nlohmann::json arr = nlohmann::json::array();
3110 for (int k = 0; k < 200; k++) {
3111 nlohmann::json o;
3112 o["image"] = i;
3113 o["entry"] = k;
3114 o["address"] = addrA;
3115 o["amount"] = (int64)(k + 1) * COIN;
3116 arr.push_back(o);
3117 }
3118 VersionedBlob v;
3119 v.blob = arr.dump(2);
3120 v.seq = (uint64)i + 1;
3121 images.push_back(v);
3122 }
3123
3124 std::vector<std::thread> threads;
3125 for (int t = 0; t < 4; t++) {
3126 threads.push_back(std::thread([&, t]() {
3127 for (size_t i = 0; i < images.size(); i++) {
3128 size_t idx = (i * 7 + (size_t)t * 13) % images.size();
3129 PublishVersioned(racePath, images[idx], raceMutex,
3130 racePublished, "publish race selftest");
3131 }
3132 }));
3133 }
3134 for (std::thread& th : threads)
3135 th.join();
3136
3137 std::string onDisk;
3138 bool read = PayoutTestReadFile(racePath, onDisk);
3139 bool parses = false;
3140 if (read) {
3141 try { parses = nlohmann::json::parse(onDisk).is_array(); }
3142 catch (...) { parses = false; }
3143 }
3144 check(read && parses,
3145 "four threads publishing the same file never leave it unparseable");
3146 check(read && onDisk == images.back().blob,
3147 "the published file settles on the highest version, whatever the"
3148 " completion order");
3149 remove(racePath.c_str());
3150 remove((racePath + ".tmp").c_str());
3151 }
3152
3153 // -- 7. Grandfathered zero-hash rounds do not answer for each other ------
3154 {
3155 uint256 h1 = 0, h2 = 0;
3156 h1.SetHex("00000000000000000000000000000000000000000000000000000000000000aa");
3157 h2.SetHex("00000000000000000000000000000000000000000000000000000000000000bb");
3158 check(!SamePayoutRound(100, 0, 200, 0),
3159 "two zero-hash entries at different heights are different rounds");
3160 check(SamePayoutRound(100, 0, 100, 0),
3161 "two zero-hash entries at the same height are the same round");
3162 check(SamePayoutRound(100, h1, 200, h1),
3163 "a real hash identifies the round on its own");
3164 check(!SamePayoutRound(100, h1, 100, h2),
3165 "different hashes at one height are different rounds");
3166 check(!SamePayoutRound(100, h1, 100, 0),
3167 "a grandfathered entry never matches a hash-keyed one");
3168 check(!PayoutRoundIdentifiable(0, 0) && PayoutRoundIdentifiable(0, h1) &&
3169 PayoutRoundIdentifiable(7, 0),
3170 "an entry with neither hash nor height is not adjudicable");
3171
3172 // End to end: recording a payment against one grandfathered round must
3173 // not settle the other's debt.
3174 PayoutTestResetState();
3175 remove(ledgerPath.c_str());
3176 {
3177 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
3178 PendingPayout first;
3179 first.blockHeight = 100; first.blockHash = 0; first.matureAtHeight = 100;
3180 first.amounts[addrA] = 1 * COIN;
3181 PendingPayout second;
3182 second.blockHeight = 200; second.blockHash = 0; second.matureAtHeight = 200;
3183 second.amounts[addrA] = 1 * COIN;
3184 gPendingPayouts.push_back(first);
3185 gPendingPayouts.push_back(second);
3186 }
3187 PayingRecord r;
3188 r.blockHeight = 200; r.blockHash = 0; r.matureAtHeight = 200;
3189 r.address = addrA; r.amount = 1 * COIN;
3190 RecordPayoutTxid(r, "txid-for-the-second-round");
3191 {
3192 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
3193 // The second round was fully paid and retired; the first is
3194 // untouched and still owed.
3195 check(gPendingPayouts.size() == 1 &&
3196 gPendingPayouts[0].blockHeight == 100 &&
3197 gPendingPayouts[0].paidTxids.empty(),
3198 "a txid recorded against one zero-hash round leaves the other"
3199 " still owed");
3200 }
3201 }
3202
3203 // -- 8. A payment with only a legacy comment is recognised as paid -------
3204 {
3205 PayoutTestResetState();
3206 const int height = 8080;
3207 const int64 amount = 7 * COIN;
3208 const std::string id = PayoutIdForBlock(height, 0);
3209 const std::string comment = PayoutComment(height);
3210 std::set<std::string> none;
3211 std::string found;
3212
3213 check(FindRecordedPoolPayout(id, height, addrA, amount, none, found)
3214 == PAYOUT_PROBE_NONE,
3215 "an empty wallet is not evidence that anything was paid");
3216
3217 const std::string legacyTxid =
3218 PayoutTestAddWalletTx(comment, addrA, amount, 1, "");
3219 check(FindRecordedPoolPayout(id, height, addrA, amount, none, found)
3220 == PAYOUT_PROBE_LEGACY && found == legacyTxid,
3221 "a payment carrying only a pre-upgrade comment is recognised as"
3222 " already paid");
3223
3224 check(FindRecordedPoolPayout(id, height, addrB, amount, none, found)
3225 == PAYOUT_PROBE_NONE,
3226 "the legacy match requires the output to pay THIS recipient");
3227 check(FindRecordedPoolPayout(id, height, addrA, amount + COIN, none, found)
3228 == PAYOUT_PROBE_NONE,
3229 "the legacy match requires the output to pay exactly this amount");
3230 check(FindRecordedPoolPayout(id, height + 1, addrA, amount, none, found)
3231 == PAYOUT_PROBE_NONE,
3232 "the legacy match requires the comment for this block height");
3233
3234 std::set<std::string> spent;
3235 spent.insert(legacyTxid);
3236 check(FindRecordedPoolPayout(id, height, addrA, amount, spent, found)
3237 == PAYOUT_PROBE_NONE,
3238 "one legacy transaction cannot settle a second debt");
3239
3240 // A transaction written by THIS build carries pool_payout_id, and must
3241 // never be offered as legacy evidence for a different round -- that is
3242 // exactly what the structured metadata exists to prevent.
3243 const std::string otherId = PayoutIdForBlock(height, 0) + "-other";
3244 PayoutTestAddWalletTx(comment, addrB, amount, 2, otherId);
3245 check(FindRecordedPoolPayout(id, height, addrB, amount, none, found)
3246 == PAYOUT_PROBE_NONE,
3247 "another round's structured payment is not legacy evidence for"
3248 " this one");
3249
3250 // The structured path still wins where it applies.
3251 const std::string structuredTxid =
3252 PayoutTestAddWalletTx(comment, addrB, amount, 3, id);
3253 check(FindRecordedPoolPayout(id, height, addrB, amount, none, found)
3254 == PAYOUT_PROBE_STRUCTURED && found == structuredTxid,
3255 "structured payout metadata is matched in preference to a comment");
3256 }
3257
3258 // -- 9. On-disk compatibility with the live production ledger ------------
3259 {
3260 PayoutTestResetState();
3261 // Exactly the shape of the file on the pool node: amounts, blockHash,
3262 // blockHeight, matureAtHeight, and nothing else.
3263 const char* live =
3264 "[{\"amounts\":{\"%s\":1234500000},"
3265 "\"blockHash\":\"00000000000000000000000000000000000000000000000000000000000000cd\","
3266 "\"blockHeight\":1000,"
3267 "\"matureAtHeight\":1120}]";
3268 check(PayoutTestWriteFile(ledgerPath, strprintf(live, addrA.c_str())),
3269 "wrote a ledger file in the live production shape");
3270 LoadPendingPayouts();
3271 {
3272 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
3273 check(gPendingPayouts.size() == 1 &&
3274 gPendingPayouts[0].blockHeight == 1000 &&
3275 gPendingPayouts[0].matureAtHeight == 1120 &&
3276 gPendingPayouts[0].blockHash != 0 &&
3277 gPendingPayouts[0].amounts.size() == 1 &&
3278 gPendingPayouts[0].amounts[addrA] == 1234500000LL,
3279 "the live production pending_payouts.json still loads");
3280 }
3281
3282 // And the shape the merged build writes, retryAfter included, which
3283 // this build no longer writes but must still read past.
3284 PayoutTestResetState();
3285 const char* merged =
3286 "[{\"amounts\":{\"%s\":100000000},"
3287 "\"blockHash\":\"00000000000000000000000000000000000000000000000000000000000000ef\","
3288 "\"blockHeight\":2000,\"matureAtHeight\":2120,\"retryAfter\":0,"
3289 "\"paidTxids\":{\"%s\":\"abc123\"}}]";
3290 check(PayoutTestWriteFile(ledgerPath,
3291 strprintf(merged, addrA.c_str(), addrA.c_str())),
3292 "wrote a ledger file in the merged build's shape");
3293 LoadPendingPayouts();
3294 {
3295 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
3296 check(gPendingPayouts.size() == 1 &&
3297 gPendingPayouts[0].blockHeight == 2000 &&
3298 gPendingPayouts[0].paidTxids.size() == 1 &&
3299 gPendingPayouts[0].paidTxids[addrA] == "abc123",
3300 "a ledger file carrying retryAfter and paidTxids still loads");
3301 }
3302
3303 // A round trip through this build must not lose anything either.
3304 VersionedBlob snap;
3305 {
3306 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
3307 snap = SerializePendingPayoutsLocked();
3308 }
3309 PublishPendingPayouts(snap);
3310 PayoutTestResetState();
3311 LoadPendingPayouts();
3312 {
3313 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
3314 check(gPendingPayouts.size() == 1 &&
3315 gPendingPayouts[0].blockHeight == 2000 &&
3316 gPendingPayouts[0].matureAtHeight == 2120 &&
3317 gPendingPayouts[0].paidTxids[addrA] == "abc123",
3318 "what this build writes, this build reads back unchanged");
3319 }
3320 }
3321
3322 // -- 10. A forfeit is published, not just dropped ------------------------
3323 {
3324 PayoutTestResetState();
3325 remove(ledgerPath.c_str());
3326 const std::string roundsPath = dir + "/pool_rounds_selftest.json";
3327 const std::string savedRoundsFile = strPoolRoundsFile;
3328 strPoolRoundsFile = roundsPath;
3329 remove(roundsPath.c_str());
3330
3331 const int height = 606;
3332 PoolRoundProof round;
3333 round.id = PayoutIdForBlock(height, 0);
3334 round.blockHeight = height;
3335 round.matureAtHeight = height;
3336 round.payouts[addrA] = 5 * COIN;
3337 round.payouts[addrB] = 6 * COIN;
3338 RecordPoolRound(round);
3339
3340 {
3341 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
3342 PendingPayout pp;
3343 pp.blockHeight = height; pp.blockHash = 0; pp.matureAtHeight = height;
3344 pp.amounts[addrA] = 5 * COIN;
3345 pp.amounts[addrB] = 6 * COIN;
3346 gPendingPayouts.push_back(pp);
3347 }
3348
3349 PayingRecord r;
3350 r.blockHeight = height; r.blockHash = 0; r.matureAtHeight = height;
3351 r.address = addrA; r.amount = 5 * COIN;
3352 ForfeitPayoutRecipient(r, "selftest reason");
3353
3354 {
3355 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
3356 check(gPendingPayouts.size() == 1 &&
3357 gPendingPayouts[0].amounts.count(addrA) == 0 &&
3358 gPendingPayouts[0].amounts.count(addrB) == 1,
3359 "a forfeit removes only that recipient from the ledger entry");
3360 }
3361
3362 std::string onDisk;
3363 bool proofOk = false;
3364 if (PayoutTestReadFile(roundsPath, onDisk)) {
3365 try {
3366 nlohmann::json root = nlohmann::json::parse(onDisk);
3367 const nlohmann::json& p = root["rounds"][0]["payouts"];
3368 proofOk = p[addrA].value("forfeited", false) &&
3369 p[addrA].value("forfeitReason", "") == "selftest reason" &&
3370 p[addrA]["txid"].is_null() &&
3371 !p[addrB].contains("forfeited");
3372 } catch (...) {
3373 proofOk = false;
3374 }
3375 }
3376 check(proofOk,
3377 "the published round proof records the forfeit instead of"
3378 " advertising the address as still owed");
3379
3380 // And it survives a reload of the proof file.
3381 {
3382 std::lock_guard<std::mutex> lk(gPoolRoundsMutex);
3383 gPoolRounds.clear();
3384 }
3385 LoadPoolRounds();
3386 {
3387 std::lock_guard<std::mutex> lk(gPoolRoundsMutex);
3388 check(gPoolRounds.size() == 1 &&
3389 gPoolRounds[0].forfeited.count(addrA) == 1 &&
3390 gPoolRounds[0].forfeited[addrA] == "selftest reason",
3391 "the forfeit is read back from pool_rounds.json");
3392 }
3393
3394 remove(roundsPath.c_str());
3395 remove((roundsPath + ".tmp").c_str());
3396 strPoolRoundsFile = savedRoundsFile;
3397 }
3398
3399 // -- clean up ------------------------------------------------------------
3400 PayoutTestResetState();
3401 CRITICAL_BLOCK(cs_mapWallet)
3402 mapWallet.clear();
3403 remove(ledgerPath.c_str());
3404 remove((ledgerPath + ".tmp").c_str());
3405 remove(payingPath.c_str());
3406 remove((payingPath + ".tmp").c_str());
3407 remove((dir + "/pool_status.json").c_str());
3408 remove((dir + "/pool_rounds.json").c_str());
3409#ifdef _WIN32
3410 _rmdir(dir.c_str());
3411#else
3412 rmdir(dir.c_str());
3413#endif
3414 strSetDataDir = savedDataDir;
3415 nBestHeight = savedBestHeight;
3416
3417 fprintf(stdout, "%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED",
3418 nFail, nFail == 1 ? "" : "s");
3419 fflush(stdout);
3420 return nFail == 0 ? 0 : 1;
3421}
3422
2752int RunPoolStratumSelfTest() 3423int RunPoolStratumSelfTest()
2753{ 3424{
2754 int nFail = 0; 3425 int nFail = 0;