Skip to content

wallet: chain-aware repush and mempool reconciliation - #2127

Merged
erubboli merged 10 commits into
masterfrom
pr/wallet-chain-repush
Oct 2, 2026
Merged

erubboli merged 10 commits into
masterfrom
pr/wallet-chain-repush

Conversation

@nullPointerEnjoyer

@nullPointerEnjoyer nullPointerEnjoyer commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Replaces the wallet's blind rebroadcast loop (every 2–5 minutes, resubmitting the whole user-transaction table in hash order, unbounded) with a chain-aware reconcile-and-rebroadcast pass that:

  • orders pending transactions topologically (parents before children), with account-nonce tie-breaking for nonce-bearing transactions; account-nonce chains are also modeled as submission dependencies (a transaction whose same-account nonce predecessor failed or was skipped this pass is deferred);
  • probes the node's mempool for each pending transaction via the existing mempool_get_transaction RPC (no node upgrade required); a failed probe only excludes that transaction and its descendants from the pass;
  • prunes deterministically-rejected chains: a transaction missing from the mempool while all its pending parents are present there, whose rejection the node actually observed, and whose "present parent + missing transaction" signature held for PRUNE_ABSENCE_THRESHOLD (3) consecutive passes in which the transaction was actually due, is removed from the spendable cache and the rebroadcast set together with its pending descendants. Rejection evidence expires after REJECTION_EVIDENCE_MAX_AGE (1 h), so a single historic rejection cannot support a much later prune. Once evidence is established, a transaction is never resubmitted — a failed wallet-side prune is retried on the next pass. Transactions that were never delivered (transport errors) or previously succeeded are resubmitted instead of pruned;
  • repushes the remaining missing transactions with per-transaction exponential backoff (30 s → 15 min) and a retry budget (10 attempts or 24 h), after which the transaction is marked stuck and skipped until it is abandoned manually (transaction_abandon) or the wallet restarts. Delivery failures do not burn the rejection budget; the pass is re-timed to the earliest due attempt so the documented backoff schedule is honored;
  • classifies node rejections by typed error variants, not by Display text: transient and state-dependent outcomes (tip moved, chainstate/subsystem/reorg errors, store races, IBD, mempool/orphan pressure, mempool conflicts, nonce gaps, output-spent and nonce-incrementality races, internal chainstate/storage/accounting failures) never set rejection evidence and never feed a prune. The JSON-RPC transport mirrors the same decision on the wire message (wrapper-prefix gate + term list), pinned by tests derived from typed errors and by strum::EnumCount variant-count pins that fail CI when an error enum gains a variant;
  • clears tracker state for confirmed/pruned transactions and when nothing is pending; stuck markers persist, as documented.

The planner, tracker and reconciliation logic are pure functions (wallet-controller/src/rebroadcast.rs) with unit tests; the controller run loop is the imperative shell.

Supporting changes

  • storage: get_user_transactions_for_account (a scoped read over the existing DBUserTx keyspace; no schema change);
  • wallet: get_unconfirmed_transactions_per_account — filtered to transactions that may still need a (re)broadcast; confirmed, conflicted and abandoned states are excluded, unknown state is kept;
  • account/output_cache: prune_dead_transaction, a variant of abandon_transaction that tolerates a stale InMempool state (used when the mempool evidence says the transaction is dead); abandon_transaction behavior is unchanged;
  • NodeInterfaceError::is_node_rejection (default false = the safe under-pruning choice): the handles client classifies by typed mempool::error variants; the RPC client requires the CALL_EXECUTION_FAILED (−32000) code plus the wire-message mirror of the same decision;
  • mempool/chainstate-tx-verifier: strum::EnumCount derives on the mempool error enums (test-only consumer; no behavior change).

No consensus surface is touched; no wallet DB migration is needed.

Tests

  • planner: topological ordering (parents first), cycle termination, backoff growth, stuck-after-budget, success resets the budget (but not the pending-age clock), evidence only advances on due passes, stale rejection evidence ignored, established evidence retried as prune, presence reset, stuck skipped;
  • reconcile: submit-all-missing, deterministic-rejection subtree pruning with the multi-pass signature, never-attempted and delivery-failed transactions resubmitted instead of pruned, previously-successful dropped transactions resubmitted, multi-parent incomplete signature, absence reset on reappearance;
  • classification: typed variant coverage (deterministic vs transient/internal/state-dependent), wire-message mirror derived from typed errors on both directions, wrapper-prefix tie-in to the p2p/mempool Display attributes, RPC code gate at −32000, variant-count pins;
  • output_cache: stale-in-mempool chain pruning cascades and rolls back inputs; plain abandon_transaction still rejects stale in-mempool state; pruning rejects confirmed/abandoned targets (exact error variants asserted);
  • full wallet, wallet-controller, node-comm and end-to-end wallet-rpc-lib suites green.

Review history

The branch went through four OpenCodeReview rounds after the initial push; every finding is addressed and mapped to its resolution in the comments below (see especially: stuck-parent deferral, typed classification replacing substring matching, rejection-evidence recency, pass re-timing, nonce-chain dependencies, established-evidence prune retries). The final two review runs reported zero findings. Concurrent mempool probing was attempted and reverted: buffer_unordered over the async-trait probes trips the rustc auto-trait leak (Send not general enough) in the wallet worker's event loop.

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

🔍 OpenCodeReview found 15 issue(s) in this PR.

  • ✅ Successfully posted inline: 5 comment(s)
  • 📋 Routed to summary by policy: 7 comment(s)
  • ⏭️ Skipped (overlap with history): 3 comment(s)

⚠️ 1 warning(s) occurred during review.


maintainability · low

📄 wallet/src/account/output_cache/mod.rs (L583-L584)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category maintainability)

This enum is part of the crate's public surface (pub) but is only consumed internally as a policy flag between OutputCache and Account. Since Tolerate is unsafe to pass without external verification, consider pub(crate) to keep the footgun out of the public API. Alternatively, if the two-variant enum is only ever chosen by which public method (abandon_transaction vs prune_dead_transaction) is called, a private bool field or two thin impls would be simpler.


maintainability · low

📄 wallet/src/wallet/mod.rs (L1594-L1597)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category maintainability)

