Add 0-conf swapping and post-swap memo chaining

🔎 Visual review of f4b85762: explainer, review flags and prompts to ask an AI about the change

Still checking the solvency code against the description; next I'll write the updated text.

Summary

Add 0-conf (mempool) swapping and post-swap memo chaining to THORChain. Eligible inbounds execute early at consensus, based on mempool observations. A durable MempoolSwap lifecycle record backs each one. It owns the reversible pool/supply change, holds every other accounting effect in a journal until finality, and makes irreversible consumers wait while the pool state it touched is still speculative.

Key Changes

  • Mempool swap execution: Eligible inbounds execute at consensus before the external chain finalizes. Outbound signing still waits for finalization, so the wait for inbound confirmations overlaps outbound preparation.

  • Durable lifecycle record: New MempoolSwap proto/keeper collection keyed by (TxID, index) with an explicit status machine (RESERVED → EXECUTING → STAGED/SETTLED → FINALIZED, plus ROLLBACK_PENDING). Each record holds:

    • the observed vault pubkey and the reserved TOR
    • the admitted, updated and next-recovery heights
    • the latest normalized MsgSwap rollback snapshot
    • the pending-effects journal

    The record outlives queue and staged entries. A stream that finalizes mid-flight keeps a zero-TOR FINALIZED tombstone until terminal settlement. Secondary indexes (per-vault, per-recovery-height due index, per-pool owner index) and persisted per-vault recovery cursors keep recovery bounded.

  • Three explicit swap execution modes: mempoolSwapExecutionNormal / Speculative / Rollback, selected by context:

    • Normal commits every ancillary effect immediately.
    • Speculative commits only the pool/custody transition and journals the rest.
    • Rollback performs only the inverse pool and custody transition. It emits no second set of accounting effects: no swap events, no volume, no telemetry. It is also exempt from three restrictions so an already-executed speculative swap can always be unwound: trading halts (including Ragnarok), EnableDerivedAssets = 0, and BurnSynths, which would otherwise block burning a synth a speculative synth-target swap already minted. Only the internal rollback caller sets this mode; normal and speculative swaps still obey all three checks.
  • Pending-effects journal: MempoolSwapPendingEffects defers these effects for each lifecycle:

    • the system liquidity fee split (Asgard→Reserve and Asgard→TCY transfers)
    • AddToLiquidityFees and AddToSwapSlip
    • per-pool volume and swapper clout
    • the affiliate liquidity fee (REVSHARE)
    • resolved dynamic-fee credits
    • unrefundable-coin cleanups (keyed for idempotency)

    At finality, applyPendingMempoolSwapEffectsCached flushes the journal in one CacheContext. On rollback, prepareMempoolSwapRollbackEffectsCached returns the held system-fee RUNE to the pool and discards the journal before the inverse swap runs. The journal replaces the separate PendingAffiliateLiquidityFee store from earlier revisions, which is gone.

  • Finality overlays and pool view: Every lifecycle records gross balance_rune/asset_increase/decrease effects and synth/derived supply_increase/decrease effects. These are summed into O(1) per-pool MempoolSwapPoolBalanceOverlay and per-denom MempoolSwapNativeSupplyOverlay keys, updated atomically with lifecycle writes. finalityPoolView (and KVStore.finalityAdjustedPool) subtracts the overlays, so irreversible consumers read finalized depth and supply. Those consumers are:

    • outbound-fee/gas pricing and TOR anchors
    • derived-asset regeneration and weighted-mean slip
    • POL deposit/withdraw and saver yield
    • slash pool updates and insolvency checks
    • vault-security valuation, CalcTxOutHeight valuation, and total-liquidity-RUNE checks
  • Overlay-failure policy depends on the use:

    • Pricing fails closed. AnchorMedian skips an anchor whose overlay cannot be removed. A contaminated sample would otherwise set the TOR price for all downstream TOR-denominated accounting. The same loop already skips halted chains and unavailable pools this way.
    • Summation falls back. getTotalLiquidityRUNE, getSortedVaultSecurity and the txout valuation use the live pool balance for just that pool. One unusable overlay therefore cannot block every MsgAddLiquidity and synth-target swap network-wide through the StrictBondLiquidityRatio TVL cap.
  • Pool ownership / serialization: A pool with a live overlay has exactly one lifecycle owner, because rollback must reverse its exact recorded deltas with no other mutation in between. Ownership is bookkeeping, not priority, so it never outranks finality:

    • A swap with no lifecycle of its own (its inbound is already final) preempts the owner via preemptMempoolSwapExecutionOwnersAtomic in both queues:

      • An unfinalized owner is rolled back. If its inbound later confirms, it takes the ordinary finalized path.
      • A finalized owner is completed.
      • Preemption is fail-closed: it commits only if it leaves every pool executable.
      • SwapQueueAdv.isSwapReady is relaxed for exactly this case, so a final swap is not filtered out before it reaches the code that preempts for it.
      • An owner that cannot be resolved is recorded once per pool per pass (mempoolSwapPreemptionAttempts). A stuck lifecycle therefore costs one rollback attempt per block, not one per queued item.
      • The legacy pass drops its memoized gate verdict (forget) after a successful preemption.
      • The advanced queue executes the persisted queue item, not the snapshot fetched at the start of the pass, and skips items that no longer exist. A preempted owner whose snapshot is still in the same batch is therefore not re-run as an ordinary swap, which would have recreated its deleted queue entry and released unbacked output.
    • A speculative swap still defers, since it has no finality to claim priority with. This goes through ensureSwapExecutionFinalityIsolation (in SwapHandler.validate) and the queue-side ensureExecutionPoolsAvailable cache. An overlay without an owner still fails closed.

    • Irreversible operations preempt the owner via preemptMempoolSwapPoolOwnerCached, inside the same CacheContext as their own write. This covers:

      • add/withdraw liquidity, errata, and pool ragnarok
      • synth redemption and TCY claiming swaps
      • gas accounting, when block gas cannot be debited from both finalized and live balances
      • migration dust settlement
    • PoolMgr.EndBlock defers the whole pool cycle while any pool is affected. It persists a MempoolSwapPoolCycleRetryHeight marker so the cycle is retried every block instead of slipping a full cycle. While that retry is pending, the advanced queue admits:

      • the established overlay owners
      • lifecycle-free swaps whose pools carry no overlay and no pending errata correction. These can neither see reversible depth nor create an overlay, so ordinary trading on untouched pools keeps running instead of pausing until finality or staged recovery clears the overlay.

      Speculative non-owners still wait. Confirmed swaps through an overlaid pool wait instead of preempting, so the overlay cannot clear mid-pass and the cycle still runs in a block with no swaps.

  • Post-swap memo chaining: New MsgSwap.PostSwapMemo lets a swap's output feed a follow-up internal action (e.g. donate) atomically as part of settlement. While the inbound is unfinalized, the action is stored on the lifecycle snapshot and runs exactly once at finality (executePostSwapMemoCached).

  • Staged outbound queue: KV-backed StagedOutbound / StagedOutboundList, keyed by inbound TxID. Native (THORChain) outbounds are held there in escrow; external outbounds carry their pre-computed scheduled_height. Exposed via /thorchain/queue/staged and gRPC Query.StagedOutbound.

  • Capacity and count gating: Three mimirs, all votable by operators:

    • MempoolSwapCapTOR: global TOR cap (default 0 = disabled).
    • MempoolSwapPoolCapBPS: per-pool cap as bps of the shallowest swap pool's RUNE depth (default 50 = 0.5%).
    • MempoolSwapMaxCount: max live lifecycle records (default 1000).

    Aggregate usage is a keeper counter (GetMempoolSwapUsageTOR / Set / Add / Sub), surfaced as QueryNetworkResponse.mempool_swap_usage_tor. Each inbound's reservation lives on its lifecycle record, with ObservedTxVoter.MempoolSwapTOR kept as a compatibility mirror. Capacity is measured through the finality pool view.

  • Eligibility policy (isMempoolSwapEligible):

    • Blocked during ragnarok, and requires the advanced swap queue.
    • Excludes native sources and trade-asset targets.
    • Excludes non-streaming market swaps between two stable-reserve-eligible assets. A TrySwap direct fill changes reserve inventory rather than pool state, so it cannot be reversed. A mode guard in SwapHandler backs this up.
    • L1 targets are always eligible.
    • THOR-native targets are eligible only when the swap will actually execute as a stream, either from explicit memo params or implicitly via willSwapExecuteAsStreaming. That check mirrors getMaxSwapQuantity normalization and fails closed.
    • isExplicitStreamingSwap keeps a limit swap's custom TTL from counting as an explicit streaming request.
    • canUseMempoolSwap also requires the observed inbound to already parse as a SWAP/limit-swap memo, so memoless r:<ref> reference inbounds never take the mempool path.
    • Mempool swaps are blocked on inactive vaults.
  • Atomic admission: Four related writes commit in one CacheContext: the advanced-queue item, aggregate TOR usage, the voter mirror, and the lifecycle record. No queue item or reservation can exist without its durable rollback record. The stored snapshot is the normalized message after AddSwapQueueItem (height, TTL, quantity), so recovery follows the same timing rules as queue execution. Replayed observations are skipped via HasMempoolSwap.

  • Deferred rejection paths: For a mempool-path inbound, memo-parse failures, trading halt / ragnarok, and swap-queue admission failures no longer refund immediately. The refund waits until finalization.

  • Inline rollback: rollbackMempoolSwapAtomic clears the effects journal, then unwinds pool state with inline reverse swaps that each carry a DONATE post-swap memo back to the original source pool.

    • The amount deducted is the source amount actually credited: State.In for a partially executed stream, via mempoolRollbackCreditedSourceAmount.
    • Staged source refunds are filtered out and netted against the deposit. When no swap-output action remains (including a partial stream that staged only its unused source refund), the executed output is recovered from the lifecycle/queue instead.
    • External staged outputs are combined per asset, and reverse swaps use deterministic synthetic TxIDs (CreateMempoolRollbackTxID).
    • Reverse execution is allowed through suspended/unavailable pools and skips derived-pool regeneration, so the recorded live transition can always be unwound.
    • The reverse swap suppresses its own swap events, so both pools are snapshotted around it and their net RUNE/asset deltas are emitted as EventPoolBalanceChanged. Each restored system fee is reported the same way, and so is the escrow return in same-asset secured rollbacks, so indexers still see every pool movement.
    • Clout Spent is reclaimed, staged records and leftover queue items are cleared, supply overlays are reconciled, and the voter is reset.
    • Zero-action rollbacks take the same reporting path as full ones.
    • AMM price impact and RUNE redistribution between the affected pools are accepted consequences of this design; exact per-pool RUNE restoration is not required. This is documented in docs/swap-guide/mempool-swaps.md.
  • Native secured-target escrow: Native secured outbounds are staged as their underlying L1 asset (escrowMempoolSecuredTarget). They are converted back to the secured asset only at finalization (restoreFinalNativeMempoolTxOutItem), so rollback can reuse the standard reverse-swap path.

  • Deferred affiliate outbound fees: Affiliate fee deduction is dry-run for minOut validation, but the actual fee is applied later by applyDeferredAffiliateFees at finalization:

    • Split outbounds are regrouped by logical destination and effective memo, so fees are taken once on the gross total.
    • The refund exemption is checked against GetMemo(), so a staged refund whose memo was cleared by EnableMemolessOutbound (leaving REFUND only in OriginalMemo) is not charged an affiliate fee on the unused input. Memoless refunds and outputs never share a group.
  • Split outbound preparation: prepareTxOutItem now takes txOutPrepareOptions (applyOutboundDeduction / commitOutboundEffects / recordVoterActions). The staged path can then build outputs without committing fee events, pool writes, synth burns, or reserve transfers. At finalization, staged outbounds go through two steps:

    • prepareStagedTxOutItem re-runs preparation with full effects.
    • The finalized (fee- and affiliate-adjusted) actions are saved on the voter.
    • Only then does dispatchStagedTxOutItem queue external outputs or execute native ones. Native payouts therefore record their OutTxs against the saved actions, and a stale voter write cannot overwrite them.

    When the voter holds a live reservation, its FinalisedHeight is no longer refreshed at action-recording time.

  • RUNE supply guard: ensureNonNormalRuneMintWithinSupplyCap keeps speculative and rollback execution away from MintToModule's fallback of borrowing from the Reserve. It checks the same headroom, adding back active speculative RUNE burns from the supply overlay.

  • Markerless-settlement recovery: Advanced-queue settlement of an unfinalized reserved swap can finish without producing any durable recovery record (errUnfinalizedMempoolSettlementWithoutRecoveryRecord). In that case settleSwap discards the settlement cache and rolls back against the parent context, where the queue still owns State.In / State.Out.

  • Shared invalidation path (revertMempoolSwap): Errata and compromised-vault cancellation both go through one helper. It marks the voter Reverted and the record ROLLBACK_PENDING, then attempts the rollback in a CacheContext.

    • If the rollback fails, only the stop markers are kept in the parent context, and the due worker retries on the next block.
    • completeMempoolSwapCached, rollbackAndRefundFinalizedMempoolSwapAtomic and rollbackAndRefundInactiveVaultMempoolSwap refuse a Reverted voter, so an invalidated inbound can never be paid out or refunded.
  • Errata integration:

    • An errata quorum on an inbound with a lifecycle record stops and unwinds the speculative swap via revertMempoolSwap.
    • Ordinary errata on an unrelated pool runs the vault debit and pool unwind as one CacheContext with owner preemption, so the debit cannot use balances a later rollback needs.
    • Deferred pool correction. Previously, if the pool's speculative owner could be neither rolled back nor completed, the attestation failed. That discarded the whole MsgErrataTx/MsgErrataTxQuorum, including the signer's vote, slash accounting and the Reverted marker. Now the attestation succeeds, and the correction is recorded on the reverted voter (ObservedTxVoter.errata_pool_correction) with a per-pool index. While a correction is pending:
      • ensureMempoolSwapPoolFinalized and mempoolSwapPoolFinalityAffected treat the pool as unfinalized, so LP add/withdraw, the pool cycle, pending-liquidity cleanup and Ragnarok wait or preempt.
      • Speculative lifecycles cannot claim the pool as a new owner.
      • preemptMempoolSwapPoolOwnerCached settles the pending correction after resolving the owner, so a preempting mutation never runs ahead of it.
      • LackSigning retries each pending pool once per block after the mempool recovery passes (recoverErrataPoolCorrections).
      • The marker and index are cleared in the same transaction as the correction, so it applies exactly once.
      • A new errata settles corrections already pending on the pool before its own, which keeps errata order for the balance clamps.
      • A malformed pending entry is dropped instead of failing every later preemption of the pool.
  • Compromised-vault cancellation: cancelCompromisedVaultMempoolSwap reverts, without refund, any lifecycle whose inbound vault is flagged by CompromisedVault-<pubkey>. That includes speculative work admitted before the flag was set, because FinalisedHeight alone is not permission to pay an inbound whose vault credit was deliberately excluded. It runs on three paths, checked before any complete/refund branch:

    • observed-tx quorum on a compromised vault. A cancel failure there is logged and left to the recovery worker instead of failing the message, so the recorded no-op, security event and Reverted marker are kept.
    • the inactive-vault recovery batch
    • the due-recovery worker
  • Ragnarok drain: Emergency shutdown takes priority. Each block, drainMempoolSwapsForRagnarok rolls back a bounded batch of lifecycles, including queue-only ones with no overlay yet. Admission is disabled while ragnarok is in progress.

  • Inactive-vault handling:

    • When a vault becomes inactive, persisted per-vault recovery cursors start, and processMempoolSwapVaultRecoveryBatch advances them in bounded batches.
    • Finalized inbounds observed to inactive vaults route through rollbackAndRefundInactiveVaultMempoolSwap, so refund scheduling happens only after a successful atomic rollback.
    • A FINALIZED stream whose inbound finality was already accepted survives vault retirement. Both the recovery batch and the due worker skip it (the cursor still advances), and isSwapReady keeps executing its remaining slices while the funds migrate to the new vault.
  • Slasher / LackSigning integration:

    • Each recovery pass runs two independent bounded workers (20 records each): one advances a vault cursor, the other drains the due-by-recovery-height index. Deferred errata pool corrections are retried after both.
    • mempoolSwapRecoveryHeight computes the deadline from live stream/limit-order progress, with saturating arithmetic, and from the lifecycle status.
    • STAGED/SETTLED records have no remaining advanced-queue work, so they drop the streaming-interval grace and the limit-order TTL and use the normal window. Previously, a one-slice market swap whose memo requested a long interval kept its pools held for StreamingSwapMaxLength plus a signing period (about 24.5h on defaults) even after settling.
    • In-flight (RESERVED/EXECUTING) records, and FINALIZED tombstones that still own queue work, keep the grace.
    • Timeouts use signingTransPeriod * maxOutboundAttempts, falling back to defaultMempoolSwapTimeoutAttempts = 3.
    • Stuck finalized swaps are force-rolled-back after 3 * timeoutAge (mempoolSwapForceRollbackTimeoutMultiplier).
    • Healthy finalized queue work has its tombstone rescheduled instead of force-rolled-back. An orphaned tombstone is repaired through finishFinalizedMempoolSwapSettlementAtomic rather than deleted outright.
  • Bifrost mempool observation gating:

    • isRBFEnabled keeps replaceable UTXO transactions out of mempool observation. It is documented as a check of the RBF signal only, not proof that an inbound cannot be replaced: Bitcoin Core has relayed full-RBF replacements by default since v28.
    • feeRateBelowMempoolThreshold closes the cheaper attack: broadcast a non-signalling transaction, underpay, and let it sit. An unconfirmed inbound must pay at least mempoolMinFeeRateBPS (5000 bps = half) of the last observed network fee rate to be observed before confirmation.
    • This is Bifrost-local, with no proto or consensus change. The threshold is a constant rather than config because observation gating must be identical across nodes.
    • It fails open on an unknown fee rate, an unknown virtual size, or ZEC, whose lastFeeRate is a whole-transaction ZIP-317 fee rather than a per-byte rate.
  • Duplicate-swap guards:

    • addSwap skips insertion when an advanced-queue item already exists for (TxID, Index).
    • addSwapDirect skips when a legacy queue item exists, or when the voter already has actions plus a live reservation. It also keys the queue item on msg.Index instead of a hardcoded 0.
  • Streaming max-length hardening: getStreamingSwapMaxLength centralizes the choice between StreamingSwapMaxLength and StreamingSwapMaxLengthNative, and errors on a non-positive value. At admission, AddSwapQueueItem clamps a market swap's State.Interval to that maximum, so an untrusted memo cannot keep a durable queue item alive past the applicable limit.

  • Quote improvements:

    • /quote/swap calls isMempoolSwapEligible plus checkMempoolSwapCapacity. When the swap is eligible, it computes total_swap_seconds as max(outbound_delay, streaming, inbound_confirmation) instead of adding the inbound confirmation time to the rest.
    • Quote simulation runs preemptMempoolSwapExecutionOwnersAtomic inside its disposable store, with events discarded, the same way live queue execution does. An in-flight mempool swap therefore no longer makes quotes through its pools fail with "swap pool is reserved".
  • Bifrost errRetryLater sentinel: When signing is halted (globally or per chain), outbounds return a sentinel error. It skips checkpoint persistence and error logging while keeping the item in storage for later retry.

