Do not debit the vault for router-bounced EVM outbounds
Problem
When an EVM vault sends the native gas asset through the THORChain Router and the recipient cannot accept it within the router's 30k gas stipend, router V6+ returns the funds to the vault, emits TransferFailed, and still emits a full-value TransferOut. THORNode treated that as a delivered outbound: it debited the vault the full amount, matched the TxOutItem (no slash), and did not underflow (the vault really still holds the coins). The vault silently under-counted forever with every alarm bypassed, and the recipient got nothing.
Fix
- bifrost flags such observations with a new
ObservedTx.Bounced(proto field 14). A bounce is aTransferFailedwith empty memo and native asset paired by(to, amount)to the nativeTransferOutthat follows it in the same receipt. The aggregator paths also emitTransferFailedbut with the memo set (funds were forwarded to the recipient) and are not flagged. Detection is gated per chain by theEVMBouncedOutbound-<CHAIN>mimir and evaluated once per scanned block. - thornode skips the vault coin debit for a
Bouncedobservation, still charges gas, emits a security event, and keeps the outbound terminal (the router bounces instead of reverting precisely so bifrost does not retry forever). - Bounced is consensus-relevant. It is part of the signed attestation payload and part of
ObservedTx.Equals/EqualsConsensus, so a lone attestation with the flag flipped forms its own minority group instead of riding the honest supermajority. - Bounce discovered after consensus is reconciled. The signer's instant observation (
auto_observedefaults to true) has no receipt and can never beBounced; where the signing committee is a supermajority it reaches quorum first and the vault is debited.correctOutboundObservation(both consensus paths) and the non-final to final consensus hand-off now credit the coins back and emit the security event when a supermajority re-observes the outbound asBounced, the same way gas is corrected today. Every correction needs a supermajority of current votes: the voter records which entry each signer filed most recently per finality level (ObservedTxVoter.LatestVotes, proto field 13), and a signer whose current vote is elsewhere no longer lends the signatures an entry keeps forever. A signer can never re-file an entry it already signed: quorum attestations carry no freshness, so a re-file is indistinguishable from a proposer replaying the original attestations. Once an errata has marked the outbound reverted, neither correction path touches the vault again. - The
EVMBouncedOutbound-<CHAIN>mimir is resolved once per block, before reorg processing, and threaded through the reorg rescan and the current block, so a lookup failure can never discard a reorg's replacement observations after the stored block metas have been rewritten.
Rollout
Set EVMBouncedOutbound-<CHAIN> only once every node runs a build that knows field 14 (old binaries reject messages carrying it), and only on chains whose deployed router is V6 or later (earlier routers emit no TransferFailed).
Known limitations
- A replacement receipt byte-identical to one every node already filed (same inclusion height and gas) cannot move a bounce verdict back; accepting it needs a signed freshness token on attestations.
- Legacy/V4 routers emit nothing on bounce and bifrost has no trace RPC, so those bounces are undetectable.
- The swap still reads as done; the unpaid recipient needs manual remediation.
- Re-inclusion of an outbound after an errata is not re-accounted (pre-existing: the quorum handler does not re-run once a consensus height is set). The errata's restore is the final word; corrections now respect that instead of moving the same coins a second time.
- Pre-existing and not addressed here: on the non-final to final consensus hand-off, gas is not reconciled between the instant observation (gas limit) and the scanner observation (gas used). Same shape as the bounce reconciliation added here; flagged for a follow-up.
Testing
- Unit:
HandlerObservedTxOutSuite.TestHandleBouncedOutbound,TestHandleBounceDiscoveredAfterConsensus,TestHandleBounceCorrectionRedeliveryReusesEarlierGas(the reported gas-reuse re-delivery),TestHandleBounceCorrectionSupersededNeedsFreshQuorum,TestHandleBounceCorrectionAfterReorgReceipt(the production zero-confirmation instant observation, corrected twice),TestHandleBounceCorrectionReturnToGasPayloadNeedsQuorum,TestHandleBounceCorrectionIgnoresAbandonedMinorityVotes,TestHandleBounceCorrectionRejectsReplayedQuorum(proposer replay throughObservedTxQuorumHandler),TestHandleBounceCorrectionSkipsCancelTx,TestHandleBounceCorrectionAfterErrata,BlockScannerTestSuite.TestProcessBlock(ethereum and evm: reorg with a single mimir lookup),ObservedTxSuite.TestEqualsBounced,TestObservedTxSignablePayloadFieldCoverage,SmartContractLogParserTestSuite.TestGetTxInItem_BouncedOutbound. - Regression:
test/regression/suites/security/bounced-outbound-no-debit.yaml. - Full
x/thorchain,x/thorchain/types,bifrost/observer,bifrost/signer,bifrost/pkg/chainclients/{evm,ethereum,shared/evm}suites pass with-tags=mocknet.