Add 0-conf swapping and post-swap memo chaining
🔎 Visual review off4b85762: 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
MempoolSwapproto/keeper collection keyed by(TxID, index)with an explicit status machine (RESERVED → EXECUTING → STAGED/SETTLED → FINALIZED, plusROLLBACK_PENDING). Each record holds:- the observed vault pubkey and the reserved TOR
- the admitted, updated and next-recovery heights
- the latest normalized
MsgSwaprollback snapshot - the pending-effects journal
The record outlives queue and staged entries. A stream that finalizes mid-flight keeps a zero-TOR
FINALIZEDtombstone 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, andBurnSynths, 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:
MempoolSwapPendingEffectsdefers these effects for each lifecycle:- the system liquidity fee split (Asgard→Reserve and Asgard→TCY transfers)
AddToLiquidityFeesandAddToSwapSlip- per-pool volume and swapper clout
- the affiliate liquidity fee (REVSHARE)
- resolved dynamic-fee credits
- unrefundable-coin cleanups (keyed for idempotency)
At finality,
applyPendingMempoolSwapEffectsCachedflushes the journal in one CacheContext. On rollback,prepareMempoolSwapRollbackEffectsCachedreturns the held system-fee RUNE to the pool and discards the journal before the inverse swap runs. The journal replaces the separatePendingAffiliateLiquidityFeestore from earlier revisions, which is gone. -
Finality overlays and pool view: Every lifecycle records gross
balance_rune/asset_increase/decreaseeffects and synth/derivedsupply_increase/decreaseeffects. These are summed into O(1) per-poolMempoolSwapPoolBalanceOverlayand per-denomMempoolSwapNativeSupplyOverlaykeys, updated atomically with lifecycle writes.finalityPoolView(andKVStore.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,
CalcTxOutHeightvaluation, and total-liquidity-RUNE checks
-
Overlay-failure policy depends on the use:
- Pricing fails closed.
AnchorMedianskips 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,getSortedVaultSecurityand the txout valuation use the live pool balance for just that pool. One unusable overlay therefore cannot block everyMsgAddLiquidityand synth-target swap network-wide through theStrictBondLiquidityRatioTVL cap.
- Pricing fails closed.
-
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
preemptMempoolSwapExecutionOwnersAtomicin 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.isSwapReadyis 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(inSwapHandler.validate) and the queue-sideensureExecutionPoolsAvailablecache. 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.EndBlockdefers the whole pool cycle while any pool is affected. It persists aMempoolSwapPoolCycleRetryHeightmarker 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.PostSwapMemolets 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-computedscheduled_height. Exposed via/thorchain/queue/stagedand gRPCQuery.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 asQueryNetworkResponse.mempool_swap_usage_tor. Each inbound's reservation lives on its lifecycle record, withObservedTxVoter.MempoolSwapTORkept 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
TrySwapdirect fill changes reserve inventory rather than pool state, so it cannot be reversed. A mode guard inSwapHandlerbacks 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 mirrorsgetMaxSwapQuantitynormalization and fails closed. isExplicitStreamingSwapkeeps a limit swap's custom TTL from counting as an explicit streaming request.canUseMempoolSwapalso requires the observed inbound to already parse as aSWAP/limit-swap memo, so memolessr:<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 viaHasMempoolSwap. -
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:
rollbackMempoolSwapAtomicclears the effects journal, then unwinds pool state with inline reverse swaps that each carry aDONATEpost-swap memo back to the original source pool.- The amount deducted is the source amount actually credited:
State.Infor a partially executed stream, viamempoolRollbackCreditedSourceAmount. - 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
Spentis 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.
- The amount deducted is the source amount actually credited:
-
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
minOutvalidation, but the actual fee is applied later byapplyDeferredAffiliateFeesat 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 byEnableMemolessOutbound(leavingREFUNDonly inOriginalMemo) is not charged an affiliate fee on the unused input. Memoless refunds and outputs never share a group.
-
Split outbound preparation:
prepareTxOutItemnow takestxOutPrepareOptions(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:prepareStagedTxOutItemre-runs preparation with full effects.- The finalized (fee- and affiliate-adjusted) actions are saved on the voter.
- Only then does
dispatchStagedTxOutItemqueue external outputs or execute native ones. Native payouts therefore record theirOutTxsagainst the saved actions, and a stale voter write cannot overwrite them.
When the voter holds a live reservation, its
FinalisedHeightis no longer refreshed at action-recording time. -
RUNE supply guard:
ensureNonNormalRuneMintWithinSupplyCapkeeps speculative and rollback execution away fromMintToModule'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 casesettleSwapdiscards the settlement cache and rolls back against the parent context, where the queue still ownsState.In/State.Out. -
Shared invalidation path (
revertMempoolSwap): Errata and compromised-vault cancellation both go through one helper. It marks the voterRevertedand the recordROLLBACK_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,rollbackAndRefundFinalizedMempoolSwapAtomicandrollbackAndRefundInactiveVaultMempoolSwaprefuse aRevertedvoter, 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 theRevertedmarker. 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:ensureMempoolSwapPoolFinalizedandmempoolSwapPoolFinalityAffectedtreat 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.
preemptMempoolSwapPoolOwnerCachedsettles the pending correction after resolving the owner, so a preempting mutation never runs ahead of it.LackSigningretries 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.
- An errata quorum on an inbound with a lifecycle record stops and unwinds the speculative swap via
-
Compromised-vault cancellation:
cancelCompromisedVaultMempoolSwapreverts, without refund, any lifecycle whose inbound vault is flagged byCompromisedVault-<pubkey>. That includes speculative work admitted before the flag was set, becauseFinalisedHeightalone 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
Revertedmarker are kept. - the inactive-vault recovery batch
- the due-recovery worker
- 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
-
Ragnarok drain: Emergency shutdown takes priority. Each block,
drainMempoolSwapsForRagnarokrolls 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
processMempoolSwapVaultRecoveryBatchadvances them in bounded batches. - Finalized inbounds observed to inactive vaults route through
rollbackAndRefundInactiveVaultMempoolSwap, so refund scheduling happens only after a successful atomic rollback. - A
FINALIZEDstream whose inbound finality was already accepted survives vault retirement. Both the recovery batch and the due worker skip it (the cursor still advances), andisSwapReadykeeps executing its remaining slices while the funds migrate to the new vault.
- When a vault becomes inactive, persisted per-vault recovery cursors start, and
-
Slasher /
LackSigningintegration:- 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.
mempoolSwapRecoveryHeightcomputes the deadline from live stream/limit-order progress, with saturating arithmetic, and from the lifecycle status.STAGED/SETTLEDrecords 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 forStreamingSwapMaxLengthplus a signing period (about 24.5h on defaults) even after settling.- In-flight (
RESERVED/EXECUTING) records, andFINALIZEDtombstones that still own queue work, keep the grace. - Timeouts use
signingTransPeriod * maxOutboundAttempts, falling back todefaultMempoolSwapTimeoutAttempts = 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
finishFinalizedMempoolSwapSettlementAtomicrather than deleted outright.
-
Bifrost mempool observation gating:
isRBFEnabledkeeps 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.feeRateBelowMempoolThresholdcloses the cheaper attack: broadcast a non-signalling transaction, underpay, and let it sit. An unconfirmed inbound must pay at leastmempoolMinFeeRateBPS(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
lastFeeRateis a whole-transaction ZIP-317 fee rather than a per-byte rate.
-
Duplicate-swap guards:
addSwapskips insertion when an advanced-queue item already exists for(TxID, Index).addSwapDirectskips when a legacy queue item exists, or when the voter already has actions plus a live reservation. It also keys the queue item onmsg.Indexinstead of a hardcoded0.
-
Streaming max-length hardening:
getStreamingSwapMaxLengthcentralizes the choice betweenStreamingSwapMaxLengthandStreamingSwapMaxLengthNative, and errors on a non-positive value. At admission,AddSwapQueueItemclamps a market swap'sState.Intervalto that maximum, so an untrusted memo cannot keep a durable queue item alive past the applicable limit. -
Quote improvements:
/quote/swapcallsisMempoolSwapEligiblepluscheckMempoolSwapCapacity. When the swap is eligible, it computestotal_swap_secondsasmax(outbound_delay, streaming, inbound_confirmation)instead of adding the inbound confirmation time to the rest.- Quote simulation runs
preemptMempoolSwapExecutionOwnersAtomicinside 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
errRetryLatersentinel: 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
- 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
- At admission,
checkMempoolSwapCapacitychecks the lifecycle-count and TOR caps (global cap ∧ per-pool BPS cap over finality-adjusted depth). The queue item, usage counter, voter mirror, andMempoolSwaprecord commit together. If admission is rejected, the swap simply waits for finalization. - 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 tonativeTxOutor the external queue. Another speculative swap touching those pools defers. A swap with a final inbound, or an irreversible operation, preempts the owner instead. - On finalization, the path depends on what the lifecycle still owns:
- Queue work remaining: TOR is released, and the record becomes a zero-reservation
FINALIZEDtombstone 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.
- Queue work remaining: TOR is released, and the record becomes a zero-reservation
- On failure, timeout, preemption, inactive vault, errata, compromised vault, or ragnarok,
rollbackMempoolSwapAtomicruns:- it discards the journal and executes inline reverse swaps with
DONATEpost-swap memos, reporting the net pool movements asEventPoolBalanceChanged - it clears staged outbounds and queue leftovers, reclaims clout, and releases TOR
- it emits
EventMempoolSwapRevertedplus a security event - it resets voter state (without clearing
Reverted)
- it discards the journal and executes inline reverse swaps with
- 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_swapsinvariant. 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-statuspendingflag. calculateNetworkSolvencytreats 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 bymigrateFunds, so the vault can go inactive while the claim is still owed. Skipping them would report that backing as over-solvency surplus thatprocessOverSolvencycould sweep. Finalization re-prepares the item against current vaults.
- Genesis export and import:
- Export includes
StagedOutboundLists andMempoolSwaprecords. 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 derivesMempoolSwapUsageTORand the count from them. The errata pool-correction index is rebuilt from the voter field. ValidateGenesisrejects duplicate identities (case-normalized like keeper keys) and conflicting per-pool owners.
- Export includes
- The regression export invariant checker models advanced-queue
State.In/State.Outagainst 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.
SetLastChainHeightnow advances under the same finalized-or-Migrategate 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 anEventSecurity. - Inline rollback emits
EventPoolBalanceChangedfor 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_totalblocked_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), andinbound.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.MempoolSwapMaxCountis 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) plusIsEmpty()/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).IsActualLegacyStreamingreplaces the repeatedIsLegacyStreaming() && !IsLimitSwap()checks. - New protos:
- in
type_mempool_swap.proto:MempoolSwap,MempoolSwapStatus,MempoolSwapPendingEffectsand its pool/supply/clout/affiliate/dynamic-fee/cleanup effects,MempoolSwapPoolBalanceOverlay,MempoolSwapNativeSupplyOverlay - elsewhere:
StagedOutbound/StagedOutboundList,QueryStagedOutbound{Request,Response,Item},EventMempoolSwapReverted, andQueryNetworkResponse.mempool_swap_usage_tor(field 19) TxOutItemfield 18 is reserved (the earlierinbound_finalizedflag was dropped)
- in
- New genesis fields:
GenesisState.staged_outbounds(50) andmempool_swaps(51). - New or extended keeper interfaces:
KeeperMempoolSwapUsage: TOR usage and lifecycle countKeeperMempoolSwap: lifecycle CRUD, overlays, pool owner, vault/due iteration, vault recovery cursors, and the errata pool-correction index (Set/Delete/GetErrataPoolCorrections,GetErrataPoolCorrectionPools)KeeperStagedOutbound: includingGetStagedOutboundSummary/GetStagedOutboundValueKeeperLiquidityProvider.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/ProcessGasnow takeManagerinstead ofkeeper.Keeper+EventManager.DynamicFeeManagergainsResolveSwapCredit/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 markedluvi_drop: [BTC.BTC](aserrata-tx.yamldoes), 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
- rollback under trading halts, disabled derived trading, and
Documentation
- New
docs/swap-guide/mempool-swaps.md(linked fromdocs/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-swapscluster stage (suites.MempoolSwaps), added to the defaultTEST_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 usingPOL.POLinbounds, 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
MsgErrataTxquorum, 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 registeredr:<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 themempool_swapsinvariant 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, wheremempoolfullrbfdefaults off) bumped to 29.x. test/simulation/README.mddocuments 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
calculateNetworkSolvencyinstead of re-reading the store for each outbound item. - Move Monero
typicalVaultOutboundWeightinto 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()inhandler_common_outbound, refreshedbuf.lock,constantname_string.goregenerated with stringer v0.28.0, and a new.claude/commands/ci-ready.mdhelper command.