upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorbitflash-maintainer <noreply@bitflash.invalid>2026-08-23 07:43:20 +0000
committerbitflash-maintainer <noreply@bitflash.invalid>2026-08-23 07:43:20 +0000
commit5892302449e1299f39812d0bb422c4184224b0f1 (patch)
treec9d382474a3212b756757a08c8020ffb99495129 /src
parentc4754fb505be75b8eedee3ce230a8991762e18c5 (diff)
payout: do not write off a debt the wallet is only temporarily unable to pay
ForfeitPayoutRecipient permanently erases a miner's money: the amount leaves the ledger, the operator keeps the coin, and nothing on disk records that it was ever owed. It was reachable from two entirely ordinary conditions. The deferral gate compared GetBalance() against r.amount, ignoring the fee. A balance anywhere between amount and amount+fee therefore passed the gate, failed inside SendMoney, and was counted as a send FAILURE -- a retry. At one a minute, 1440 of those is 24 hours, and then the recipient is gone. A wallet left locked overnight did exactly the same thing: every attempt fails because nothing can be signed, and each failure spent budget. Three changes. The gate now requires amount + nTransactionFee, which is the part of the fee knowable before the transaction exists. It deliberately does not try to reserve GetMinFee(), which depends on the serialised size and so on how many inputs SelectCoins happens to pick: refusing to attempt a payment the wallet could actually have made is its own way of not paying a miner. A locked wallet is now a deferral at the same gate, for the same reason maturity is. Failures are classified after the fact instead of all counting as faults. The balance and lock state are re-read after SendMoney returns -- it holds cs_main and can block for a long time, so both may have moved -- and if the wallet is locked, or the balance no longer covers amount + fee + one fee tier of headroom, the attempt is recorded as a deferral. That headroom is where the size-dependent part of the fee lands, so a fee shortfall is a wait rather than a fault, which is what it is. The retry budget therefore now only counts failures that happened while the payment was funded, the wallet was unlocked, and the funding block was on the main chain. Reaching 1440 of those means the wallet refuses a payment it has every means to make, which is the only state in which the payment can be called impossible rather than merely blocked. The give-up log says which conditions held, rather than implying it. Finally, the forfeit is reconciled with the public record. Dropping the amount from the ledger while pool_rounds.json goes on advertising the address as owed with "txid": null left the pool's two accounts of the same round permanently disagreeing, with the operator holding the difference. PoolRoundProof gains a per-address forfeited/forfeitReason, written by MarkPoolRoundForfeited when the ledger writes the debt off, and pool_status.json counts those separately from genuinely unpaid ones instead of reporting them as still pending. On-disk: pool_rounds.json gains optional "forfeited" and "forfeitReason" keys inside a payout object, and pool_status.json gains "forfeitedPayouts" per round. Both are emitted only when a forfeit has actually happened, both are read back with defaults, and files written by any earlier build load unchanged.
Diffstat (limited to 'src')
-rw-r--r--src/rpc.cpp214
1 files changed, 175 insertions, 39 deletions
diff --git a/src/rpc.cpp b/src/rpc.cpp
index b71457c..6f6d101 100644
--- a/src/rpc.cpp
+++ b/src/rpc.cpp
@@ -386,6 +386,12 @@ struct PoolRoundProof {
386 std::map<std::string, uint64> shares; 386 std::map<std::string, uint64> shares;
387 std::map<std::string, int64> payouts; 387 std::map<std::string, int64> payouts;
388 std::map<std::string, std::string> paidTxids; 388 std::map<std::string, std::string> paidTxids;
389 // Recipients the ledger has written off, and why. Without this the public
390 // round proof goes on advertising an address as owed with "txid": null
391 // forever, while the ledger has already dropped the obligation -- the two
392 // records of the same round permanently disagreeing, with the operator
393 // holding the money.
394 std::map<std::string, std::string> forfeited; // address -> reason
389 395
390 PoolRoundProof() 396 PoolRoundProof()
391 : blockHeight(0), matureAtHeight(0), createdAt(0), feePercent(0.0), 397 : blockHeight(0), matureAtHeight(0), createdAt(0), feePercent(0.0),
@@ -890,6 +896,13 @@ static nlohmann::json PoolRoundToJson(const PoolRoundProof& r)
890 p["txid"] = tx->second; 896 p["txid"] = tx->second;
891 else 897 else
892 p["txid"] = nullptr; 898 p["txid"] = nullptr;
899 // Only emitted when it happened, so the common shape of the file is
900 // unchanged for anything already parsing it.
901 std::map<std::string, std::string>::const_iterator fo = r.forfeited.find(kv.first);
902 if (fo != r.forfeited.end()) {
903 p["forfeited"] = true;
904 p["forfeitReason"] = fo->second;
905 }
893 j["payouts"][kv.first] = p; 906 j["payouts"][kv.first] = p;
894 } 907 }
895 return j; 908 return j;
@@ -966,6 +979,9 @@ static void LoadPoolRounds()
966 r.payouts[it.key()] = it.value().value("amountSatoshis", (int64)0); 979 r.payouts[it.key()] = it.value().value("amountSatoshis", (int64)0);
967 if (it.value().contains("txid") && it.value()["txid"].is_string()) 980 if (it.value().contains("txid") && it.value()["txid"].is_string())
968 r.paidTxids[it.key()] = it.value()["txid"].get<std::string>(); 981 r.paidTxids[it.key()] = it.value()["txid"].get<std::string>();
982 if (it.value().value("forfeited", false))
983 r.forfeited[it.key()] =
984 it.value().value("forfeitReason", std::string("unspecified"));
969 } else if (it.value().is_number_integer()) { 985 } else if (it.value().is_number_integer()) {
970 r.payouts[it.key()] = it.value().get<int64>(); 986 r.payouts[it.key()] = it.value().get<int64>();
971 } 987 }
@@ -1132,6 +1148,33 @@ static void MarkPoolRoundPaid(const std::string& id,
1132 PublishPoolRounds(snap); 1148 PublishPoolRounds(snap);
1133} 1149}
1134 1150
1151// Publish the fact that a recipient was written off, so the round proof and
1152// the ledger agree. The ledger erases the amount (it is not owed any more and
1153// must not be reported as sent); the proof keeps the amount visible and marks
1154// it forfeited with a reason, because a miner who was promised a share is
1155// entitled to see what happened to it.
1156static void MarkPoolRoundForfeited(const std::string& id,
1157 const std::string& address,
1158 const std::string& reason)
1159{
1160 VersionedBlob snap;
1161 {
1162 std::lock_guard<std::mutex> lk(gPoolRoundsMutex);
1163 bool found = false;
1164 for (PoolRoundProof& r : gPoolRounds) {
1165 if (r.id != id)
1166 continue;
1167 r.forfeited[address] = reason;
1168 found = true;
1169 break;
1170 }
1171 if (!found)
1172 return;
1173 snap = SerializePoolRoundsLocked();
1174 }
1175 PublishPoolRounds(snap);
1176}
1177
1135// Which wallet transaction, if any, already paid this? 1178// Which wallet transaction, if any, already paid this?
1136// 1179//
1137// Two kinds of evidence, in order of quality. 1180// Two kinds of evidence, in order of quality.
@@ -1634,14 +1677,21 @@ static void RecordPayoutTxid(const PayingRecord& r, const std::string& txid)
1634 LogPrint("payout", "[payout] block %d fully settled\n", r.blockHeight); 1677 LogPrint("payout", "[payout] block %d fully settled\n", r.blockHeight);
1635} 1678}
1636 1679
1637// Write a recipient off. Only reached after the full retry budget against a 1680// Write a recipient off. Only reached after a full budget of failures that
1638// block that is still on the main chain with coin available to spend, i.e. a 1681// were NOT the wallet being locked or short of coin -- see the classification
1639// wallet fault rather than a maturity or reorg question. 1682// in PayoutThreadFn -- against a block still on the main chain.
1640// 1683//
1641// The amount is removed from the entry rather than marked paid, so the money 1684// The amount is removed from the entry rather than marked paid, so the money
1642// is not reported as sent. Once nothing is left owed the entry retires. 1685// is not reported as sent. Once nothing is left owed the entry retires. The
1643static void ForfeitPayoutRecipient(const PayingRecord& r) 1686// round proof is told separately, because dropping the obligation from the
1687// ledger while pool_rounds.json goes on publishing the address as owed with
1688// "txid": null leaves the pool's two records of the same round permanently
1689// disagreeing.
1690static void ForfeitPayoutRecipient(const PayingRecord& r, const std::string& reason)
1644{ 1691{
1692 LogPrint("payout", "[payout] writing off %s owed to %s for block %d: %s\n",
1693 FormatMoney(r.amount).c_str(), r.address.c_str(), r.blockHeight,
1694 reason.c_str());
1645 VersionedBlob blob; 1695 VersionedBlob blob;
1646 { 1696 {
1647 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex); 1697 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
@@ -1657,6 +1707,8 @@ static void ForfeitPayoutRecipient(const PayingRecord& r)
1657 blob = SerializePendingPayoutsLocked(); 1707 blob = SerializePendingPayoutsLocked();
1658 } 1708 }
1659 PublishPendingPayouts(blob); 1709 PublishPendingPayouts(blob);
1710 MarkPoolRoundForfeited(PayoutIdForBlock(r.blockHeight, r.blockHash),
1711 r.address, reason);
1660} 1712}
1661 1713
1662// Payout execution thread: works through gPaying, calls SendMoney. 1714// Payout execution thread: works through gPaying, calls SendMoney.
@@ -1766,17 +1818,51 @@ static void PayoutThreadFn(void*)
1766 // payment the pool fully intends to honour can hit GIVING UP 1818 // payment the pool fully intends to honour can hit GIVING UP
1767 // 24h later purely because the subsidy funding it had not 1819 // 24h later purely because the subsidy funding it had not
1768 // matured yet -- the miner is then silently never paid. 1820 // matured yet -- the miner is then silently never paid.
1821 //
1822 // The comparison includes the fee. It used to be against the
1823 // amount alone, so a balance anywhere between amount and
1824 // amount+fee sailed past this gate, failed in SendMoney, and was
1825 // counted as a send FAILURE rather than a wait -- 1440 of those is
1826 // 24 hours, and the recipient was erased with the money left in
1827 // the operator's wallet and nothing on disk saying it was owed.
1828 //
1829 // nTransactionFee is the part of the fee that can be known before
1830 // the transaction exists. The rest is GetMinFee(), a function of
1831 // the serialised size and therefore of how many inputs SelectCoins
1832 // ends up picking, which cannot be predicted from here -- so this
1833 // gate deliberately does NOT try to reserve it. Refusing to attempt
1834 // a payment the wallet could actually have made would be its own
1835 // way of not paying a miner. That residual is handled after the
1836 // fact, by classifying the failure below.
1769 int64 nSpendable = GetBalance(); 1837 int64 nSpendable = GetBalance();
1770 if (nSpendable < r.amount) { 1838 if (nSpendable < r.amount + nTransactionFee) {
1771 r.deferrals++; 1839 r.deferrals++;
1772 r.retryAfter = GetTime() + 60; 1840 r.retryAfter = GetTime() + 60;
1773 // Hourly, not every minute: a dry pool would otherwise 1841 // Hourly, not every minute: a dry pool would otherwise
1774 // write one line per payment per minute forever. 1842 // write one line per payment per minute forever.
1775 if (r.deferrals == 1 || r.deferrals % 60 == 0) 1843 if (r.deferrals == 1 || r.deferrals % 60 == 0)
1776 LogPrint("payout", "[payout] deferring %s to %s -- only %s" 1844 LogPrint("payout", "[payout] deferring %s to %s -- only %s"
1777 " spendable (waiting %d min so far)\n", 1845 " spendable, need %s with fee (waiting %d min so far)\n",
1846 FormatMoney(r.amount).c_str(), r.address.c_str(),
1847 FormatMoney(nSpendable).c_str(),
1848 FormatMoney(r.amount + nTransactionFee).c_str(),
1849 r.deferrals);
1850 ReschedulePayment(r);
1851 continue;
1852 }
1853
1854 // Likewise a locked wallet: it cannot sign, so every attempt fails
1855 // for a reason that has nothing to do with this payment being
1856 // payable. Left to the retry budget, an operator who locks the
1857 // wallet overnight comes back to erased debts.
1858 if (IsWalletLocked()) {
1859 r.deferrals++;
1860 r.retryAfter = GetTime() + 60;
1861 if (r.deferrals == 1 || r.deferrals % 60 == 0)
1862 LogPrint("payout", "[payout] deferring %s to %s -- wallet is"
1863 " locked (waiting %d min so far)\n",
1778 FormatMoney(r.amount).c_str(), r.address.c_str(), 1864 FormatMoney(r.amount).c_str(), r.address.c_str(),
1779 FormatMoney(nSpendable).c_str(), r.deferrals); 1865 r.deferrals);
1780 ReschedulePayment(r); 1866 ReschedulePayment(r);
1781 continue; 1867 continue;
1782 } 1868 }
@@ -1803,36 +1889,76 @@ static void PayoutThreadFn(void*)
1803 LogPrint("payout", "[payout] paid %s to %s (tx %s)\n", 1889 LogPrint("payout", "[payout] paid %s to %s (tx %s)\n",
1804 FormatMoney(r.amount).c_str(), r.address.c_str(), 1890 FormatMoney(r.amount).c_str(), r.address.c_str(),
1805 wtx.GetHash().ToString().substr(0, 12).c_str()); 1891 wtx.GetHash().ToString().substr(0, 12).c_str());
1806 } else if (r.retries < 1440) {
1807 // Transient failures (reorg, locked wallet) must not silently
1808 // burn the payment -- retry with backoff. Insufficient funds
1809 // never lands here; it defers above.
1810 r.retries++;
1811 r.retryAfter = GetTime() + 60;
1812 ReschedulePayment(r);
1813 LogPrint("payout", "[payout] FAILED to pay %s to %s"
1814 " (%s spendable at the time) -- retry %d/1440 in 60s\n",
1815 FormatMoney(r.amount).c_str(), r.address.c_str(),
1816 FormatMoney(nSpendable).c_str(), r.retries);
1817 } else { 1892 } else {
1818 // Reaching here means 1440 attempts that were NOT 1893 // Why did it fail? The answer decides whether this costs a
1819 // insufficient-funds waits (those defer instead) against a 1894 // retry, and the retry budget is what eventually erases a
1820 // block still on the main chain -- i.e. a real wallet fault, 1895 // miner's money -- so guessing here is how a pool keeps coin
1821 // not a maturity or reorg question. 1896 // it owes.
1822 LogPrint("payout", "[payout] GIVING UP on %s to %s after %d" 1897 //
1823 " retries -- block %d is still on-chain and %s was" 1898 // Re-read the conditions rather than trusting the ones checked
1824 " spendable, so this is a wallet fault, not maturity" 1899 // before the attempt: SendMoney takes cs_main and can block for
1825 " -- payment forfeited\n", 1900 // a long time, so the balance and the lock state may both have
1826 FormatMoney(r.amount).c_str(), r.address.c_str(), 1901 // moved underneath it.
1827 r.retries, r.blockHeight, 1902 //
1828 FormatMoney(nSpendable).c_str()); 1903 // Short of coin covers the size-dependent part of the fee that
1829 // Write the debt off in the ledger as well as dropping the 1904 // the gate above deliberately does not reserve: if the balance
1830 // in-flight record. Under the survives-until-fully-paid model 1905 // no longer covers amount + configured fee + one fee tier, the
1831 // the entry would otherwise never complete, and every later 1906 // failure is a funding shortfall, not a fault.
1832 // flush would explode it again -- a forfeit that silently 1907 int64 nAfter = GetBalance();
1833 // becomes an infinite retry. 1908 bool fLocked = IsWalletLocked();
1834 ForfeitPayoutRecipient(r); 1909 bool fUnfunded = nAfter < r.amount + nTransactionFee + CENT;
1835 ForgetPayment(r); 1910
1911 if (fLocked || fUnfunded) {
1912 // Conditions are temporarily wrong. Wait; do not spend the
1913 // budget that leads to a permanent write-off.
1914 r.deferrals++;
1915 r.retryAfter = GetTime() + 60;
1916 if (r.deferrals == 1 || r.deferrals % 60 == 0)
1917 LogPrint("payout", "[payout] deferring %s to %s -- send"
1918 " failed with %s (%s spendable, %s needed with"
1919 " fee headroom); waiting %d min so far\n",
1920 FormatMoney(r.amount).c_str(), r.address.c_str(),
1921 fLocked ? "the wallet locked" : "too little coin",
1922 FormatMoney(nAfter).c_str(),
1923 FormatMoney(r.amount + nTransactionFee + CENT).c_str(),
1924 r.deferrals);
1925 ReschedulePayment(r);
1926 } else if (r.retries < 1440) {
1927 // A genuine fault: funded, unlocked, block on the main
1928 // chain, and the wallet still would not build or accept
1929 // the transaction. Retry with backoff.
1930 r.retries++;
1931 r.retryAfter = GetTime() + 60;
1932 ReschedulePayment(r);
1933 LogPrint("payout", "[payout] FAILED to pay %s to %s"
1934 " (%s spendable, wallet unlocked) -- retry %d/1440"
1935 " in 60s\n",
1936 FormatMoney(r.amount).c_str(), r.address.c_str(),
1937 FormatMoney(nAfter).c_str(), r.retries);
1938 } else {
1939 // 1440 failures that were each individually impossible to
1940 // explain by maturity, funding, the wallet being locked, or
1941 // a reorg. That is the only state in which this payment can
1942 // be called impossible rather than merely blocked.
1943 const std::string reason = strprintf(
1944 "wallet refused this payment %d times while funded"
1945 " (%s spendable), unlocked, and block %d on the main"
1946 " chain", r.retries, FormatMoney(nAfter).c_str(),
1947 r.blockHeight);
1948 LogPrint("payout", "[payout] GIVING UP on %s to %s: %s"
1949 " -- payment forfeited\n",
1950 FormatMoney(r.amount).c_str(), r.address.c_str(),
1951 reason.c_str());
1952 // Write the debt off in the ledger as well as dropping the
1953 // in-flight record. Under the survives-until-fully-paid
1954 // model the entry would otherwise never complete, and every
1955 // later flush would explode it again -- a forfeit that
1956 // silently becomes an infinite retry. The round proof is
1957 // told too, so the public record does not keep claiming the
1958 // address is owed.
1959 ForfeitPayoutRecipient(r, reason);
1960 ForgetPayment(r);
1961 }
1836 } 1962 }
1837 } 1963 }
1838 1964
@@ -1942,10 +2068,19 @@ static void WritePoolStatusJsonLocked()
1942 size_t start = gPoolRounds.size() > 10 ? gPoolRounds.size() - 10 : 0; 2068 size_t start = gPoolRounds.size() > 10 ? gPoolRounds.size() - 10 : 0;
1943 for (size_t i = start; i < gPoolRounds.size(); i++) { 2069 for (size_t i = start; i < gPoolRounds.size(); i++) {
1944 const PoolRoundProof& r = gPoolRounds[i]; 2070 const PoolRoundProof& r = gPoolRounds[i];
2071 // A forfeited recipient is not "unpaid and pending" -- the ledger
2072 // has already dropped the obligation. Counting it as unpaid is
2073 // what made the round proof and the ledger disagree for good.
1945 int unpaid = 0; 2074 int unpaid = 0;
1946 for (const auto& kv : r.payouts) 2075 int forfeited = 0;
1947 if (r.paidTxids.count(kv.first) == 0) 2076 for (const auto& kv : r.payouts) {
2077 if (r.paidTxids.count(kv.first))
2078 continue;
2079 if (r.forfeited.count(kv.first))
2080 forfeited++;
2081 else
1948 unpaid++; 2082 unpaid++;
2083 }
1949 j["recentRounds"].push_back({ 2084 j["recentRounds"].push_back({
1950 {"id", r.id}, 2085 {"id", r.id},
1951 {"blockHeight", r.blockHeight}, 2086 {"blockHeight", r.blockHeight},
@@ -1954,6 +2089,7 @@ static void WritePoolStatusJsonLocked()
1954 {"totalShares", r.totalShares}, 2089 {"totalShares", r.totalShares},
1955 {"payouts", (int)r.payouts.size()}, 2090 {"payouts", (int)r.payouts.size()},
1956 {"unpaidPayouts", unpaid}, 2091 {"unpaidPayouts", unpaid},
2092 {"forfeitedPayouts", forfeited},
1957 {"coinbaseValueBtf", FormatMoney(r.coinbaseValue)}, 2093 {"coinbaseValueBtf", FormatMoney(r.coinbaseValue)},
1958 {"minerRewardBtf", FormatMoney(r.minerReward)} 2094 {"minerRewardBtf", FormatMoney(r.minerReward)}
1959 }); 2095 });