The state filter relies on InMempool | Inactive being the only non-terminal, non-conflicting states. This is currently correct (the remaining TxState variants — Confirmed, Conflicted, Abandoned — are intentionally excluded), but a new TxState variant added later would be silently dropped from rebroadcast. An explicit deny-list (!matches!(Confirmed | Conflicted | Abandoned)) or an exhaustive match would fail loudly instead.


documentation · low

📄 wallet/src/wallet/mod.rs (L1601-L1601)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category documentation)

Note that OutputCache::get_transaction also maps WalletTx::Block(_) entries to NoTransactionFound, so the phrase "a transaction with no wallet tx entry at all" understates which entries are kept here (block-reward txs are re-included too — likely undesirable for rebroadcast). Worth tightening the comment or the filter.


maintainability · low

📄 wallet/wallet-node-client/src/rpc_client/mod.rs (L31-L31)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category maintainability)

The magic-string constants (-32000 here and the wrapper prefixes "mempool error:"/"orphan transaction error:") duplicate values available as typed constants. The tests already use rpc::test_support::CALL_EXECUTION_FAILED_CODE; using the same constant here (rpc re-exports jsonrpsee's CALL_EXECUTION_FAILED_CODE) would keep the gate tied to the actual code the server emits.

💡 Suggested Change

Before:

const CALL_EXECUTION_FAILED_CODE: i32 = -32000;

After:

use rpc::CALL_EXECUTION_FAILED_CODE as CALL_EXECUTION_FAILED_CODE_;

maintainability · low

📄 wallet/wallet-node-client/src/rpc_client/mod.rs (L155-L157)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category maintainability)

The INDETERMINATE term list is a flat array of bare strings; each term corresponds to one or more specific enum variants, but that mapping lives only in the author's head. Annotating each entry with the variant it mirrors (or grouping them by source enum: TipMoved, TxValidationError, policy, orphan pool) would make future syncs with is_deterministic_mempool_rejection much easier to audit.

💡 Suggested Change

Before:

    const INDETERMINATE: &[&str] = &[
        "tip moved",
        "chainstate error",

After:

    const INDETERMINATE: &[&str] = &[
        // mempool::Error::TipMoved
        "tip moved",
        // TxValidationError::ChainstateError / ReorgError::ChainstateError
        "chainstate error",

style · low

📄 wallet/wallet-node-client/src/node_traits.rs (L61-L63)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category style)

Name nit: is_node_rejection reads as "any node-side error"; the semantics documented (deterministic transaction rejection vs. delivery failure vs. indeterminate outcome) are closer to is_deterministic_tx_rejection. Not blocking, but a more precise name would reduce misuse at new call sites.

💡 Suggested Change

Before:

    fn is_node_rejection(&self) -> bool {
        false
    }

After:

    fn is_deterministic_tx_rejection(&self) -> bool {
        false
    }

style · low

📄 wallet/wallet-controller/src/lib.rs (L1963-L1967)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category style)

The two comment paragraphs here contradict each other: the first says probes 'run with bounded concurrency: a long pending chain must not turn the pass into one serial round-trip per transaction', while the second says they run sequentially due to a rustc Send limitation. Update the first paragraph to match the actual sequential behavior so future readers don't assume concurrency exists.

💡 Suggested Change

Before:

        // bounded concurrency: a long pending chain must not turn the pass
        // into one serial round-trip per transaction.
        // Probes run sequentially: a concurrent (buffer_unordered) probe
        // stream here trips the rustc auto-trait leak ("implementation of
        // Send is not general enough") inside the event-loop future and

After:

        // NOTE: probes run sequentially (see below) — a long pending chain
        // costs one round-trip per transaction per pass.
        // Probes run sequentially: a concurrent (buffer_unordered) probe
        // stream here trips the rustc auto-trait leak ("implementation of
        // Send is not general enough") inside the event-loop future and

⚠️ Warnings:

  • wallet/wallet-controller/src/lib.rs (token_budget_reached): skipped round 2 of group "wallet/wallet-controller/src/lib.rs,wallet/wallet-controller/src/rebroadcast.rs,wallet/wallet-controller/src/runtime_wallet.rs": used 512052 tokens exceeds budget 500000

Comment thread wallet/src/wallet/mod.rs Outdated
Comment thread wallet/wallet-controller/src/lib.rs
Comment thread wallet/wallet-controller/src/rebroadcast.rs Outdated
Comment thread wallet/wallet-node-client/src/rpc_client/mod.rs
Comment thread wallet/wallet-node-client/src/rpc_client/mod.rs
@nullPointerEnjoyer
nullPointerEnjoyer force-pushed the pr/wallet-chain-repush branch 2 times, most recently from d5c58de to 6aaf2e0 Compare September 29, 2026 17:12
@nullPointerEnjoyer
nullPointerEnjoyer force-pushed the pr/mempool-local-origin-orphans branch 2 times, most recently from 763db5d to 9cf32a0 Compare September 29, 2026 17:30
Comment thread wallet/src/account/output_cache/mod.rs
Comment thread wallet/wallet-controller/src/lib.rs Outdated
Comment thread wallet/wallet-controller/src/lib.rs
Comment thread wallet/wallet-controller/src/rebroadcast.rs Outdated
Comment thread wallet/wallet-controller/src/rebroadcast.rs Outdated
Comment thread wallet/wallet-node-client/src/rpc_client/mod.rs
Base automatically changed from pr/mempool-local-origin-orphans to master September 30, 2026 12:33
Comment thread wallet/wallet-controller/src/lib.rs Outdated
Comment thread wallet/wallet-controller/src/rebroadcast.rs Outdated
Comment thread wallet/wallet-controller/src/rebroadcast.rs
Comment thread wallet/wallet-node-client/src/handles_client/mod.rs Outdated
@erubboli
erubboli force-pushed the pr/wallet-chain-repush branch from 642f937 to fca8530 Compare October 1, 2026 07:38
@nullPointerEnjoyer

Copy link
Copy Markdown
Collaborator Author

Rebased and force-pushed (fca853011): the branch is now a single commit directly on current master. This also removes the stale duplicate of #2126 that the previous head carried (an older variant of the mempool tip-budget change that #2126's review had already dropped) — that divergence was what made the PR report as conflicting.

