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
+47
View File
@@ -7395,6 +7395,52 @@ void testNoteConsolidation()
}
}
// A merge op that fails with a transient mempool conflict defers its address and retries once the window
// clears, instead of failing it — so a batch that outran an earlier, still-unconfirmed batch recovers.
void testConsolidationDefer()
{
using namespace dragonx::services;
// The classifier: mempool-conflict reject codes defer; a genuine "no funds" failure does not.
EXPECT_TRUE(isDeferrableMergeOpError("18: bad-txns-shielded-requirements-not-met"));
EXPECT_TRUE(isDeferrableMergeOpError("txn-mempool-conflict"));
EXPECT_TRUE(isDeferrableMergeOpError("bad-txns-inputs-spent"));
EXPECT_FALSE(isDeferrableMergeOpError("could not find any funds"));
EXPECT_FALSE(isDeferrableMergeOpError(""));
ConsolidationRun run;
RunConfig cfg; cfg.limit = 10; cfg.maxBatches = 3; cfg.fee = 0.0001;
std::vector<NoteGroup> groups{ {"zA", 100, 50.0} };
EXPECT_TRUE(run.start(groups, cfg, 0.0));
RunEnv env; env.nodeUsable = true; // idle + callable (callsAllowed defaults true; the rest false)
// A batch is submitted for address 0.
RunStep s = run.next(env, 1.0);
EXPECT_TRUE(s.action == RunAction::Call && s.addrIdx == 0);
run.markCallPosted(0, 1.0);
MergeCallResult cr; cr.opid = "op1"; cr.merging = 10; cr.remaining = 90;
run.onCallResult(run.gen(), 0, cr, dragonx::rpc::RpcFailureKind::None, 0.5, 2.0);
EXPECT_FALSE(run.addresses()[0].done);
// Its op is polled and reports a mempool conflict.
EXPECT_TRUE(run.next(env, 3.0).action == RunAction::Poll);
run.markPollPosted(3.0);
std::vector<OpReport> reps{ {"op1", "failed", "18: bad-txns-shielded-requirements-not-met", ""} };
run.onPollResult(run.gen(), true, reps, 4.0);
// Deferred, not failed: still live, held, and counted as a defer (not an address failure).
EXPECT_TRUE(run.active());
EXPECT_FALSE(run.addresses()[0].done);
EXPECT_TRUE(run.addresses()[0].outcome != AddrOutcome::Failed);
EXPECT_EQ(run.addresses()[0].defers, 1);
EXPECT_EQ(run.summary().addressesFailed, 0);
// Held before the window, retried after it.
EXPECT_TRUE(run.next(env, 5.0).action != RunAction::Call);
RunStep retry = run.next(env, 4.0 + kRunMempoolClearSec + 1.0);
EXPECT_TRUE(retry.action == RunAction::Call && retry.addrIdx == 0);
}
void testDatadirLockGate()
{
using dragonx::daemon::EmbeddedDaemon;
@@ -20298,6 +20344,7 @@ int main()
testOwnershipRecord();
testDebugLogTailRules();
testNoteConsolidation();
testConsolidationDefer();
testScopeScanAndLockedNotes();
testMergeCallResult();
testOpReports();