Architecture

  1. Bifrost observes an unfinalized inbound that is non-replaceable and adequately priced. THORNode parses the memo and checks eligibility:
    • swap or limit-swap memo
    • not a native source, not a trade target, not a stable-reserve direct fill
    • advanced queue enabled, vault active and not compromised, not ragnarok
  2. At admission, checkMempoolSwapCapacity checks the lifecycle-count and TOR caps (global cap ∧ per-pool BPS cap over finality-adjusted depth). The queue item, usage counter, voter mirror, and MempoolSwap record commit together. If admission is rejected, the swap simply waits for finalization.
  3. Speculative execution changes pool balances and synth/derived supply, records the gross deltas as overlays, and journals every ancillary effect. TxOutStorage.shouldStageForMempoolSwap (keyed on a live lifecycle owner) stages outbounds instead of sending them to nativeTxOut or the external queue. Another speculative swap touching those pools defers. A swap with a final inbound, or an irreversible operation, preempts the owner instead.
  4. On finalization, the path depends on what the lifecycle still owns:
    • Queue work remaining: TOR is released, and the record becomes a zero-reservation FINALIZED tombstone that keeps its journal and rollback snapshot. The advanced queue finishes the stream normally, even if the observed vault retires meanwhile. Terminal settlement then applies the journal and deletes the tombstone in one transaction.
    • Terminal: in one CacheContext:
      • the journal is applied
      • any deferred post-swap memo runs once
      • staged outputs are prepared with real outbound-fee accounting, secured-asset restore and deferred affiliate fees
      • the finalized actions are recorded on the voter, then the outputs are dispatched
      • TOR is released, and the record is deleted
    • Compatibility / orphan paths: a finalized voter with a reservation but no lifecycle record still resolves through the staged, queue-finality, or release-only orphan-recovery branches.
  5. On failure, timeout, preemption, inactive vault, errata, compromised vault, or ragnarok, rollbackMempoolSwapAtomic runs:
    • it discards the journal and executes inline reverse swaps with DONATE post-swap memos, reporting the net pool movements as EventPoolBalanceChanged
    • it clears staged outbounds and queue leftovers, reclaims clout, and releases TOR
    • it emits EventMempoolSwapReverted plus a security event
    • it resets voter state (without clearing Reverted)
  6. Stuck finalized swaps that can neither finalize nor roll back are force-rolled-back and refunded after 3x the normal timeout to release capacity.

