MMR proof indexing in a bridge dispatcher audit
The dispatcher routed selector 0x8c49b257 into three internal stages: _createImports(bytes), proveImports(bytes), and processTransactions(bytes,uint256). That shape matters because each stage changed the data before the next one used it. If the proof path validates one byte sequence and payout code later consumes another, the check has already lost its purpose.
Trace analysis shows the chain moving through delegatecall-heavy modules rather than a single linear verifier. The entry module, verification module, and payout module each handled part of the flow. That makes the proof path easy to get wrong in a way that still looks tidy in a trace.
Map the delegatecall chain before you trust the proof path
_createImports(bytes) transformed submitted material before verification. proveImports(bytes) then handled hashing and MMR proof-index helper calls. processTransactions(bytes,uint256) deserialised transfers from the proved payload and sent them out of bridge custody.
That order is the whole problem. If import material is rewritten first, proof verification has to bind to the exact bytes that later reach the payout stage. A bridge dispatcher cannot safely prove one representation and spend another. Delegatecall makes this easier to miss because the code path looks internal even when the attacker controls the inputs.
The trace also showed repeated helper activity, which is a warning sign in itself. createHash(bytes) was called 21 times, while GetMMRProofIndex(uint64,uint64,uint8) was called five times. Repeated helper use is not a flaw by itself, but it raises the question of whether index selection stayed tied to the payload that was actually validated.
Pin the MMR index helper to the payload it actually validated
MMR proof indexing only works if the index helper is bound to one immutable message. If the dispatcher re-encodes imports, or if proof code derives an index from fields that later change shape, the proof no longer protects the transfer list. That kind of mismatch is enough to turn a valid-looking proof into a spending primitive.
The safer boundary is boring: derive the proof index from the same canonical payload that will reach deserialisation, then reject any later change in length, ordering, or field count. If the proof helper can be called on attacker-chosen import material and the payout stage accepts a different byte stream, the system has already accepted drift between verification and custody movement.
Where the audit boundary failed to hold
The flow reached outbound payouts to an externally owned address after the import and proof stages completed. Native ETH, tBTC, and USDC moved from bridge custody. There was no trace-visible rejection, rollback, or second approval step before the transfers.
That is the key control failure. A bridge should not treat proof acceptance as permission to spend unless the proof also covers the exact transfers being executed and the authorisation state is checked again at the point of payout. Here, the dispatcher appears to have let proof acceptance stand in for spend approval.
Test the proof verification step against replay and malformed transfer data
The verification path needs hostile input tests that do more than check happy-path proofs. Replay accounting must fail closed if the same import material is submitted twice, and malformed transfer lists must be rejected before deserialisation reaches payout logic. If the deserialiser can be driven with unexpected lengths, counts, or token entries, the proof layer has already lost control of the message.
The traced payout count argument was 3, and the resulting transfers included ETH, tBTC, and USDC. That is a narrow enough shape to test directly. If proof verification does not lock the transfer set to a single authenticated payload, then replayed imports or malformed message encodings can still slide through into processTransactions(bytes,uint256).
Check whether authorisation happened before custody moved
Authorisation should happen before custody moves, not after a proof helper has returned something plausible. In a cross-chain bridge, proof verification is not the same thing as permission to spend. It only becomes permission when the verified payload and the payout instruction are the same object, with no hidden re-encoding between them.
The trace gave no sign of a secondary authorisation step before the outbound calls. That leaves a simple operational boundary: do not let the dispatcher spend from custody unless the exact transfer list has been authenticated, replay-checked, and matched against the current authorisation state at the point of execution. If that check sits only in the import path, it is too early to matter.