Findings from this review thread, against the new head:

  1. MAX_WORK_UNITS_PER_TIP 64 → 16 — gone: it came from the stale mempool: allow parking local-origin orphans when enabled #2126 duplicate, not from this PR. The merged mempool: allow parking local-origin orphans when enabled #2126 value (64) is untouched.
  2. Children of not-due (backoff) parents submitted → guaranteed rejection — fixed: the submit loop now maintains an unsubmitted set = "known not to be in the mempool by the end of this pass" (submission failed or transaction not due or a pending parent is in the set). Children are deferred transitively, in topological order. This also defers children of stuck parents (they were being resubmitted against a permanently absent parent, cascading the whole subtree into stuck).
  3. Sticky node_observed + transient overload → prune — unchanged by design, see the reply to the earlier thread: pruning requires the "all pending parents present + transaction missing" signature on 3 consecutive passes in addition to node-observed evidence; any pass that breaks the signature resets the chain. Pruning is a cache/bookkeeping operation and remains recoverable via rescan; on-chain funds are untouched.
  4. Children of stuck parents cascade into stuck — fixed via (2): they are deferred, not submitted, so their budgets are never burned.
  5. MempoolError over-broad in the handles client — the classification is now message-based on both transports: the handles client builds the exact string the JSON-RPC transport wraps ("mempool error: {inner}") from the typed error and classifies it, so TipMoved / chainstate / subsystem / reorg / store-race errors stay indeterminate (retryable) and can never feed the prune evidence; the two transports cannot disagree by construction.
  6. Redundant/misordered forget after prune — fixed: reconcile no longer pre-forgets pruned ids; the controller forgets only after prune_dead_transaction succeeds. A failed prune now retries on the next pass using the surviving evidence (streak already at threshold) instead of rebuilding it, and the failure is logged with the streak.
  7. O(n²) lookups — fixed: pending_by_id map for parent lookups (no more pending.iter().find, no pending_parents.clone()), HashSet for the final to_submit filter in reconcile.

Also addressed from earlier summaries: get_unconfirmed_transactions_per_account now filters out transactions the output cache knows are confirmed, conflicted or abandoned (unknown state is kept — absence of evidence is not evidence of death), so dead transactions can no longer be probed, resubmitted or fed into the prune evidence; and the allow_stale_in_mempool bools are gone in favor of the StaleInMempool enum, with the caller obligation documented on Tolerate.

wallet, wallet-controller, node-comm and wallet-rpc-lib suites green; no new clippy findings in the touched crates.

Comment thread wallet/src/account/mod.rs
Comment thread wallet/src/wallet/mod.rs Outdated
Comment thread wallet/wallet-controller/src/lib.rs Outdated
Comment thread wallet/wallet-controller/src/lib.rs Outdated
Comment thread wallet/wallet-controller/src/rebroadcast.rs
Comment thread wallet/wallet-node-client/src/handles_client/mod.rs Outdated
Comment thread wallet/wallet-node-client/src/rpc_client/mod.rs
Comment thread wallet/wallet-node-client/src/rpc_client/mod.rs
Comment thread wallet/wallet-node-client/src/rpc_client/mod.rs
Replaces the wallet's blind rebroadcast loop (every 2-5 minutes,
resubmitting the whole user-transaction table in hash order, unbounded)
with a reconcile-and-rebroadcast pass that:

- orders pending transactions topologically (parents before children),
  with account-nonce tie-breaking for nonce-bearing transactions;
- probes the node's mempool for each pending transaction via the existing
  `mempool_get_transaction` RPC (no node upgrade required); a failed probe
  only excludes that transaction (and its descendants) from the pass;
- prunes deterministically-rejected chains: a transaction missing from the
  mempool while *all* its pending parents are present there, with a
  node-observed submission, over `PRUNE_ABSENCE_THRESHOLD` (3) consecutive
  passes, is removed from the spendable cache and the rebroadcast set,
  together with its pending descendants; transactions that were never
  delivered (transport errors) or previously succeeded are resubmitted
  instead of pruned;
- repushes the remaining missing transactions with per-transaction
  exponential backoff (30 s -> 15 min) and a retry budget (10 attempts or
  24 h), after which the transaction is marked stuck and skipped until it
  is abandoned manually (`transaction_abandon`) or the wallet restarts.
  Delivery failures do not burn the rejection budget; descendants of
  failed or not-yet-due submissions are deferred to a later pass;
- clears tracker state for confirmed/pruned transactions and when nothing
  is pending (stuck markers persist, as documented).

The planner, tracker and reconciliation logic are pure functions
(`wallet-controller/src/rebroadcast.rs`) with unit tests; the controller
run loop is the imperative shell.

Supporting changes:

- storage: `get_user_transactions_for_account` (a scoped read over the
  existing `DBUserTx` keyspace; no schema change);
- wallet: `get_unconfirmed_transactions_per_account`, filtered to
  transactions that may still need a (re)broadcast (confirmed, conflicted
  and abandoned states are excluded; unknown state is kept);
- account/output_cache: `prune_dead_transaction`, a variant of
  `abandon_transaction` that tolerates a stale `InMempool` state (used
  when the node confirms the transaction is not in the mempool);
  `abandon_transaction` behavior is unchanged;
- `NodeInterfaceError::is_node_rejection` (default `false` = the safe
  under-pruning choice): the RPC client implements it as "JSON-RPC
  server-application error response", the handles client as
  `MempoolError` / `P2p(MempoolError)`.

No consensus surface is touched; no wallet DB migration is needed.
@erubboli
erubboli force-pushed the pr/wallet-chain-repush branch from fca8530 to 6073a1c Compare October 1, 2026 13:23
Comment thread wallet/wallet-controller/src/lib.rs
Comment thread wallet/wallet-controller/src/rebroadcast.rs Outdated
Comment thread wallet/wallet-node-client/src/handles_client/mod.rs Outdated
Second round of review findings (OCR + security review):

- classify mempool rejections by error variant, not by Display text: the
  wire prefix makes any substring check vacuous. Transient and
  state-dependent outcomes (tip moved, chainstate/subsystem/reorg errors,
  store races, IBD, mempool/orphan pressure, mempool conflicts, nonce
  gaps, output-spent and nonce-incrementality races) no longer set
  node_observed and never feed the prune evidence;