Safety

  • All multi-step state transitions (admission, finalize, rollback, settle, effects apply, preemption, invalidation, errata pool correction) run inside CacheContext. Partial changes are discarded on error and retried on later passes. Preemption commits whether or not the caller's own swap then succeeds, because resolving the owner is a valid final outcome on its own.
  • Rollback is never blocked by trading halts, disabled derived-asset trading, or BurnSynths. Reverted inbounds can never be completed or refunded.
  • An errata attestation never fails because of an unresolvable speculative pool owner. The pool correction is deferred and applied exactly once, and the pool stays finality-affected until then.
  • Effective cap is min(global MempoolSwapCapTOR, shallowestPoolDepth * MempoolSwapPoolCapBPS / 10_000). A missing pool or zero depth fails closed.
  • New mempool_swaps invariant. It checks that:
    • every live reservation has one canonical owner, both recovery indexes, and a voter mirror
    • the aggregate TOR total matches the canonical sum
    • the count matches the indexed population
    • each pool with reversible balance or synth-supply state has exactly one owner pointer
  • The Asgard module-balance invariant accounts for system-fee RUNE held by the journal and for native staged outbound coins (excluding escrowed secured/trade assets). Staged outbounds are also counted in calculateNetworkSolvency, GetPendingOutbounds, vault-security valuation, CalcTxOutHeight, and the tx-status pending flag.
  • calculateNetworkSolvency treats staged outbounds as follows:
    • Genuine native outbounds are excluded.
    • Native secured escrow is kept as an external liability, because the target pool balance has already dropped while secured depth has not yet been credited.
    • External staged outbounds (and their MaxGas) count as liabilities whatever the status of their selected signing vault, until finalization or rollback removes them. Unlike scheduled TxOut items, staged amounts are not reserved by migrateFunds, so the vault can go inactive while the claim is still owed. Skipping them would report that backing as over-solvency surplus that processOverSolvency could sweep. Finalization re-prepares the item against current vaults.
  • Genesis export and import:
    • Export includes StagedOutboundLists and MempoolSwap records. It also includes voters reachable via TxOut, staged outbounds, lifecycle records, both queues, and pending errata pool corrections. The list is deduped, and key-only phantom voters are skipped.
    • Import restores staged lists with their StagedHeight. It rebuilds the vault/timeout/owner indexes and both overlay aggregates from the lifecycle records, which are the source of truth, and derives MempoolSwapUsageTOR and the count from them. The errata pool-correction index is rebuilt from the voter field.
    • ValidateGenesis rejects duplicate identities (case-normalized like keeper keys) and conflicting per-pool owners.
  • The regression export invariant checker models advanced-queue State.In/State.Out against voter finalization status: unfinalized mempool items credit the pool but defer the vault credit. It also adds staged-outbound inbound coins back into vault accounting.
  • Dynamic-fee attribution is resolved at execution time (ResolveSwapCredit) and applied as-is at finality (CreditSwapResolved). A later whitelist or THORName change therefore cannot change how a speculative swap was accounted. Both functions now return errors instead of logging and dropping them.
  • Mempool finalize, rollback and compromised-vault cancellation do not return errors to the outer handler, which would discard voter, chain-height and vault state. They log and leave the speculative state where the recovery workers can find it.
  • SetLastChainHeight now advances under the same finalized-or-Migrate gate already used for vault accounting, so unfinalized mempool observations no longer move the chain height forward.

