Failures and recovery
The hub chain is the chain where the vault and the adapter are deployed, and a source chain is any other chain that users send deposits and redemptions from.
In this guide, “vault operator” refers to the team that operates and configures the vault and its adapter, not Chainlink Labs, the Chainlink Foundation, or Chainlink node operators.
When a cross-chain deposit or redemption fails on the hub chain, the user gets the tokens back with a cross-chain refund to the source chain or a local recovery on the hub chain. The adapter, which is how users on other chains reach the vault, calls the vault inside the transaction that receives the CCIP message, so a failure leaves the tokens in the adapter, not in the vault. The adapter records the failure and holds the tokens until someone recovers them. Some failures happen inside CCIP before the adapter records anything. Most of those are fixed with CCIP manual execution. Two sender mistakes have no recovery path, and their tokens are permanently lost, as described in Failures with no recovery path.
What counts as a failure
A request fails when anything reverts while the adapter processes the message. ccipReceive calls processMessage inside a try/catch, so every revert in the following table becomes a failed message. The errors reference lists every error with its parameters.
Error | Trigger |
|---|---|
InvalidChain | The source chain is not configured on the adapter (its chain type is NONE) |
InvalidTokenCount | The message carries no token, or more than one |
InvalidPayloadLength | message.data is not exactly 128 bytes |
InvalidTarget | The payload's target vault is not allowlisted |
InvalidTargetToken | The token is neither the vault's asset nor its share token |
AmountIsZero | The token amount is 0 |
DepositsDisabled, RedeemsDisabled | Deposits or redemptions are disabled |
FeeExceedsAmount | The adapter fee is greater than or equal to the amount it is taken from (return to the source chain only) |
| The vault's own error | The vault's deposit or redeem call reverts |
NoOutputReceived | The vault returns zero shares or zero assets |
MinimumOutputNotMet | The output, after the adapter fee, is below the payload's minimumOut |
InvalidEVMAddress | The beneficiary is not a valid EVM address (its upper 12 bytes are not zero) |
| The token's own error | The transfer to a local beneficiary reverts |
InsufficientNativeBalance | The adapter's native gas token balance does not cover the CCIP fee for the return leg |
| The router's, OnRamp's, or token pool's error | The CCIP Router rejects the return leg, for example because the lane is not supported, the token has no pool on the lane, an outbound rate limit is exhausted, or the lane does not accept the configured return format |
When one of these happens:
- Every effect of the processing rolls back: the vault call, the adapter fee, and any return leg.
- The adapter holds the inbound tokens: the asset for a failed deposit, the shares for a failed redemption.
- The adapter sets the message's state to
BASICand stores a failure record with the source chain selector, the sender, the token amounts, and the local refund address from the payload. - The adapter emits
MessageFailed(messageId).
The adapter never refunds a failed message on its own and never retries it. Fixing the cause, for example by enabling deposits, allowlisting the vault, or funding the adapter, does not reprocess the message. The tokens leave the adapter only through a cross-chain refund or a local recovery.
Why the CCIP Explorer shows Success
The adapter catches the error, so ccipReceive returns normally and CCIP execution succeeds. The CCIP Explorer shows such a message as Success. That status only means CCIP delivered the tokens to the adapter. The vault call may not have happened.
To learn what the adapter did, look up the message on the hub chain by its CCIP message ID. This is the ID that ccipSend returned on the source chain and that the CCIP Explorer displays. Check either:
- The adapter's events in the delivery transaction:
MessageSucceeded(messageId)orMessageFailed(messageId). - The adapter's
messageErrorCodevalue for the message ID:0isNONE,1isBASIC(failed, tokens waiting to be recovered),2isRESOLVED.
The following command reads the adapter's state for a message with Foundry's cast. Replace the variables with your adapter address, the CCIP message ID, and an RPC URL for the hub chain:
cast call $ADAPTER "messageErrorCode(bytes32)(uint8)" $MESSAGE_ID --rpc-url $HUB_CHAIN_RPC_URL
When processing succeeds, the same transaction also shows where the output went. LocalTokenDelivered means the beneficiary received the shares or assets on the hub chain. MessageSent means the adapter started a return leg. Its first field is the return leg's own CCIP message ID, which you track separately in the CCIP Explorer. Deposit from a source chain runs these checks on a real deposit and follows its return leg.
Cross-chain refund
refundFailedMessage sends the inbound tokens back over CCIP to the address that sent the original message.
- Who can call it: Anyone. The recipient comes from the stored failure record, not from the caller, so the user, the vault team's support desk, or a relayer can trigger it.
- What comes back: The original inbound tokens at the amount the adapter received: the asset for a failed deposit, the shares for a failed redemption. The adapter fee is not charged.
- Where it goes: The original
message.senderon the original source chain. It does not go to the beneficiary or the local refund address. - Who pays: The caller, in the native gas token through
msg.value. The adapter's own native gas token balance is not used. Ifmsg.valuedoes not cover the fees actually paid, the call reverts withInsufficientRecoveryFeeorInsufficientNativeBalance. The adapter returns any excess to the caller in the same transaction. The refund reverts withRefundFailedif the caller cannot receive the native gas token. - Result: The state becomes
RESOLVED. The adapter emitsMessageRefundedand aMessageSentevent that carries the refund's CCIP message ID.
A refund of shares works only if the share token is a CCIP cross-chain token (CCT) on the lane back to the source chain. Returning shares to the source chain has the same requirement.
Refund sequence
A refund takes three calls on the hub chain, each with the failed message's CCIP message ID:
checkRefundEligibility(messageId)returnscanRefund, the recipient, the token, the amount, and a fee estimate. It returnscanRefund = falsewhen the message is not in theBASICstate, when the stored sender is not exactly 32 bytes, or when no token amount is above zero. It can also revert when the CCIP Router cannot quote the refund, for example when the lane is not supported or the token has no pool on it. Treat that revert as "not refundable right now".estimateRefundFee(messageId)returns the fee estimate on its own. The estimate can be lower than the actual cost, because each refund send quotes its fee again when it executes.refundFailedMessage(messageId), sent with amsg.valueof the estimate plus a margin. The unused amount comes back to the caller.
Refunds to contracts
If a contract sent the original message, such as a sender contract or a smart account on the source chain, that contract receives the refund. The refund is a token-only transfer with empty data and a gas limit of 0, so CCIP does not call ccipReceive on the recipient. The contract must be able to move or use tokens it receives without a callback, or the tokens stay in that contract.
When a refund is unavailable
A refund uses the same settings as a return leg to that chain: the return format set with setEvmReturnLaneFormat and the requested finality set with setEvmReturnRequestedFinality for the inbound token. If the lane, the token pool, or a rate limit cannot carry the token at that moment, the refund transaction reverts. The message stays BASIC, and anyone can try again later.
Refunds keep working after the vault operator sets the source chain's type to NONE. The adapter records the source chain's family when the message fails, and infers it from the sender address if the chain was not configured at that time.
Local recovery
recoverFailedMessageLocally transfers the inbound tokens to the local refund address on the hub chain.
- Who can call it: Only the local refund address stored from the payload. Any other caller gets
UnauthorizedLocalRefund. The address must be able to send that transaction, so use an account the user controls, not a contract that cannot make arbitrary calls. - What it needs: A non-zero
localRefundAddressin bits 1 to 160 of the payload'sdeliveryAndRefundfield, set when the message is sent. See What a user sends. It cannot be added after the message is sent. If it is zero, the call reverts withNoLocalRefundAddress. If the payload is not exactly 128 bytes, the adapter cannot read it and stores no local refund address. - What comes back: The inbound tokens on the hub chain: the asset for a failed deposit, the shares for a failed redemption. The user can then deposit into or redeem from the vault directly.
- Who pays: No CCIP fee. The caller pays only hub-chain gas.
- Result: The state becomes
RESOLVEDand the adapter emitsMessageRecoveredLocally.
A local recovery takes two calls on the hub chain, each with the failed message's CCIP message ID:
checkLocalRecoveryEligibility(messageId)returnscanRecover, the local refund address, the token, and the amount. It never reverts, and it does not check who the caller is.recoverFailedMessageLocally(messageId), sent from the local refund address with nomsg.value.
The following table compares the two recovery paths:
| Cross-chain refund | Local recovery | |
|---|---|---|
| Function | refundFailedMessage | recoverFailedMessageLocally |
| Who can call | Anyone | Only the local refund address |
| Where tokens go | The original sender on the source chain | The local refund address on the hub chain |
| Who pays | The caller pays the CCIP fees in the native gas token through msg.value | The caller pays hub-chain gas only |
| Set in advance | Nothing in the payload; the sender must be exactly 32 bytes | A non-zero localRefundAddress in a 128-byte payload |
| Depends on | The lane and token pool back to the source chain being available | The hub chain only |
| Event | MessageRefunded | MessageRecoveredLocally |
Both paths stay open while the message is BASIC. Whichever succeeds first resolves the message, and the other then reverts with MessageNotFailed.
Setting a local refund address on every message gives the user a recovery path that does not depend on the lane back to the source chain. The trade-off is that recovered tokens arrive on the hub chain, not the source chain.
Failures the adapter can't catch
Some failures happen inside CCIP, before the adapter's try/catch can record anything. In the cases in the following table, the adapter emits no event, its state for the message stays NONE, and the tokens are not released on the hub chain. The CCIP Explorer shows the message as failed, usually as Ready for manual execution. After the cause is fixed, anyone can retry the message with manual execution.
| Cause | Recovery |
|---|---|
The gas limit in the user's message is too low, so ccipReceive runs out of gas, usually before the adapter can record a failure | Manual execution with a higher gas limit |
| On a CCIP 2.0 lane, the message requests a finality that the adapter's inbound finality setting does not allow | The vault operator updates setInboundFinality, then manual execution |
| On a CCIP 2.0 lane, the message lacks attestations from a required Cross-Chain Verifier (CCV), or the optional CCV threshold is not met | The verifier attests or the vault operator updates setCCVsConfig, then manual execution |
| A token pool rate limit on the hub chain is exhausted, or the token's pool on the hub chain is missing or incompatible | Wait for the rate limit to refill or for the pool to be configured, then manual execution |
The adapter's default inbound finality is 0x00000000, which means wait for finality. On a CCIP 2.0 lane, CCIP rejects a faster-than-finality message from a source chain until the vault operator allows it, and waiting for the source chain to finalize does not help. CCIP 1.6 lanes do not read the adapter's CCV or finality settings.
A return leg or a refund is a separate CCIP message from the hub chain to the source chain. If it fails on the source chain, for example on a token pool rate limit, recover it with manual execution using the CCIP message ID from the adapter's MessageSent event.
Failures with no recovery path
Two sender mistakes have no recovery path, and can result in a permanent loss of funds: A message with empty data and a gas limit of 0, and a malformed payload. Neither the adapter nor CCIP manual execution can return these tokens to the user, so the tokens are permanently lost.
CCIP treats a message with empty data and a gas limit of 0 as a token-only transfer and delivers the tokens to the adapter without calling ccipReceive. The adapter records nothing and has no function that returns those tokens.
Message lifecycle
The following diagram shows the states a message can reach after CCIP delivers it to the adapter, and the CCIP-level branch where the adapter records nothing:
The adapter tracks each message ID in messageErrorCode with three states:
State | Value | Meaning | Related events |
|---|---|---|---|
NONE | 0 | The message has not arrived yet, or it was processed successfully | MessageSucceeded when processed |
BASIC | 1 | Processing failed and the adapter holds the inbound tokens; refund or local recovery is open | MessageFailed |
RESOLVED | 2 | The failed message was returned through a refund or a local recovery | MessageRefunded or MessageRecoveredLocally |
MessageSucceeded tells the two meanings of NONE apart, and MessageRefunded or MessageRecoveredLocally shows which recovery path produced RESOLVED. While a message is BASIC, getFailedMessageRecord returns its stored source chain selector, sender, token amounts, and local refund address.
To find the next action for a request, combine the CCIP Explorer status with the adapter's state:
| CCIP Explorer status | Adapter state and event | Meaning | Action |
|---|---|---|---|
| Any status before Success | NONE, no event | CCIP has not delivered the message yet | Wait for CCIP |
| Success | NONE, MessageSucceeded | The request completed | For local delivery, nothing. For a return, track the return leg from MessageSent |
| Success | BASIC, MessageFailed | The adapter rejected the request and holds the tokens | Refund to the source chain or recover locally |
| Success | RESOLVED, MessageRefunded | The tokens were sent back to the source chain | Track the refund's CCIP message ID from MessageSent |
| Success | RESOLVED, MessageRecoveredLocally | The local refund address received the tokens on the hub chain | Nothing |
| Success | NONE, no adapter event | The message had empty data and a gas limit of 0, so the tokens reached the adapter unprocessed | None. The tokens are permanently lost, because the adapter has no function that returns them |
| Ready for manual execution | NONE, no event | CCIP execution failed and the adapter recorded nothing | Fix the cause, then use manual execution. For a malformed payload, manual execution always fails and the tokens are permanently lost |
Where to go next
- Limitations: what the adapter does not do, and every case where funds can get stuck.
- Adapter contract recovery functions: signatures, return values, and errors for refunds and local recovery.
- CCIP message lifecycle: how CCIP verifies and executes a message before the adapter runs.