ByAUJay
Cross-Chain Error Handling: The New Frontier in Intent UX Debugging
Meta description: Intents are reshaping cross-chain UX—but they also introduce new failure modes. This guide maps the concrete error surfaces across CCIP, LayerZero v2, Wormhole, Axelar, IBC, and Arbitrum and shows precisely how to detect, remediate, and prevent them—with runnable examples and emerging standards like ERC‑7683 and the Open Intents Framework.
Why error handling is now a strategic problem
Intent-centric UX hides chains, bridges, and solvers behind a single user goal (“Swap X for Y on chain Z”). When anything breaks, your support tickets say “my intent failed,” not “the CCIP destination revert reason was empty due to OOG.” That’s why decision-makers need an error architecture that:
- normalizes failures across heterogeneous protocols,
- correlates events to a single intentId across chains and services,
- offers reliable, auditable recovery paths users (or bots) can trigger,
- and fits emerging standards (ERC‑7683, Open Intents Framework) to reduce bespoke glue. (eips.ethereum.org)
Below, we unpack the concrete failure surfaces and show how to build an “intent-aware” debugging stack you can put into prod.
The failure map: where cross-chain intents break (and what to do)
Think in “protocol slices.” Each interop stack has distinct failure modes and levers:
- Chainlink CCIP (DON-signed commit + execution)
- Destination gas cap (default 90,000) is a hard failure edge; OOG or unhandled exceptions flip the message into Manual Execution. CCIP Explorer lets you override gas and re-execute after you fix the receiver. Look specifically for ReceiverError with empty revert data (0x) to identify OOG. (docs.chain.link)
- LayerZero v2 (DVNs + Executor; OApps)
- Misconfigured channels (“LzDeadDVN” in defaults) cause quoteSend to revert until you wire DVNs and confirmations on both ends. Also mind non-blocking receive patterns, executor gas, and resume flows. LayerZero Scan’s Default Config Checker helps spot dead DVNs. (docs.sei.io)
- Wormhole Relayer (delivery providers + redelivery)
- If a delivery OOGs or needs more value, call resend() with a higher gasLimit or new provider; the API exposes quoteEVMDeliveryPrice and a deliveryHash your receiver can log. (wormhole.com)
- Axelar GMP (validator network + relayers)
- Two common stuck states: not yet approved (relay hiccup) and destination execution failure or underfunded gas. Both are recoverable via Axelarscan UI (“Approve,” “Execute,” “Add Gas”) or programmatically with AxelarGMPRecoveryAPI. (docs.axelar.dev)
- IBC (light client proofs; ack/timeout semantics)
- Success and error acknowledgements are first-class, and timeouts trigger source-side callbacks. Design your apps to emit deterministic error acks and to handle OnTimeoutPacket for refunds/reversals. (evm.cosmos.network)
- Arbitrum L1→L2 (retryable tickets)
- Auto-redeem can fail (fee spikes). Anyone can redeem within ~7 days; you can extend or cancel. Build monitors around TicketCreated and RedeemScheduled; support manual redeem flows. (docs.arbitrum.io)
Practical debugging playbooks (copy/paste ready)
1) CCIP: Diagnose OOG vs logic bugs, then override gas and replay
- Symptom: CCIP Explorer shows “Ready for manual execution.” If revert reason is 0x, it’s usually OOG; increase gas and trigger manual execution in Explorer. If your receiver logic is wrong, deploy a fix, then replay. (docs.chain.link)
- Defensive receiver: Persist failures for later retries; expose retry function gated by owner or governance.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import {CCIPReceiver} from "@chainlink/contracts-ccip/src/v0.8/ccip/CCIPReceiver.sol"; import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol"; contract DefensiveCcipReceiver is CCIPReceiver { error OnlyRouter(); mapping(bytes32 => Client.Any2EVMMessage) public failed; event MessageFailed(bytes32 indexed messageId, bytes reason); event MessageProcessed(bytes32 indexed messageId); constructor(address router) CCIPReceiver(router) {} function _ccipReceive(Client.Any2EVMMessage memory m) internal override { try this.handle(m) { emit MessageProcessed(m.messageId); } catch (bytes memory reason) { failed[m.messageId] = m; emit MessageFailed(m.messageId, reason); // no revert: let CCIP mark as failed and eligible for manual execution } } function handle(Client.Any2EVMMessage memory m) external { if (msg.sender != address(this)) revert OnlyRouter(); // ...do work; require adequate gasLimit on destination or this will OOG... } function retry(bytes32 messageId) external { Client.Any2EVMMessage memory m = failed[messageId]; require(m.messageId != bytes32(0), "no such"); delete failed[messageId]; this.handle(m); // will revert if still bad } }
- Operational tip: For token transfers + receiver logic, keep total work within CCIP’s default 90k gas or explicitly set a higher gas limit when sending; otherwise expect to trigger manual execution. (docs.chain.link)
2) LayerZero v2 OApps: Fix dead DVNs, make receives non-blocking, tune executor gas
- Symptom A (wiring): quoteSend reverts; Default Config Checker shows LzDeadDVN. Solution: configure DVNs/confirmations in layerzero.config.ts and re-run wiring. (docs.sei.io)
// layerzero.config.ts (excerpt) const EVM_ENFORCED_OPTIONS = [ { msgType: 1, optionType: ExecutorOptionType.LZ_RECEIVE, gas: 150_000, value: 0 } ]; const pathways: TwoWayConfig[] = [[ optimismContract, currentContract, [['LayerZero Labs'], []], // required DVNs, optional DVNs [1, 1], // confirmations in each direction [EVM_ENFORCED_OPTIONS, EVM_ENFORCED_OPTIONS], ]];
- Symptom B (destination OOG / reverts): Use non-blocking receive to store failed payloads and allow resume without blocking the channel.
// Nonblocking pattern (v2-compatible idea; simplified) abstract contract NonblockingLzAppUpgradeable /* is LzAppUpgradeable */ { event MessageFailed(uint16 srcEid, bytes srcAddr, uint64 nonce, bytes payload, bytes reason); mapping(uint16 => mapping(bytes => mapping(uint64 => bytes32))) internal failed; function _blockingLzReceive( uint16 srcEid, bytes memory srcAddr, uint64 nonce, bytes memory payload ) internal virtual /* override */ { (bool ok, bytes memory reason) = address(this).call( abi.encodeWithSignature("nonblockingLzReceive(uint16,bytes,uint64,bytes)", srcEid, srcAddr, nonce, payload) ); if (!ok) { failed[srcEid][srcAddr][nonce] = keccak256(payload); emit MessageFailed(srcEid, srcAddr, nonce, payload, reason); } } function retryPayload(uint16 srcEid, bytes calldata srcAddr, uint64 nonce, bytes calldata payload) external { require(failed[srcEid][srcAddr][nonce] == keccak256(payload), "no failed"); delete failed[srcEid][srcAddr][nonce]; this.nonblockingLzReceive(srcEid, srcAddr, nonce, payload); } function nonblockingLzReceive(uint16,bytes memory,uint64,bytes memory) public virtual; }
- Risk note: Under-provisioned gas can still grief channels; audits show that attackers can set low gas and trigger stored payloads unless you adopt non-blocking patterns and enforce minimum gas. Always set executor options high enough for worst-case paths. (github.com)
- Tooling: LayerZero Scan tools provide default configs and APIs for runtime diagnosis; the CLI exposes lz:oapp:config:get for mismatches. (layerzeroscan.com)
3) Wormhole Relayer: Redelivery with more gas/value
- Symptom: Destination call OOGs or needs more receiver value.
- Fix: Call resend() with a new gasLimit or delivery provider; use quoteEVMDeliveryPrice to compute nativePriceQuote; log deliveryHash in receiveWormholeMessages for correlation.
IWormholeRelayer relayer = IWormholeRelayer(RELAYER); (bytes32 digest, /*...*/) = abi.decode(payload, (bytes32, /*...*/)); function redeliver(VaaKey memory key, uint16 dstChain, uint256 gasLimit) external payable { (uint256 price,) = relayer.quoteEVMDeliveryPrice(dstChain, 0, gasLimit); relayer.resend{value: price}( key, dstChain, TargetNative.wrap(0), encodeEvmExecutionParamsV1(EvmExecutionParamsV1(Gas.wrap(uint(gasLimit)))), address(0) // default provider ); }
- Reference: resend(), quoteEVMDeliveryPrice, IWormholeReceiver’s receiveWormholeMessages signature (note deliveryHash). (wormhole.com)
4) Axelar GMP: Query status, add gas, or manually execute
- Symptom: Transaction stuck unapproved or failed at destination due to gas or logic.
- Fix: In UI, Axelarscan shows “APPROVE,” “Execute,” and “Add gas.” In code, AxelarGMPRecoveryAPI exposes queryTransactionStatus, manualRelayToDestChain, and gas top-ups. (docs.axelar.dev)
import { AxelarGMPRecoveryAPI, Environment } from "@axelar-network/axelarjs-sdk"; const recovery = new AxelarGMPRecoveryAPI({ environment: Environment.MAINNET }); const s = await recovery.queryTransactionStatus(srcTxHash); if (s.status === "GAS_PAID_NOT_ENOUGH_GAS") { await recovery.addNativeGas({ txHash: srcTxHash, amount: "0.02" }); // chain-dependent } if (s.status === "APPROVAL_REQUIRED") { await recovery.manualRelayToDestChain({ txHash: srcTxHash }); } if (s.executionInfo?.status === "ERROR_EXECUTION") { // fix destination logic, then: await recovery.executeOnDestChain({ txHash: srcTxHash, overrideGas: 500000 }); }
- Error taxonomy: “Insufficient gas” vs “destination contract error” with guidance to Tenderly for traces. (docs.axelar.dev)
5) IBC apps: Treat acks and timeouts as first-class error channels
- Key idea: Your app’s OnRecvPacket must return an Acknowledgement (success or error). Source chain will later invoke OnAcknowledgementPacket or OnTimeoutPacket—so implement both for compensation logic and user refunds. (evm.cosmos.network)
Implementation notes:
- Compose middleware for cross-cutting concerns (fees via ICS‑29, callbacks); wrap WriteAcknowledgement to standardize error payloads. (docs.cosmos.network)
- Adopt the recommended Acknowledgement envelope (result|error) and include intentId + deterministic error codes so your indexer can reconcile user-visible status. (docs.cosmos.network)
6) Arbitrum retryables: Monitor, redeem, and set refund addresses safely
- Lifecycle: createRetryableTicket (L1) → auto-redeem attempt (L2) → manual redeem if needed → expiry ~7 days (extendable). Monitor TicketCreated and RedeemScheduled; provide a manual redeem UI action. (docs.arbitrum.io)
Operational cautions and mitigations:
- Attackers can grief if they can cancel retryables or starve gas; set callValueRefundAddress/admin correctly and avoid patterns that give griefers control. Build playbooks for cancel/extend flows. (code4rena.com)
Make failures “intent-native”: a uniform correlation model
Normalize every cross-chain hop into one intent timeline:
- Primary key: intentId
- If you use ERC‑7683, derive intentId = keccak256(ResolvedCrossChainOrder) and store it on origin and destination events. (eips.ethereum.org)
- Attach protocol breadcrumbs:
- CCIP: ccipMessageId, receiver chain tx hash; record “manualExecutionNeeded” + suggested gas. (docs.chain.link)
- LayerZero: src/dst eids, nonce, endpoint msg hash; “stored payload” flag if non-blocking stored. Also record DVN set and confirmations at send time for postmortems. (docs.layerzero.network)
- Wormhole: sequence, emitter, deliveryHash; record provider address and gasLimit for redelivery. (github.com)
- Axelar: source tx hash, callApproved, gasPaid, executed, refund; recovery actions taken. (docs.axelar.dev)
- IBC: port/channel/sequence and ack.error or timeout proof height. (evm.cosmos.network)
- Arbitrum: ticketId, autoRedeem success/fail, manual redeem txs, expiry timestamp. (docs.arbitrum.io)
Recommended event schema (EVM):
event IntentTrace( bytes32 indexed intentId, bytes32 protocolMsgId, // e.g., CCIP messageId, Wormhole deliveryHash, Axelar GMP id uint256 stage, // enum: SENT, APPROVED, EXECUTED, MANUAL_NEEDED, ERROR_ACK, TIMEOUT bytes data // ABI-encoded protocol-specific context (gasLimit, provider, ack.error, etc.) );
This lets your ops console render a single timeline per user intent across any protocol.
Observability that actually works in production
- Explorers you should integrate:
- LayerZero Scan Tools/API for defaults and message lookups; add the Default Configs by Chain view to internal runbooks. (layerzeroscan.com)
- Axelarscan links in-app for “Approve / Execute / Add Gas” self-service. (docs.axelar.dev)
- Wormhole docs list delivery price quoting and redelivery; expose a “Bump gas & redeliver” button fed by your own price hints. (wormhole.com)
- Tracing:
- Emit intentId in every on-chain event; mirror it in off-chain solver logs. Use OpenTelemetry baggage to propagate intentId from API to solver to relayer.
- Health SLOs (what we enforce for clients):
- P95 time “intent opened → destination executed” per route.
- P99 redelivery success after first failure with automated gas bump (Wormhole/CCIP).
- <0.1% rate of “manual user action required” for retail flows; enterprise dashboards for the rest.
Design patterns that prevent incidents (not just fix them)
- Guarantee idempotency
- Use intentId gating on destination receivers to prevent double execution across redeliveries and retries.
- Always support “operator-triggered” recovery
- CCIP: expose runbook to use Manual Execution with gas overrides; store precomputed overrideGas by path. (docs.chain.link)
- Axelar: wire AxelarGMPRecoveryAPI in backoffice; call manualRelayToDestChain on known stuck classes. (docs.axelar.dev)
- Wormhole: codify resend() with policy to choose new provider or higher gas based on recent block gas. (wormhole.com)
- Non-blocking by default on LayerZero
- Adopt non-blocking receive scaffolds; set minimum executor gas via enforced options to avoid griefing. Track DVN sets in config drift monitors. (sepolia.etherscan.io)
- IBC acks as product surface
- Bubble error acknowledgements up to users with actionable messages; timeouts should trigger automatic refund logic with receipts. (evm.cosmos.network)
- Arbitrum retryables
- Build automated redeemer bots; page SRE before 6 days to extend life if still failing; keep a panel to cancel malicious tickets safely. (docs.arbitrum.io)
Standards you should adopt now
- ERC‑7683 (Cross-Chain Intents)
- Standardizes order structs, the Open event, and settlement interfaces—gives you a canonical intentId and common filler/solver semantics. Start mapping existing order data to ResolvedCrossChainOrder. (eips.ethereum.org)
- Open Intents Framework (OIF)
- A modular, EF‑kicked collaborative effort that ships reference solvers and settlement modules aligned with ERC‑7683, backed by major L2s. Treat it as your default design lane for new intent features. (theblock.co)
What this buys you immediately:
- less bespoke glue between solvers and bridges,
- a shared language for error states,
- pluggable verification backends (DVN-based, optimistic, light-client) per route—without changing your app surface. (blog.ethereum.org)
Brief, in‑depth examples across stacks
- “CCIP stablecoin + call” flow
- Set gasLimit high enough for token pool releaseOrMint + receiver logic; if you still OOG on bursty chains, manual execute from Explorer with override gas (operators only), then reduce business logic inside ccipReceive to finish under the steady-state cap. (docs.chain.link)
- “LayerZero OFT send → dead DVN”
- quoteSend revert often means you haven’t wired DVNs; fix layerzero.config.ts, re‑wire, verify peers, re‑quote, and raise executor LZ_RECEIVE gas to your worst path. (docs.layerzero.network)
- “Wormhole delivery underpriced”
- Redeliver with higher gasLimit using resend(); persist deliveryHash and your own intentId so dashboards show “redelivery #n.” (wormhole.com)
- “Axelar callContractWithToken underfunded”
- Add gas via Axelarscan or SDK, or hit Execute on destination after congestion subsides; escalate to contract fix only if executionReason shows logic error. (docs.axelar.dev)
- “IBC timeout refund”
- OnTimeoutPacket triggers your refund path; publish an IntentTrace with stage TIMEOUT and details (height/sequence) to keep users informed. (evm.cosmos.network)
- “Arbitrum auto-redeem failed”
- Run a redeemer; if repeated reverts, patch the L2 target, redeem again; before expiry, extend if needed. Confirm TicketCreated → RedeemScheduled receipts. (docs.arbitrum.io)
What “great” looks like in 2025
- Unified intent console
- Single-pane timeline keyed by intentId with deep links: LayerZero Scan, Axelarscan, CCIP Explorer, your IBC channel view, and Arbitrum retryable panel. (layerzeroscan.com)
- Automated remediations
- Bots detect known signatures (CCIP ReceiverError 0x; Axelar GAS_PAID_NOT_ENOUGH_GAS; Wormhole revert) and apply preapproved retries (gas bump, provider switch) with circuit breakers. (docs.chain.link)
- Standards-first development
- Use ERC‑7683 for new flows; plug into OIF reference solver; keep protocol adapters thin and replaceable. (eips.ethereum.org)
7Block Labs’ quick-start checklist
- Adopt ERC‑7683 order structs; emit intentId everywhere. (eips.ethereum.org)
- For LayerZero v2, wire DVNs explicitly, check Default Configs by Chain, and ship non-blocking receives with enforced LZ_RECEIVE gas. (layerzeroscan.com)
- For CCIP, budget gas for worst-case receiver paths; document Manual Execution runbook with Explorer. (docs.chain.link)
- For Wormhole, integrate resend() and quoteEVMDeliveryPrice; log deliveryHash for correlation. (wormhole.com)
- For Axelar, embed AxelarGMPRecoveryAPI in ops and enable user self-serve via Axelarscan. (docs.axelar.dev)
- For IBC, formalize error ack envelopes and implement deterministic refunds on timeout. (evm.cosmos.network)
- For Arbitrum, deploy redeemer bots, manage ticket lifecycles, and secure refund addresses. (docs.arbitrum.io)
If you need a production-ready intent console, recovery automations, or ERC‑7683/OIF alignment, 7Block Labs ships reference implementations and SRE runbooks tailored to your stack.
References and further reading
- ERC‑7683: Cross‑Chain Intents (spec + site). (eips.ethereum.org)
- Open Intents Framework (announcement and site). (theblock.co)
- Chainlink CCIP manual execution and receiver error semantics. (docs.chain.link)
- LayerZero v2 wiring, DVNs, and troubleshooting; Scan tools. (docs.sei.io)
- Wormhole Relayer resend and delivery pricing; receiver interface. (wormhole.com)
- Axelar transaction recovery and error debugging. (docs.axelar.dev)
- IBC packet lifecycle, acks, and timeouts. (evm.cosmos.network)
- Arbitrum retryable ticket lifecycle and manual redeem. (docs.arbitrum.io)
Like what you're reading? Let's build together.
Get a free 30‑minute consultation with our engineering team.