Telemetry / Events

  • New EventMempoolSwapReverted (TxID, BlockHeight, source asset/amount, donated amount), emitted on every completed rollback including zero-action ones, alongside an EventSecurity.
  • Inline rollback emits EventPoolBalanceChanged for the net reverse-swap movement of each pool and for each restored system fee. A test sums every pool-changing event from a complete rollback and checks the total against the actual before/after pool balances.
  • Counters under thornode.mempool_swap.*:
    • admitted_total
    • blocked_total{reason=inactive_vault|capacity|capacity_error|count}
    • rolled_back_total{reason=inactive_vault|timeout|rollback_pending|force_rollback|markerless_settlement|errata|compromised_vault|ragnarok}
    • completed_total{path=staged_finalize|queue_finality|finalized_settlement|finalized_orphan_recovery} (plus unlabeled lifecycle completions)
    • finalize_retry_total
  • Existing counters kept intact on the new paths: errata.reverted{direction=in} is also emitted on the mempool-lifecycle errata branch (incrErrataRevertedIn), and inbound.refund{reason=inactive_vault} is emitted on both the direct and the rollback-and-refund paths (incrInactiveVaultRefund).
  • End-block gauges: cap.tor, usage.tor, staged_inbounds, staged_outbounds, staged_oldest_age_blocks, staged_value.{rune,usd}, staged_value_scan_duration_ms.