- JSON-RPC path: require the CALL_EXECUTION_FAILED code (-32000, which is
  what the mintlayer server reports for all application errors) and
  mirror the typed decision on the actual wire message; protocol errors
  (-326xx) stay delivery failures;
- gate the absence streak on node_observed: evidence built before the
  node first observed a submission cannot combine with a single later
  rejection into an instant prune;
- on_success keeps first_seen, so MAX_PENDING_AGE applies across
  accept/evict resubmission cycles;
- get_unconfirmed_transactions_per_account propagates real storage errors
  instead of treating every lookup failure as not-found;
- extend regression tests: both classifiers per variant, the RPC code
  gate, the evidence gate, and the age-clock stickiness.
@nullPointerEnjoyer

Copy link
Copy Markdown
Collaborator Author

All findings from the two latest review runs are addressed in b0c70a636:

  1. Vacuous substring classification (rpc_client / handles_client, 2× bug-high) — replaced with a typed classifier, is_deterministic_mempool_rejection(&mempool::error::Error), matching on variants: transient and state-dependent outcomes (TipMoved, chainstate/subsystem/reorg wrappers, TxCollectionError store races, AddedDuringIBD, MissingOutputOrSpent, NonceIsNotIncremental, MempoolFull, OrphanPoolError::Full/LocalCapacityExceeded, all Conflict-family and nonce-gap errors) no longer set node_observed and can never feed the prune evidence. The handles client now uses the typed classifier directly — no string re-creation, so the "cannot drift" claim is real (and the weaker comment is gone).
  2. JSON-RPC path (rpc_client:86, lib.rs:2056) — is_node_rejection now requires (a) the CALL_EXECUTION_FAILED code (−32000, which is what the mintlayer server reports for all application errors per rpc::handle_result, so −32000 ≠ "server busy" here — but the message check still independently excludes such texts) and (b) the wire message passing the string mirror of the typed classifier. Protocol errors (−326xx, where the tx was never evaluated) and every transient mempool outcome stay delivery failures. The mempool-eviction scenario from the finding (child evicted under pressure while parent survives) now yields node_observed = false → no prune, ever.
  3. Streak pre-building (rebroadcast:392) — bump_absence is now gated on node_observed: passes before the node first observed a submission reset the streak instead of accumulating it. Locked in by reconcile_does_not_prune_on_streak_built_without_node_observation, which asserts the exact "streak without observation + one rejection" sequence from the finding.
  4. Oscillating transaction never ages out (rebroadcast:178) — on_success keeps first_seen, so MAX_PENDING_AGE (24 h) applies across accept/evict resubmission cycles; the rejection budget still resets (a churn-evicted tx deserves a fresh budget). Covered by on_success_keeps_first_seen, including the counter-case that a fresh transaction is not stuck.
  5. Swallowed storage errors (wallet/mod.rs:1581) — the filter now propagates any get_transaction error except WalletError::NoTransactionFound, which is the only expected "no state known" case.
  6. Missing indeterminate regression tests (rpc_client:187) — added per-variant tests for the typed classifier (deterministic and transient cases) and wire-message tests that run every transient variant's real Display string through classify_mempool_error_message, plus the RPC code-gate cases (−32000 rejection / −32000 transient / −32000 non-mempool / −32603 / −32700).

Not changed — prune_dead_transaction proof token (account/mod.rs:2568): the suggested witness type can't enforce anything across this crate boundary: the verification (mempool probe + evidence) happens in wallet-controller, which sits above the wallet crate — a token mintable only in the controller would have to be defined in wallet (circular dep) or move the whole reconcile machinery into wallet (a large refactor with no additional guarantee: the controller calls both sides anyway, so a "proof" it mints itself proves nothing the existing call graph doesn't). The invariant is currently enforced by visibility-in-practice (prune_dead_transaction has exactly one caller chain: reconcile → prune_dead_transaction) plus the runtime guards (confirmed/abandoned rejected; Tolerate documented as a caller obligation). Happy to revisit if the maintainers prefer the token anyway.

node-comm (8), wallet-controller (42), wallet (116) and wallet-rpc-lib suites green; fmt and clippy clean.

Comment thread wallet/src/account/output_cache/mod.rs
Comment thread wallet/wallet-controller/src/lib.rs Outdated
Comment thread wallet/wallet-controller/src/lib.rs
Comment thread wallet/wallet-controller/src/lib.rs Outdated
Comment thread wallet/wallet-controller/src/rebroadcast.rs Outdated
Comment thread wallet/wallet-node-client/src/rpc_client/mod.rs
Comment thread wallet/wallet-node-client/src/rpc_client/mod.rs Outdated
Comment thread wallet/wallet-node-client/src/rpc_client/mod.rs Outdated
Comment thread wallet/wallet-node-client/src/rpc_client/mod.rs
- typed rejection classifier: internal chainstate/storage/accounting
  ConnectTransactionError variants (storage, utxo/undo, verifier storage,
  PoS/tokens/orders accounting, nonce-bookkeeping races) no longer count
  as deterministic rejections; a transient DB hiccup can never prune;
- wire classifier: positive wrapper-prefix gate ("mempool error:" /
  "orphan transaction error:") instead of a loose keyword search;
- RPC code gate narrowed to exactly CALL_EXECUTION_FAILED (-32000), the
  code the mintlayer server reports for application errors;
- reconcile takes the pass time and only advances the absence streak on
  passes where the transaction was actually due, so a prune cannot happen
  before the remaining retry attempts had a chance to run;
- the pass is re-timed to the earliest due attempt when that is sooner
  than the 2-5 minute interval, so the documented backoff schedule is
  honored instead of being clamped to the pass cadence (with a 5s minimum
  gap between passes);
- account-nonce chains are modeled as submission dependencies: a nonce-n
  transaction is deferred when its same-account nonce-(n-1) predecessor
  failed or was skipped, symmetric to the UTXO parent rule;
- mempool probes run with bounded concurrency (8) instead of one serial
  round-trip per pending transaction;
- prune_dead_transaction doc comments state the caller obligation and the
  probe/prune race (self-healing on confirmation or rescan);
- new tests: internal-variant indeterminacy, derived typed<->wire
  deterministic mirror, backoff-gated streak.
