feat(wallet): defer + retry a merge that hits a transient mempool conflict

A consolidation batch can be rejected by the daemon with
bad-txns-shielded-requirements-not-met (REJECT_DUPLICATE) when it clashes with an
earlier, still-unconfirmed batch — the batch outran its predecessors into the
mempool. That isn't a real failure: once the earlier batches confirm, the same
merge succeeds. Previously it failed the whole address.

Now a merge OP failure whose message is a mempool conflict (shielded- /
joinsplit-requirements-not-met, txn-mempool-conflict, inputs-spent — new pure
helper isDeferrableMergeOpError) HOLDS the address for kRunMempoolClearSec (2 min)
instead of failing it, then retries from where it left off; up to
kRunMaxDeferRetries consecutive defers (reset on any success), after which it is a
genuine failure. Deferrals don't feed the halt breaker, and since the address
outcome stays non-failed the run verdict reads Clean when the retry succeeds.
applyOpState takes `now` to stamp the hold; next() waits on a deferred address
until its window passes.

Unit-tested (testConsolidationDefer): the classifier; and a driven run where an
op reports the conflict → the address is deferred (not failed), held before the
window and retried after it. Build + tests + hygiene green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 18:26:28 -05:00
co-authored by Claude Opus 4.8
parent 94d41e2932
commit 0ebd1ac5d5
3 changed files with 87 additions and 3 deletions
+29 -2
View File
@@ -192,6 +192,22 @@ MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, const std::string& m
return MergeFailure::Fatal;
}
bool isDeferrableMergeOpError(const std::string& message)
{
// A batch rejected from the mempool because it clashed with an earlier, still-unconfirmed batch (its
// Sapling nullifier/anchor, or an input, already in flight). Re-trying after those confirm succeeds.
// Substring match — the daemon prefixes a reject code (e.g. "18: bad-txns-shielded-requirements-not-met").
static const char* const kNeedles[] = {
"shielded-requirements-not-met", // Sapling nullifier/anchor clash (REJECT_DUPLICATE)
"joinsplit-requirements-not-met", // the Sprout analogue
"txn-mempool-conflict", // spends an output another mempool tx already spends
"bad-txns-inputs-spent", // an input already spent by an unconfirmed sibling
};
for (const char* n : kNeedles)
if (message.find(n) != std::string::npos) return true;
return false;
}
rpc::RpcFailureKind mergeCallFailureKind(const std::exception& e)
{
const rpc::RpcFailureKind kind = rpc::rpcFailureKindOf(e);
@@ -298,7 +314,7 @@ void ConsolidationRun::endAddress(RunAddress& a, AddrOutcome o, const std::strin
if (a.run.opids.empty() && ++deadStreak_ >= kRunMaxFailStreak) halt(msg);
}
void ConsolidationRun::applyOpState(RunBatch& b, OpState st, const OpReport* rep)
void ConsolidationRun::applyOpState(RunBatch& b, OpState st, const OpReport* rep, double now)
{
b.state = st;
if (b.addrIdx < 0 || b.addrIdx >= static_cast<int>(addrs_.size())) return;
@@ -307,10 +323,20 @@ void ConsolidationRun::applyOpState(RunBatch& b, OpState st, const OpReport* rep
case OpState::Success:
if (rep) b.txid = rep->txid;
++a.opsDone;
a.defers = 0; // progress made: this address's consecutive-defer budget resets
opFailStreak_ = 0;
break;
case OpState::Failed:
if (rep) b.error = rep->error;
// A transient mempool conflict (clashed with an earlier, still-unconfirmed batch): don't fail the
// address — hold it and retry once the earlier batches confirm. Bounded, and it doesn't feed the
// halt breaker. The batch itself stays terminal (Failed) so it isn't re-polled; the address stays
// not-done and gets a fresh batch after the hold (see next()).
if (isDeferrableMergeOpError(b.error) && a.defers < kRunMaxDeferRetries) {
++a.defers;
a.deferUntil = now + kRunMempoolClearSec;
break;
}
if (a.failure.empty()) a.failure = b.error;
a.done = true;
worsen(a, AddrOutcome::Failed);
@@ -375,6 +401,7 @@ RunStep ConsolidationRun::next(const RunEnv& env, double now)
if (env.locked || now < unlockHoldUntil_) return wait(RunWait::Locked);
if (env.userSendBusy) return wait(RunWait::UserSend);
if (now < nextCallAt_) return wait(RunWait::Batch);
if (now < addrs_[cur_].deferUntil) return wait(RunWait::Batch); // held after a mempool conflict; retry once it clears
if (!batches_.empty() && (batches_.back().state == OpState::Unknown || batches_.back().state == OpState::Queued))
return wait(RunWait::Batch); // at most 2 unfinished ops, at most 2 x limit notes locked
if (env.scanBusy) return wait(RunWait::Scan); // another GUI wallet scan is in the daemon
@@ -484,7 +511,7 @@ void ConsolidationRun::onPollResult(std::uint64_t gen, bool ok, const std::vecto
}
b.misses = 0;
const OpState st = opStateFromStatus(it->second->status);
if (st != OpState::Unknown) applyOpState(b, st, it->second);
if (st != OpState::Unknown) applyOpState(b, st, it->second, now);
}
}