fix(fullnode): work around daemon note-selection fee-gap on shielded sends

dragonxd's z_sendmany picks notes to cover the recipient total (nTotalOut) but not
the miner fee, then rejects the build unless the selected notes cover amount+fee
(rpcwallet.cpp:5312 vs asyncrpcoperation_sendmany.cpp:278). So a shielded send whose
largest notes sum exactly to the amount fails with "Insufficient shielded funds,
have H, need H+fee" despite ample balance — e.g. sending exactly 2.0 from an address
whose biggest note is 2.0.

Since the failure is async (reported via the opid poll), detect it there: when a
shielded send fails with that message and the selected total H >= the requested
amount (selection covered the amount but stopped one note short of the fee — vs a
genuine shortfall where H < amount), re-issue the send once with a tiny self-output
(= fee) back to the from-address. That lifts the daemon's selection target past the
boundary so it grabs another note and can cover the fee; the recipient still receives
the exact amount. Retries are tracked so a second failure surfaces normally (no loop).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 14:27:52 -05:00
parent 0fe12d65df
commit de70e68472
3 changed files with 138 additions and 4 deletions

View File

@@ -865,6 +865,11 @@ void App::update()
// its own error toast); otherwise surface a generic notification (this is
// how shield/merge/auto-shield failures become visible).
for (const auto& [opid, rawMsg] : parsed.failureByOpid) {
// Auto-work-around the daemon's note-selection fee-gap: re-issues the send with a
// self-output so it covers the fee. If a retry was issued, defer the outcome to it.
if (maybeRetrySendForFeeGap(opid, rawMsg)) {
continue;
}
std::string msg = sendErrorNeedsRescan(rawMsg)
? rawMsg + "\n\n" + TR("send_err_needs_rescan")
: rawMsg;

View File

@@ -411,7 +411,18 @@ private:
const std::string& from,
const std::string& to,
double amount,
const std::string& memo);
const std::string& memo,
double fee = 0.0);
// Work around a dragonxd note-selection bug: its z_sendmany picks notes to cover the recipient
// total but not the miner fee, so a shielded send whose largest notes sum exactly to the amount
// fails with "Insufficient shielded funds, have H, need H+fee" despite ample balance. When a
// failed opid matches that (H >= the requested amount), re-issue the send once with a tiny
// self-output that lifts the daemon's selection target past the boundary so it grabs another
// note; the recipient still receives the exact amount. Returns true if a retry was issued.
bool maybeRetrySendForFeeGap(const std::string& opid, const std::string& rawMsg);
void resendWithFeeGapWorkaround(const std::string& from, const std::string& to,
double amount, double fee, const std::string& memo,
std::function<void(bool, const std::string&)> callback);
void markPendingSendTransactionSucceeded(const std::string& opid,
const std::string& txid);
void removePendingSendTransactions(const std::vector<std::string>& opids,
@@ -618,9 +629,13 @@ private:
std::string to;
std::string memo;
double amount = 0.0;
double fee = 0.0;
std::int64_t timestamp = 0;
};
std::unordered_map<std::string, PendingSendInfo> pending_send_info_;
// Opids issued as a fee-gap auto-retry (see maybeRetrySendForFeeGap). Tracked so a retry that
// fails again is reported to the user instead of looping.
std::unordered_set<std::string> send_feegap_retried_opids_;
// z_sendmany UI callbacks held until the opid reaches a terminal status, so the
// user isn't told "sent successfully" before the tx is actually built/broadcast.
std::unordered_map<std::string, std::function<void(bool, const std::string&)>>

View File

