diff options
| author | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 07:52:52 +0000 |
|---|---|---|
| committer | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 07:52:52 +0000 |
| commit | cd4e86d431c3f5848183ca362175d1cb3edfa107 (patch) | |
| tree | a1ddd2b3011a9e71f55085f7752624a924558c59 | |
| parent | 86b39b952d81c8aa6db56beb293e08befdca296e (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.
| -rw-r--r-- | src/Makefile | 1 | ||||
| -rw-r--r-- | src/rpc.cpp | 671 | ||||
| -rw-r--r-- | src/selftest.cpp | 5 |
3 files changed, 676 insertions, 1 deletions
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 | |||
| 192 | ./bitflash-node -selftest=net-message | 192 | ./bitflash-node -selftest=net-message |
| 193 | ./bitflash-node -selftest=consensus-limits | 193 | ./bitflash-node -selftest=consensus-limits |
| 194 | ./bitflash-node -selftest=pool-stratum | 194 | ./bitflash-node -selftest=pool-stratum |
| 195 | ./bitflash-node -selftest=payout-ledger | ||
| 195 | ./bitflash-node -selftest=parse-money | 196 | ./bitflash-node -selftest=parse-money |
| 196 | ./bitflash-node -selftest=socks5-proxy | 197 | ./bitflash-node -selftest=socks5-proxy |
| 197 | 198 | ||
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 | |||
| 2762 | static 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 | |||
| 2778 | static 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. | ||
| 2788 | static 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. | ||
| 2811 | static 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 | |||
| 2819 | static 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. | ||
| 2830 | static 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 | |||
| 2856 | int 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 | |||
| 2752 | int RunPoolStratumSelfTest() | 3423 | int RunPoolStratumSelfTest() |
| 2753 | { | 3424 | { |
| 2754 | int nFail = 0; | 3425 | 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 @@ | |||
| 13 | #include "walletcmd.h" | 13 | #include "walletcmd.h" |
| 14 | 14 | ||
| 15 | extern int RunPoolStratumSelfTest(); | 15 | extern int RunPoolStratumSelfTest(); |
| 16 | extern int RunPayoutLedgerSelfTest(); | ||
| 16 | 17 | ||
| 17 | // Test results go to the terminal, not to debug.log. | 18 | // Test results go to the terminal, not to debug.log. |
| 18 | // | 19 | // |
| @@ -1573,12 +1574,14 @@ int RunSelfTest(const std::string& name) | |||
| 1573 | return RunConsensusLimitsSelfTest(); | 1574 | return RunConsensusLimitsSelfTest(); |
| 1574 | if (name == "pool-stratum") | 1575 | if (name == "pool-stratum") |
| 1575 | return RunPoolStratumSelfTest(); | 1576 | return RunPoolStratumSelfTest(); |
| 1577 | if (name == "payout-ledger") | ||
| 1578 | return RunPayoutLedgerSelfTest(); | ||
| 1576 | if (name == "parse-money") | 1579 | if (name == "parse-money") |
| 1577 | return RunParseMoneySelfTest(); | 1580 | return RunParseMoneySelfTest(); |
| 1578 | if (name == "socks5-proxy") | 1581 | if (name == "socks5-proxy") |
| 1579 | return RunSocks5ProxySelfTest(); | 1582 | return RunSocks5ProxySelfTest(); |
| 1580 | 1583 | ||
| 1581 | printf("Unknown self-test '%s'\n", name.c_str()); | 1584 | printf("Unknown self-test '%s'\n", name.c_str()); |
| 1582 | 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"); | 1585 | 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"); |
| 1583 | return 1; | 1586 | return 1; |
| 1584 | } | 1587 | } |