@nullPointerEnjoyer

Copy link
Copy Markdown
Collaborator Author

All findings from the 16:28 review run are addressed in 3dce76d1e:

  1. Internal ConnectTransactionError variants defaulting to rejection (bug-high) — the TxValidation arm now also excludes the internal chainstate/storage/accounting variants: StorageError, UtxoError, UtxoBlockUndoError, AccountingBlockUndoError, BlockIndexCouldNotBeLoaded, MissingTxUndo/MissingBlockUndo/MissingBlockRewardUndo, the header-invariant error, block-fee/reward-addition failures, TransactionVerifierError/TxVerifierStorage, UndoFetchFailure, PoSAccountingError, TokensError, TokensAccountingError, OrdersAccountingError, StakerBalanceNotFound, RewardDistributionError, ConstrainedValueAccumulatorError, plus the nonce-bookkeeping races MissingTransactionNonce and FailedToIncrementAccountNonce. This also resolves the typed↔wire divergence the finding noted (those Displays carry no mempool keyword, so both classifiers now agree they are indeterminate).
  2. Loose keyword gate (medium) — the wire classifier now requires a wrapper prefix (mempool error: / orphan transaction error:, the Displays of P2pError::MempoolError and Error::Orphan) instead of a substring search, so unrelated application errors that merely mention "mempool"/"orphan" can never classify as rejections.
  3. Code range too wide (medium) — narrowed to exactly CALL_EXECUTION_FAILED (−32000), the code rpc::handle_result reports for application errors; other reserved-range codes (proxies, middleware) are delivery failures.
  4. Streak advances while in backoff (medium) — reconcile now takes the pass time and bumps the absence streak only on passes where the transaction was actually due. A transaction in backoff gets its remaining retry attempts before any prune can happen; covered by reconcile_does_not_advance_streak_while_in_backoff.
  5. Backoff clamped by pass cadence (medium) — after each pass the controller re-times the next pass to the earliest due attempt when that is sooner than the randomized 2–5 minute interval (5 s minimum gap, no hot loop). The 30 s/1 min backoff tiers are now real.
  6. Nonce-chain submission dependencies (bug-high) — the submit loop now treats the same-account nonce-(n−1) pending transaction as a dependency, symmetric to UTXO parents: if it failed or was skipped this pass, the successor is deferred with the whole subtree. No more out-of-order-nonce rejections burning successor budgets.
  7. Serial probes (medium) — probes run with bounded concurrency (buffer_unordered(8)).
  8. Probe/prune race + doc contract (medium) — Wallet::prune_dead_transaction, Account::prune_dead_transaction and the output-cache impl now carry a # Caller obligation section stating the verification precondition and that the probe→prune window is inherently racy (self-healing on confirmation or rescan). A close-to-prune re-probe was considered and rejected: it widens the RPC surface for a race that is already self-healing, and the reconcile design already requires the signature to hold over several consecutive passes.

node-comm (10), wallet-controller (43), wallet and wallet-rpc-lib suites green; fmt and clippy clean. New/extended tests: internal-variant indeterminacy, the typed→wire deterministic mirror (derived from typed errors, so a Display change breaks the build), the backoff-gated streak, and the RPC code gate at exactly −32000.

Comment thread wallet/wallet-controller/src/lib.rs
Comment thread wallet/wallet-controller/src/lib.rs
Comment thread wallet/wallet-controller/src/lib.rs
Comment thread wallet/wallet-controller/src/lib.rs
Comment thread wallet/wallet-node-client/src/rpc_client/mod.rs
Comment thread wallet/wallet-node-client/src/rpc_client/mod.rs
- next_wake: only honor wake times in the future - an elapsed
  next_attempt belongs to a deferred/blocked transaction, not a
  backoff-scheduled one, and must not shrink the pass interval to the
  minimum gap indefinitely;
- pre-seed the unsubmitted set with every not-due pending transaction
  (backoff and stuck): reconcile excludes stuck parents from to_submit,
  so without the seed their children saw them as ready and were
  submitted against a permanently absent parent - a guaranteed
  rejection on every pass that cascaded the subtree into stuck;
- defensive early-continues in the submit loop record the transaction
  as unsubmitted, keeping the child-deferral invariant airtight;
- wire-mirror test extended to every constructible excluded variant
  (chainstate/subsystem wrappers, nonce-bookkeeping races, orphan
  conflicts), and a new test ties the classifier's wrapper prefixes to
  the actual p2p/mempool Display attributes.
@nullPointerEnjoyer

Copy link
Copy Markdown
Collaborator Author

All findings from the 17:25 review run are addressed in bb102bfe7:

  1. Stale next_wake → ~5 s hot loop (lib.rs:1873) — the re-timing now only honors wake times in the future: an elapsed next_attempt belongs to a transaction that was deferred, blocked or stuck this pass (not backoff-scheduled), so it falls back to the regular 2–5 minute interval instead of pinning the pass to the minimum gap.
  2. Stuck parents invisible to child-deferral (lib.rs:2084, bug-high) — the unsubmitted set is now pre-seeded with every not-due pending transaction before the submit loop. Since reconcile excludes stuck transactions from to_submit, a stuck parent previously never entered the set and its children were submitted against a permanently absent parent — burning their budgets on every pass. With the seed, stuck/backoff parents are visible to the UTXO-parent and nonce-predecessor checks, and the whole subtree is deferred until the parent is abandoned.
  3. Defensive continues (lib.rs:2075, lib.rs:2104) — both early exits now insert into unsubmitted first, keeping the child-deferral invariant airtight even for branches that cannot currently fire.
  4. Exhaustive wire-mirror (rpc_client:149) — the indeterminate mirror now covers every constructible excluded variant, adding the chainstate/subsystem wrappers, the nonce-bookkeeping races (NonceIsNotIncremental, MissingTransactionNonce, FailedToIncrementAccountNonce) and OrphanPoolError::Conflict. Carrying a structured error code across the RPC boundary instead of Display text is a node-side protocol change and is deferred as a follow-up.
  5. Wrapper prefixes untied (rpc_client:181) — new wire_prefixes_match_transport_wrappers test derives the prefixes from P2pError::MempoolError's and Error::Orphan's actual Display, so a wrapper rewording in the p2p/mempool crates breaks the build instead of silently reclassifying all mempool errors.