@@ -767,7 +767,8 @@ void App::upsertPendingSendTransaction(const std::string& opid,
const std::string& from,
const std::string& to,
double amount,
const std::string& memo)
const std::string& memo,
double fee)
{
if (opid.empty()) return;
@@ -778,6 +779,7 @@ void App::upsertPendingSendTransaction(const std::string& opid,
pendingInfo.to = to;
pendingInfo.amount = std::abs(amount);
pendingInfo.memo = memo;
pendingInfo.fee = fee;
TransactionInfo pending;
pending.txid = opid;
@@ -2357,7 +2359,7 @@ void App::sendTransaction(const std::string& from, const std::string& to,
} catch (const std::exception& e) {
result_str = e.what();
}
return [this, callback, ok, result_str, from, to, amount, memo]() {
return [this, callback, ok, result_str, from, to, amount, fee, memo]() {
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
if (ok) {
// A send changes address balances — refresh on next cycle
@@ -2372,7 +2374,7 @@ void App::sendTransaction(const std::string& from, const std::string& to,
// "sent successfully" for an operation that may still fail.
if (!result_str.empty()) {
pending_opids_.push_back(result_str);
upsertPendingSendTransaction(result_str, from, to, amount, memo);
upsertPendingSendTransaction(result_str, from, to, amount, memo, fee);
if (callback) pending_send_callbacks_[result_str] = callback;
} else if (callback) {
callback(true, result_str); // no opid to track — report as-is
@@ -2385,4 +2387,116 @@ void App::sendTransaction(const std::string& from, const std::string& to,
});
}
// Parse the daemon's "Insufficient shielded funds, have <H>, need <N>" message. std::stod stops at
// the trailing comma/text, so it extracts each amount cleanly. Returns false if it isn't that error.
static bool parseInsufficientShielded(const std::string& msg, double& have, double& need)
{
if (msg.find("Insufficient shielded funds") == std::string::npos) return false;
auto hp = msg.find("have ");
auto np = msg.find("need ");
if (hp == std::string::npos || np == std::string::npos) return false;
try {
have = std::stod(msg.substr(hp + 5));
need = std::stod(msg.substr(np + 5));
} catch (...) {
return false;
}
return true;
}
bool App::maybeRetrySendForFeeGap(const std::string& opid, const std::string& rawMsg)
{
double have = 0.0, need = 0.0;
if (!parseInsufficientShielded(rawMsg, have, need)) return false;
auto it = pending_send_info_.find(opid);
if (it == pending_send_info_.end()) return false;
const PendingSendInfo info = it->second; // copy before any cleanup
// Only shielded sends hit the bug; a transparent "from" uses a different (correct) selector.
if (info.from.empty() || info.from[0] != 'z') return false;
// Discriminator: the daemon stopped once it covered the amount but short of the fee, so the
// selected total (have) is >= the amount. A genuine shortfall reports have < amount (it grabbed
// every note and still couldn't reach the amount) — don't "retry" those.
if (have + 1e-9 < info.amount) return false;
// Never retry a retry (the self-output already widened the target; a second failure is real).
if (send_feegap_retried_opids_.count(opid)) return false;
const double fee = info.fee > 0.0 ? info.fee : 0.0001;
// Hand the waiting UI callback to the retry so the user sees the final outcome, not the
// intermediate "insufficient" we're working around.
std::function<void(bool, const std::string&)> cb;
auto cbIt = pending_send_callbacks_.find(opid);
if (cbIt != pending_send_callbacks_.end()) {
cb = cbIt->second;
pending_send_callbacks_.erase(cbIt);
}
DEBUG_LOGF("[send] fee-gap workaround: retrying %s with self-output (have=%.8f need=%.8f amount=%.8f fee=%.8f)\n",
opid.c_str(), have, need, info.amount, fee);
resendWithFeeGapWorkaround(info.from, info.to, info.amount, fee, info.memo, std::move(cb));
return true;
}
void App::resendWithFeeGapWorkaround(const std::string& from, const std::string& to,
double amount, double fee, const std::string& memo,
std::function<void(bool, const std::string&)> callback)
{
if (!state_.connected || !rpc_ || !worker_) {
if (callback) callback(false, "Not connected");
return;
}
// recipients = the real recipient + a tiny self-output (= fee) back to `from`. The extra output
// raises the daemon's note-selection target (nTotalOut) above the single largest note, so its
// greedy picker grabs another note and can therefore also cover the miner fee. The recipient
// still receives EXACTLY `amount`; the self-output and any change return to `from`.
nlohmann::json recipients = nlohmann::json::array();
nlohmann::json primary;
primary["address"] = to;
primary["amount"] = util::formatAmountFixed(amount);
if (!memo.empty()) primary["memo"] = memo;
recipients.push_back(primary);
nlohmann::json selfOut;
selfOut["address"] = from;
selfOut["amount"] = util::formatAmountFixed(fee);
recipients.push_back(selfOut);
send_progress_active_ = true;
++send_submissions_in_flight_;
worker_->post([this, from, to, amount, fee, memo, recipients, callback]() -> rpc::RPCWorker::MainCb {
bool ok = false;
std::string result_str;
try {
rpc::RPCClient::TraceScope trace("Send tab / Fee-gap retry");
auto result = rpc_->call("z_sendmany", {from, recipients, 1, fee});
result_str = result.get<std::string>();
ok = true;
} catch (const std::exception& e) {
result_str = e.what();
}
return [this, callback, ok, result_str, from, to, amount, fee, memo]() {
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
if (ok) {
addresses_dirty_ = true;
transactions_dirty_ = true;
last_tx_block_height_ = -1;
network_refresh_.markWalletMutationRefresh();
if (!result_str.empty()) {
pending_opids_.push_back(result_str);
send_feegap_retried_opids_.insert(result_str); // a retry of a retry is a real error
upsertPendingSendTransaction(result_str, from, to, amount, memo, fee);
if (callback) pending_send_callbacks_[result_str] = callback;
} else if (callback) {
callback(true, result_str);
}
} else {
send_progress_active_ = false;
if (callback) callback(false, result_str);
}
};
});
}
} // namespace dragonx