New / Modified Constants and Fields

  • New mimirs: MempoolSwapCapTOR (default 0, disabled), MempoolSwapPoolCapBPS (default 50), MempoolSwapMaxCount (default 1000), all votable by operators. MempoolSwapMaxCount is appended at the end of the constant enum so existing numeric values do not change.
  • New context key CtxSkipSwapOutbounds, used by inline rollback and cached-settlement paths to suppress the swapper's direct outbound scheduling.
  • New voter fields:
    • ObservedTxVoter.MempoolSwapTOR (proto field 12) plus IsEmpty() / HasReservedMempoolSwapTOR()
    • ObservedTxVoter.errata_pool_correction (proto field 13): the memo type (swap/add) of a reverted inbound whose pool correction is deferred
  • New MsgSwap.PostSwapMemo (proto field 18). IsActualLegacyStreaming replaces the repeated IsLegacyStreaming() && !IsLimitSwap() checks.
  • New protos:
    • in type_mempool_swap.proto: MempoolSwap, MempoolSwapStatus, MempoolSwapPendingEffects and its pool/supply/clout/affiliate/dynamic-fee/cleanup effects, MempoolSwapPoolBalanceOverlay, MempoolSwapNativeSupplyOverlay
    • elsewhere: StagedOutbound/StagedOutboundList, QueryStagedOutbound{Request,Response,Item}, EventMempoolSwapReverted, and QueryNetworkResponse.mempool_swap_usage_tor (field 19)
    • TxOutItem field 18 is reserved (the earlier inbound_finalized flag was dropped)
  • New genesis fields: GenesisState.staged_outbounds (50) and mempool_swaps (51).
  • New or extended keeper interfaces:
    • KeeperMempoolSwapUsage: TOR usage and lifecycle count
    • KeeperMempoolSwap: lifecycle CRUD, overlays, pool owner, vault/due iteration, vault recovery cursors, and the errata pool-correction index (Set/Delete/GetErrataPoolCorrections, GetErrataPoolCorrectionPools)
    • KeeperStagedOutbound: including GetStagedOutboundSummary / GetStagedOutboundValue
    • KeeperLiquidityProvider.GetBankSupply: raw bank supply without ragnarok masking
  • The mempool swap, staged outbound and invariant keeper code opens the module store through develop's kvStore(ctx) / store-key accessor.
  • GasManager.EndBlock / ProcessGas now take Manager instead of keeper.Keeper + EventManager. DynamicFeeManager gains ResolveSwapCredit / CreditSwapResolved.
  • New regression suites under swaps/: mempool-swaps, mempool-native-swaps, mempool-native-secured, mempool-native-synth, mempool-timeout-rollback, mempool-timeout-rollback-secured, mempool-markerless-rollback, mempool-errata-rollback. The errata, timeout and markerless rollback blocks are marked luvi_drop: [BTC.BTC] (as errata-tx.yaml does), because unwinding a swap gives back the swap fee it earned the pool. Existing exports, including the eddsa XMR exports, are regenerated for the new genesis fields.
  • New unit coverage:
    • rollback under trading halts, disabled derived trading, and BurnSynths
    • compromised-vault cancellation, including a failed cancel on the quorum path
    • finalized streams surviving vault retirement
    • deferred errata pool corrections, including their ordering and export
    • rollback pool-event reconciliation
    • refund-only partial-stream rollback
    • quote simulation through an owned pool
    • queue passes skipping items removed by an earlier preemption in the same pass
    • confirmed trading on untouched pools during a pool-cycle retry
    • memoless staged refunds exempt from deferred affiliate fees
    • staged outbounds of retired vaults counted as solvency liabilities