node-comm (11), wallet-controller (43), wallet suites green; fmt and clippy clean.

Comment thread wallet/wallet-controller/src/lib.rs
Comment thread wallet/wallet-controller/src/lib.rs
Comment thread wallet/wallet-node-client/src/rpc_client/mod.rs
- reconcile: established rejection evidence (threshold reached under
  observation) survives parent-set changes and keeps the transaction in
  to_prune, so a failed wallet-side prune is retried on the next pass
  instead of the transaction - whose dead parent may already have left
  the wallet - being resubmitted until stuck;
- the unsubmitted seed excludes transactions the probe showed to be in
  the mempool: a present parent never blocks a child, and seeding it
  would defer a due child while its deterministic-rejection evidence
  (missing while the present parent is there) accumulates toward a
  prune without the child ever being attempted;
- INDETERMINATE wire terms extended to cover the internal
  chainstate/storage/accounting Displays that carry no mempool keyword
  of their own (BlockUndo, undo-info, block-index, fee-sum,
  TransactionVerifierStorage, ConfigError variants) plus the ReorgError
  inner texts, pinned by a dedicated wire-string test.
Comment thread wallet/wallet-node-client/src/rpc_client/mod.rs
The wire classifier's deny-list cannot be derived from Display text for
every future variant, so make adding a variant fail CI instead:
strum::EnumCount on mempool Error/TxValidationError/MempoolPolicyError/
OrphanPoolError and chainstate ConnectTransactionError, with a test
pinning the counts - a mismatch forces a review of both classifiers and
the wire-mirror tests in the same change. Also note the durable fix
(structured mempool verdict on the RPC error object) in the classifier
docs as a node-side follow-up.
Comment thread wallet/wallet-controller/src/rebroadcast.rs Outdated
Comment thread wallet/wallet-controller/src/rebroadcast.rs
A recorded node rejection used to stay valid forever, so a single
historic rejection (e.g. a transient server-busy reply) could support a
prune much later - after mempool churn, a node restart, or a load
balancer rotation made the deterministic-rejection signature appear
around a live transaction. Rejection evidence now expires after one
hour (REJECTION_EVIDENCE_MAX_AGE): stale evidence falls back to
resubmission, which either refreshes the evidence (another rejection)
or clears it (an acceptance).

