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:
@@ -865,6 +865,11 @@ void App::update()
|
|||||||
// its own error toast); otherwise surface a generic notification (this is
|
// its own error toast); otherwise surface a generic notification (this is
|
||||||
// how shield/merge/auto-shield failures become visible).
|
// how shield/merge/auto-shield failures become visible).
|
||||||
for (const auto& [opid, rawMsg] : parsed.failureByOpid) {
|
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)
|
std::string msg = sendErrorNeedsRescan(rawMsg)
|
||||||
? rawMsg + "\n\n" + TR("send_err_needs_rescan")
|
? rawMsg + "\n\n" + TR("send_err_needs_rescan")
|
||||||
: rawMsg;
|
: rawMsg;
|
||||||
|
|||||||
17
src/app.h
17
src/app.h
@@ -411,7 +411,18 @@ private:
|
|||||||
const std::string& from,
|
const std::string& from,
|
||||||
const std::string& to,
|
const std::string& to,
|
||||||
double amount,
|
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,
|
void markPendingSendTransactionSucceeded(const std::string& opid,
|
||||||
const std::string& txid);
|
const std::string& txid);
|
||||||
void removePendingSendTransactions(const std::vector<std::string>& opids,
|
void removePendingSendTransactions(const std::vector<std::string>& opids,
|
||||||
@@ -618,9 +629,13 @@ private:
|
|||||||
std::string to;
|
std::string to;
|
||||||
std::string memo;
|
std::string memo;
|
||||||
double amount = 0.0;
|
double amount = 0.0;
|
||||||
|
double fee = 0.0;
|
||||||
std::int64_t timestamp = 0;
|
std::int64_t timestamp = 0;
|
||||||
};
|
};
|
||||||
std::unordered_map<std::string, PendingSendInfo> pending_send_info_;
|
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
|
// 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.
|
// 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&)>>
|
std::unordered_map<std::string, std::function<void(bool, const std::string&)>>
|
||||||
|
|||||||
@@ -767,7 +767,8 @@ void App::upsertPendingSendTransaction(const std::string& opid,
|
|||||||
const std::string& from,
|
const std::string& from,
|
||||||
const std::string& to,
|
const std::string& to,
|
||||||
double amount,
|
double amount,
|
||||||
const std::string& memo)
|
const std::string& memo,
|
||||||
|
double fee)
|
||||||
{
|
{
|
||||||
if (opid.empty()) return;
|
if (opid.empty()) return;
|
||||||
|
|
||||||
@@ -778,6 +779,7 @@ void App::upsertPendingSendTransaction(const std::string& opid,
|
|||||||
pendingInfo.to = to;
|
pendingInfo.to = to;
|
||||||
pendingInfo.amount = std::abs(amount);
|
pendingInfo.amount = std::abs(amount);
|
||||||
pendingInfo.memo = memo;
|
pendingInfo.memo = memo;
|
||||||
|
pendingInfo.fee = fee;
|
||||||
|
|
||||||
TransactionInfo pending;
|
TransactionInfo pending;
|
||||||
pending.txid = opid;
|
pending.txid = opid;
|
||||||
@@ -2357,7 +2359,7 @@ void App::sendTransaction(const std::string& from, const std::string& to,
|
|||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
result_str = e.what();
|
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 (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
// A send changes address balances — refresh on next cycle
|
// 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.
|
// "sent successfully" for an operation that may still fail.
|
||||||
if (!result_str.empty()) {
|
if (!result_str.empty()) {
|
||||||
pending_opids_.push_back(result_str);
|
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;
|
if (callback) pending_send_callbacks_[result_str] = callback;
|
||||||
} else if (callback) {
|
} else if (callback) {
|
||||||
callback(true, result_str); // no opid to track — report as-is
|
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
|
} // namespace dragonx
|
||||||
|
|||||||
Reference in New Issue
Block a user