Documentation

  • New docs/swap-guide/mempool-swaps.md (linked from docs/SUMMARY.md) covers execution, finality, capacity limits, and the accepted rollback economics: price impact and RUNE redistribution across pools are known consequences, and exact per-pool RUNE restoration is not required. The rollback implementation links to this section.

Simulation coverage

  • New mempool-swaps cluster stage (suites.MempoolSwaps), added to the default TEST_SIMULATION_CLUSTER_STAGES (off by default in the single-node stage list). It drives 17 fail-fast scenarios end-to-end over the real L1 → Bifrost → THORNode path using POL.POL inbounds, which give a repeatable window where the inbound is confirmed but not finalized:
    • MempoolSwapSimulationActor (10):
      • happy market path
      • affiliate REVSHARE deferral across finality
      • stream settling before finality
      • finality before stream settlement (reservation released while the queue keeps streaming)
      • pool preemption
      • max-count fallback, global TOR-cap fallback, per-pool cap fallback
      • timeout rollback at the exact recovery height
      • errata rollback via a real validator MsgErrataTx quorum, including late-signer idempotency and proof that finality cannot revive a reverted swap
    • MempoolSwapNativeSimulationActor (6): non-streaming exclusion and streaming lifecycle for THOR.RUNE, synth, and secured targets, including underlying-L1 escrow assertions.
    • MempoolSwapMemolessActor (1): a registered r:<ref> reference inbound stays entirely outside every speculative code path until finality and is consumed exactly once.
  • Mocknet POL confirmations go back to 15 (~30s at 2s blocks); develop had cut them to 3. Every other simulated chain finalizes within seconds on mocknet (BASE immediately), so this is the only usable pre-finality window. The cost is that every POL inbound in the simulation now takes about 24s longer to finalize.
  • Excluded-swap proofs (assertNoSpeculativeWindow) retry transient query errors until the deadline; only violated invariants are terminal. The TOR-cap and pool-cap fallbacks handle the race where POL finality lands before the first observation the same way the memoless actor does: they verify the abandoned swap settles, then retry the check that nothing speculative happened on a fresh swap.
  • The security-event watcher now takes an expectation registry (OpConfig.ExpectedSecurityEvents, ConsumeExpectedSecurityEvent). Rollback security events that a test triggers on purpose are allow-listed one at a time instead of failing the run. The watcher also starts from the current tip rather than genesis.
  • The invariant-availability watcher test compares against InvariantRoutes() instead of a hardcoded route count, so adding the mempool_swaps invariant does not break it.
  • UTXO unit coverage adds a Bitcoin 29.1 full-RBF replacement pair and the underpriced-mempool-inbound cases. An end-to-end replacement suite still needs the mocknet bitcoin image (currently bitcoin-daemon-26.2, where mempoolfullrbf defaults off) bumped to 29.x.
  • test/simulation/README.md documents the stage, how to run it, the full scenario/assertion table, and the deliberate limits: there is no real Polygon reorg, and scanner-level reorg coverage stays a separate concern.

Drive-by fixes:

  • ensureAffiliateFromAddress: for secured/trade-asset sources with a THORChain destination, reuse the destination as the "from" address instead of falling back to the Asgard module address, so the affiliate swap stays tied to the user.
  • Cache vault lookups in calculateNetworkSolvency instead of re-reading the store for each outbound item.
  • Move Monero typicalVaultOutboundWeight into the per-network default files: 2500 on mainnet/testnet, keeping the higher 10000 reservation only on mocknet.
  • Bifrost ETH: go-ethereum wraps ErrNonceTooLow ("nonce too low: next nonce N, tx nonce M"), so the exact-string match failed. A node whose peer had already mined the identical TSS-signed approval then aborted the item and fell out of the keysign phase, stalling ERC20 migrations. Approval and broadcast now match by prefix, as the EVM client already does.
  • Minor cleanups: max() in handler_common_outbound, refreshed buf.lock, constantname_string.go regenerated with stringer v0.28.0, and a new .claude/commands/ci-ready.md helper command.
Edited by Huginn Ai

Merge request reports

Loading
Loading