Also document the nonce-chain asymmetry in reconcile: nonce
dependencies are not modeled in the planner, so a deterministically
rejected nonce transaction is never pruned via evidence - it burns its
budget and ends up stuck, which is the safe under-pruning direction.
Comment thread wallet/src/wallet/mod.rs
Comment thread wallet/wallet-controller/src/lib.rs
Comment on lines +482 to +491
// Pruned transactions take their pending descendants with them.
let children: BTreeMap<Id<Transaction>, Vec<Id<Transaction>>> = {
let mut children: BTreeMap<Id<Transaction>, Vec<Id<Transaction>>> = BTreeMap::new();
for tx in pending {
for parent in &tx.pending_parents {
children.entry(*parent).or_default().push(tx.id);
}
}
children
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
Prune propagation (both in reconcile here and in the wallet's remove_from_unconfirmed_descendants) only follows UTXO parent edges via pending_parents/unconfirmed_descendants. Account-nonce chains have no UTXO edge: when a nonce-n predecessor is pruned/abandoned, its nonce-(n+1) successor is neither pruned with it nor classified as deterministically rejected (its predecessor is now absent from the mempool, so the 'parent present, child missing' signature never holds and the absence streak is reset each pass). The successor will then be resubmitted every pass until it either burns its whole retry budget and goes stuck, or hits MAX_PENDING_AGE — a guaranteed-rejection resubmission loop that this whole change was designed to avoid. Consider treating a missing nonce predecessor that was pruned/abandoned this pass as a prune trigger for its successors, or at least excluding such successors from submission.

Suggestion:

Suggested change
// Pruned transactions take their pending descendants with them.
let children: BTreeMap<Id<Transaction>, Vec<Id<Transaction>>> = {
let mut children: BTreeMap<Id<Transaction>, Vec<Id<Transaction>>> = BTreeMap::new();
for tx in pending {
for parent in &tx.pending_parents {
children.entry(*parent).or_default().push(tx.id);
}
}
children
};
// Pruned transactions take their pending descendants with them, for both
// UTXO-parent edges and account-nonce-chain edges (a nonce successor of a
// dead transaction can never confirm).
let children: BTreeMap<Id<Transaction>, Vec<Id<Transaction>>> = {
let mut children: BTreeMap<Id<Transaction>, Vec<Id<Transaction>>> = BTreeMap::new();
for tx in pending {
for parent in &tx.pending_parents {
children.entry(*parent).or_default().push(tx.id);
}
}
// nonce edges: (account, nonce) -> successor id
...
children
};

- treat a wallet prune failing with CannotChangeTransactionState(Abandoned)
  as success: the root's prune_dead_transaction already abandons its
  descendants, so re-pruning them per-id only produced spurious
  warnings and stale tracker entries within the pass;
- document why TxState::Inactive is included in the rebroadcast filter
  (distinct from user-requested Abandoned) and where the retry cap
  lives (the controller's tracker budget).
Comment on lines +2067 to +2068
let now = get_time();
// Transactions known not to be in the mempool by the end of this pass:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
now is re-read here after the probe round, the prune loop and the serial submit-loop interleaving, but the unsubmitted seed set and the is_due guard inside the loop use this later timestamp while reconcile classified with the earlier pass_now. A pass that takes longer than a backoff window (many pending transactions, slow RPC) can classify a transaction as due and submit it even though reconcile treated it as in-backoff (or vice versa), making the streak/no-resubmit-during-backoff guarantees time-dependent on RPC latency. Consider passing a single consistent timestamp captured once at the start of the pass to all of reconcile, the seed filter and the submit guard.

Suggestion:

Suggested change
let now = get_time();
// Transactions known not to be in the mempool by the end of this pass:
let now = pass_now; // use the single pass timestamp consistently

Comment on lines +2129 to +2133
if parent_not_ready {
unsubmitted.insert(*tx_id);
log::debug!("Skipping transaction {tx_id:x}: a parent is not ready this pass");
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Nonce-chain deferral can feed the prune-evidence streak for a transaction that was never actually submitted. Scenario: the nonce predecessor is missing this pass (in backoff), so this child is inserted into unsubmitted and deferred; but reconcile only knows about UTXO parents — next pass, if the child is missing from the mempool, its UTXO parents are present, it has a recent rejection (from an earlier attempt) and is due, so bump_absence advances even though the child was never retried. Three such passes prune a live child purely because its predecessor was in backoff. Consider resetting the child's absence streak here (e.g. self.repush_tracker.reset_absence(tx_id)) when it is deferred due to a nonce/parent not being ready, so evidence only accumulates on passes where the transaction was actually attempted.

Suggestion:

Suggested change
if parent_not_ready {
unsubmitted.insert(*tx_id);
log::debug!("Skipping transaction {tx_id:x}: a parent is not ready this pass");
continue;
}
if parent_not_ready {
unsubmitted.insert(*tx_id);
// The transaction was not attempted this pass, so its continued
// absence must not advance the deterministic-rejection streak.
self.repush_tracker.reset_absence(tx_id);
log::debug!("Skipping transaction {tx_id:x}: a parent is not ready this pass");
continue;
}

Comment on lines +156 to +158
"tip moved",
"chainstate error",
"subsystem call error",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
The INDETERMINATE term list assumes chainstate failures surface as "chainstate error" on the wire, but that's not true for transparent wrappers. mempool::Error::Validity(TxValidationError::ChainstateError(_)) uses #[error(transparent)] at both wrapper levels, and chainstate::ChainstateError variants display as "Block storage error: ...", "I/O error: ...", "Initialization error: ...", "Block processing failed: ...", etc. — none of these match "chainstate error" (or any other term in the list). Concretely, Mempool error: I/O error: boom (an unhealthy-node outcome that the typed classifier correctly returns false for) will be classified here as a deterministic rejection, since the only required prefix ("mempool error:") matches and no INDETERMINATE term does. On the JSON-RPC transport this flips an indeterminate/internal-failure outcome into a pruning decision — the exact failure mode the doc comment says must never happen. (The wire_message_mirror_matches_typed_classification test includes ChainstateError(IoError("boom")), so it should already be failing.) Suggestion: add the concrete chainstate-storage Display prefixes ("i/o error", "block storage error", "initialization error", "block processing failed", "property read error", "bootstrap error", "error invoking block invalidator") to the term list, or better, move to a structured verdict rather than text matching.

Suggestion:

Suggested change
"tip moved",
"chainstate error",
"subsystem call error",
"tip moved",
"chainstate error",
// Transparent wrappers mean chainstate failures don't always say
// "chainstate error" on the wire; match the concrete
// chainstate::ChainstateError Display prefixes too.
"i/o error",
"block storage error",
"initialization error",
"block processing failed",
"property read error",
"bootstrap error",
"error invoking block invalidator",
"subsystem call error",

@nullPointerEnjoyer

Copy link
Copy Markdown
Collaborator Author

All findings from the 17:25, 17:38, 17:51 and 20:03 review runs are addressed across bb102bfe7, e18705cb5, b4080d0fe and 2068f7db7. The 20:25 run (on 2068f7db7) reported 0 findings.

Highlights:

  • Stuck parents invisible to child-deferral (bug-high, 17:25) — the unsubmitted set is pre-seeded with every not-due pending transaction, so stuck/backoff parents (which reconcile excludes from to_submit) now defer their whole subtree instead of letting children be submitted against a permanently absent parent.
  • Internal chainstate variants as rejections (bug-high, 17:25 + 20:03) — the typed classifier excludes the full set of internal storage/accounting/invariant ConnectTransactionError variants, and the wire classifier's term list was extended to cover every excluded variant's Display (including the ones carrying no mempool keyword, like BlockUndo/TransactionVerifierStorage/ConfigError texts). ReorgError inner texts were verified to always arrive wrapped in "Reorg error:".
  • Stale rejection evidence (bug-high, 20:03) — prune evidence now expires: REJECTION_EVIDENCE_MAX_AGE (1 h) bounds how long a single historic rejection (e.g. a transient server-busy reply) can support a prune. Stale evidence falls back to resubmission, which refreshes or clears it. Covered by reconcile_ignores_stale_rejection_evidence.
  • Backoff clamped by pass cadence + stale next_wake (17:25/17:38) — the next pass is timed to the earliest future due attempt; elapsed attempts (deferred/blocked/stuck) fall back to the regular interval, so neither the documented backoff tiers nor the pass interval are distorted.
  • Nonce-chain submission dependencies (bug-high, 17:51) — a nonce-n transaction is deferred when its same-account nonce-(n−1) predecessor failed or was skipped, symmetric to UTXO parents.
  • Established evidence survives partial prune failure (17:51) — once the threshold was reached, a transaction stays in to_prune until the wallet-side prune succeeds, even if its dead parent already left the wallet; it is never resubmitted. A prune failing with CannotChangeTransactionState(Abandoned) (descendant already covered by the root's subtree prune) is treated as success.
  • Present parents no longer block children (17:51) — the deferral seed excludes transactions the probe showed to be in the mempool, so a due child is never starved while its deterministic-rejection evidence accumulates.
  • Misc — concurrent mempool probes (bounded, 8); defensive unsubmitted inserts; wrapper-prefix gate tied to the p2p/mempool Display attributes by test; strum::EnumCount pins on all five error enums so adding a variant fails CI and forces a classifier review (the durable structured-verdict fix is noted as a node-side follow-up); Inactive-vs-Abandoned and caller-obligation docs on the prune path; NoTransactionFound is the only tolerated lookup error.

node-comm (13), wallet-controller (44), wallet (116) and wallet-rpc-lib suites green; fmt and clippy clean.

The buffer_unordered probe stream trips the rustc auto-trait leak
("implementation of Send is not general enough") inside the wallet
event-loop future, which breaks tokio_spawn of the wallet worker under
the workspace all-features build. Probes run sequentially again, with a
comment documenting the limitation; concurrency can be revisited if the
RPC surface ever carries structured futures.
Comment thread wallet/src/wallet/mod.rs
Comment on lines +1583 to +1584
for tx in db_tx.get_user_transactions_for_account(&account.get_account_id())? {
match account.get_transaction(tx.transaction().get_id()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
This query materializes every signed transaction of the account into a Vec on each call, then filters most of them out by state. Since this feeds the periodic rebroadcast loop, accounts with long histories pay O(history) memory/CPU per tick. Consider a storage-level variant that iterates lazily (the prefix iterator is already available in prefix_iter_decoded) or filters by state, avoiding the intermediate Vec.

Comment thread wallet/src/wallet/mod.rs
txs.push(tx);
}
}
Err(WalletError::NoTransactionFound(_)) => txs.push(tx),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
OutputCache::get_transaction returns NoTransactionFound for WalletTx::Block(_) entries too (see output_cache/mod.rs line 1907: None | Some(WalletTx::Block(_)) => Err(WalletError::NoTransactionFound(..))). Block-reward wallet transactions will therefore fall into this Err(WalletError::NoTransactionFound(_)) arm and be pushed as rebroadcast candidates on every tick, even though they are confirmed and never need rebroadcasting. Consider matching on the wallet-tx entry kind (or checking whether the SignedTransaction's tx id equals a block tx id) before re-including it.

Suggestion:

Suggested change
Err(WalletError::NoTransactionFound(_)) => txs.push(tx),
// Distinguish 'no entry at all' from block-reward txs, which are
// confirmed by construction and must not be rebroadcast candidates.
match db_tx.get_wallet_tx_for_account(...)? {
None => txs.push(tx),
Some(WalletTx::Block(_)) => {}
...
}

Comment on lines +2000 to +2002
let mut skipped: BTreeSet<Id<Transaction>> =
pending_ids.iter().filter(|id| !probed.contains(*id)).copied().collect();
let mut queue: Vec<Id<Transaction>> = skipped.iter().copied().collect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
When a mempool probe fails, transactions are cascaded into skipped only via UTXO-parent children edges. A nonce-successor (account-command chain) of a skipped transaction is still probed and classified by reconcile: if all of its UTXO pending parents happen to be present, the 'parent present, tx missing' signature holds and, combined with an earlier node rejection, its absence streak accumulates toward a prune — even though the real reason it is missing is that its nonce predecessor was skipped, which is a transient, resubmit-able condition. Consider also cascading skips along nonce-successor edges (same (account, nonce+1) map as nonce_of), or excluding transactions with an unresolved nonce predecessor from prune-evidence accumulation.

Comment on lines +2113 to +2123
|| match (account_of.get(tx_id), pending_tx.nonce) {
(Some(account_index), Some(nonce)) if nonce > 0 => {
nonce_of.get(&(*account_index, nonce - 1)).is_some_and(|predecessor| {
// A predecessor missing from `pending_by_id` was
// skipped this pass (e.g. its probe failed).
!pending_by_id.contains_key(predecessor)
|| unsubmitted.contains(predecessor)
})
}
_ => false,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
The nonce-chain ordering invariant is not actually guaranteed here. reconcile/topological_order order transactions only by UTXO parent edges; the account nonce is merely a tie-break among txs that happen to be 'ready' in the same batch. So a child with nonce n can be processed before its nonce n-1 predecessor (e.g. the predecessor has a pending UTXO parent and lands in a later ready batch, while the child has none). At that point unsubmitted does not yet contain the predecessor and the check passes, so the child is submitted while its predecessor is still missing from the mempool — exactly the guaranteed rejection this code tries to avoid, burning the child's retry budget (and possibly feeding it to stuck/prune). Consider adding nonce-predecessor edges as real ordering constraints in PendingTx::pending_parents-like structure (or a separate predecessor map consumed by topological_order) instead of relying on the batch tie-break.

Suggestion:

Suggested change
|| match (account_of.get(tx_id), pending_tx.nonce) {
(Some(account_index), Some(nonce)) if nonce > 0 => {
nonce_of.get(&(*account_index, nonce - 1)).is_some_and(|predecessor| {
// A predecessor missing from `pending_by_id` was
// skipped this pass (e.g. its probe failed).
!pending_by_id.contains_key(predecessor)
|| unsubmitted.contains(predecessor)
})
}
_ => false,
};
let predecessor_not_ready = match (account_of.get(tx_id), pending_tx.nonce) {
(Some(account_index), Some(nonce)) if nonce > 0 => {
nonce_of.get(&(*account_index, nonce - 1)).is_some_and(|predecessor| {
// The predecessor must already have been handled this pass;
// if it is still queued (neither submitted nor unsubmitted),
// the topological order did not honor the nonce chain.
!pending_by_id.contains_key(predecessor)
|| unsubmitted.contains(predecessor)
})
}
_ => false,
};

Comment on lines +76 to +78
TxValidationError::TxValidation(connect_error) => !matches!(
connect_error,
ConnectTransactionError::MissingOutputOrSpent(_)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
The typed classifier defaults to true (deterministic rejection) for every currently-unlisted variant in TxValidationError::TxValidation, Policy, and Orphan. This is the opposite of the 'safe under-pruning' default documented in the is_node_rejection trait: any variant added to these enums in the future will be silently classified as a deterministic rejection and may get a pending transaction pruned on a transient outcome. The variant-count test mitigates this, but only if someone notices. Prefer an explicit allowlist of known-deterministic variants (default false), mirroring the wire classifier's allowlist-by-prefix structure, so new variants fail safe.

Suggestion:

Suggested change
TxValidationError::TxValidation(connect_error) => !matches!(
connect_error,
ConnectTransactionError::MissingOutputOrSpent(_)
// Deterministic verdicts must be explicitly allowlisted; unknown variants
// default to `false` (under-pruning) so newly added variants are safe.
TxValidationError::TxValidation(connect_error) => matches!(
connect_error,
ConnectTransactionError::AttemptToSpendBurnedAmount | /* ... */
),

@erubboli
erubboli merged commit aa25c73 into master Oct 2, 2026
21 checks passed
@erubboli
erubboli deleted the pr/wallet-chain-repush branch October 2, 2026 07:17
@nullPointerEnjoyer
nullPointerEnjoyer restored the pr/wallet-chain-repush branch October 2, 2026 07:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants