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;