# Cross-Chain Vault Adapter Source: https://docs.chain.link/solutions/cross-chain-vault-adapter ## What is it A CCIP adapter that lets users on other chains deposit into and redeem from your existing ERC-4626 vault, while vault accounting stays on the hub chain, the chain where the vault and the adapter are deployed. [CCIP](/ccip) cross-chain, vaults [View on GitHub](https://github.com/smartcontractkit/cross-chain-vault-adapters) ## What the adapter does With the Cross-Chain Vault Adapter, a vault team can accept deposits and redemptions from users on other chains. 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. The vault is the product, CCIP is the bridge, and the adapter is how users on other chains reach the vault. The vault holds one asset and earns yield. The adapter is a contract on the hub chain that receives tokens and instructions through CCIP and calls the vault for the user. Without the adapter, a user on a source chain who wants those shares in a vault on the hub chain must: 1. Bridge the asset manually from the source chain to the hub chain, 2. Switch their wallet to the hub chain, 3. Get some native gas token on the hub chain, 4. Approve the vault on the hub chain, 5. Deposit into the vault on the hub chain, and, 6. Bridge the shares back to the source chain. With the Chainlink Cross-Chain Vault Adapter on the other hand, the user: 1. Approves the CCIP Router and sends one CCIP message from the source chain. > **NOTE: Message payload** > > A single message carries: > > 1. The asset itself, and, > 2. A 128-byte payload that names: the vault, the beneficiary, the minimum output, the delivery option, and a local > refund address (in case the message fails on delivery and a refund is needed). And that's it. The user doesn't have to do anything else. The adapter deposits the asset, then delivers the shares on the hub chain or returns them to the user on the source chain. Returning the shares to the source chain completes the one-message experience: the user never has to touch the hub chain. A return requires the vault's share token to be a CCIP cross-chain token on both chains, as [Requirements](#requirements) lists. Redemptions work the same way, just in the reverse direction. [How it works](/solutions/cross-chain-vault-adapter/overview/how-it-works) covers each step. The following diagram shows a deposit from a source chain into a vault on the hub chain, with both delivery options. ![A user's wallet on a source chain sends the asset and a payload through Chainlink CCIP to the adapter on the hub chain. The adapter deposits into the ERC-4626 vault and receives shares, then delivers them to the user's wallet on the hub chain, or returns them through CCIP to the source chain when the share token is a CCIP cross-chain token.](/images/solutions/cross-chain-vault-adapter/at-a-glance.svg) The repository also contains a separate [multibridge implementation](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/docs/multibridge/README.md) for vaults whose asset or share token moves over other cross-chain protocols. These docs cover the CCIP implementation. ## Who it's for - **Vault teams**, such as issuers, protocols, and curators, that run an [ERC-4626](https://eips.ethereum.org/EIPS/eip-4626) vault on one chain and want deposits and redemptions from users on other chains connected by CCIP. - **Integrators** who build deposit and redeem interfaces on source chains. They encode the payload and send the CCIP message, and deploy no contracts. The contract also supports Solana source chains. These docs cover EVM source chains. Asynchronous vaults, multi-asset vaults, and targets that are not ERC-4626 vaults need a fork; see [Use it as-is or customize it](#use-it-as-is-or-customize-it). An asynchronous vault that accepts synchronous ERC-4626 deposits needs the fork only for redemptions: deposits work with the adapter as-is. ## Requirements The vault's **asset** is the token users deposit and receive when they redeem. **Shares** represent their position in the vault. Which tokens need CCIP support depends on where users send tokens from and where they want to receive the result: | If you want users to... | Tokens that must be CCIP-enabled | | ------------------------------------------------------------------------ | -------------------------------- | | Deposit from another chain and receive shares on the hub chain | Asset only | | Deposit from another chain and receive shares back on the source chain | Asset and shares | | Redeem from another chain and receive the asset on the hub chain | Shares only | | Redeem from another chain and receive the asset back on the source chain | Shares and asset | Each token that crosses chains must be configured as a [CCIP cross-chain token (CCT)](/ccip/concepts/cross-chain-token/overview) between the source chain and the hub chain. **If your vault's asset is not CCIP-enabled, you can still offer redemptions from another chain with delivery on the hub chain.** Users send their CCIP-enabled shares to the adapter, and the adapter redeems them and delivers the asset locally. The asset does not cross chains in this flow. To enable cross-chain transfers of shares, the share token typically uses Lock & Release on the hub chain, because only the vault may mint shares, and Burn & Mint on source chains. See the [Lock & Mint tutorial](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry). Your vault and deployment must also meet these requirements: | Requirement | Needed for | Details | | ------------------------------------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Standard ERC-4626 vault | All flows | Synchronous and single-asset. The adapter calls `deposit` and `redeem` itself as depositor and share owner, so the vault must accept a contract in both roles. | | Standard ERC-20 behavior | All flows | The asset and share token must transfer exactly the amount requested. The adapter does not handle fee-on-transfer or rebasing tokens. | | Shanghai-compatible hub chain | Deploying the adapter | The adapter bytecode uses the `PUSH0` opcode. | | Native gas token held by the adapter | Returning output to the source chain | The adapter pays each return leg's CCIP fee from its own native gas token balance. If the balance is too low, the request becomes a failed message. | > **NOTE: Need the adapter to support cross-chain tokens from other protocols?** > > 1. The multi-bridge version of our adapter contract is compatible with cross-chain tokens from some other protocols. > 2. Please reach out to the [Chainlink team](https://chain.link/ccip-contact) for support if your use case demands that. ## What you deploy and operate You deploy one adapter on the hub chain with one call to `deploy` on the `CrossChainERC4626AdapterFactory`. That transaction sets the accepted source chains, allowlists your vault when you pass its address and enable it, sets the deposit and redeem switches and the adapter fees, and grants the admin, fee setter, and fee collector roles to your accounts. The factory then renounces all of its own roles. The [Factory contract](/solutions/cross-chain-vault-adapter/reference/factory-contract) reference lists the factory's deployed addresses and its inputs. One adapter can allowlist several vaults on the same chain, and each payload names the vault it targets. A lane's version is defined as the version of the CCIP OnRamp and OffRamp contracts that serve it, and the [CCIP Directory](/ccip/directory) shows it. The factory does not apply CCIP 2.0 lane settings or fund the adapter, so after deployment you: - Set the return format for each CCIP 2.0 lane to `GENERIC_EXTRA_ARGS_V3_BASIC`. - Optionally set the inbound finality and [Cross-Chain Verifier (CCV)](/ccip/concepts/ccvs/overview) settings for each CCIP 2.0 source chain. - Fund the adapter with the native gas token for return legs. Your team holds the three roles on the adapter: | Role | Controls | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `DEFAULT_ADMIN_ROLE` | Source chains, the vault allowlist, the deposit and redeem switches, CCIP 2.0 lane settings, roles, and withdrawing the entire native gas token balance | | `FEE_SETTER_ROLE` | Adapter fees | | `FEE_COLLECTOR_ROLE` | Withdrawing collected adapter fees | Roles use OpenZeppelin [`AccessControlEnumerable`](https://docs.openzeppelin.com/contracts/5.x/api/access#AccessControlEnumerable). Role changes take effect in one step, and renouncing the last admin locks the configuration permanently. You also monitor the `MessageFailed` event. A failed request leaves its tokens in the adapter until someone refunds or recovers them. Disabling a source chain, a vault, or processing does not pause CCIP delivery. Messages that arrive become failed messages. See [What counts as a failure](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#what-counts-as-a-failure). > **NOTE: Chainlink does not run your adapter** > > Chainlink does not deploy, operate, or control adapters. See [CCIP service > responsibility](/ccip/concepts/service-responsibility). ## Use it as-is or customize it Use the adapter as-is through the factory when your vault meets the [requirements](#requirements). Otherwise, fork the [repository](https://github.com/smartcontractkit/cross-chain-vault-adapters) and edit [`CrossChainERC4626Adapter.sol`](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/src/ccip/CrossChainERC4626Adapter.sol). The contract has no virtual hooks for processing, so you change `processMessage` and `_processTarget` directly. Common reasons to fork are asynchronous vaults (typically for the redemption path), multi-asset vaults, targets that are not ERC-4626 vaults, custom payload encoding, and custom validation of the source chain, sender, or beneficiary. A fork must keep these safety properties: - `ccipReceive` accepts calls only from the CCIP Router. - Processing runs in a self-call (`this.processMessage`) inside `try`/`catch`, so a processing revert does not revert CCIP execution. - Failed messages are stored and stay recoverable through cross-chain refund and local recovery. - Address conversion checks reject malformed addresses, such as a beneficiary whose upper 96 bits are not zero. - The failure handler (the `catch` path) must not revert on any input, because a revert there strands the tokens, as described in [Failures with no recovery path](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#failures-with-no-recovery-path). The contracts in `src/ccip` are audited. A fork is not the audited code and needs its own review and its own deployment, because the factory deploys only the original adapter. ## Design choices | Choice | What it means | Trade-off | | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Vault accounting stays on the hub chain | Every deposit and redemption executes against the vault on the hub chain. No copy of the vault or its share price runs elsewhere. | Each request waits for CCIP delivery and uses the vault's exchange rate at execution, not at send time. A [minimum output](/solutions/cross-chain-vault-adapter/overview/how-it-works#slippage-protection) bounds the difference. | | No contracts on source chains | Users send a standard CCIP message. The adapter has no sender allowlist, so any address on an enabled source chain can use it. | Nothing checks the payload before the tokens reach the hub chain, and a malformed payload can strand tokens permanently. Build it as described in [What a user sends](/solutions/cross-chain-vault-adapter/overview/how-it-works#what-a-user-sends). | | Processing failures are held, not reverted | When processing reverts, for example in the vault call, the minimum output check, or the return send, the adapter keeps the tokens and records a failed message. | The [CCIP Explorer](https://ccip.chain.link) still shows Success, so track `MessageFailed` instead. Nothing is retried or refunded automatically. | | Permissionless cross-chain refund, optional local recovery | Anyone can call `refundFailedMessage` to return the tokens to the original sender on the source chain. The payload's local refund address can call `recoverFailedMessageLocally` instead. | The refund caller pays the CCIP fee in the native gas token. Refunds go to the original sender, not the beneficiary. Local recovery needs a local refund address set before sending. See [Failures and recovery](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery). | [Fees](/solutions/cross-chain-vault-adapter/overview/how-it-works#fees) explains the adapter fee, and [Limitations](/solutions/cross-chain-vault-adapter/reference/limitations) covers the remaining trade-offs. ## Where to go next - [How it works](/solutions/cross-chain-vault-adapter/overview/how-it-works): the payload, both flows, delivery options, and fees. - [Failures and recovery](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery): failed requests and how users get their tokens back. - [Limitations](/solutions/cross-chain-vault-adapter/reference/limitations): what the adapter does not do. - [Adapter contract](/solutions/cross-chain-vault-adapter/reference/adapter-contract): the `CrossChainERC4626Adapter` interface. - [Factory contract](/solutions/cross-chain-vault-adapter/reference/factory-contract): the `CrossChainERC4626AdapterFactory` interface and deployed addresses. - [Deploy the vault and set up its share token](/solutions/cross-chain-vault-adapter/guides/deploy-the-vault): deploy an example ERC-4626 vault on Ethereum Sepolia and make its share token a CCIP cross-chain token. - [Deploy the adapter](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter): deploy, configure, and fund an adapter for that vault through the factory. - [Deposit from a source chain](/solutions/cross-chain-vault-adapter/guides/deposit-from-source-chain): deposit into the vault from Arbitrum Sepolia through the adapter and receive the shares on Arbitrum Sepolia. - [Redeem from a source chain](/solutions/cross-chain-vault-adapter/guides/redeem-from-source-chain): redeem the shares from Arbitrum Sepolia, receive CCIP-BnM back, and withdraw the adapter fees as the vault operator. ## Disclaimer Please note, this repo contains community examples only — these are not Chainlink products or services and are not supported or maintained by Chainlink. This code represents an example of using a Chainlink product or service, and is intended for demonstration and educational purposes only. It is provided “AS IS” and “AS AVAILABLE” without warranties of any kind, may not have been audited, and may omit checks or error handling. Each party intending to use this example code does so entirely at their own risk and must perform its own audits, security and code review, key management, and testing before any production deployment and ensure the operation and performance of such code matches expectations. Neither Chainlink Labs nor the Chainlink Foundation deploys, operates, monitors, maintains or endorses any deployment of this code. Note that this is not a Chainlink product, feature or service, and there are no commitments made with respect to the code, including compatibility with future Chainlink releases. You should not rely on this code without first conducting your own technical, engineering, and security review. This code is also outside the scope of any Chainlink bug bounty programs. Neither Chainlink Labs, the Chainlink Foundation, nor Chainlink node operators are responsible for outcomes due to errors in this example or how it is deployed or operated, or liable for any resulting claims or damages. Use of the Chainlink Network is subject to the Chainlink Foundation [Terms of Service](https://chain.link/terms), which provides important information and disclosures. By using this code, you acknowledge and agree to these terms. --- # How it works Source: https://docs.chain.link/solutions/cross-chain-vault-adapter/overview/how-it-works 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. A request to the Cross-Chain Vault Adapter is one CCIP message that carries the vault's asset or shares from a source chain to the hub chain. In the transaction that receives the message, the adapter calls the vault, then either delivers the output on the hub chain or sends it back to the source chain. This page follows a request that succeeds. [Failures and recovery](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery) covers requests that do not. ## Architecture The vault is the product. It holds one asset and earns the yield. CCIP is the bridge that moves tokens between chains. The adapter is how users on other chains reach the vault. It turns an incoming CCIP message into a vault call. For how CCIP itself carries a message, see the [CCIP architecture overview](/ccip/concepts/architecture/overview). The following diagram shows the contracts in a deployment, where each one lives, and what the adapter holds: its configuration, its three roles, and its link to the hub chain's CCIP Router. ![Components diagram. On the source chain, the user or app calls ccipSend on the CCIP Router. A CCIP lane connects that router with the CCIP Router on the hub chain. On the hub chain, the factory deploys the adapter, grants its roles, and keeps none. The adapter box lists its configuration (allowed source chains, allowed vaults, deposit and redeem switches, adapter fees, CCIP 2.0 settings) and its roles (DEFAULT_ADMIN_ROLE, FEE_SETTER_ROLE, FEE_COLLECTOR_ROLE). The adapter allowlists one or more ERC-4626 vaults and is fixed to the hub chain's CCIP Router.](/images/solutions/cross-chain-vault-adapter/architecture.svg) All three contracts live on the hub chain: - **Vault:** the team's existing [ERC-4626](https://eips.ethereum.org/EIPS/eip-4626) vault, unchanged. It holds one asset and issues the share token. - **Adapter:** [`CrossChainERC4626Adapter`](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/src/ccip/CrossChainERC4626Adapter.sol) receives CCIP messages, calls the vault, and delivers or returns the output. The vault team deploys and operates it. - **Factory:** [`CrossChainERC4626AdapterFactory`](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/src/ccip/CrossChainERC4626AdapterFactory.sol) deploys an adapter with its initial configuration in one transaction, then hands every role to the vault team's accounts. The adapter needs no contract on source chains. Users call `ccipSend` on the CCIP Router there directly. On the hub chain, the CCIP Router delivers messages to the adapter and sends the adapter's return legs. Shares that travel to or from a source chain do need a share token deployed there, as described in [Redeem flow](#redeem-flow). The adapter enforces the configuration shown in the diagram: - **Allowed source chains:** a chain type per CCIP chain selector, set with `setChainType`. A selector set to `NONE` is not allowed. - **Allowed vaults:** set with `setTargetEnabled`. The payload names one of them. - **Deposit and redeem switches:** `depositsEnabled` and `redeemsEnabled`, set together with `setProcessingEnabled`. - **Adapter fees:** see [Fees](#fees). - **CCIP 2.0 settings:** the inbound verifier and finality policy per source chain, and the return format and finality per destination. See [CCIP 2.0 lanes](#ccip-20-lanes). - **Roles:** `DEFAULT_ADMIN_ROLE` changes the configuration, `FEE_SETTER_ROLE` sets adapter fees, and `FEE_COLLECTOR_ROLE` withdraws them. See [Roles](/solutions/cross-chain-vault-adapter/reference/adapter-contract#roles). One adapter can serve several vaults and several source chains. The adapter does not check who sent a message. Any sender on an allowed source chain can deposit into or redeem from any allowlisted vault. The CCIP Router address is fixed when the adapter is deployed, and only that router can call `ccipReceive`. ## What a user sends A user sends one CCIP message to the adapter. The message must carry exactly one token in `tokenAmounts` and a payload of exactly 128 bytes in `data`. The token decides the action: - The vault's asset (the address returned by the vault's `asset()`) means deposit. - The vault's share token (the vault address itself) means redeem. - Any other token fails the request. The payload is the ABI encoding of four 32-byte fields: | Field | Type | Meaning | | ------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `target` | `address` | The vault to deposit into or redeem from. It must be on the adapter's allowlist. | | `beneficiary` | `bytes32` | The account that receives the output: on the hub chain for local delivery, on the source chain for a return. It must be an EVM address left-padded with zeros, so the upper 12 bytes are zero. | | `minimumOut` | `uint256` | The smallest acceptable output after the adapter fee, in output units: shares for a deposit, asset for a redemption. See [Slippage protection](#slippage-protection). | | `deliveryAndRefund` | `uint256` | Where the output goes, and who can recover the tokens on the hub chain if the request fails. Bit 0 is the return-to-source flag, and bits 1 to 160 hold the local refund address. | The sender packs the last field as `(uint256(uint160(localRefundAddress)) << 1) | (returnToSourceChain ? 1 : 0)`. The adapter ignores bits 161 to 255. The local refund address is the hub-chain account allowed to take back the tokens if the request fails. The [message format](/solutions/cross-chain-vault-adapter/reference/adapter-contract#message-format) reference has the exact encoding. [Build the payload](/solutions/cross-chain-vault-adapter/guides/deposit-from-source-chain#build-the-payload) builds one with the repository's `pnpm ccip:build-payload` script. The rest of the message follows standard CCIP rules: - **Receiver:** the adapter address on the hub chain. - **Fee token:** the sender pays the CCIP fee on the source chain in the native gas token or LINK. - **Extra arguments:** on a CCIP 2.0 lane, the message carries `GenericExtraArgsV3`, encoded with [`ExtraArgsCodec`](/ccip/evm/api-reference/v2.0.0/extra-args-codec). The gas limit in the extra arguments pays for everything the adapter does on the hub chain: its checks, the vault call, and, when the output returns to the source chain, the outbound CCIP send. CCIP bills the sender for the gas limit, not the gas used. Measure the gas with a test message against your own vault, and give requests that return output to the source chain more headroom than local delivery, because only they include the outbound send. If the limit is too low, `ccipReceive` can run out of gas before it records the failure, and CCIP marks the message as failed. Recovery then needs CCIP [manual execution](/ccip/concepts/manual-execution) with a higher limit. The requested finality in the extra arguments must be one that both the adapter and the token pools allow. By default, the adapter accepts only messages that wait for finality. > **CAUTION: The payload is final once sent** > > Nobody can change the payload after the message leaves the source chain. Before the user signs, check that: > > - The message carries exactly one token: the vault's asset or its share token. > - `data` is exactly 128 bytes. > - The payload is built with a standard ABI encoder, such as Solidity `abi.encode`, ethers, viem, or `cast`. > - `target` is a vault on the adapter's allowlist. > - `beneficiary` is an EVM address whose upper 12 bytes are zero. > - The local refund address is non-zero and is an account the user controls on the hub chain. > > Without a local refund address, the only way back for a failed request is a cross-chain refund to the original sender. A malformed payload can strand tokens permanently, as described in [Failures with no recovery path](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#failures-with-no-recovery-path). ## Deposit flow A deposit turns the asset sent from the source chain into vault shares. The following diagram shows a deposit from a source chain into a vault on the hub chain, with the return-to-source branch dashed. ![Deposit flow diagram with numbered steps. On the source chain, the user sends the asset and the payload. CCIP verifies and delivers the message. On the hub chain, in one transaction, the adapter validates the message, routes it as a deposit, deducts the adapter fee only when returning, deposits into the vault, and checks the output against minimumOut. The shares then go to the beneficiary on the hub chain, or, on the dashed branch, back to the beneficiary in a second CCIP message on the same source chain: the diagram draws a single source-chain region holding both the first and the last step, because a return goes only to the chain the request came from.](/images/solutions/cross-chain-vault-adapter/deposit-flow.svg) The request passes these checks and decisions in order. From the second item on, the first check that fails turns the request into a failed message, and [What counts as a failure](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#what-counts-as-a-failure) lists the error for each. 1. **CCIP 2.0 policy, before the adapter runs.** On a CCIP 2.0 lane, the OffRamp on the hub chain checks the message against the adapter's [Cross-Chain Verifier (CCV)](/ccip/concepts/ccvs/overview) and finality policy for the source chain. If it passes, CCIP transfers the asset to the adapter and calls `ccipReceive`, which runs every later check in an isolated self-call, `processMessage`. 2. **Source chain and message shape.** The source chain's selector must not be set to `NONE`. The message must carry exactly one token and a 128-byte payload. 3. **Vault and amount.** The payload's `target` must be allowlisted, which the adapter checks before it looks at the token. The amount must be greater than zero. 4. **Action.** The token is the vault's asset, so the request is a deposit, and `depositsEnabled` must be true. 5. **Adapter fee, return only.** When bit 0 of `deliveryAndRefund` asks for a return, the adapter deducts the fee keyed by the source chain's selector and the share token. The fee must be smaller than the amount. 6. **Vault deposit.** The adapter deposits the rest with itself as the receiver, so it is the depositor and the first owner of the new shares. If the vault's `deposit` reverts, the request fails. 7. **Output.** The vault must mint more than zero shares and at least `minimumOut`. 8. **Delivery.** In both branches, the beneficiary's upper 12 bytes must be zero. For local delivery, the adapter transfers the shares on the hub chain. For a return, it quotes the return-leg CCIP fee, pays it from its own native gas token balance, and sends the shares to the source chain in a second CCIP message. The adapter's checks, the vault call, and the delivery all run in one transaction on the hub chain. A request that passes them ends with `MessageSucceeded`. If any check or call fails, the adapter rolls back the vault call, the fee, and the outbound send, keeps the inbound asset, and records a failed message. The [CCIP Explorer](https://ccip.chain.link) still shows Success, as [Failures and recovery](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#why-the-ccip-explorer-shows-success) explains. ## Redeem flow A redemption turns shares sent from the source chain into the vault's asset. It follows the deposit flow with these differences: - **The user sends shares.** The vault's share token must be a [CCIP cross-chain token (CCT)](/ccip/concepts/cross-chain-token/overview) on the lane. A typical setup uses a Lock & Release pool on the hub chain, because only the vault may mint shares, and a Burn & Mint pool on the source chain. The [Lock & Mint tutorial](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry) shows that setup. - **The adapter calls `redeem`.** The token is the vault address, so the adapter routes the request as a redemption, which requires `redeemsEnabled`. It redeems the shares as both owner and receiver. - **The adapter fee comes from the output.** When returning, the adapter deducts the fee from the redeemed assets instead of from the input. - **`minimumOut` is in asset units.** The adapter compares it with the redeemed assets after the fee. - **The output is the asset.** The adapter transfers it to the beneficiary on the hub chain or sends it back to the source chain. ## Delivery options Bit 0 of `deliveryAndRefund` chooses where the output goes. The following table compares the two options. | | Local delivery | Return to the source chain | | -------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------- | | Where the output goes | The beneficiary on the hub chain | The beneficiary on the source chain the message came from | | Adapter fee | None | Charged in asset units | | CCIP fee for delivery | None, because there is no second message | Paid by the adapter from its native gas token balance on the hub chain | | Tokens that must be CCIP-enabled for deposit | The asset | The asset and the share token | | Tokens that must be CCIP-enabled for redeem | The share token | The share token and the asset | | Extra latency | None | One more CCIP message, from the hub chain to the source chain | | Event on the hub chain | `LocalTokenDelivered` | `MessageSent`, with the return message's CCIP message ID | A return goes only to the chain the request came from, never to a third chain. The return leg is a separate, token-only CCIP message with empty `data` and a gas limit of 0. The adapter builds it itself and never copies extra arguments from the user's message. Track it on the CCIP Explorer with the message ID from `MessageSent`. ## Fees A request can involve up to four fees. The [CCIP billing page](/ccip/concepts/fees-and-billing) explains how CCIP prices a message. | Fee | Who pays | Paid in | When | Controlled by | | ----------------------------- | --------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | CCIP fee for the user message | The sender, on the source chain | Native gas token or LINK | Every request | CCIP | | Adapter fee | The user, from the deposited asset or the redeemed asset | The asset's smallest units | Only when the output returns to the source chain | Set by `FEE_SETTER_ROLE` with `setAssetFee` or `setAssetFees`; withdrawn by `FEE_COLLECTOR_ROLE` with `withdrawFee` | | Return-leg CCIP fee | The adapter, from its native gas token balance on the hub chain | Native gas token | Every return to the source chain | CCIP | | Refund CCIP fee | Whoever calls the cross-chain refund, through `msg.value` | Native gas token | Only for a [cross-chain refund](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#cross-chain-refund) of a failed message | CCIP; the adapter returns any excess `msg.value` to the caller | ### Adapter fee The adapter fee is a flat amount, stored in `assetFees`. The adapter charges the fee on both deposits and redemptions whose output returns to the source chain, and it takes the fee from the deposited asset before a deposit or from the redeemed asset after a redemption. The fee is keyed by the source chain selector and the token that goes back: the share token for a deposit return, the asset for a redeem return. The amount is in the asset's smallest units even when shares go back. The adapter reads the fee when the message executes on the hub chain, so a fee change made while a message is in flight applies to that message. If the fee is equal to or larger than the deposit amount or the redeemed assets, the request fails. Fees accrue per asset in `collectedFees`, and the fee collector withdraws them with `withdrawFee`. ### Return-leg CCIP fee The adapter quotes the return-leg fee from the CCIP Router at send time and pays it from its own native gas token balance, which the vault team funds by sending the native gas token to the adapter. If the balance is below the quote, the send reverts and the request becomes a failed message. Collected adapter fees are a separate balance. The adapter never converts them into the native gas token. ## Slippage protection `minimumOut` is the smallest output the beneficiary accepts. It is measured in output units after the adapter fee: shares for a deposit, asset for a redemption. The adapter checks it after the vault call and before delivery. An output of zero always fails, even when `minimumOut` is 0. An output below `minimumOut` fails the request, which becomes a failed message that the user can [refund or recover](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery). To choose a value, call [`preview`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#preview) on the adapter on the hub chain. Pass the inbound token (the asset for a deposit, the share token for a redemption), the vault, the amount, the return flag, and the source chain selector, which the [CCIP Directory](/ccip/directory) lists. Pass the selector for local delivery too. For a non-zero amount, `preview` reverts when the selector is not configured. `preview` applies the same routing and adapter fee as a real request and asks the vault's `previewDeposit` or `previewRedeem` for the result. For some requests that would fail, such as one where the adapter fee consumes the amount, `preview` returns 0 instead of reverting. For others, such as one that names a vault that is not allowlisted, it reverts. The `preview` reference lists each condition in the order the function checks it. The vault's exchange rate can move between the send and the execution on the hub chain, a gap that includes the lane's CCIP latency. Subtract a tolerance for that movement from the `preview` result and use the remainder as `minimumOut`. [Build the payload](/solutions/cross-chain-vault-adapter/guides/deposit-from-source-chain#build-the-payload) uses a 1% tolerance. `minimumOut` compares the amounts the vault reports. It does not protect against fee-on-transfer or rebasing tokens, or against donation or share inflation effects on the vault. [Limitations](/solutions/cross-chain-vault-adapter/reference/limitations#vault-and-token-compatibility) lists which vaults and tokens the adapter supports. ## CCIP 2.0 lanes A lane's version is the version of the CCIP OnRamp and OffRamp contracts that serve it, and the [CCIP Directory](/ccip/directory) shows it. The adapter implements the CCIP 2.0 receiver interface, [`IAny2EVMMessageReceiverV2`](/ccip/evm/api-reference/v2.0.0/i-any2-evm-message-receiver-v2). On a CCIP 2.0 lane, it controls which messages it accepts and how its own return legs are encoded. ### Inbound settings The admin sets two values per source chain: - **CCV policy:** `setCCVsConfig` sets the required CCVs, the optional CCVs, and how many optional CCVs must attest. An empty configuration means the lane's default verifiers. - **Allowed finality:** `setInboundFinality` sets which finality modes the adapter accepts. The default, `0x00000000`, accepts only messages that wait for finality, so CCIP rejects [faster-than-finality](/ccip/concepts/execution-latency/ftf-dapps) messages to the adapter. The CCIP 2.0 OffRamp reads both values through `getCCVsAndFinalityConfig` before it calls `ccipReceive`. A message that lacks the required CCV attestations or requests a finality the adapter does not allow fails at the CCIP level, before the adapter runs. The adapter records no failed message in that case. Recovery is described in [Failures the adapter can't catch](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#failures-the-adapter-cant-catch). CCIP 1.6 lanes ignore both inbound settings. ### Outbound settings Return legs and cross-chain refunds are messages that the adapter sends, so the admin sets their format per destination chain: - **Return format:** `setEvmReturnLaneFormat` sets `GENERIC_EXTRA_ARGS_V3_BASIC` for a CCIP 2.0 lane. For a CCIP 1.6 lane, leave the format unset or set `LEGACY_EXTRA_ARGS_V2`. The adapter then sends `GenericExtraArgsV2` with a gas limit of 0 and out-of-order execution allowed. - **Requested finality:** `setEvmReturnRequestedFinality` sets the finality for each destination and returned token. It accepts a non-zero value only after the destination's format is V3. `0x00000000` waits for finality and is the default. `0x00010000` waits for the `safe` tag. `0x00000001` to `0x0000FFFF` waits for that number of blocks. The token pools on the lane must allow the requested finality. Do not set the V3 format on a CCIP 1.6 lane. The CCIP 1.6 FeeQuoter accepts only the V1 and V2 extra arguments tags and reverts with `InvalidExtraArgsTag` for any other. The adapter quotes the fee before every send, so return requests to that chain become failed messages and cross-chain refunds to it revert. The following table lists the CCIP settings for an EVM source chain connected to the hub chain by CCIP 2.0 lanes. | Setting | Function | Value on a CCIP 2.0 lane | | -------------------- | ------------------------------- | --------------------------------------------------------------------- | | Allowed source chain | `setChainType` | The source chain's selector, chain type `EVM` | | Inbound verifiers | `setCCVsConfig` | Empty lists to use the lane's default verifiers | | Inbound finality | `setInboundFinality` | `0x00000000` (default) to accept only messages that wait for finality | | Return format | `setEvmReturnLaneFormat` | `GENERIC_EXTRA_ARGS_V3_BASIC` | | Return finality | `setEvmReturnRequestedFinality` | `0x00000000` (default) per returned token, or a value the pools allow | The factory can set the chain type at deployment, but none of the other four settings, so the admin sets them after deployment. For an example that sets the return format with `pnpm ccip:configure`, see [Set the return format and fund the adapter](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter#fund-the-adapter). ## Where to go next - [Failures and recovery](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery): what happens when a request fails and how the tokens come back. - [Limitations](/solutions/cross-chain-vault-adapter/reference/limitations): vault and token compatibility, operational limits, and when funds can get stuck. - [Adapter contract](/solutions/cross-chain-vault-adapter/reference/adapter-contract): every function, event, and error of `CrossChainERC4626Adapter`. - [Deploy the vault and set up its share token](/solutions/cross-chain-vault-adapter/guides/deploy-the-vault) and [Deploy the adapter](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter): the tutorials for the vault team. - [Deposit from a source chain](/solutions/cross-chain-vault-adapter/guides/deposit-from-source-chain) and [Redeem from a source chain](/solutions/cross-chain-vault-adapter/guides/redeem-from-source-chain): the tutorials that send a deposit and a redemption through the adapter as a user on Arbitrum Sepolia. ## Disclaimer Please note, this repo contains community examples only — these are not Chainlink products or services and are not supported or maintained by Chainlink. This code represents an example of using a Chainlink product or service, and is intended for demonstration and educational purposes only. It is provided “AS IS” and “AS AVAILABLE” without warranties of any kind, may not have been audited, and may omit checks or error handling. Each party intending to use this example code does so entirely at their own risk and must perform its own audits, security and code review, key management, and testing before any production deployment and ensure the operation and performance of such code matches expectations. Neither Chainlink Labs nor the Chainlink Foundation deploys, operates, monitors, maintains or endorses any deployment of this code. Note that this is not a Chainlink product, feature or service, and there are no commitments made with respect to the code, including compatibility with future Chainlink releases. You should not rely on this code without first conducting your own technical, engineering, and security review. This code is also outside the scope of any Chainlink bug bounty programs. Neither Chainlink Labs, the Chainlink Foundation, nor Chainlink node operators are responsible for outcomes due to errors in this example or how it is deployed or operated, or liable for any resulting claims or damages. Use of the Chainlink Network is subject to the Chainlink Foundation [Terms of Service](https://chain.link/terms), which provides important information and disclosures. By using this code, you acknowledge and agree to these terms. --- # Failures and recovery Source: https://docs.chain.link/solutions/cross-chain-vault-adapter/overview/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](#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](/solutions/cross-chain-vault-adapter/reference/adapter-contract#errors) 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 `BASIC` and 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. > **CAUTION: Disabling processing is not a pause** > > Calling `setProcessingEnabled(false, false)`, disabling a vault with `setTargetEnabled`, or setting a chain's type to > `NONE` does not stop CCIP from delivering messages. Messages already in flight, and messages sent afterward, still > arrive and become failed messages that someone must refund or recover. ## Why the CCIP Explorer shows Success The adapter catches the error, so `ccipReceive` returns normally and CCIP execution succeeds. The [CCIP Explorer](https://ccip.chain.link) 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)` or `MessageFailed(messageId)`. - The adapter's [`messageErrorCode`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#state-variables) value for the message ID: `0` is `NONE`, `1` is `BASIC` (failed, tokens waiting to be recovered), `2` is `RESOLVED`. 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: ```bash 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](/solutions/cross-chain-vault-adapter/guides/deposit-from-source-chain#check-the-result) runs these checks on a real deposit and follows its return leg. ## Cross-chain refund [`refundFailedMessage`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#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.sender` on 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. If `msg.value` does not cover the fees actually paid, the call reverts with `InsufficientRecoveryFee` or `InsufficientNativeBalance`. The adapter returns any excess to the caller in the same transaction. The refund reverts with `RefundFailed` if the caller cannot receive the native gas token. - **Result:** The state becomes `RESOLVED`. The adapter emits `MessageRefunded` and a `MessageSent` event 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)](/ccip/concepts/cross-chain-token/overview) 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: 1. [`checkRefundEligibility(messageId)`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#checkrefundeligibility) returns `canRefund`, the recipient, the token, the amount, and a fee estimate. It returns `canRefund = false` when the message is not in the `BASIC` state, 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". 2. [`estimateRefundFee(messageId)`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#estimaterefundfee) 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. 3. `refundFailedMessage(messageId)`, sent with a `msg.value` of 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`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#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 `localRefundAddress` in bits 1 to 160 of the payload's `deliveryAndRefund` field, set when the message is sent. See [What a user sends](/solutions/cross-chain-vault-adapter/overview/how-it-works#what-a-user-sends). It cannot be added after the message is sent. If it is zero, the call reverts with `NoLocalRefundAddress`. 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 `RESOLVED` and the adapter emits `MessageRecoveredLocally`. A local recovery takes two calls on the hub chain, each with the failed message's CCIP message ID: 1. [`checkLocalRecoveryEligibility(messageId)`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#checklocalrecoveryeligibility) returns `canRecover`, the local refund address, the token, and the amount. It never reverts, and it does not check who the caller is. 2. `recoverFailedMessageLocally(messageId)`, sent from the local refund address with no `msg.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](/ccip/concepts/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)](/ccip/concepts/ccvs/overview), 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](/ccip/concepts/execution-latency/ftf-dapps) 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 > **DANGER: Sender mistakes with no recovery path** > > Two sender mistakes have no recovery path, and can result in a permanent loss of funds: > > 1. A message with empty data and a gas limit of 0. 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. > 2. A message with a malformed payload. Neither the adapter nor CCIP manual execution can return these tokens to the user, so the tokens are permanently lost. 1. A payload can be exactly 128 bytes and still be malformed when its `target` word has non-zero upper 12 bytes, so it is not a valid ABI-encoded address. `processMessage` reverts when it decodes the payload, and the adapter catches that revert. While recording the failure, the adapter decodes the same bytes again to read the local refund address. That second decode runs outside the `try/catch`, so `ccipReceive` itself reverts. CCIP then reverts the destination-side token release together with the receiver call and marks the message failed. 2. Manual execution can raise the gas limit but cannot change the message, so every attempt reverts, with `NoStateProgressMade` on a CCIP 2.0 lane or `ExecutionError` on a CCIP 1.6 lane. The tokens are never released or minted on the hub chain. They stay locked in, or burned by, the token pool on the source chain. 3. Standard ABI encoders (Solidity `abi.encode`, ethers, viem, `cast`) cannot produce this payload; only hand-built encoders can. > **Important:** Before a final signature, build the payload with a standard ABI encoder and check it against the [payload checklist](/solutions/cross-chain-vault-adapter/overview/how-it-works#what-a-user-sends). Also set a non-zero local refund address. Without one, a failed request whose cross-chain refund is also impossible leaves the tokens in the adapter permanently. ## 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: ![Message lifecycle: CCIP delivers the message, and processing either succeeds (state NONE, MessageSucceeded) or fails (state BASIC, MessageFailed). From BASIC, a cross-chain refund (anyone calls, caller pays CCIP fees) or a local recovery (local refund address only, no fee) moves the message to RESOLVED. A dashed branch shows a CCIP-level failure, where ccipReceive reverts or the message is rejected before the adapter records anything, followed by manual execution after the cause is fixed, which is not possible for a malformed payload.](/images/solutions/cross-chain-vault-adapter/message-lifecycle.svg) 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`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#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](/solutions/cross-chain-vault-adapter/reference/limitations): what the adapter does not do, and every case where funds can get stuck. - [Adapter contract recovery functions](/solutions/cross-chain-vault-adapter/reference/adapter-contract#recovery-functions): signatures, return values, and errors for refunds and local recovery. - [CCIP message lifecycle](/ccip/concepts/message-lifecycle): how CCIP verifies and executes a message before the adapter runs. ## Disclaimer Please note, this repo contains community examples only — these are not Chainlink products or services and are not supported or maintained by Chainlink. This code represents an example of using a Chainlink product or service, and is intended for demonstration and educational purposes only. It is provided “AS IS” and “AS AVAILABLE” without warranties of any kind, may not have been audited, and may omit checks or error handling. Each party intending to use this example code does so entirely at their own risk and must perform its own audits, security and code review, key management, and testing before any production deployment and ensure the operation and performance of such code matches expectations. Neither Chainlink Labs nor the Chainlink Foundation deploys, operates, monitors, maintains or endorses any deployment of this code. Note that this is not a Chainlink product, feature or service, and there are no commitments made with respect to the code, including compatibility with future Chainlink releases. You should not rely on this code without first conducting your own technical, engineering, and security review. This code is also outside the scope of any Chainlink bug bounty programs. Neither Chainlink Labs, the Chainlink Foundation, nor Chainlink node operators are responsible for outcomes due to errors in this example or how it is deployed or operated, or liable for any resulting claims or damages. Use of the Chainlink Network is subject to the Chainlink Foundation [Terms of Service](https://chain.link/terms), which provides important information and disclosures. By using this code, you acknowledge and agree to these terms. --- # Deploy the vault and set up its share token Source: https://docs.chain.link/solutions/cross-chain-vault-adapter/guides/deploy-the-vault In this tutorial, we will: 1. Deploy `ExampleERC4626Vault` on Ethereum Sepolia and verify it on Etherscan. 2. Deposit 1 CCIP-BnM and receive 1 vCCIP-BnM. 3. Transform `vCCIP-BnM` into a cross-chain token using the CCIP protocol under the [Lock & Mint mechanism](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry), by deploying: - A Lock & Release pool on Ethereum Sepolia, and, - A Burn & Mint pool on Arbitrum Sepolia. 4. Return to this page, transfer 0.1 vCCIP-BnM to Arbitrum Sepolia, and confirm the balances on both chains. > An [ERC-4626](https://eips.ethereum.org/EIPS/eip-4626) vault holds one asset and gives depositors shares in return. The vault contract is itself an ERC-20 token contract, and the shares are its tokens, so the vault's address is also the share token's address. In this tutorial, the asset is CCIP-BnM and the share token is Vault CCIP-BnM (vCCIP-BnM). Two adapter features need the share token to be a [CCIP cross-chain token (CCT)](/ccip/concepts/cross-chain-token/overview) on the Arbitrum Sepolia lane: returning shares to Arbitrum Sepolia, and redeeming shares from Arbitrum Sepolia. 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. Here, Ethereum Sepolia is the hub chain and Arbitrum Sepolia is the source chain. ## Before you begin You need: - [Foundry](https://book.getfoundry.sh/getting-started/installation), - [Node.js](https://nodejs.org/en/download) 22.10 or later, - pnpm 10, and, - A Foundry keystore for your wallet, which deploys and owns the vault and the share token contracts in this tutorial. Use the same keystore in every tutorial of this solution. > **CAUTION: Before you continue** > > 1. **Ethereum Sepolia ETH**: required for Ethereum Sepolia transactions. Get it from the [Chainlink > faucet](https://faucets.chain.link). > 2. **Arbitrum Sepolia ETH**: required for Arbitrum Sepolia transactions in the `Lock & Mint` tutorial. Get it from > the same faucet. > 3. **CCIP-BnM on Ethereum Sepolia**: the vault's asset. [Get CCIP-BnM on Ethereum > Sepolia](https://docs.chain.link/ccip/getting-started/test-tokens) from this page. > 4. **Etherscan API key**: required for `--verify`, which publishes the source code of the contracts you deploy on > Etherscan. One [Etherscan API key](https://etherscan.io/myapikey) works on Ethereum Sepolia and Arbitrum Sepolia. > **TIP: Bringing your own vault?** > > Skip only what you already have: > > - Complete [Set up your development environment](#set-up-your-development-environment) and [Set your shell variables](#set-your-shell-variables) in every case. Deploy the adapter uses the same repository, keystore, and variables. > - If you have an ERC-4626 vault on Ethereum Sepolia that meets the [requirements](/solutions/cross-chain-vault-adapter#requirements): > - skip **Get CCIP-BnM on Ethereum Sepolia** and **Deploy the vault**, and run `export VAULT=`. > - Set `ETHEREUM_SEPOLIA_CCIP_BNM` to your vault's asset, and scale the amounts on this page to its decimals. > - If you already hold at least 0.1 of your vault's shares on Ethereum Sepolia: > - skip **Deposit 1 CCIP-BnM**. > - If your share token already has pools on Ethereum Sepolia and Arbitrum Sepolia that are configured for each other and registered in the TokenAdminRegistry on both chains: > - skip [Set up the share token in CCIP](#set-up-the-share-token-in-ccip) and run `export ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN=`. > In [Confirm the balances](#confirm-the-balances), skip the lockbox check, and expect your balances to move by 0.1 instead of matching the values shown. > - If you set up your own share token now, it must implement `getCCIPAdmin()` or OpenZeppelin's `owner()`, so the Lock & Mint tutorial's Claim Admin Role step can identify its administrator. > - If you have already transferred shares from Ethereum Sepolia to Arbitrum Sepolia, skip [Test the share token](#test-the-share-token). ### 1. Set up your development environment 1. Install [ccip-cli](https://docs.chain.link/ccip/tools/cli) and check its version: ```bash filename="Terminal" npm install -g @chainlink/ccip-cli ccip-cli --version ``` This tutorial was tested with ccip-cli 1.14.0. 2. Check that Foundry is installed: ```bash filename="Terminal" forge --version ``` 3. Enable pnpm through Corepack. The repository pins `pnpm@10.11.1`: ```bash filename="Terminal" corepack enable ``` 4. Clone the adapter repository, install its dependencies, and build it: [Cross-chain vault adapters](https://github.com/smartcontractkit/cross-chain-vault-adapters) The adapter, its factory, the example vault, and their deploy scripts. ```bash filename="Terminal" git clone https://github.com/smartcontractkit/cross-chain-vault-adapters.git cd cross-chain-vault-adapters pnpm install forge build ``` 5. Create an encrypted Foundry keystore. Skip this step if `cast wallet list` already shows the keystore you want to use: ```bash filename="Terminal" cast wallet import your_keystore_name --interactive ``` 6. Create a `.env` file from the example: ```bash filename="Terminal" cp .env.example .env ``` 7. In `.env`, fill in these variables: ```bash filename=".env" ETHEREUM_SEPOLIA_RPC_URL=your_ethereum_sepolia_rpc_url ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL=your_arbitrum_sepolia_rpc_url ETHERSCAN_API_KEY=your_etherscan_api_key KEYSTORE_NAME=your_keystore_name ``` Leave the other variables as they are. Run `source .env` to load the environment variables into your shell. ### 2. Set your shell variables The `cast` and `ccip-cli` commands in these tutorials read the following variables from your shell. Use one terminal for this tutorial and the next one, and run these commands again if you open a new terminal: > You can fetch the keystore names available inside your Foundry keystore by using the `cast wallet list` command. ```bash filename="Terminal" export MY_ADDRESS=$(cast wallet address --account $KEYSTORE_NAME) export ETHEREUM_SEPOLIA_ROUTER=0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59 export ETHEREUM_SEPOLIA_CCIP_BNM=0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05 ``` > `(cast wallet address --account $KEYSTORE_NAME)` sets the public address of the keystore specified by `KEYSTORE_NAME` into your terminal. You will need it. The RPC variable names match the Lock & Mint tutorial, so the same values work in both repositories. The router and CCIP-BnM addresses come from the [Ethereum Sepolia page of the CCIP Directory](/ccip/directory/testnet/chain/ethereum-testnet-sepolia). ### 3. Get CCIP-BnM on Ethereum Sepolia You have two ways to get CCIP-BnM tokens on Ethereum Sepolia to follow through the tutorial: 1. Mint CCIP-BnM on Ethereum Sepolia from [CCIP test tokens](/ccip/getting-started/test-tokens). 2. Use the following cast command to call `drip` on the CCIP-BnM token: ```bash filename="Terminal" cast send $ETHEREUM_SEPOLIA_CCIP_BNM "drip(address)" $MY_ADDRESS \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \ --account $KEYSTORE_NAME ``` Check that your wallet holds at least 1 CCIP-BnM. CCIP-BnM has 18 decimals, so 1 CCIP-BnM is `1000000000000000000`: ```bash filename="Terminal" cast call $ETHEREUM_SEPOLIA_CCIP_BNM "balanceOf(address)(uint256)" $MY_ADDRESS \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` Example Output: ```bash filename="Terminal" 1000000000000000000 [1e18] ``` ## Deploy and fund the vault ### 1. Deploy the vault `ExampleERC4626Vault` combines OpenZeppelin's audited `ERC4626` and `Ownable` contracts with a constructor and no other code. This implementation is deliberately minimal to serve as a tool to follow through the tutorials. Please use your own, audited implementation in production. `Ownable` gives the vault an `owner()` function. The Lock & Mint tutorial's Claim Admin Role step uses it to register you as the share token's CCIP administrator. > **CAUTION: Use your own vault in production** > > `ExampleERC4626Vault` exists for these tutorials. The OpenZeppelin contracts it inherits are audited, but the vault > itself is not, and it has no yield strategy. [ExampleERC4626Vault.sol](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/src/ccip/dev/ExampleERC4626Vault.sol) View the vault contract on GitHub. [DeployExampleERC4626Vault.s.sol](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/script/ccip/DeployExampleERC4626Vault.s.sol) View the deployment script on GitHub. 1. From the root of the `cross-chain-vault-adapters` repository, deploy the vault with CCIP-BnM as its asset, and verify it on Etherscan: ```bash filename="Terminal" VAULT_ASSET=$ETHEREUM_SEPOLIA_CCIP_BNM \ VAULT_NAME="Vault CCIP-BnM" \ VAULT_SYMBOL=vCCIP-BnM \ pnpm ccip:deploy-example-vault \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \ --account $KEYSTORE_NAME \ --broadcast \ --verify \ --delay 20 \ --retries 12 \ --gas-estimate-multiplier 700 ``` Etherscan needs time to index a new contract. `--delay 20 --retries 12` makes Forge retry verification up to 12 times, 20 seconds apart. The script makes your wallet the vault owner. To make another address the owner, set `VAULT_OWNER`. Without `--broadcast`, the script only simulates the deployment. Your output should look something like this: ```bash filename="Terminal" == Return == vault: contract ExampleERC4626Vault 0xE4Ef963183C29401Ff6F67CC13B500dB1354cfB6 == Logs == ExampleERC4626Vault: 0xE4Ef963183C29401Ff6F67CC13B500dB1354cfB6 Asset: 0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05 Name / symbol: Vault CCIP-BnM vCCIP-BnM Decimals: 18 Owner: 0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994 ``` 2. Export the vault address from the `ExampleERC4626Vault` line: ```bash filename="Terminal" export VAULT= ``` 3. Check that your wallet owns the vault. The output must equal `$MY_ADDRESS`: ```bash filename="Terminal" cast call $VAULT "owner()(address)" --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` ### 2. Deposit 1 CCIP-BnM A deposit moves CCIP-BnM into the vault and mints vCCIP-BnM to the receiver. Amounts in these commands use 18 decimals: `1000000000000000000` is 1 token. 1. Approve the vault to transfer 1 CCIP-BnM from your wallet: ```bash filename="Terminal" cast send $ETHEREUM_SEPOLIA_CCIP_BNM "approve(address,uint256)" $VAULT 1000000000000000000 \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \ --account $KEYSTORE_NAME ``` 2. Deposit 1 CCIP-BnM with your wallet as the receiver: ```bash filename="Terminal" cast send $VAULT "deposit(uint256,address)" 1000000000000000000 $MY_ADDRESS \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \ --account $KEYSTORE_NAME ``` Each `cast send` prints a receipt. Check that it shows `status 1 (success)`. 3. Check your vCCIP-BnM balance and the value of 1 vCCIP-BnM in CCIP-BnM: ```bash filename="Terminal" cast call $VAULT "balanceOf(address)(uint256)" $MY_ADDRESS --rpc-url $ETHEREUM_SEPOLIA_RPC_URL cast call $VAULT "convertToAssets(uint256)(uint256)" 1000000000000000000 --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` At this point: You hold 1 vCCIP-BnM, and 1 vCCIP-BnM converts to 1 CCIP-BnM. \ Anyone can change this rate by transferring CCIP-BnM directly to the vault. The amounts in the next tutorials assume nobody has. ## Set up the share token in CCIP The [Lock & Mint tutorial](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry) makes a token a cross-chain token with a Lock & Release pool on Ethereum Sepolia and a Burn & Mint pool on Arbitrum Sepolia. On Ethereum Sepolia, only the vault can mint vCCIP-BnM, so the pool locks and releases existing shares. On Arbitrum Sepolia, you deploy a new vCCIP-BnM token that its pool mints and burns. ### 1. Prepare the Lock & Mint repository The Lock & Mint tutorial uses its own repository, [docs-cct-foundry](https://github.com/smartcontractkit/docs-cct-foundry). Work in the same terminal, so `VAULT` and the other shell variables stay set. 1. Move to the directory that contains `cross-chain-vault-adapters`: ```bash filename="Terminal" cd .. ``` 2. Follow the Lock & Mint tutorial's [Set up your development environment](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry#set-up-your-development-environment) step. 3. From the `docs-cct-foundry` directory, after `source .env`, point the Lock & Mint scripts at the vault as the Ethereum Sepolia token: ```bash filename="Terminal" export ETHEREUM_SEPOLIA_TOKEN=$VAULT ``` > You need to follow the rest of the tutorial as it is. You will essentially set up your Vault's token on Ethereum Sepolia as the canonical deployment and enable it to have cross-chain functionality via CCIP under the Lock & Mint mechanism. ### 2. Run the Lock & Mint tutorial for vCCIP-BnM Follow the Lock & Mint tutorial from [Deploy Tokens](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry#deploy-tokens), with these changes: | Lock & Mint step | What to do for vCCIP-BnM | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Deploy Tokens](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry#deploy-tokens) | Skip the Ethereum Sepolia deployment, because the vault is the token there. Deploy only the Arbitrum Sepolia token with the command below, then export its address. Keep `ETHEREUM_SEPOLIA_TOKEN=$VAULT`. | | [Deploy Token Pools](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry#deploy-token-pools) | Run every step: the ERC20LockBox, the Lock & Release pool, and the lockbox authorization on Ethereum Sepolia, and the Burn & Mint pool on Arbitrum Sepolia. | | [Claim Admin Role](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry#claim-admin-role) | Run it on both chains with `CCIP_ADMIN_ADDRESS=$MY_ADDRESS`. On Ethereum Sepolia, the script reads the admin from the vault's `owner()`. | | [Accept Admin Role](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry#accept-admin-role) | Run it on both chains. | | [Configure Token Pools](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry#configure-token-pools) | Run it on both chains with the tutorial's rate limits. | | [Configure Faster Than Finality](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry#optional-configure-fast-finality-for-the-token-pool) | Skip it. These tutorials use default finality. | | [Activate the Token Pool](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry#activate-the-token-pool) | Run it on both chains. | | [Mint Tokens](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry#mint-tokens) | Skip it. Only deposits mint vCCIP-BnM on Ethereum Sepolia, and you already hold 1 vCCIP-BnM. | | [Transfer Tokens Across Networks](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry#transfer-tokens-across-networks) | Skip it. You transfer vCCIP-BnM in [Test the share token](#test-the-share-token). | | [Hand Off Token Administration](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry#optional-hand-off-token-administration-after-enablement) | Skip it. | Deploy the Arbitrum Sepolia token with the vault's name, symbol, and decimals, and no pre-mint. Because the decimals match, 0.1 vCCIP-BnM on Ethereum Sepolia arrives as 0.1 vCCIP-BnM on Arbitrum Sepolia: ```bash filename="Terminal" TOKEN_NAME="Vault CCIP-BnM" \ TOKEN_SYMBOL=vCCIP-BnM \ TOKEN_DECIMALS=18 \ TOKEN_PRE_MINT=0 \ forge script \ script/deploy/DeployToken.s.sol \ --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL \ --account $KEYSTORE_NAME \ --broadcast \ --verify ``` Under Token Parameters, the output must show `Vault CCIP-BnM`, `vCCIP-BnM`, `18`, and `Pre-mint: 0`. Export the token's address: ```bash filename="Terminal" export ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN= ``` When Activate the Token Pool succeeds on both chains, return to this page. ### 3. Check the registration 1. Once your token pool is activated on both chains, go back to the adapter repository: ```bash filename="Terminal" cd ../cross-chain-vault-adapters ``` 2. Read the pool that the TokenAdminRegistry on each chain holds for the share token: ```bash filename="Terminal" cast call 0x95F29FEE11c5C55d26cCcf1DB6772DE953B37B82 "getPool(address)(address)" $VAULT \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL cast call 0x8126bE56454B628a88C17849B9ED99dd5a11Bd2f "getPool(address)(address)" $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN \ --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL ``` 1. The first address must equal `$ETHEREUM_SEPOLIA_TOKEN_POOL`, and, 2. The second must equal `$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN_POOL`. A zero address means Activate the Token Pool did not run on that chain. ## Test the share token ### 1. Transfer vCCIP-BnM to Arbitrum Sepolia Send 0.1 vCCIP-BnM from your wallet on Ethereum Sepolia to the same address on Arbitrum Sepolia. The Lock & Release pool locks the shares in the lockbox, and the Burn & Mint pool mints 0.1 vCCIP-BnM on Arbitrum Sepolia. `ccip-cli` approves the router for the exact amount. Without `--fee-token`, it pays the CCIP fee in Ethereum Sepolia ETH: ```bash filename="Terminal" ccip-cli send \ --source ethereum-testnet-sepolia \ --router $ETHEREUM_SEPOLIA_ROUTER \ --dest ethereum-testnet-sepolia-arbitrum-1 \ --transfer-tokens $VAULT=0.1 \ --receiver $MY_ADDRESS \ --wallet foundry:$KEYSTORE_NAME \ --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \ --rpc "$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL" ``` `--transfer-tokens` takes the amount in whole tokens, so `0.1` is 0.1 vCCIP-BnM. Your output should look something like this: ```bash filename="Terminal" ✔ Enter password for Foundry keystore 'PRIVATE_KEY' Estimated gasLimit for sender = 0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994 : 0 Fee: 257831635941687n = 0.000257831635941687 ETH 🚀 Sending message to 0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994 @ ethereum-testnet-sepolia-arbitrum-1 , tx => 0x621f4cd397751b87bd8edb6bb90afb4e9bcfd274ccc228cb54e4d66d8bde787d , messageId => 0x81ef360a4235f6333e7725e38c171e018f14f73c19559aefb19ff70ab6ca3c9e Lane: ┌────────────────┬──────────────────────────────────────────────┬───────────────────────────────────────┐ │ (index) │ source │ dest │ ├────────────────┼──────────────────────────────────────────────┼───────────────────────────────────────┤ │ name │ 'ethereum-testnet-sepolia' │ 'ethereum-testnet-sepolia-arbitrum-1' │ │ chainId │ 11155111 │ 421614 │ │ chainSelector │ 16015286601757825753n │ 3478487238524512106n │ │ onRamp/version │ '0x8dcf17f298c881A547D91ca4aA3C2AD7568C6777' │ '2.0.0' │ └────────────────┴──────────────────────────────────────────────┴───────────────────────────────────────┘ Request (source): ┌────────────────────────────────┬──────────────────────────────────────────────────────────────────────┐ │ (index) │ Values │ ├────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤ │ messageId │ '0x81ef360a4235f6333e7725e38c171e018f14f73c19559aefb19ff70ab6ca3c9e' │ │ origin │ '0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994' │ │ sender │ '0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994' │ │ receiver │ '0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994' │ ``` Export the message ID from the output: ```bash filename="Terminal" export MESSAGE_ID= ``` ### 2. Track the transfer Open the CCIP Explorer link from the output, or follow the message from the terminal with [`ccip-cli show`](https://docs.chain.link/ccip/tools/cli/show). `--wait` keeps the command running until the message executes on Arbitrum Sepolia: ```bash filename="Terminal" ccip-cli show $MESSAGE_ID \ --wait \ --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \ --rpc "$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL" ``` CCIP delivers the message after the source transaction reaches finality on Ethereum Sepolia, which typically takes about 20 minutes. ### 3. Confirm the balances 1. Check your vCCIP-BnM balance on Ethereum Sepolia: ```bash filename="Terminal" cast call $VAULT "balanceOf(address)(uint256)" $MY_ADDRESS --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` ```bash filename="Terminal" 900000000000000000 [9e17] ``` If you set up the share token with the Lock & Mint tutorial in this terminal, `LOCK_BOX` holds the lockbox address from its Deploy ERC20LockBox step. Check that the lockbox holds the 0.1 vCCIP-BnM you sent: ```bash filename="Terminal" cast call $VAULT "balanceOf(address)(uint256)" $LOCK_BOX --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` ```bash filename="Terminal" 100000000000000000 [1e17] ``` 2. Check your vCCIP-BnM balance on Arbitrum Sepolia: ```bash filename="Terminal" cast call $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN "balanceOf(address)(uint256)" $MY_ADDRESS \ --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL ``` ```bash filename="Terminal" 100000000000000000 [1e17] ``` Your wallet holds 0.9 vCCIP-BnM on Ethereum Sepolia and 0.1 vCCIP-BnM on Arbitrum Sepolia. The lockbox holds the 0.1 vCCIP-BnM that backs the Arbitrum Sepolia supply. The vault still holds 1 CCIP-BnM against 1 vCCIP-BnM of total supply, so shares still convert 1:1. ## Where to go next - [Deploy the adapter](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter): deploy a Cross-Chain Vault Adapter for this vault on Ethereum Sepolia, with Arbitrum Sepolia as a source chain. Stay in this terminal and in the `cross-chain-vault-adapters` directory. - [How it works](/solutions/cross-chain-vault-adapter/overview/how-it-works): how the adapter uses the vault and its share token for deposits and redemptions. ## Disclaimer Please note, this repo contains community examples only — these are not Chainlink products or services and are not supported or maintained by Chainlink. This code represents an example of using a Chainlink product or service, and is intended for demonstration and educational purposes only. It is provided “AS IS” and “AS AVAILABLE” without warranties of any kind, may not have been audited, and may omit checks or error handling. Each party intending to use this example code does so entirely at their own risk and must perform its own audits, security and code review, key management, and testing before any production deployment and ensure the operation and performance of such code matches expectations. Neither Chainlink Labs nor the Chainlink Foundation deploys, operates, monitors, maintains or endorses any deployment of this code. Note that this is not a Chainlink product, feature or service, and there are no commitments made with respect to the code, including compatibility with future Chainlink releases. You should not rely on this code without first conducting your own technical, engineering, and security review. This code is also outside the scope of any Chainlink bug bounty programs. Neither Chainlink Labs, the Chainlink Foundation, nor Chainlink node operators are responsible for outcomes due to errors in this example or how it is deployed or operated, or liable for any resulting claims or damages. Use of the Chainlink Network is subject to the Chainlink Foundation [Terms of Service](https://chain.link/terms), which provides important information and disclosures. By using this code, you acknowledge and agree to these terms. --- # Deploy the adapter Source: https://docs.chain.link/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter 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. Here, Ethereum Sepolia is the hub chain and Arbitrum Sepolia is the source chain. In this tutorial, we will: 1. Deploy an adapter through the factory at `0x12A0f45738F3DDA931C4D3ECd1b267355e587921` on Ethereum Sepolia, with your vault allowlisted and Arbitrum Sepolia accepted as a source chain. 2. Set an adapter fee of 0.001 CCIP-BnM on output that returns to Arbitrum Sepolia. 3. Set the return format for the CCIP 2.0 lane to Arbitrum Sepolia, and fund the adapter with 0.01 Ethereum Sepolia ETH for return-leg CCIP fees. 4. Check the configuration and preview a deposit and a redemption. > This tutorial continues from [Deploy the vault and set up its share token](/solutions/cross-chain-vault-adapter/guides/deploy-the-vault), which deploys Vault CCIP-BnM (vCCIP-BnM), an ERC-4626 vault on Ethereum Sepolia whose asset is CCIP-BnM. You can use your own vault implementation instead. ## Before you begin You need: - The `cross-chain-vault-adapters` repository, cloned and built, with all the required variables being set in the `.env` file. [Set up your development environment](/solutions/cross-chain-vault-adapter/guides/deploy-the-vault#set-up-your-development-environment) lists the tools and the steps. - A Foundry keystore for your wallet, which deploys the adapter and receives its three roles in this tutorial. Use the same keystore in every tutorial of this solution. - An ERC-4626 vault on Ethereum Sepolia. [Deploy the vault and set up its share token](/solutions/cross-chain-vault-adapter/guides/deploy-the-vault) deploys one. > **CAUTION: Before you continue** > > - **Ethereum Sepolia ETH**: required for Ethereum Sepolia transactions. Get it from the [Chainlink faucet](https://faucets.chain.link). This tutorial also sends 0.01 Ethereum Sepolia ETH to the adapter for return-leg CCIP fees. > - **Etherscan API key**: required for `--verify`, which publishes the source code of the contracts you deploy on Etherscan. Get one from [Etherscan](https://etherscan.io/myapikey). > **TIP: Bringing your own vault or adapter?** > > Skip only what you already have: > > - If you completed [Deploy the vault and set up its share token](/solutions/cross-chain-vault-adapter/guides/deploy-the-vault) in this terminal, your shell variables are set. Continue with [Configure the deployment](#configure-the-deployment). > - If you bring your own vault, it must meet the [requirements](/solutions/cross-chain-vault-adapter#requirements). > Set `VAULT` to its address, and set `ETHEREUM_SEPOLIA_CCIP_BNM` to your vault's asset, as returned by `asset()`. > Use that asset address wherever `.env` below shows the CCIP-BnM address. > Adapter fees are in the asset's smallest units, so scale `1000000000000000` (0.001 of an 18-decimal token) to your asset's decimals. > - If your vault's share token is not a cross-chain token yet, you can still deploy the adapter. > But until and unless you enable the share token on the lane, users can deposit only with delivery on Ethereum Sepolia: the adapter cannot send shares to Arbitrum Sepolia, and users there hold no shares to redeem. > - If you already deployed an adapter for your vault, run `export ADAPTER=` and continue with [Set the return format and fund the adapter](#fund-the-adapter). ### 1. Set your shell variables Run every command in this tutorial from the root of the `cross-chain-vault-adapters` repository. Forge loads `.env` from there for every `forge script` run. The `cast` commands in this tutorial read the following variables from your shell. If you opened a new terminal since the previous tutorial, change to the repository root and set them again: ```bash filename="Terminal" export KEYSTORE_NAME=your_keystore_name export ETHEREUM_SEPOLIA_RPC_URL=your_ethereum_sepolia_rpc_url export ETHEREUM_SEPOLIA_CCIP_BNM=0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05 export VAULT= ``` Set `MY_ADDRESS` to your keystore's address. `cast` asks for the keystore password: ```bash filename="Terminal" export MY_ADDRESS=$(cast wallet address --account $KEYSTORE_NAME) echo $MY_ADDRESS ``` Confirm that `VAULT` is your vault and that its asset is CCIP-BnM: ```bash filename="Terminal" cast call $VAULT "asset()(address)" --rpc-url $ETHEREUM_SEPOLIA_RPC_URL cast call $VAULT "symbol()(string)" --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` The output shows the CCIP-BnM address and the share symbol: ```bash filename="Terminal" 0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05 "vCCIP-BnM" ``` ## Deploy and configure the adapter ### 1. Configure the deployment The `pnpm ccip:deploy` script reads its configuration from `.env`, calls `deploy` on the factory, and writes a deployment record. [DeployAndActivateCrossChainERC4626Adapter.s.sol](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/script/ccip/DeployAndActivateCrossChainERC4626Adapter.s.sol) View the deployment script on GitHub. Print your wallet address and the vault address, which you paste into `.env`: ```bash filename="Terminal" echo $MY_ADDRESS $VAULT ``` Open `.env` and set the following variables: ```bash filename=".env" # CCIP Router on Ethereum Sepolia ROUTER=0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59 # Role holders. This tutorial gives all three roles to your wallet. DEFAULT_ADMIN= FEE_SETTER= FEE_COLLECTOR= # Accept deposits and redemptions DEPOSITS_ENABLED=true REDEEMS_ENABLED=true # The vault the adapter may call. TARGET_ENABLED defaults to true. VAULT_TARGET= # Accept messages from Arbitrum Sepolia (chain type 1 = EVM) CHAIN_SELECTORS=3478487238524512106 CHAIN_TYPES=1 # Adapter fee of 0.001 CCIP-BnM on output returned to Arbitrum Sepolia. # Row 1: deposits that return shares. Row 2: redemptions that return CCIP-BnM. FEE_DESTINATION_CHAIN_SELECTORS=3478487238524512106,3478487238524512106 FEE_BRIDGED_TOKENS=,0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05 FEE_VALUES=1000000000000000,1000000000000000 # Deploy through the existing factory on Ethereum Sepolia FACTORY=0x12A0f45738F3DDA931C4D3ECd1b267355e587921 ``` What each setting does: - `ROUTER` is represents the CCIP Router address on Ethereum Sepolia, which you can verify from the [CCIP Directory](/ccip/directory/testnet/chain/ethereum-testnet-sepolia). The adapter stores it as the immutable `ROUTER`, so a wrong router means deploying a new adapter. - `DEFAULT_ADMIN`, `FEE_SETTER`, and `FEE_COLLECTOR` receive the adapter's three roles. The factory then renounces its own roles. - See [Role handoff](/solutions/cross-chain-vault-adapter/reference/factory-contract#role-handoff). - `VAULT_TARGET` connects the adapter to your vault: the factory adds the vault to the adapter's allowlist, `enabledTargets`. Each user payload names the vault it targets, and the adapter rejects vaults that are not allowlisted. - `CHAIN_SELECTORS` and `CHAIN_TYPES` accept messages from Arbitrum Sepolia, whose selector number on the CCIP protocol is `3478487238524512106`. The adapter uses the same entry to encode return legs to that chain. - `FEE_DESTINATION_CHAIN_SELECTORS`, `FEE_BRIDGED_TOKENS`, and `FEE_VALUES` are parallel lists, one fee row per position. - The `FACTORY` variable being set makes the script deploy through an existing factory instead of deploying a new one. > **CAUTION: Use a multisig for the admin role in production** > > `DEFAULT_ADMIN_ROLE` controls source chains, the vault allowlist, the processing switches, and the adapter's native > gas token balance. Role changes take effect in one step, and if the last admin renounces the role, nobody can change > the configuration again. ### 2. Simulate the deployment We recommend simulating the deployment against Ethereum Sepolia, before actually executing on-chain. Without the `--broadcast` flag, Forge sends no transaction, and `--sender` runs the simulation from your wallet address: ```bash filename="Terminal" pnpm ccip:deploy --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --sender $MY_ADDRESS ``` Before it simulates, the script performs a few checks: 1. The `ROUTER` and `FACTORY` contracts have code, 2. `FACTORY` should be an instance of the `CrossChainERC4626AdapterFactory 1.0.0`, 3. `VAULT_TARGET` answers `asset()`, 4. That the chain type should be a valid number. A successful simulation prints the configuration it would deploy (illustrative): ```bash filename="Terminal" == Logs == Dry run: nothing was deployed. Re-run with --broadcast to deploy and write the deployment record. CrossChainERC4626AdapterFactory: 0x12A0f45738F3DDA931C4D3ECd1b267355e587921 CrossChainERC4626Adapter: 0x... Router: 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59 Default admin: 0x... Fee setter / collector: 0x... 0x... Vault target (enabled): 0x... true Deposits / redeems enabled: true true Chain configs / fee rows: 1 2 ... SIMULATION COMPLETE. To broadcast these transactions, add --broadcast and wallet configuration(s) to the previous command. See forge script --help for more. ``` Check that `Chain configs / fee rows` shows `1 2`. The adapter address in the simulation matches the real one only if nobody else deploys through the factory before you broadcast, so read the real address from the deployment record after the broadcast. ### 3. Broadcast the deployment Finally, we can deploy the adapter. Forge asks for your keystore password, sends one transaction that calls `deploy` on the factory, and verifies the new adapter on Etherscan: ```bash filename="Terminal" pnpm ccip:deploy --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account $KEYSTORE_NAME --broadcast --verify --delay 20 --retries 12 ``` > **NOTE: About verification** > > - `--verify` reads `ETHERSCAN_API_KEY` from `.env`. Your output should look something like this: ```bash filename="Terminal" == Logs == CrossChainERC4626AdapterFactory: 0x12A0f45738F3DDA931C4D3ECd1b267355e587921 CrossChainERC4626Adapter: 0x... ... Deployment record: deployments/ccip/11155111.json Next steps: 1. Fund the adapter with native gas for return legs (ADAPTER= FUND_NATIVE_WEI=...). 2. For CCIP v2 lanes, set return-lane formats and finality with pnpm ccip:configure. 3. Verify the result with pnpm ccip:check. ... ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. ... All (1) contracts were verified! ``` With `--broadcast`, the script writes a deployment record to `deployments/ccip/11155111.json`, where `11155111` is the Ethereum Sepolia chain ID. You can print it using this command: ```bash filename="Terminal" cat deployments/ccip/11155111.json ``` The record lists the factory, the adapter, the router, the role holders, the vault, and the processing switches (illustrative): ```json filename="deployments/ccip/11155111.json" { "adapter": "0x...", "chainId": 11155111, "defaultAdmin": "0x...", "depositsEnabled": true, "factory": "0x12A0f45738F3DDA931C4D3ECd1b267355e587921", "feeCollector": "0x...", "feeSetter": "0x...", "redeemsEnabled": true, "router": "0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59", "vaultTarget": "0x..." } ``` Export the adapter address for the next steps. `pnpm ccip:configure` and `pnpm ccip:check` also read `ADAPTER` from your shell: ```bash filename="Terminal" export ADAPTER= echo $ADAPTER ``` Open `https://sepolia.etherscan.io/address/` and confirm that the **Contract** tab shows verified source code for `CrossChainERC4626Adapter`. ### 4. Set the return format and fund the adapter The adapter pays the CCIP fee for each return leg to Arbitrum Sepolia from its own Ethereum Sepolia ETH balance. With no balance, every request that returns output fails with `InsufficientNativeBalance` and becomes a failed message. [ConfigureCrossChainERC4626Adapter.s.sol](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/script/ccip/ConfigureCrossChainERC4626Adapter.s.sol) View the configuration script on GitHub. The lane from Ethereum Sepolia to Arbitrum Sepolia is a CCIP 2.0 lane, and we need to tell the adapter that, using the `RETURN_LANE_FORMATS` param. We can do that in one run, alongside funding the adapter with Ethereum Sepolia ETH. ```bash filename="Terminal" RETURN_LANE_SELECTORS=3478487238524512106 \ RETURN_LANE_FORMATS=2 \ FUND_NATIVE_WEI=10000000000000000 \ pnpm ccip:configure --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account $KEYSTORE_NAME --broadcast ``` - Before it sends anything, the script checks that `ADAPTER` reports `CrossChainERC4626Adapter 1.0.0`, so a mistyped address does not receive the Ethereum Sepolia ETH. - Setting the return format needs `DEFAULT_ADMIN_ROLE`. Funding needs no role: anyone can send Ethereum Sepolia ETH to the adapter. The script logs each setting it applied (illustrative): ```bash filename="Terminal" == Logs == Return lane format set: 3478487238524512106 2 Funded adapter (wei): 10000000000000000 ``` For what the return format and the other CCIP 2.0 settings control, see [CCIP 2.0 lanes](/solutions/cross-chain-vault-adapter/overview/how-it-works#ccip-20-lanes). ## Verify the adapter ### 1. Run the deployment check `pnpm ccip:check` reads the adapter and compares it with the values in `.env`: ```bash filename="Terminal" pnpm ccip:check --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` The report shows the switches, the adapter's Ethereum Sepolia ETH balance, the role holders, the vault, and the settings for Arbitrum Sepolia (illustrative): ```bash filename="Terminal" == Logs == Adapter: 0x... typeAndVersion: CrossChainERC4626Adapter 1.0.0 Router: 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59 Deposits enabled: true Redeems enabled: true Native balance: 10000000000000000 DEFAULT_ADMIN holds role: 0x... true FEE_SETTER holds role: 0x... true FEE_COLLECTOR holds role: 0x... true Vault target: 0x... true Vault asset: 0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05 Chain selector: 3478487238524512106 chain type: EVM return format: GenericExtraArgsV3 basic (CCIP v2 lane) inbound finality: 0x00000000 fee (share out): 1000000000000000 fee (asset out): 1000000000000000 Warnings: 0 ``` Make sure that the report ends with `Warnings: 0`. The script prints each problem on a `WARNING:` line but still exits successfully, so read the count instead of relying on the exit code. An inbound finality of `0x00000000` is the default: the adapter accepts only messages that wait for finality. ### 2. Read the configuration with cast Read the source chain, the vault allowlist, and the two fee rows directly from the adapter's public mappings: ```bash filename="Terminal" cast call $ADAPTER "chains(uint64)(uint8)" 3478487238524512106 --rpc-url $ETHEREUM_SEPOLIA_RPC_URL cast call $ADAPTER "enabledTargets(address)(bool)" $VAULT --rpc-url $ETHEREUM_SEPOLIA_RPC_URL cast call $ADAPTER "assetFees(uint64,address)(uint256)" 3478487238524512106 $VAULT --rpc-url $ETHEREUM_SEPOLIA_RPC_URL cast call $ADAPTER "assetFees(uint64,address)(uint256)" 3478487238524512106 $ETHEREUM_SEPOLIA_CCIP_BNM --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` The output has one line per call: ```bash filename="Terminal" 1 true 1000000000000000 [1e15] 1000000000000000 [1e15] ``` | Call | Value | Meaning | | ------------------------------------- | ------------------ | ---------------------------------------------------------------------------- | | `chains` | `1` | Arbitrum Sepolia is an accepted source chain of type EVM (`0` NONE, `2` SVM) | | `enabledTargets` | `true` | The adapter may call your vault | | `assetFees` with the vault address | `1000000000000000` | 0.001 CCIP-BnM fee on deposits whose shares return to Arbitrum Sepolia | | `assetFees` with the CCIP-BnM address | `1000000000000000` | 0.001 CCIP-BnM fee on redemptions whose CCIP-BnM returns to Arbitrum Sepolia | ### 3. Preview the adapter fee [`preview`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#preview) simulates a request with the same routing and adapter fee as a real message from Arbitrum Sepolia. Preview a deposit of 0.1 CCIP-BnM (`100000000000000000` wei) whose shares return to Arbitrum Sepolia: ```bash filename="Terminal" cast call $ADAPTER "preview(address,address,uint256,bool,uint64)(uint256)" \ $ETHEREUM_SEPOLIA_CCIP_BNM $VAULT 100000000000000000 true 3478487238524512106 \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` The adapter deducts the 0.001 CCIP-BnM fee before it deposits, so 0.099 CCIP-BnM enters the vault, and the 1:1 vault mints 0.099 vCCIP-BnM: ```bash filename="Terminal" 99000000000000000 [9.9e16] ``` Preview the same deposit with delivery on Ethereum Sepolia by passing `false`. The adapter charges no fee when output stays on Ethereum Sepolia: ```bash filename="Terminal" cast call $ADAPTER "preview(address,address,uint256,bool,uint64)(uint256)" \ $ETHEREUM_SEPOLIA_CCIP_BNM $VAULT 100000000000000000 false 3478487238524512106 \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` The output is the full 0.1 vCCIP-BnM: ```bash filename="Terminal" 100000000000000000 [1e17] ``` Preview a redemption of 0.1 vCCIP-BnM whose CCIP-BnM returns to Arbitrum Sepolia. For a redemption, the token is the vault address: ```bash filename="Terminal" cast call $ADAPTER "preview(address,address,uint256,bool,uint64)(uint256)" \ $VAULT $VAULT 100000000000000000 true 3478487238524512106 \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` The vault redeems 0.1 vCCIP-BnM for 0.1 CCIP-BnM, and the adapter deducts the 0.001 CCIP-BnM fee: ```bash filename="Terminal" 99000000000000000 [9.9e16] ``` A user sets `minimumOut` in the payload from this value minus a tolerance for exchange-rate movement. See [Slippage protection](/solutions/cross-chain-vault-adapter/overview/how-it-works#slippage-protection). > **NOTE: Where to go next** > > 1. [Deposit from a source chain](/solutions/cross-chain-vault-adapter/guides/deposit-from-source-chain): as a user on Arbitrum Sepolia, deposit 0.1 CCIP-BnM into the vault through this adapter and receive the shares on Arbitrum Sepolia. > 2. Stay in this terminal and in the `cross-chain-vault-adapters` directory. ## Disclaimer Please note, this repo contains community examples only — these are not Chainlink products or services and are not supported or maintained by Chainlink. This code represents an example of using a Chainlink product or service, and is intended for demonstration and educational purposes only. It is provided “AS IS” and “AS AVAILABLE” without warranties of any kind, may not have been audited, and may omit checks or error handling. Each party intending to use this example code does so entirely at their own risk and must perform its own audits, security and code review, key management, and testing before any production deployment and ensure the operation and performance of such code matches expectations. Neither Chainlink Labs nor the Chainlink Foundation deploys, operates, monitors, maintains or endorses any deployment of this code. Note that this is not a Chainlink product, feature or service, and there are no commitments made with respect to the code, including compatibility with future Chainlink releases. You should not rely on this code without first conducting your own technical, engineering, and security review. This code is also outside the scope of any Chainlink bug bounty programs. Neither Chainlink Labs, the Chainlink Foundation, nor Chainlink node operators are responsible for outcomes due to errors in this example or how it is deployed or operated, or liable for any resulting claims or damages. Use of the Chainlink Network is subject to the Chainlink Foundation [Terms of Service](https://chain.link/terms), which provides important information and disclosures. By using this code, you acknowledge and agree to these terms. --- # Deposit from a source chain Source: https://docs.chain.link/solutions/cross-chain-vault-adapter/guides/deposit-from-source-chain The steps we undertook in the previous two tutorials make it possible for us to work with the Cross-Chain Vault Adapter and perform cross-chain deposits seamlessly. On this page, your wallet acts as a user on Arbitrum Sepolia: \ It sends one CCIP message and deploys nothing. [Deposit flow](/solutions/cross-chain-vault-adapter/overview/how-it-works#deposit-flow) shows each step the adapter takes. > This tutorial continues from [Deploy the adapter](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter), which deploys an adapter for Vault CCIP-BnM (vCCIP-BnM) that accepts requests from Arbitrum Sepolia and charges a 0.001 CCIP-BnM adapter fee on output that returns there. With the adapter deployed and configured, you will be able to deposit into an ERC-4626 vault on Ethereum Sepolia from Arbitrum Sepolia with one CCIP message, and receive the vault's shares on Arbitrum Sepolia. 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. Here, Ethereum Sepolia is the hub chain and Arbitrum Sepolia is the source chain. The tutorial walks you through four main steps: 1. Get CCIP-BnM on Arbitrum Sepolia. 2. Preview a deposit of 0.1 CCIP-BnM and set a minimum output 1% below the preview. 3. Build the payload and send it with 0.1 CCIP-BnM from Arbitrum Sepolia to the adapter. 4. Confirm that the adapter deposited into the vault, find the return leg, and check that 0.099 vCCIP-BnM reached your wallet on Arbitrum Sepolia. ## Before you begin You need: - ccip-cli, Foundry, Node.js, and the `cross-chain-vault-adapters` repository, set up in [Set up your development environment](/solutions/cross-chain-vault-adapter/guides/deploy-the-vault#set-up-your-development-environment). - A Foundry keystore for your wallet, which acts as a user on Arbitrum Sepolia in this tutorial. Use the same keystore in every tutorial of this solution. - An ERC-4626 vault on Ethereum Sepolia whose share token is a CCIP cross-chain token between Ethereum Sepolia and Arbitrum Sepolia. [Deploy the vault and set up its share token](/solutions/cross-chain-vault-adapter/guides/deploy-the-vault) sets one up. - An adapter on Ethereum Sepolia for that vault that accepts requests from Arbitrum Sepolia and has deposits enabled. [Deploy the adapter](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter) deploys one. > **CAUTION: Before you continue** > > - **Arbitrum Sepolia ETH**: required for Arbitrum Sepolia transactions and for the CCIP fee of your request, about 0.0023 Arbitrum Sepolia ETH at the time of writing. Get it from the [Chainlink faucet](https://faucets.chain.link). > - **CCIP-BnM on Arbitrum Sepolia**: at least 0.1, the amount you deposit. [Get CCIP-BnM on Arbitrum Sepolia](#get-ccip-bnm-on-arbitrum-sepolia) shows how. > - **Ethereum Sepolia ETH in the adapter**: at least 0.001 Ethereum Sepolia ETH, which pays the return leg's CCIP fee. [Check the adapter](#check-the-adapter) reads the balance. Topping it up takes Ethereum Sepolia ETH from your wallet. > **TIP: Bringing your own vault or adapter?** > > Skip only what you already have: > > - If you ran both previous tutorials in this terminal, run only the first command block in [Set your shell variables](#set-your-shell-variables), then continue with [Check the adapter](#check-the-adapter). > - If you bring your own vault, adapter, or share token, set the variables in [Set your shell variables](#set-your-shell-variables) to your addresses. Set `ETHEREUM_SEPOLIA_CCIP_BNM` to your vault's asset on Ethereum Sepolia and `ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_CCIP_BNM` to the same token on Arbitrum Sepolia. In the `preview` call and in the payload builder's `AMOUNT`, scale `100000000000000000` (0.1 of an 18-decimal asset) to your asset's decimals. `ccip-cli` takes whole tokens, so `=0.1` stays the same. > - Your adapter must accept Arbitrum Sepolia as a source chain, allowlist your vault, allow deposits, and hold Ethereum Sepolia ETH. Your share token must be a cross-chain token between Ethereum Sepolia and Arbitrum Sepolia. > - If you already hold at least 0.1 CCIP-BnM on Arbitrum Sepolia, skip `drip` in [Get CCIP-BnM on Arbitrum Sepolia](#get-ccip-bnm-on-arbitrum-sepolia). If your starting balances differ from the values shown, compare the changes instead. If you did not set up the share token with the Lock & Mint tutorial, skip the lockbox check in [Confirm the balances](#confirm-the-balances). ### 1. Set your shell variables Run the commands in this tutorial from the root of the `cross-chain-vault-adapters` repository, in the terminal you used for the previous tutorials. Set the Arbitrum Sepolia router and CCIP-BnM addresses, which this tutorial adds. > Refer to the [Arbitrum Sepolia page of the CCIP Directory](/ccip/directory/testnet/chain/ethereum-testnet-sepolia-arbitrum-1) for the latest addresses: ```bash filename="Terminal" export ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_ROUTER=0x2a9C5afB0d0e4BAb2BCdaE109EC4b0c4Be15a165 export ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_CCIP_BNM=0xA8C0c11bf64AF62CDCA6f93D3769B88BdD7cb93D ``` If you have a fresh terminal that may not have the context of all the values required, these are the values you will need to set up: ```bash filename="Terminal" export KEYSTORE_NAME=your_keystore_name export ETHEREUM_SEPOLIA_RPC_URL=your_ethereum_sepolia_rpc_url export ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL=your_arbitrum_sepolia_rpc_url export ETHEREUM_SEPOLIA_CCIP_BNM=0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05 export VAULT= export ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN= export LOCK_BOX= export ADAPTER= ``` - `ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN` is the vCCIP-BnM token you deployed on Arbitrum Sepolia. - `LOCK_BOX` is the lockbox from the Lock & Mint tutorial's Deploy `ERC20LockBox` step. - `ADAPTER` is the adapter address you exported in [Broadcast the deployment](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter#broadcast-the-deployment), also stored as `adapter` in `deployments/ccip/11155111.json`. If you deployed your adapter another way, use that address instead. In a new terminal, also set `MY_ADDRESS` to your keystore's address. `cast` asks for the keystore password: ```bash filename="Terminal" export MY_ADDRESS=$(cast wallet address --account $KEYSTORE_NAME) echo $MY_ADDRESS ``` ### 2. Check the adapter 1. Check that `ADAPTER` points to an adapter. Your request sends 0.1 CCIP-BnM to this address: ```bash filename="Terminal" cast call $ADAPTER "typeAndVersion()(string)" --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` ```bash filename="Terminal" "CrossChainERC4626Adapter 1.0.0" ``` 2. Check the adapter's Ethereum Sepolia ETH balance. The adapter pays the return leg's CCIP fee from it: ```bash filename="Terminal" cast balance $ADAPTER --ether --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` [Set the return format and fund the adapter](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter#fund-the-adapter) sent the adapter 0.01 Ethereum Sepolia ETH: ```bash filename="Terminal" 0.010000000000000000 ``` Make sure to have enough Ethereum Sepolia ETH in the adapter. With too little, the adapter cannot send the return leg, and your request becomes a failed message. If the balance is below 0.001 Ethereum Sepolia ETH, send the adapter more Ethereum Sepolia ETH. Any account can fund it: ```bash filename="Terminal" cast send $ADAPTER --value 0.01ether --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account $KEYSTORE_NAME ``` ### 3. Get CCIP-BnM on Arbitrum Sepolia Mint CCIP-BnM on Arbitrum Sepolia from [CCIP test tokens](/ccip/getting-started/test-tokens#evm-chains). Each mint calls `drip` on the token and sends 1 CCIP-BnM to your wallet. You can also call `drip` from the terminal: ```bash filename="Terminal" cast send $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_CCIP_BNM "drip(address)" $MY_ADDRESS \ --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL \ --account $KEYSTORE_NAME ``` Check your vCCIP-BnM and CCIP-BnM balances on Arbitrum Sepolia: ```bash filename="Terminal" cast call $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN "balanceOf(address)(uint256)" $MY_ADDRESS \ --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL cast call $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_CCIP_BNM "balanceOf(address)(uint256)" $MY_ADDRESS \ --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL ``` ```bash filename="Terminal" 100000000000000000 [1e17] 1000000000000000000 [1e18] ``` You hold 0.1 vCCIP-BnM from the test transfer in Deploy the vault and set up its share token, and 1 CCIP-BnM from `drip`. The balances later on this page start from these values. ## Build the request ### 1. Preview the deposit `minimumOut` in the payload is the smallest amount of vCCIP-BnM, after the adapter fee, that you accept for your `0.1 CCIP-BnM`. \ You're essentially defining the maximum slippage you're willing to tolerate. The adapter checks it on Ethereum Sepolia when your request executes, about 20 minutes after you send it. By then, the vault's exchange rate or the adapter fee, which the adapter reads at execution, can have changed. If the output is below `minimumOut`, the adapter deposits nothing and sends nothing back: it keeps your 0.1 CCIP-BnM and records a failed message, which you then [refund or recover](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery). 1. Preview the deposit with the adapter's [`preview`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#preview) function, and store the result: ```bash filename="Terminal" export DEPOSIT_PREVIEW=$(cast call $ADAPTER "preview(address,address,uint256,bool,uint64)(uint256)" \ $ETHEREUM_SEPOLIA_CCIP_BNM $VAULT 100000000000000000 true 3478487238524512106 \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL | awk '{print $1}') echo $DEPOSIT_PREVIEW ``` - The first argument is the token as the adapter receives it. CCIP delivers your CCIP-BnM from Arbitrum Sepolia as CCIP-BnM on Ethereum Sepolia, the vault's asset, so you pass `$ETHEREUM_SEPOLIA_CCIP_BNM`, not the Arbitrum Sepolia address. - `100000000000000000` is 0.1 CCIP-BnM, `true` returns the output to the source chain, and `3478487238524512106` is the Arbitrum Sepolia chain selector. - `awk` keeps the number and drops the `[9.9e16]` that `cast` appends. ```bash filename="Terminal" 99000000000000000 ``` The adapter deducts its 0.001 CCIP-BnM fee and deposits 0.099 CCIP-BnM. The 1:1 vault mints 0.099 vCCIP-BnM. Do not continue if the output is `0` or `cast` prints an error. A smaller tolerance rejects more requests when the rate moves, and a larger one accepts a worse rate. The vault in these tutorials has no yield strategy, so its exchange rate changes only if someone transfers CCIP-BnM to it directly. [Build the payload](#build-the-payload) applies your tolerance to this preview to set `minimumOut`. ### 2. Build the payload The payload is the `data` of your CCIP message: four 32-byte fields that tell the adapter what to do. For this deposit, they hold: | Field | Value | Meaning | | ------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `target` | `$VAULT` | The vault to deposit into. It must be on the adapter's allowlist. | | `beneficiary` | `$MY_ADDRESS` | The account that receives the vCCIP-BnM on Arbitrum Sepolia. | | `minimumOut` | the preview minus 1% | The smallest acceptable output, in vCCIP-BnM. The builder subtracts the `TOLERANCE_BPS` tolerance from the preview. | | `deliveryAndRefund` | your address and `true` | Bit 0 set to `1` returns the vCCIP-BnM to Arbitrum Sepolia. Bits 1 to 160 hold `$MY_ADDRESS` as the local refund address, which can take back the CCIP-BnM on Ethereum Sepolia if the request fails. The builder packs both. | The payload does not name the action. The token you send decides it: CCIP-BnM, the vault's asset, makes this request a deposit. 1. Build the payload with the repository's `pnpm ccip:build-payload` script. It calls your adapter's `preview` with your vault, token, amount, source chain and return flag, subtracts the tolerance from the preview to set `minimumOut`, packs the return flag and your local refund address into `deliveryAndRefund`, and ABI-encodes the four fields: ```bash filename="Terminal" export DEPOSIT_PAYLOAD=$(REQUEST_TOKEN=$ETHEREUM_SEPOLIA_CCIP_BNM \ REQUEST_ADAPTER=$ADAPTER \ REQUEST_TARGET=$VAULT \ AMOUNT=100000000000000000 \ SOURCE_CHAIN_SELECTOR=3478487238524512106 \ TOLERANCE_BPS=100 \ MY_ADDRESS=$MY_ADDRESS \ pnpm ccip:build-payload --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \ | tee /dev/stderr \ | awk '/PAYLOAD:/ {print $2}') ``` - `REQUEST_TOKEN` is the token as the adapter receives it. CCIP delivers your CCIP-BnM from Arbitrum Sepolia as CCIP-BnM on Ethereum Sepolia, the vault's asset, so you pass `$ETHEREUM_SEPOLIA_CCIP_BNM`, not the Arbitrum Sepolia address. - `TOLERANCE_BPS=100` is a 1% tolerance: the script sets `minimumOut` to `preview * (10_000 - 100) / 10_000`. Use `50` for a 0.5% tolerance. - `tee /dev/stderr` shows the script's output in your terminal, and `awk` stores the value of its `PAYLOAD:` line in `DEPOSIT_PAYLOAD`. Your output should look something like this: ```bash filename="Terminal" == Logs == Adapter: 0x3f73Cc1547F2606A20535Bd6620622107059a463 Token: 0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05 Vault target: 0xE4Ef963183C29401Ff6F67CC13B500dB1354cfB6 Amount: 100000000000000000 Return to source: true Source selector: 3478487238524512106 Tolerance (bps): 100 Preview: 99000000000000000 Minimum out: 98010000000000000 Beneficiary: 0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994 Local refund: 0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994 Delivery and refund: 664579538352300329284056227591069458130104865577 Byte length: 128 PAYLOAD: 0x000000000000000000000000e4ef963183c29401ff6f67cc13b500db1354cfb60000000000000000000000003a34637a41ab08519d30fdb65344aba8e9b2e994000000000000000000000000000000000000000000000000015c3393626fa0000000000000000000000000007468c6f4835610a33a61fb6ca6895751d365d329 ``` 2. Check the output before you continue. The vault target must be your `VAULT`, the preview must be the value from the previous accordion, the minimum out must be `98010000000000000`, the beneficiary and the local refund must be your address, the return-to-source flag must be `true`, and the byte length must be `128`. The last line is the value of `DEPOSIT_PAYLOAD`. Do not continue if the preview is `0` or `forge` prints an error. > **CAUTION: The payload is final once sent** > > Nobody can change the payload after your message leaves Arbitrum Sepolia. If a check does not match, build the payload > again before you send it. The [payload > checklist](/solutions/cross-chain-vault-adapter/overview/how-it-works#what-a-user-sends) lists every rule. ## Send the request ### 1. Send the deposit Send 0.1 CCIP-BnM and the payload from Arbitrum Sepolia to the adapter on Ethereum Sepolia. `ccip-cli` first approves the Arbitrum Sepolia router for exactly 0.1 CCIP-BnM: ```bash filename="Terminal" ccip-cli send \ --source ethereum-testnet-sepolia-arbitrum-1 \ --router $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_ROUTER \ --dest ethereum-testnet-sepolia \ --transfer-tokens $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_CCIP_BNM=0.1 \ --receiver $ADAPTER \ --data $DEPOSIT_PAYLOAD \ --gas-limit 1500000 \ --wallet foundry:$KEYSTORE_NAME \ --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \ --rpc "$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL" ``` - `--transfer-tokens` takes the amount in whole tokens, so `0.1` is 0.1 CCIP-BnM. The Burn & Mint pool on Arbitrum Sepolia burns it, and CCIP delivers it on Ethereum Sepolia as CCIP-BnM, the vault's asset, so the adapter treats the request as a deposit. - `--receiver $ADAPTER` sends the message to the adapter. Your wallet receives the vCCIP-BnM because it is the payload's `beneficiary`. - `--data` sends the 128-byte payload as is, because it starts with `0x`. - `--gas-limit 1500000` is the gas available on Ethereum Sepolia for everything the adapter does when it receives the message: its checks, the vault deposit, and the CCIP send of the return leg. CCIP bills you for the limit, not for the gas used. If the limit is too low, `ccipReceive` can run out of gas before the adapter records the failure, and CCIP marks the message as failed. The message then needs [manual execution](/ccip/concepts/manual-execution) with a higher limit. - Without `--fee-token`, `ccip-cli` pays the CCIP fee in Arbitrum Sepolia ETH and prints it on the `Fee:` line. Your output should look something like this: ```bash filename="Terminal" Fee: 589279412957535n = 0.000589279412957535 ETH 🚀 Sending message to 0x3f73Cc1547F2606A20535Bd6620622107059a463 @ ethereum-testnet-sepolia , tx => 0xb1b36ceb18282e6758719439fa68677bfb464bd19edc43ef91a536be659908de , messageId => 0xc066972bedcc79e7a0072a1a259aa7f2362401b10e88333ba1fb20a63d12dd46 Lane: ┌────────────────┬──────────────────────────────────────────────┬────────────────────────────┐ │ (index) │ source │ dest │ ├────────────────┼──────────────────────────────────────────────┼────────────────────────────┤ │ name │ 'ethereum-testnet-sepolia-arbitrum-1' │ 'ethereum-testnet-sepolia' │ │ chainId │ 421614 │ 11155111 │ │ chainSelector │ 3478487238524512106n │ 16015286601757825753n │ │ onRamp/version │ '0x6B9a7cF69F90Ae2659bfe3069fba5Aa308A48cC4' │ '2.0.0' │ └────────────────┴──────────────────────────────────────────────┴────────────────────────────┘ Request (source): ┌────────────────────────────────┬──────────────────────────────────────────────────────────────────────┐ │ (index) │ Values │ ├────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤ │ messageId │ '0xc066972bedcc79e7a0072a1a259aa7f2362401b10e88333ba1fb20a63d12dd46' │ │ origin │ '0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994' │ │ sender │ '0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994' │ │ receiver │ '0x3f73Cc1547F2606A20535Bd6620622107059a463' │ ``` Export the message ID from the output: ```bash filename="Terminal" export DEPOSIT_MESSAGE_ID= ``` ### 2. Track the request Open the CCIP Explorer link from the output, or follow the request from the terminal with `ccip-cli show`. `--wait` keeps the command running until the request executes on Ethereum Sepolia: ```bash filename="Terminal" ccip-cli show $DEPOSIT_MESSAGE_ID \ --wait \ --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \ --rpc "$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL" ``` CCIP delivers the request after your transaction reaches finality on Arbitrum Sepolia, which typically takes about 20 minutes. Your output should look something like this: ```bash filename="Terminal" ----------------OTHER LOGS---------------------------- CCIP Explorer: https://ccip.chain.link/msg/0xc066972bedcc79e7a0072a1a259aa7f2362401b10e88333ba1fb20a63d12dd46 [SENT] Waiting for source chain finalization... [SOURCE_FINALIZED] Source chain finalized [SOURCE_FINALIZED] Waiting for commit on destination chain... [SUCCESS] Message executed on destination chain ┌─────────────────┬──────────────────────────────────────────────────────────────────────┐ │ (index) │ Values │ ├─────────────────┼──────────────────────────────────────────────────────────────────────┤ │ state │ '✅ success' │ │ origin │ '0x44ec57f311544F371c7f81B167195c78a81F2496' │ │ contract │ '0xc6A246A9AcdAaE651708706494720F79C3E5d0A1' │ │ transactionHash │ '0x316d8252f6b40cae8edffa137871975b26df7d4bdf8a26ca1093b35944d352b1' │ │ logIndex │ 159 │ │ blockNumber │ 11862362 │ │ timestamp │ '2026-10-07 10:23:00 (19m22s after request)' │ └─────────────────┴──────────────────────────────────────────────────────────────────────┘ ``` ## Check the result In the transaction that executed your request, the CCIP-BnM pool on Ethereum Sepolia minted 0.1 CCIP-BnM to the adapter. The token the adapter received is the vault's asset, so the adapter treated the request as a deposit. In the same transaction, the adapter: 1. Kept the 0.001 CCIP-BnM adapter fee. 2. Deposited the remaining 0.099 CCIP-BnM into the vault, which in turn minted 0.099 vCCIP-BnM back to the adapter. 3. The adapter then checked the 0.099 vCCIP-BnM against your `minimumOut` of 0.09801 vCCIP-BnM. 4. Sent the 0.099 vCCIP-BnM to your wallet on Arbitrum Sepolia in the return leg, and paid the CCIP fee from its Ethereum Sepolia ETH. 5. The Lock & Release pool locked the shares in the lockbox on Ethereum Sepolia, while you received the corresponding vCCIP-BnM on Arbitrum Sepolia. ### 1. Confirm the vault deposit `[SUCCESS]` means that CCIP delivered your request to the adapter. It does not mean that the vault deposit happened. The adapter catches the errors that occur while it processes a request, so CCIP execution also succeeds for a request that the adapter rejected. [Why the CCIP Explorer shows Success](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#why-the-ccip-explorer-shows-success) explains this. Check the adapter's own result on Ethereum Sepolia. 1. From the `[SUCCESS]` table, export the hash and the block number of the Ethereum Sepolia transaction that executed your request. Do not use the `transactionHash` and `blockNumber` rows of the request table above it: they belong to your send transaction on Arbitrum Sepolia. If you run `ccip-cli show` again later, the request table also shows the execution values as `receiptTransactionHash` and `receiptBlockNumber`: ```bash filename="Terminal" export DEPOSIT_EXECUTION_TX= export DEPOSIT_EXECUTION_BLOCK= ``` 2. Look for the adapter's `MessageSucceeded` event for your message ID in that block: ```bash filename="Terminal" cast logs --from-block $DEPOSIT_EXECUTION_BLOCK --to-block $DEPOSIT_EXECUTION_BLOCK --address $ADAPTER \ "MessageSucceeded(bytes32 indexed messageId)" $DEPOSIT_MESSAGE_ID \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` Your output should look something like this: ```bash filename="Terminal" - address: 0x3f73Cc1547F2606A20535Bd6620622107059a463 blockHash: 0x8ee780be56adc65327d949a9e12eb1dd56ca52eadbcaa0d824d4d0f3de7c1d97 blockNumber: 11862362 data: 0x logIndex: 157 removed: false topics: [ 0xdf6958669026659bac75ba986685e11a7d271284989f565f2802522663e9a70f 0xc066972bedcc79e7a0072a1a259aa7f2362401b10e88333ba1fb20a63d12dd46 ] transactionHash: 0x316d8252f6b40cae8edffa137871975b26df7d4bdf8a26ca1093b35944d352b1 transactionIndex: 89 ``` The adapter emits `MessageSucceeded` only after it completes the whole request. In the same transaction, it also emits `TargetProcessed` for the vault deposit and `MessageSent` for the return leg. When a request fails, the adapter rolls back those events and emits `MessageFailed` instead. 3. Read the adapter's state for your message ID: ```bash filename="Terminal" cast call $ADAPTER "messageErrorCode(bytes32)(uint8)" $DEPOSIT_MESSAGE_ID --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` Example Output: ```bash filename="Terminal" 0 ``` 1. `0` (`NONE`) confirms success only together with `MessageSucceeded`, because a message that has not arrived yet also reads `0`. 2. `1` (`BASIC`) means that the adapter rejected the request and holds your 0.1 CCIP-BnM. 3. In that case, [What counts as a failure](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#what-counts-as-a-failure) lists the possible causes. [Failures and recovery](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery) shows how to refund the CCIP-BnM to Arbitrum Sepolia, or recover it on Ethereum Sepolia with your wallet, the payload's local refund address. ### 2. Find the return leg The adapter sent your vCCIP-BnM in the return leg, a second CCIP message with its own message ID, from the transaction that executed your request. Find the ID with the CCIP API, then confirm it on-chain. 1. Search the CCIP API for the latest message that the adapter sent to your wallet. > `--no-interactive` makes the command exit after it prints the result, instead of opening a menu: ```bash filename="Terminal" ccip-cli search messages $ADAPTER --receiver $MY_ADDRESS --limit 1 --no-interactive ``` Your output should look something like this: ```bash filename="Terminal" ┌───────────┬──────────────────────────────────────────────────────────────────────┐ │ (index) │ Values │ ├───────────┼──────────────────────────────────────────────────────────────────────┤ │ messageId │ '0xe0977e67438c5116084634de44f79db5feb995582b3f7645abc380a82c6dc8c0' │ │ status │ 'SENT' │ │ source │ 'ethereum-testnet-sepolia [11155111]' │ │ dest │ 'ethereum-testnet-sepolia-arbitrum-1 [421614]' │ │ sender │ '0x3f73Cc1547F2606A20535Bd6620622107059a463' │ │ receiver │ '0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994' │ │ txHash │ '0x316d8252f6b40cae8edffa137871975b26df7d4bdf8a26ca1093b35944d352b1' │ │ timestamp │ '2026-10-07T10:23:00.000Z' │ └───────────┴──────────────────────────────────────────────────────────────────────┘ 1 message(s) found. ``` The `txHash` must equal `$DEPOSIT_EXECUTION_TX`. If the command finds no message, or `txHash` differs, the CCIP API has not indexed the return leg yet, so run the command again after a minute. Export the message ID: ```bash filename="Terminal" export DEPOSIT_RETURN_MESSAGE_ID= ``` 2. Read the same ID from the adapter's `MessageSent` event. This way needs only your Ethereum Sepolia RPC, not the CCIP API: ```bash filename="Terminal" cast logs --from-block $DEPOSIT_EXECUTION_BLOCK --to-block $DEPOSIT_EXECUTION_BLOCK --address $ADAPTER \ "MessageSent(bytes32 indexed messageId,uint64 indexed destinationChainSelector,uint8 indexed chainType,bytes32 beneficiary,address token,uint256 amount,uint256 fee)" \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` Your output should look something like this: ```bash filename="Terminal" - address: 0x3f73Cc1547F2606A20535Bd6620622107059a463 blockHash: 0x8ee780be56adc65327d949a9e12eb1dd56ca52eadbcaa0d824d4d0f3de7c1d97 blockNumber: 11862362 data: 0x0000000000000000000000003a34637a41ab08519d30fdb65344aba8e9b2e994000000000000000000000000e4ef963183c29401ff6f67cc13b500db1354cfb6000000000000000000000000000000000000000000000000015fb7f9b8c380000000000000000000000000000000000000000000000000000000ea7f1b127937 logIndex: 156 removed: false topics: [ 0xb6bd76b2fc831b8df833914080c10bae0c3951d7550d7c24a72e3d2fa8b7ff63 0xe0977e67438c5116084634de44f79db5feb995582b3f7645abc380a82c6dc8c0 0x000000000000000000000000000000000000000000000000304611b6affba76a 0x0000000000000000000000000000000000000000000000000000000000000001 ] transactionHash: 0x316d8252f6b40cae8edffa137871975b26df7d4bdf8a26ca1093b35944d352b1 transactionIndex: 89 ``` The second entry of `topics` is the return leg's message ID and must equal `$DEPOSIT_RETURN_MESSAGE_ID`. The third is the Arbitrum Sepolia chain selector, and the fourth is the chain type, `1` for EVM. 3. Decode the log's `data` field. It holds the beneficiary, the token, the amount, and the CCIP fee that the adapter paid in Ethereum Sepolia ETH, in wei. Replace `` with the `data` value from the log: ```bash filename="Terminal" cast abi-decode --input "f(bytes32,address,uint256,uint256)" ``` The token is your vault address, because the vault contract is the vCCIP-BnM token on Ethereum Sepolia, and the amount is `99000000000000000 [9.9e16]`, the 0.099 vCCIP-BnM. `ccip-cli show $DEPOSIT_EXECUTION_TX`, with the same `--rpc` options, also finds the return leg, because it lists the CCIP messages sent in a transaction. ### 3. Track the return leg Follow the return leg until it executes on Arbitrum Sepolia: ```bash filename="Terminal" ccip-cli show $DEPOSIT_RETURN_MESSAGE_ID \ --wait \ --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \ --rpc "$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL" ``` The return leg carries only tokens: its `data` is empty and its gas limit is 0, so CCIP delivers the vCCIP-BnM to your wallet without calling a contract. \ The Burn & Mint pool on Arbitrum Sepolia mints it. CCIP delivers the return leg after the execution transaction reaches finality on Ethereum Sepolia, which typically takes about 20 minutes. Your output should look something like this: ```bash filename="Terminal" ----------------OTHER LOGS---------------------------- ┌─────────────────┬──────────────────────────────────────────────────────────────────────┐ │ (index) │ Values │ ├─────────────────┼──────────────────────────────────────────────────────────────────────┤ │ state │ '✅ success' │ │ origin │ '0xD91Ae3D50Aa667d648ca93DF18AE36DA13eeedD5' │ │ contract │ '0xC93218EB7B778bC0c13E5296140C8E4Fa1C440DA' │ │ transactionHash │ '0x884ed79b184604ed399fe716dacb9d64cecf6b1d85d9778f17a4358dd5039965' │ │ logIndex │ 18 │ │ blockNumber │ 316683886 │ │ timestamp │ '2026-10-07 10:41:45 (18m45s after request)' │ └─────────────────┴──────────────────────────────────────────────────────────────────────┘ ``` ### 4. Confirm the balances 1. Check your vCCIP-BnM and CCIP-BnM balances on Arbitrum Sepolia: ```bash filename="Terminal" cast call $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN "balanceOf(address)(uint256)" $MY_ADDRESS \ --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL cast call $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_CCIP_BNM "balanceOf(address)(uint256)" $MY_ADDRESS \ --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL ``` Example Output: ```bash filename="Terminal" 199000000000000000 [1.99e17] 900000000000000000 [9e17] ``` Your vCCIP-BnM grew by 0.099, from 0.1 to 0.199. Your CCIP-BnM dropped by the 0.1 you deposited, from 1 to 0.9. You paid 0.1 CCIP-BnM and received 0.099 vCCIP-BnM, and the 0.001 difference is the adapter fee. 2. Check the vault's total assets and total share supply, and the fees that the adapter collected, on Ethereum Sepolia: ```bash filename="Terminal" cast call $VAULT "totalAssets()(uint256)" --rpc-url $ETHEREUM_SEPOLIA_RPC_URL cast call $VAULT "totalSupply()(uint256)" --rpc-url $ETHEREUM_SEPOLIA_RPC_URL cast call $ADAPTER "collectedFees(address)(uint256)" $ETHEREUM_SEPOLIA_CCIP_BNM --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` Example Output: ```bash filename="Terminal" 1099000000000000000 [1.099e18] 1099000000000000000 [1.099e18] 1000000000000000 [1e15] ``` Both vault totals grew by 0.099, from 1 to 1.099, so shares still convert 1:1. `collectedFees` holds the 0.001 CCIP-BnM adapter fee until the fee collector withdraws it with `withdrawFee`. If you set up the share token with the Lock & Mint tutorial, check that the lockbox holds 0.199 vCCIP-BnM, which backs your 0.199 vCCIP-BnM on Arbitrum Sepolia: ```bash filename="Terminal" cast call $VAULT "balanceOf(address)(uint256)" $LOCK_BOX --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` Example Output: ```bash filename="Terminal" 199000000000000000 [1.99e17] ``` > **NOTE: Where to go next** > > [Redeem from a source chain](/solutions/cross-chain-vault-adapter/guides/redeem-from-source-chain): redeem 0.1 > vCCIP-BnM from Arbitrum Sepolia for CCIP-BnM, withdraw the adapter fees as the vault operator, and review all four > CCIP messages. Stay in this terminal and in the `cross-chain-vault-adapters` directory. ## Disclaimer Please note, this repo contains community examples only — these are not Chainlink products or services and are not supported or maintained by Chainlink. This code represents an example of using a Chainlink product or service, and is intended for demonstration and educational purposes only. It is provided “AS IS” and “AS AVAILABLE” without warranties of any kind, may not have been audited, and may omit checks or error handling. Each party intending to use this example code does so entirely at their own risk and must perform its own audits, security and code review, key management, and testing before any production deployment and ensure the operation and performance of such code matches expectations. Neither Chainlink Labs nor the Chainlink Foundation deploys, operates, monitors, maintains or endorses any deployment of this code. Note that this is not a Chainlink product, feature or service, and there are no commitments made with respect to the code, including compatibility with future Chainlink releases. You should not rely on this code without first conducting your own technical, engineering, and security review. This code is also outside the scope of any Chainlink bug bounty programs. Neither Chainlink Labs, the Chainlink Foundation, nor Chainlink node operators are responsible for outcomes due to errors in this example or how it is deployed or operated, or liable for any resulting claims or damages. Use of the Chainlink Network is subject to the Chainlink Foundation [Terms of Service](https://chain.link/terms), which provides important information and disclosures. By using this code, you acknowledge and agree to these terms. --- # Redeem from a source chain Source: https://docs.chain.link/solutions/cross-chain-vault-adapter/guides/redeem-from-source-chain This tutorial continues from [Deposit from a source chain](/solutions/cross-chain-vault-adapter/guides/deposit-from-source-chain), which left your wallet with 0.199 vCCIP-BnM on Arbitrum Sepolia. 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. Here, Ethereum Sepolia is the hub chain and Arbitrum Sepolia is the source chain. In this tutorial, to simulate the redeem flow, you will: 1. Preview a redemption of 0.1 vCCIP-BnM and set a minimum output with a 1% tolerance. 2. Send 0.1 vCCIP-BnM and a 128-byte payload from Arbitrum Sepolia to the adapter on Ethereum Sepolia. 3. Confirm that the adapter redeemed the shares on Ethereum Sepolia, and follow the return leg that carries 0.099 CCIP-BnM to Arbitrum Sepolia. 4. Acting as the vault team, withdraw the 0.002 CCIP-BnM of adapter fees that your deposit and this redemption paid. 5. Review the four CCIP messages of the deposit and the redemption, and reconcile the balances on both chains. ## Before you begin You need: - ccip-cli, Foundry, Node.js, and the `cross-chain-vault-adapters` repository, set up in [Set up your development environment](/solutions/cross-chain-vault-adapter/guides/deploy-the-vault#set-up-your-development-environment). - A Foundry keystore for your wallet, which acts as a user on Arbitrum Sepolia and then withdraws the adapter fees as the fee collector in this tutorial. Use the same keystore in every tutorial of this solution. - An ERC-4626 vault on Ethereum Sepolia whose share token is a CCIP cross-chain token between Ethereum Sepolia and Arbitrum Sepolia. [Deploy the vault and set up its share token](/solutions/cross-chain-vault-adapter/guides/deploy-the-vault) sets one up. - An adapter on Ethereum Sepolia for that vault that accepts requests from Arbitrum Sepolia and has redemptions enabled. [Deploy the adapter](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter) deploys one. > **CAUTION: Before you continue** > > - **Ethereum Sepolia ETH**: required for Ethereum Sepolia transactions. Get it from the [Chainlink faucet](https://faucets.chain.link). In this tutorial, the fee withdrawal needs it. > - **Arbitrum Sepolia ETH**: required for Arbitrum Sepolia transactions. Get it from the same faucet. The redemption request pays a CCIP fee of about 0.0023 Arbitrum Sepolia ETH at the time of writing. > - **vCCIP-BnM on Arbitrum Sepolia**: at least 0.1, the amount you redeem. [Check the starting balances](#check-the-starting-balances) reads your balance. > - **Ethereum Sepolia ETH in the adapter**: at least 0.001 Ethereum Sepolia ETH, which pays the return leg's CCIP fee. [Check the adapter](#check-the-adapter) reads the balance and shows how to top it up. > **TIP: Bringing your own vault or adapter?** > > Skip only what you already have: > > - If you completed [Deposit from a source chain](/solutions/cross-chain-vault-adapter/guides/deposit-from-source-chain) in this terminal, your shell variables are set. Continue with [Check the adapter](#check-the-adapter). > - If you skipped Deposit from a source chain, run every command in [Set your shell variables](#set-your-shell-variables), then redeem the 0.1 vCCIP-BnM that [Deploy the vault and set up its share token](/solutions/cross-chain-vault-adapter/guides/deploy-the-vault) sent to Arbitrum Sepolia. Your balances then differ from the values shown, so compare the changes instead. > - If you bring your own vault, adapter, or share token, set the variables in [Set your shell variables](#set-your-shell-variables) to your addresses. Set the two CCIP-BnM variables to your vault's asset on each chain. In the `preview` call and in the payload builder's `AMOUNT`, scale `100000000000000000` (0.1 of an 18-decimal share token) to your share token's decimals. `ccip-cli` takes whole tokens, so its `=0.1` stays the same. If you did not set up the share token with the Lock & Mint tutorial, skip the lockbox checks. > - Your adapter must accept Arbitrum Sepolia as a source chain, allowlist your vault, allow redemptions, and hold Ethereum Sepolia ETH. Your share token must be a cross-chain token between Ethereum Sepolia and Arbitrum Sepolia. To withdraw the adapter fees, your wallet needs `FEE_COLLECTOR_ROLE` on the adapter. ### 1. Set your shell variables Run the commands in this tutorial from the root of the `cross-chain-vault-adapters` repository, in the terminal you used for the previous tutorials. If you opened a new terminal, change to the repository root and set the variables from the previous tutorials again: ```bash filename="Terminal" export KEYSTORE_NAME=your_keystore_name export ETHEREUM_SEPOLIA_RPC_URL=your_ethereum_sepolia_rpc_url export ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL=your_arbitrum_sepolia_rpc_url export ETHEREUM_SEPOLIA_CCIP_BNM=0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05 export ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_ROUTER=0x2a9C5afB0d0e4BAb2BCdaE109EC4b0c4Be15a165 export ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_CCIP_BNM=0xA8C0c11bf64AF62CDCA6f93D3769B88BdD7cb93D export VAULT= export ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN= export LOCK_BOX= export ADAPTER= ``` - The Arbitrum Sepolia router and CCIP-BnM addresses come from the [Arbitrum Sepolia page of the CCIP Directory](/ccip/directory/testnet/chain/ethereum-testnet-sepolia-arbitrum-1). - `ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN` is the vCCIP-BnM token you deployed on Arbitrum Sepolia with the Lock & Mint tutorial. `LOCK_BOX` is the lockbox from its Deploy ERC20LockBox step, which also saved the address under `script/deployments/lock-boxes/` in `docs-cct-foundry`. - `ADAPTER` is the adapter address you exported in [Broadcast the deployment](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter#broadcast-the-deployment), also stored as `adapter` in `deployments/ccip/11155111.json`. If you deployed your adapter another way, use that address instead. In a new terminal, also set `MY_ADDRESS` to your keystore's address. `cast` asks for the keystore password: ```bash filename="Terminal" export MY_ADDRESS=$(cast wallet address --account $KEYSTORE_NAME) echo $MY_ADDRESS ``` ### 2. Check the adapter 1. Check that `ADAPTER` points to an adapter: ```bash filename="Terminal" cast call $ADAPTER "typeAndVersion()(string)" --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` ```bash filename="Terminal" "CrossChainERC4626Adapter 1.0.0" ``` 2. Check the adapter's Ethereum Sepolia ETH balance. The adapter pays the return leg's CCIP fee from it: ```bash filename="Terminal" cast balance $ADAPTER --ether --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` The balance is the 0.01 Ethereum Sepolia ETH that [Set the return format and fund the adapter](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter#fund-the-adapter) sent, plus any top-up, minus the CCIP fee of your deposit's return leg. Make sure to have some Sepolia ETH in the adapter. With too little, the adapter cannot send the return leg, and your request becomes a failed message. If the balance is below 0.001 Ethereum Sepolia ETH, top it up. Any account can send Ethereum Sepolia ETH to the adapter: ```bash filename="Terminal" cast send $ADAPTER --value 0.01ether --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account $KEYSTORE_NAME ``` ### 3. Check the starting balances Check your vCCIP-BnM and CCIP-BnM balances on Arbitrum Sepolia: ```bash filename="Terminal" cast call $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN "balanceOf(address)(uint256)" $MY_ADDRESS \ --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL cast call $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_CCIP_BNM "balanceOf(address)(uint256)" $MY_ADDRESS \ --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL ``` Example output: ```bash filename="Terminal" 199000000000000000 [1.99e17] 900000000000000000 [9e17] ``` You hold 0.199 vCCIP-BnM: 0.1 from the test transfer in Deploy the vault and set up its share token, and 0.099 from your deposit. The 0.9 CCIP-BnM is what remains of the 1 CCIP-BnM that you minted with `drip` in Deposit from a source chain. The balances later on this page start from these values. ## Build the request ### 1. Preview the redemption `minimumOut` in the payload is the smallest amount of CCIP-BnM, after the adapter fee, that you accept for your 0.1 vCCIP-BnM. The adapter checks it on Ethereum Sepolia when your request executes, about 20 minutes after you send it. By then, the vault's exchange rate or the adapter fee, which the adapter reads at execution, can have changed. If the output is below `minimumOut`, the adapter redeems nothing and sends nothing back: it keeps your 0.1 vCCIP-BnM and records a failed message, which you then [refund or recover](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery). 1. Preview the redemption with the adapter's [`preview`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#preview) function, and store the result: ```bash filename="Terminal" export REDEEM_PREVIEW=$(cast call $ADAPTER "preview(address,address,uint256,bool,uint64)(uint256)" \ $VAULT $VAULT 100000000000000000 true 3478487238524512106 \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL | awk '{print $1}') echo $REDEEM_PREVIEW ``` - The first argument is the token as the adapter receives it. CCIP delivers your vCCIP-BnM from Arbitrum Sepolia as vCCIP-BnM on Ethereum Sepolia, where the vault contract is the share token, so you pass `$VAULT` as both the token and the vault. - `100000000000000000` is 0.1 vCCIP-BnM, `true` returns the output to the source chain, and `3478487238524512106` is the Arbitrum Sepolia chain selector. - `awk` keeps the number and drops the `[9.9e16]` that `cast` appends. Example Output: ```bash filename="Terminal" 99000000000000000 ``` The 1:1 vault redeems 0.1 vCCIP-BnM for 0.1 CCIP-BnM, and the adapter deducts its 0.001 CCIP-BnM fee from it. Do not continue if the output is `0` or `cast` prints an error. `preview` returns `0` when the adapter fee consumes the redeemed CCIP-BnM. It reverts with `InvalidTarget` when the vault is not allowlisted, `InvalidChain` when Arbitrum Sepolia is not a source chain, and `RedeemsDisabled` when redemptions are off. 2. No separate command applies the tolerance: [Build the payload](#build-the-payload) subtracts it from the preview to set `minimumOut`. With the 1% tolerance this page uses, the minimum output is `98010000000000000` (0.09801 CCIP-BnM), which the builder prints. For a 0.5% tolerance, pass `TOLERANCE_BPS=50` to the builder instead of `100`. A smaller tolerance rejects more requests when the rate moves, and a larger one accepts a worse rate. The vault in these tutorials has no yield strategy, so its exchange rate changes only if someone transfers CCIP-BnM to it directly, and 1% is only an example. For your own vault, choose the tolerance from how far its exchange rate can move during CCIP delivery. See [Slippage protection](/solutions/cross-chain-vault-adapter/overview/how-it-works#slippage-protection). ### 2. Build the payload The payload is the `data` of your CCIP message: four 32-byte fields that tell the adapter what to do. For this redemption, they hold: | Field | Value | Meaning | | ------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `target` | `$VAULT` | The vault to redeem from. It must be on the adapter's allowlist. | | `beneficiary` | `$MY_ADDRESS` | The account that receives the CCIP-BnM on Arbitrum Sepolia. | | `minimumOut` | the preview minus 1% | The smallest acceptable output, in CCIP-BnM. The builder subtracts the `TOLERANCE_BPS` tolerance from the preview. | | `deliveryAndRefund` | your address and `true` | Bit 0 set to `1` returns the CCIP-BnM to Arbitrum Sepolia. Bits 1 to 160 hold `$MY_ADDRESS` as the local refund address, which can take back the vCCIP-BnM on Ethereum Sepolia if the request fails. The builder packs both. | The payload does not name the action. The token you send decides it: vCCIP-BnM, the vault's share token, makes this request a redemption, so `minimumOut` counts CCIP-BnM. With the 1:1 vault and the equal adapter fees in these tutorials, this payload is byte for byte the same payload you built in [Deposit from a source chain](/solutions/cross-chain-vault-adapter/guides/deposit-from-source-chain). 1. Build the payload with the repository's `pnpm ccip:build-payload` script. It calls your adapter's `preview` with your vault, token, amount, source chain and return flag, subtracts the tolerance from the preview to set `minimumOut`, packs the return flag and your local refund address into `deliveryAndRefund`, and ABI-encodes the four fields: ```bash filename="Terminal" export REDEEM_PAYLOAD=$(REQUEST_TOKEN=$VAULT \ REQUEST_ADAPTER=$ADAPTER \ REQUEST_TARGET=$VAULT \ AMOUNT=100000000000000000 \ SOURCE_CHAIN_SELECTOR=3478487238524512106 \ TOLERANCE_BPS=100 \ MY_ADDRESS=$MY_ADDRESS \ pnpm ccip:build-payload --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \ | tee /dev/stderr \ | awk '/PAYLOAD:/ {print $2}') ``` - `REQUEST_TOKEN` is the token as the adapter receives it. CCIP delivers your vCCIP-BnM from Arbitrum Sepolia as vCCIP-BnM on Ethereum Sepolia, where the vault contract is the share token, so you pass `$VAULT` as the token. - `TOLERANCE_BPS=100` is the 1% tolerance from the previous accordion: the script sets `minimumOut` to `preview * (10_000 - 100) / 10_000`. Use `50` for a 0.5% tolerance. - `tee /dev/stderr` shows the script's output in your terminal, and `awk` stores the value of its `PAYLOAD:` line in `REDEEM_PAYLOAD`. Your output should look something like this: ```bash filename="Terminal" == Logs == Adapter: 0x3f73Cc1547F2606A20535Bd6620622107059a463 Token: 0xE4Ef963183C29401Ff6F67CC13B500dB1354cfB6 Vault target: 0xE4Ef963183C29401Ff6F67CC13B500dB1354cfB6 Amount: 100000000000000000 Return to source: true Source selector: 3478487238524512106 Tolerance (bps): 100 Preview: 99000000000000000 Minimum out: 98010000000000000 Beneficiary: 0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994 Local refund: 0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994 Delivery and refund: 664579538352300329284056227591069458130104865577 Byte length: 128 PAYLOAD: 0x000000000000000000000000e4ef963183c29401ff6f67cc13b500db1354cfb60000000000000000000000003a34637a41ab08519d30fdb65344aba8e9b2e994000000000000000000000000000000000000000000000000015c3393626fa0000000000000000000000000007468c6f4835610a33a61fb6ca6895751d365d329 ``` 2. Check the output before you continue. - The vault target must be your `VAULT` (the token and the vault are the same address for a redemption), - the preview must be the value from the previous accordion, - the minimum out must be `98010000000000000`, - the beneficiary and the local refund must be your address, - the return-to-source flag must be `true`, - the byte length must be `128`. - The last line is the value of `REDEEM_PAYLOAD`: byte for byte the same payload as `DEPOSIT_PAYLOAD` in the deposit tutorial. Do not continue if the preview is `0` or `forge` prints an error. > **CAUTION: The payload is final once sent** > > Nobody can change the payload after your message leaves Arbitrum Sepolia. If a check does not match, build the payload > again before you send it. The [payload > checklist](/solutions/cross-chain-vault-adapter/overview/how-it-works#what-a-user-sends) lists every rule. ## Send the request ### 1. Send the redemption Send 0.1 vCCIP-BnM and the payload from Arbitrum Sepolia to the adapter on Ethereum Sepolia. `ccip-cli` first approves the Arbitrum Sepolia router for exactly 0.1 vCCIP-BnM: ```bash filename="Terminal" ccip-cli send \ --source ethereum-testnet-sepolia-arbitrum-1 \ --router $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_ROUTER \ --dest ethereum-testnet-sepolia \ --transfer-tokens $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN=0.1 \ --receiver $ADAPTER \ --data $REDEEM_PAYLOAD \ --gas-limit 1500000 \ --wallet foundry:$KEYSTORE_NAME \ --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \ --rpc "$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL" ``` - `--transfer-tokens` takes the amount in whole tokens, so `0.1` is 0.1 vCCIP-BnM. The Burn & Mint pool on Arbitrum Sepolia burns it, and the Lock & Release pool on Ethereum Sepolia releases 0.1 vCCIP-BnM from the lockbox to the adapter. The adapter receives the vault's share token, so it treats the request as a redemption. - `--receiver $ADAPTER` sends the message to the adapter. Your wallet receives the CCIP-BnM because it is the payload's `beneficiary`. - `--data` sends the 128-byte payload as is, because it starts with `0x`. - `--gas-limit 1500000` is the gas available on Ethereum Sepolia for everything the adapter does when it receives the message: its checks, the vault redemption, and the CCIP send of the return leg. CCIP bills you for the limit, not for the gas used. If the limit is too low, `ccipReceive` can run out of gas before the adapter records the failure, and CCIP marks the message as failed. The message then needs [manual execution](/ccip/concepts/manual-execution) with a higher limit. - Without `--fee-token`, `ccip-cli` pays the CCIP fee in Arbitrum Sepolia ETH and prints it on the `Fee:` line. Your output should look something like this: ```bash filename="Terminal" Estimated gasLimit for sender = 0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994 : 744376 Fee: 836394747647981n = 0.000836394747647981 ETH 🚀 Sending message to 0x3f73Cc1547F2606A20535Bd6620622107059a463 @ ethereum-testnet-sepolia , tx => 0xa46ff978365a06bd6f7500532ae0ef4b6de3bf5f876b98dfa265baf718df871c , messageId => 0xabad1355c344bcc670d783494829eaf68a197f85f7d263c64a0b66a34b5119ef Lane: ┌────────────────┬──────────────────────────────────────────────┬────────────────────────────┐ │ (index) │ source │ dest │ ├────────────────┼──────────────────────────────────────────────┼────────────────────────────┤ │ name │ 'ethereum-testnet-sepolia-arbitrum-1' │ 'ethereum-testnet-sepolia' │ │ chainId │ 421614 │ 11155111 │ │ chainSelector │ 3478487238524512106n │ 16015286601757825753n │ │ onRamp/version │ '0x6B9a7cF69F90Ae2659bfe3069fba5Aa308A48cC4' │ '2.0.0' │ └────────────────┴──────────────────────────────────────────────┴────────────────────────────┘ Request (source): ┌────────────────────────────────┬──────────────────────────────────────────────────────────────────────┐ │ (index) │ Values │ ├────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤ │ messageId │ '0xabad1355c344bcc670d783494829eaf68a197f85f7d263c64a0b66a34b5119ef' │ │ origin │ '0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994' │ │ sender │ '0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994' │ │ receiver │ '0x3f73Cc1547F2606A20535Bd6620622107059a463' │ ``` Export the message ID from the output: ```bash filename="Terminal" export REDEEM_MESSAGE_ID= ``` ### 2. Track the request Open the CCIP Explorer link from the output, or follow the request from the terminal with `ccip-cli show`. `--wait` keeps the command running until the request executes on Ethereum Sepolia: ```bash filename="Terminal" ccip-cli show $REDEEM_MESSAGE_ID \ --wait \ --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \ --rpc "$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL" ``` CCIP delivers the request after your transaction reaches finality on Arbitrum Sepolia, which typically takes about 20 minutes. Once your request has finalized, your output should look something like this: ```bash filename="Terminal" ---------------------OTHER LOGS---------------------------------------------------------- [SENT] Waiting for source chain finalization... [SOURCE_FINALIZED] Source chain finalized [SOURCE_FINALIZED] Waiting for commit on destination chain... [SUCCESS] Message executed on destination chain ┌─────────────────┬──────────────────────────────────────────────────────────────────────┐ │ (index) │ Values │ ├─────────────────┼──────────────────────────────────────────────────────────────────────┤ │ state │ '✅ success' │ │ origin │ '0x64d20bbd0a280307884A99C43E8baf9fC5D5BC11' │ │ contract │ '0xc6A246A9AcdAaE651708706494720F79C3E5d0A1' │ │ transactionHash │ '0xfd83d4a1704797b75ad228810f0c8dd79e8e774bed22cb06d155116c5afeecd5' │ │ logIndex │ 93 │ │ blockNumber │ 11863985 │ │ timestamp │ '2026-10-07 15:49:24 (17m10s after request)' │ └─────────────────┴──────────────────────────────────────────────────────────────────────┘ ``` ## Check the result In the transaction that executed your request, the Lock & Release pool on Ethereum Sepolia released 0.1 vCCIP-BnM from the lockbox to the adapter. These are shares that the vault minted on Ethereum Sepolia: the lockbox held them while the same amount existed on Arbitrum Sepolia, where the Burn & Mint pool burned your 0.1 vCCIP-BnM when you sent the request. The token the adapter received is the vault's share token, so the adapter treated the request as a redemption. In the same transaction, the adapter: 1. Redeemed the 0.1 vCCIP-BnM from the vault, which burned the shares and paid 0.1 CCIP-BnM to the adapter. 2. Kept the 0.001 CCIP-BnM adapter fee from the redeemed CCIP-BnM. 3. Checked the remaining 0.099 CCIP-BnM against your `minimumOut` of 0.09801 CCIP-BnM. 4. Sent the 0.099 CCIP-BnM to your wallet on Arbitrum Sepolia in the return leg, and paid its CCIP fee from its Ethereum Sepolia ETH. The CCIP-BnM pool on Ethereum Sepolia burned the CCIP-BnM. ### 1. Confirm the vault redemption `[SUCCESS]` means that CCIP delivered your request to the adapter. It does not mean that the vault redemption happened. The adapter catches the errors that occur while it processes a request, so CCIP execution also succeeds for a request that the adapter rejected. [Why the CCIP Explorer shows Success](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#why-the-ccip-explorer-shows-success) explains this. Check the adapter's own result on Ethereum Sepolia. 1. From the `[SUCCESS]` table, export the hash and the block number of the Ethereum Sepolia transaction that executed your request. Do not use the `transactionHash` and `blockNumber` rows of the request table above it: they belong to your send transaction on Arbitrum Sepolia. If you run `ccip-cli show` again later, the request table also shows the execution values as `receiptTransactionHash` and `receiptBlockNumber`: ```bash filename="Terminal" export REDEEM_EXECUTION_TX= export REDEEM_EXECUTION_BLOCK= ``` 2. Look for the adapter's `MessageSucceeded` event for your message ID in that block: ```bash filename="Terminal" cast logs --from-block $REDEEM_EXECUTION_BLOCK --to-block $REDEEM_EXECUTION_BLOCK --address $ADAPTER \ "MessageSucceeded(bytes32 indexed messageId)" $REDEEM_MESSAGE_ID \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` Your output should look something like this: ```bash filename="Terminal" - address: 0x3f73Cc1547F2606A20535Bd6620622107059a463 blockHash: 0x075286b104df9919e1eb6c395f9683017abe1fcdbc93cd765f92bc3b058559cd blockNumber: 11863985 data: 0x logIndex: 91 removed: false topics: [ 0xdf6958669026659bac75ba986685e11a7d271284989f565f2802522663e9a70f 0xabad1355c344bcc670d783494829eaf68a197f85f7d263c64a0b66a34b5119ef ] transactionHash: 0xfd83d4a1704797b75ad228810f0c8dd79e8e774bed22cb06d155116c5afeecd5 transactionIndex: 53 ``` The adapter emits `MessageSucceeded` only after it completes the whole request. In the same transaction, it also emitted `TargetProcessed` for the vault redemption and `MessageSent` for the return leg. When a request fails, the adapter rolls back those events and emits `MessageFailed` instead. 3. Read the adapter's state for your message ID: ```bash filename="Terminal" cast call $ADAPTER "messageErrorCode(bytes32)(uint8)" $REDEEM_MESSAGE_ID --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` ```bash filename="Terminal" 0 ``` 1. `0` (`NONE`) confirms success only together with `MessageSucceeded`, because a message that has not arrived yet also reads `0`. 2. `1` (`BASIC`) means that the adapter rejected the request and holds your 0.1 vCCIP-BnM. In that case, [What counts as a failure](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#what-counts-as-a-failure) lists the possible causes. 3. [Failures and recovery](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery) shows how to refund the vCCIP-BnM to Arbitrum Sepolia, or recover it on Ethereum Sepolia with your wallet, the payload's local refund address. ### 2. Find the return leg The adapter sent your CCIP-BnM in the return leg, a second CCIP message with its own message ID, from the transaction that executed your request. Find the ID with the CCIP API, then confirm it on-chain. 1. Search the CCIP API for the latest message that the adapter sent to your wallet. `--no-interactive` makes the command exit after it prints the result, instead of opening a menu: ```bash filename="Terminal" ccip-cli search messages $ADAPTER --receiver $MY_ADDRESS --limit 1 --no-interactive ``` Your output should look something like this: ```bash filename="Terminal" ┌───────────┬──────────────────────────────────────────────────────────────────────┐ │ (index) │ Values │ ├───────────┼──────────────────────────────────────────────────────────────────────┤ │ messageId │ '0xd8f1f245b1884df38538b295def1b0bafe16a116893c3371102e88ad78243662' │ │ status │ 'SENT' │ │ source │ 'ethereum-testnet-sepolia [11155111]' │ │ dest │ 'ethereum-testnet-sepolia-arbitrum-1 [421614]' │ │ sender │ '0x3f73Cc1547F2606A20535Bd6620622107059a463' │ │ receiver │ '0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994' │ │ txHash │ '0xfd83d4a1704797b75ad228810f0c8dd79e8e774bed22cb06d155116c5afeecd5' │ │ timestamp │ '2026-10-07T15:49:24.000Z' │ └───────────┴──────────────────────────────────────────────────────────────────────┘ 1 message(s) found. ``` The `txHash` must equal `$REDEEM_EXECUTION_TX`. If it differs, the CCIP API has not indexed this return leg yet and still shows the return leg of your deposit, so run the command again after a minute. Export the message ID: ```bash filename="Terminal" export REDEEM_RETURN_MESSAGE_ID= ``` 2. Read the same ID from the adapter's `MessageSent` event. This way needs only your Ethereum Sepolia RPC, not the CCIP API: ```bash filename="Terminal" cast logs --from-block $REDEEM_EXECUTION_BLOCK --to-block $REDEEM_EXECUTION_BLOCK --address $ADAPTER \ "MessageSent(bytes32 indexed messageId,uint64 indexed destinationChainSelector,uint8 indexed chainType,bytes32 beneficiary,address token,uint256 amount,uint256 fee)" \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` Your output should look something like this: ```bash filename="Terminal" - address: 0x3f73Cc1547F2606A20535Bd6620622107059a463 blockHash: 0x075286b104df9919e1eb6c395f9683017abe1fcdbc93cd765f92bc3b058559cd blockNumber: 11863985 data: 0x0000000000000000000000003a34637a41ab08519d30fdb65344aba8e9b2e994000000000000000000000000fd57b4ddbf88a4e07ff4e34c487b99af2fe82a05000000000000000000000000000000000000000000000000015fb7f9b8c380000000000000000000000000000000000000000000000000000000ea7f1b127937 logIndex: 90 removed: false topics: [ 0xb6bd76b2fc831b8df833914080c10bae0c3951d7550d7c24a72e3d2fa8b7ff63 0xd8f1f245b1884df38538b295def1b0bafe16a116893c3371102e88ad78243662 0x000000000000000000000000000000000000000000000000304611b6affba76a 0x0000000000000000000000000000000000000000000000000000000000000001 ] transactionHash: 0xfd83d4a1704797b75ad228810f0c8dd79e8e774bed22cb06d155116c5afeecd5 transactionIndex: 53 ``` The second entry of `topics` is the return leg's message ID and must equal `$REDEEM_RETURN_MESSAGE_ID`. The third is the Arbitrum Sepolia chain selector, and the fourth is the chain type, `1` for EVM. 3. Decode the log's `data` field. It holds the beneficiary, the token, the amount, and the CCIP fee that the adapter paid in Ethereum Sepolia ETH, in wei. Replace `` with the `data` value from the log: ```bash filename="Terminal" cast abi-decode --input "f(bytes32,address,uint256,uint256)" ``` ### 3. Track the return leg Follow the return leg until it executes on Arbitrum Sepolia: ```bash filename="Terminal" ccip-cli show $REDEEM_RETURN_MESSAGE_ID \ --wait \ --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \ --rpc "$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL" ``` The return leg carries only tokens: its `data` is empty and its gas limit is 0, so CCIP delivers the CCIP-BnM to your wallet without calling a contract. The CCIP-BnM pool on Arbitrum Sepolia mints it. CCIP delivers the return leg after the execution transaction reaches finality on Ethereum Sepolia, which typically takes about 20 minutes. Your output should look something like this: ```bash filename="Terminal" ----------------------OTHER LOGS--------------------------------------- [SENT] Waiting for source chain finalization... [SOURCE_FINALIZED] Source chain finalized [SOURCE_FINALIZED] Waiting for commit on destination chain... [SUCCESS] Message executed on destination chain ┌─────────────────┬──────────────────────────────────────────────────────────────────────┐ │ (index) │ Values │ ├─────────────────┼──────────────────────────────────────────────────────────────────────┤ │ state │ '✅ success' │ │ origin │ '0x40699f4108fC51a4c50BDA0BF3Aae904FB4e9F1B' │ │ contract │ '0xC93218EB7B778bC0c13E5296140C8E4Fa1C440DA' │ │ transactionHash │ '0x888e522f8a6b6032e8d4ad5034cd9630168308e5a3885c436cdb533ec77b06b7' │ │ logIndex │ 3 │ │ blockNumber │ 316762230 │ │ timestamp │ '2026-10-07 16:08:19 (18m55s after request)' │ └─────────────────┴──────────────────────────────────────────────────────────────────────┘ ``` ### 4. Confirm the balances 1. Check your vCCIP-BnM and CCIP-BnM balances on Arbitrum Sepolia: ```bash filename="Terminal" cast call $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN "balanceOf(address)(uint256)" $MY_ADDRESS \ --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL cast call $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_CCIP_BNM "balanceOf(address)(uint256)" $MY_ADDRESS \ --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL ``` Example Output: ```bash filename="Terminal" 99000000000000000 [9.9e16] 999000000000000000 [9.99e17] ``` Your vCCIP-BnM dropped by the 0.1 you redeemed, from 0.199 to 0.099. Your CCIP-BnM grew by 0.099, from 0.9 to 0.999. You redeemed 0.1 vCCIP-BnM for 0.099 CCIP-BnM, and the 0.001 difference is the adapter fee. 2. Check the vault's total assets and total share supply on Ethereum Sepolia: ```bash filename="Terminal" cast call $VAULT "totalAssets()(uint256)" --rpc-url $ETHEREUM_SEPOLIA_RPC_URL cast call $VAULT "totalSupply()(uint256)" --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` Example Output: ```bash filename="Terminal" 999000000000000000 [9.99e17] 999000000000000000 [9.99e17] ``` Both vault totals dropped by 0.1, from 1.099 to 0.999: the vault burned 0.1 vCCIP-BnM and paid out 0.1 CCIP-BnM, so shares still convert 1:1. If you set up the share token with the Lock & Mint tutorial, check that the lockbox holds 0.099 vCCIP-BnM, which backs your 0.099 vCCIP-BnM on Arbitrum Sepolia: ```bash filename="Terminal" cast call $VAULT "balanceOf(address)(uint256)" $LOCK_BOX --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` Example Output: ```bash filename="Terminal" 99000000000000000 [9.9e16] ``` ## Withdraw the adapter fees Now you will act as the vault team. The adapter keeps each adapter fee in the vault's asset, CCIP-BnM, and adds it to `collectedFees`. Only an account with `FEE_COLLECTOR_ROLE` can withdraw the fees, with `withdrawFee`. [Configure the deployment](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter#configure-the-deployment) gave that role to your wallet through `FEE_COLLECTOR`. ### 1. Check the collected fees Store the collected CCIP-BnM fees, then check the adapter's CCIP-BnM balance and whether your wallet holds `FEE_COLLECTOR_ROLE`: ```bash filename="Terminal" export COLLECTED_FEES=$(cast call $ADAPTER "collectedFees(address)(uint256)" $ETHEREUM_SEPOLIA_CCIP_BNM \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL | awk '{print $1}') echo $COLLECTED_FEES cast call $ETHEREUM_SEPOLIA_CCIP_BNM "balanceOf(address)(uint256)" $ADAPTER --rpc-url $ETHEREUM_SEPOLIA_RPC_URL cast call $ADAPTER "hasRole(bytes32,address)(bool)" $(cast keccak FEE_COLLECTOR_ROLE) $MY_ADDRESS \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` Example Output: ```bash filename="Terminal" 2000000000000000 2000000000000000 [2e15] true ``` - The adapter collected 0.001 CCIP-BnM from your deposit, before it deposited, and 0.001 CCIP-BnM from this redemption, out of the redeemed CCIP-BnM. - Its CCIP-BnM balance equals its collected fees, because every other CCIP-BnM it received went into the vault or back to Arbitrum Sepolia. - `FEE_COLLECTOR_ROLE` is the keccak-256 hash of the string `FEE_COLLECTOR_ROLE`, which `cast keccak` computes. If the output is `false`, run the withdrawal from the account that holds the role. ### 2. Withdraw the fees 1. Withdraw all collected fees to your wallet. The arguments are the asset, the recipient, and the amount in the asset's smallest units: ```bash filename="Terminal" cast send $ADAPTER "withdrawFee(address,address,uint256)" $ETHEREUM_SEPOLIA_CCIP_BNM $MY_ADDRESS $COLLECTED_FEES \ --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \ --account $KEYSTORE_NAME ``` Check that the receipt's `status` is `1 (success)`. The recipient can be any address other than the zero address, such as a treasury, and you can withdraw part of the fees. An amount of 0 reverts with `AmountIsZero`, an amount above `collectedFees` reverts with `InsufficientFeeBalance`, and a call from an account without `FEE_COLLECTOR_ROLE` reverts with `AccessControlUnauthorizedAccount`. See [`withdrawFee`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#withdrawfee). 2. Check the collected fees and your CCIP-BnM balance on Ethereum Sepolia: ```bash filename="Terminal" cast call $ADAPTER "collectedFees(address)(uint256)" $ETHEREUM_SEPOLIA_CCIP_BNM --rpc-url $ETHEREUM_SEPOLIA_RPC_URL cast call $ETHEREUM_SEPOLIA_CCIP_BNM "balanceOf(address)(uint256)" $MY_ADDRESS --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` Example Output: ```bash filename="Terminal" 0 2000000000000000 [2e15] ``` The collected fees are back to 0, and your wallet received the 0.002 CCIP-BnM. Your balance is exactly `2000000000000000` only if your wallet held no other CCIP-BnM on Ethereum Sepolia. ## Review the round trip The values below assume that you followed all four tutorials with the amounts shown, starting with no vCCIP-BnM or CCIP-BnM in your wallet. ### 1. List the four CCIP messages The deposit and the redemption each took two CCIP messages: a request that you sent and paid for on Arbitrum Sepolia, and a return leg that the adapter sent and paid for on Ethereum Sepolia. | Message | Direction | Sender and receiver | Tokens | CCIP fee paid by | Adapter fee | | ------------------------ | ------------------------------------ | -------------------------- | ------------------------------- | ------------------------------------ | --------------------------------------------------- | | 1. Deposit request | Arbitrum Sepolia to Ethereum Sepolia | Your wallet to the adapter | 0.1 CCIP-BnM, with the payload | You, in Arbitrum Sepolia ETH | 0.001 CCIP-BnM, deducted before the deposit | | 2. Deposit return leg | Ethereum Sepolia to Arbitrum Sepolia | The adapter to your wallet | 0.099 vCCIP-BnM | The adapter, in Ethereum Sepolia ETH | None | | 3. Redemption request | Arbitrum Sepolia to Ethereum Sepolia | Your wallet to the adapter | 0.1 vCCIP-BnM, with the payload | You, in Arbitrum Sepolia ETH | 0.001 CCIP-BnM, deducted from the redeemed CCIP-BnM | | 4. Redemption return leg | Ethereum Sepolia to Arbitrum Sepolia | The adapter to your wallet | 0.099 CCIP-BnM | The adapter, in Ethereum Sepolia ETH | None | The two tokens cross the lane differently: - CCIP-BnM has a Burn & Mint pool on both chains, so messages 1 and 4 burned it on the source chain and minted it on the destination chain. - vCCIP-BnM has a Lock & Release pool on Ethereum Sepolia and a Burn & Mint pool on Arbitrum Sepolia. Message 2 locked 0.099 vCCIP-BnM in the lockbox and minted it on Arbitrum Sepolia. Message 3 burned 0.1 vCCIP-BnM on Arbitrum Sepolia and released it from the lockbox. List your requests to the adapter and the adapter's return legs to you through the CCIP API: ```bash filename="Terminal" ccip-cli search messages $MY_ADDRESS --receiver $ADAPTER --limit 2 --no-interactive ccip-cli search messages $ADAPTER --receiver $MY_ADDRESS --limit 2 --no-interactive ``` Your output should look something like this: ```bash filename="Terminal" 2 message(s) found. (node:58794) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead. (Use `node --trace-deprecation ...` to show where the warning was created) ┌───────────┬──────────────────────────────────────────────────────────────────────┐ │ (index) │ Values │ ├───────────┼──────────────────────────────────────────────────────────────────────┤ │ messageId │ '0xd8f1f245b1884df38538b295def1b0bafe16a116893c3371102e88ad78243662' │ │ status │ 'SUCCESS' │ │ source │ 'ethereum-testnet-sepolia [11155111]' │ │ dest │ 'ethereum-testnet-sepolia-arbitrum-1 [421614]' │ │ sender │ '0x3f73Cc1547F2606A20535Bd6620622107059a463' │ │ receiver │ '0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994' │ │ txHash │ '0xfd83d4a1704797b75ad228810f0c8dd79e8e774bed22cb06d155116c5afeecd5' │ │ timestamp │ '2026-10-07T15:49:24.000Z' │ └───────────┴──────────────────────────────────────────────────────────────────────┘ ┌───────────┬──────────────────────────────────────────────────────────────────────┐ │ (index) │ Values │ ├───────────┼──────────────────────────────────────────────────────────────────────┤ │ messageId │ '0xe0977e67438c5116084634de44f79db5feb995582b3f7645abc380a82c6dc8c0' │ │ status │ 'SUCCESS' │ │ source │ 'ethereum-testnet-sepolia [11155111]' │ │ dest │ 'ethereum-testnet-sepolia-arbitrum-1 [421614]' │ │ sender │ '0x3f73Cc1547F2606A20535Bd6620622107059a463' │ │ receiver │ '0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994' │ │ txHash │ '0x316d8252f6b40cae8edffa137871975b26df7d4bdf8a26ca1093b35944d352b1' │ │ timestamp │ '2026-10-07T10:23:00.000Z' │ └───────────┴──────────────────────────────────────────────────────────────────────┘ 2 message(s) found. ``` Each command lists the newest message first: the first command prints messages 3 and 1, and the second prints messages 4 and 2. All four show the status `SUCCESS`. ### 2. Reconcile the final balances 1. Read the two balances that this tutorial has not checked yet: your vCCIP-BnM on Ethereum Sepolia, and the vCCIP-BnM supply on Arbitrum Sepolia: ```bash filename="Terminal" cast call $VAULT "balanceOf(address)(uint256)" $MY_ADDRESS --rpc-url $ETHEREUM_SEPOLIA_RPC_URL cast call $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN "totalSupply()(uint256)" \ --rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL ``` ```bash filename="Terminal" 900000000000000000 [9e17] 99000000000000000 [9.9e16] ``` 2. Read the adapter's Ethereum Sepolia ETH balance, in wei: ```bash filename="Terminal" cast balance $ADAPTER --rpc-url $ETHEREUM_SEPOLIA_RPC_URL ``` The balance is 0.01 Ethereum Sepolia ETH (`10000000000000000` wei) minus the `fee` values of the `MessageSent` events for messages 2 and 4, plus any top-up you sent. The following table lists every balance at the end of the series and how it got there: | Chain | Balance | Final value | How it got there | | ---------------- | ------------------------------------------ | ----------- | ------------------------------------------------------------------------------------------------ | | Ethereum Sepolia | Your vCCIP-BnM | 0.9 | 1 from your deposit in Deploy the vault and set up its share token, minus the 0.1 test transfer | | Ethereum Sepolia | vCCIP-BnM in the lockbox | 0.099 | 0.1 locked by the test transfer, plus 0.099 locked by message 2, minus 0.1 released by message 3 | | Ethereum Sepolia | Vault total supply and total assets | 0.999 each | 1 from your deposit, plus 0.099 deposited by message 1, minus 0.1 redeemed by message 3 | | Ethereum Sepolia | Adapter collected fees | 0 | 0.001 each from messages 1 and 3, then withdrawn | | Ethereum Sepolia | Your CCIP-BnM | 0.002 | The adapter fees you withdrew | | Arbitrum Sepolia | vCCIP-BnM total supply, all in your wallet | 0.099 | 0.1 from the test transfer, plus 0.099 from message 2, minus 0.1 burned by message 3 | | Arbitrum Sepolia | Your CCIP-BnM | 0.999 | 1 minted in Deposit from a source chain, minus 0.1 sent in message 1, plus 0.099 from message 4 | The vault's 0.999 vCCIP-BnM supply is your 0.9 on Ethereum Sepolia plus the 0.099 in the lockbox, and the lockbox holds exactly the 0.099 vCCIP-BnM that exists on Arbitrum Sepolia. The vault holds 0.999 CCIP-BnM against 0.999 vCCIP-BnM, so shares still convert 1:1. The round trip cost you the CCIP fees of messages 1 and 3, in Arbitrum Sepolia ETH, and 0.002 CCIP-BnM in adapter fees. Because your wallet also acted as the vault team, the adapter fees came back to it through `withdrawFee`. ## Where to go next - [Failures and recovery](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery): what happens to a request that fails a check, such as the minimum output, and how its tokens come back. - [What a user sends](/solutions/cross-chain-vault-adapter/overview/how-it-works#what-a-user-sends): the payload checklist for an app that sends requests for its users. - [Fee functions](/solutions/cross-chain-vault-adapter/reference/adapter-contract#fee-functions): change the adapter fee per chain and token with `setAssetFee`. - [Limitations](/solutions/cross-chain-vault-adapter/reference/limitations): what to check before you use the adapter with your own vault in production. ## Disclaimer Please note, this repo contains community examples only — these are not Chainlink products or services and are not supported or maintained by Chainlink. This code represents an example of using a Chainlink product or service, and is intended for demonstration and educational purposes only. It is provided “AS IS” and “AS AVAILABLE” without warranties of any kind, may not have been audited, and may omit checks or error handling. Each party intending to use this example code does so entirely at their own risk and must perform its own audits, security and code review, key management, and testing before any production deployment and ensure the operation and performance of such code matches expectations. Neither Chainlink Labs nor the Chainlink Foundation deploys, operates, monitors, maintains or endorses any deployment of this code. Note that this is not a Chainlink product, feature or service, and there are no commitments made with respect to the code, including compatibility with future Chainlink releases. You should not rely on this code without first conducting your own technical, engineering, and security review. This code is also outside the scope of any Chainlink bug bounty programs. Neither Chainlink Labs, the Chainlink Foundation, nor Chainlink node operators are responsible for outcomes due to errors in this example or how it is deployed or operated, or liable for any resulting claims or damages. Use of the Chainlink Network is subject to the Chainlink Foundation [Terms of Service](https://chain.link/terms), which provides important information and disclosures. By using this code, you acknowledge and agree to these terms. --- # Limitations Source: https://docs.chain.link/solutions/cross-chain-vault-adapter/reference/limitations Use this page to check whether your vault and tokens fit the Cross-Chain Vault Adapter as shipped, and to find the conditions that turn requests into failed messages or leave tokens stuck. 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. The vault stays the product and CCIP stays the bridge, so the limits below sit in the adapter, the path that users on other chains take to reach the vault, and in its CCIP settings. For a request that succeeds, see [How it works](/solutions/cross-chain-vault-adapter/overview/how-it-works). For how tokens come back after a failure, see [Failures and recovery](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery). ## Vault and token compatibility The adapter works with a standard synchronous, single-asset [ERC-4626](https://eips.ethereum.org/EIPS/eip-4626) vault. Which tokens need CCIP support depends on the flow you want to offer; see [Requirements](/solutions/cross-chain-vault-adapter#requirements). If your vault's asset is not CCIP-enabled, users can still send CCIP-enabled shares from another chain and receive the redeemed asset on the hub chain, where the asset stays local. Deposits from another chain and redemptions that return the asset to the source chain require a CCIP-enabled asset. The cases below need a fork of the adapter or are not supported. | Case | What happens | What to do | | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | Asynchronous (request-based) vault | The adapter calls `deposit` and `redeem` and needs the output in the same transaction. A vault that queues requests does not return shares or assets in that call, so the message fails. | Fork the adapter for redemptions. Deposits need no fork when the vault's ERC-4626 `deposit` mints shares synchronously. | | Multi-asset vault | The adapter routes by comparing the bridged token with the vault's single `asset()`. Any other token reverts `InvalidTargetToken`, and the message fails. | Fork the adapter. | | Target that is not ERC-4626 | The adapter calls `asset()`, `deposit`, and `redeem` on the payload's target. If the target does not implement them, the call reverts and the message fails. | Fork the adapter. | | Vault that restricts depositors, or applies per-account limits or cooldowns | The adapter is the depositor, the initial share owner, and the redeemer, so the vault sees one account for every user. Per-account caps apply to all users combined. Allowlisting the adapter in the vault opens the vault to any sender on a configured source chain. | Fork the adapter and add your own sender or beneficiary checks. | | Fee-on-transfer, rebasing, or other non-standard ERC-20 asset or share token | The adapter assumes a transfer moves exactly the stated amount. Adapter fee accounting and `minimumOut` checks use nominal amounts, so they drift from real balances. | Use standard ERC-20 tokens only. | | Share token that is not a [CCIP cross-chain token (CCT)](/ccip/concepts/cross-chain-token/overview) | Deposits that ask to return shares to the source chain fail, because the return leg cannot bridge shares, and the whole message becomes a failed message. Users cannot move shares to another chain, so redeeming from another chain is not possible. Deposits with local delivery still work, because they need only the asset to be CCIP-enabled. | Register the share token as a CCT on both chains, typically Lock & Release on the hub chain and Burn & Mint on the other. | | Hub chain without the Shanghai upgrade | The audited bytecode uses the `PUSH0` opcode, which only Shanghai-or-later chains support. | Deploy only on chains that support `PUSH0`. Recompiling for an older EVM version changes the audited bytecode. | To register the share token as a CCT, follow the [Lock & Mint tutorial](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry). Only the vault may mint shares, which is why the hub-chain side typically uses Lock & Release. ## Fees and accounting The adapter fee is set with `setAssetFee` or `setAssetFees` and stored in `assetFees`. For how the adapter charges it, including why a fee change affects messages in flight and why a fee at or above the amount fails the message, see [Fees](/solutions/cross-chain-vault-adapter/overview/how-it-works#fees). The accounting limits below come on top of that. - **Vaults that share an asset share fee state.** Redeem-return fees are keyed by the asset, so every vault on one adapter with that asset uses the same redeem-return fee for a destination. `collectedFees` is keyed by the asset too, so their fees pool in one balance. Deposit-return fees are keyed by each vault's share token and stay separate. - **Only accounted fees can be withdrawn.** [`withdrawFee`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#withdrawfee) moves at most `collectedFees[asset]`. The adapter has no general ERC-20 sweep. - **Adapter fees do not pay CCIP fees.** The adapter pays each return leg's CCIP fee from its own native gas token balance. Collected adapter fees stay in the asset. Fund the adapter with the native gas token separately. If the balance is below the router's fee, the return reverts `InsufficientNativeBalance` and the whole message fails. - **`minimumOut` checks nominal amounts.** It compares against the amount the vault returns from `deposit` or `redeem`, after the adapter fee, not against balance changes. See [Slippage protection](/solutions/cross-chain-vault-adapter/overview/how-it-works#slippage-protection). ## Operational limits | Limit | What it means | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | No pause | Turning processing off does not stop CCIP delivery. See [What counts as a failure](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#what-counts-as-a-failure). | | No retry | The adapter never reprocesses a failed message. Re-enabling a vault, chain, or processing switch does not help messages that already failed. | | No automatic refund | Someone must call [`refundFailedMessage`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#refundfailedmessage) and pay its CCIP fee, or the local refund address must call [`recoverFailedMessageLocally`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#recoverfailedmessagelocally). | | No sender allowlist | Any sender on a configured source chain can deposit into or redeem from allowlisted vaults. The adapter checks only the source chain and the target. | | One token, one vault | A message must carry exactly one token, or it fails with `InvalidTokenCount`. One adapter can allowlist several vaults, and the payload's target picks one per message. | | Global processing switches | `depositsEnabled` and `redeemsEnabled` apply to every vault on the adapter. | | Two destinations only | Output goes to a beneficiary on the hub chain or back to the chain the message came from. The adapter cannot route output to a third chain. | | Native gas token only | The adapter pays CCIP fees for return legs in the hub chain's native gas token, never in LINK. Cross-chain refunds are paid by the caller. | | Token-only returns | Return legs carry no data and a gas limit of 0, so a beneficiary contract on the source chain receives tokens without a callback. | ## Configuration risks The first four risks concern CCIP 2.0 lane settings. [CCIP 2.0 lanes](/solutions/cross-chain-vault-adapter/overview/how-it-works#ccip-20-lanes) explains each setting. - **V3 return format on a CCIP 1.6 lane.** The router rejects every return leg and cross-chain refund to that chain. Set `GENERIC_EXTRA_ARGS_V3_BASIC` with [`setEvmReturnLaneFormat`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#setevmreturnlaneformat) only for CCIP 2.0 lanes; correcting the format unblocks later refunds. - **Return finality the token pool does not allow.** Return legs and refunds of that token revert. Check the pool's allowed finality before you call [`setEvmReturnRequestedFinality`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#setevmreturnrequestedfinality). - **Inbound finality or Cross-Chain Verifier (CCV) settings that do not match senders.** CCIP rejects the message before the adapter runs, so the adapter's recovery functions do not apply. Fix [`setInboundFinality`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#setinboundfinality) or [`setCCVsConfig`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#setccvsconfig), then use [manual execution](/ccip/concepts/manual-execution). - **CCIP 2.0 settings left at their defaults.** The factory applies none of these settings and does not fund the adapter. Until the admin sets them, return legs use `GenericExtraArgsV2`, return finality cannot be requested, the adapter rejects faster-than-finality messages, and an unfunded adapter fails return legs with `InsufficientNativeBalance`. See [Deploy the adapter](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter#fund-the-adapter). - **The CCIP Router is fixed at deployment.** `ROUTER` is immutable, so moving to a different CCIP Router means deploying and configuring a new adapter. Failed messages on the old adapter stay there until someone refunds or recovers them from it. - **Refunds use the chain family recorded at failure time.** If the chain type was misconfigured when a message failed, a later `setChainType` fix does not change how that message is refunded, so use local recovery. Setting a chain to `NONE` after a failure does not block its refund. - **`recoverNative` takes the whole native gas token balance.** [`recoverNative`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#recovernative) has no amount parameter. Afterward, every return leg fails with `InsufficientNativeBalance` until the adapter is funded again. - **Role changes take effect in one step.** The adapter uses OpenZeppelin [AccessControlEnumerable](https://docs.openzeppelin.com/contracts/5.x/api/access#AccessControlEnumerable), so `grantRole`, `revokeRole`, and `renounceRole` need no acceptance step. If the last `DEFAULT_ADMIN_ROLE` holder renounces, no one can change admin-controlled configuration or grant roles again. Fee setters and collectors keep only their own functions. Grant the new admin before you revoke the old one. - **The factory and the constructor grant roles differently.** Deployed directly, the constructor gives `defaultAdmin` all three roles, in addition to granting `feeSetter` and `feeCollector` their own. Deployed through the factory, each role goes only to its configured address, and the factory renounces its own roles. With separate addresses, a factory-deployed admin cannot set or withdraw adapter fees until it grants itself those roles. See [Role handoff](/solutions/cross-chain-vault-adapter/reference/factory-contract#role-handoff). - **Changes apply to messages in flight.** Disabling a chain, a vault, or a processing switch fails every message that arrives afterward, including messages sent before the change. A fee change likewise applies to messages sent before it. ## When funds can get stuck In these scenarios, tokens are lost or stranded, or one of the two recovery paths is gone. The payload checklist in [What a user sends](/solutions/cross-chain-vault-adapter/overview/how-it-works#what-a-user-sends) prevents the first two. | Scenario | Why | Prevention | | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | No local refund address, and a cross-chain refund is not possible | Local recovery needs `localRefundAddress` in the payload when the message is sent. It cannot be added later, and a payload that is not exactly 128 bytes leaves it zero. A cross-chain refund fails if, for example, the router no longer supports the return lane or the token has no pool for it. | Always set `localRefundAddress` to an address the user controls on the hub chain. | | Malformed payload | A hand-built payload can make the message fail inside CCIP with no recovery path, as described in [Failures with no recovery path](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#failures-with-no-recovery-path). | Build the payload with a standard ABI encoder, such as Solidity `abi.encode`, ethers, viem, or cast. | | Refund recipient is a contract that cannot use the returned tokens | A cross-chain refund always goes to the original `message.sender` on the source chain, not to the beneficiary or the local refund address. | Send from an account or contract that can move refunded tokens, or set `localRefundAddress`. | | Tokens sent to the adapter outside a normal request | An ERC-20 transfer straight to the adapter, or a CCIP message with empty data and a gas limit of 0, delivers tokens without calling `ccipReceive`. The adapter records nothing, `withdrawFee` withdraws only accounted fees, and there is no ERC-20 sweep. | Send tokens only in CCIP messages with a 128-byte payload and a non-zero gas limit. `recoverNative` recovers the native gas token. | | Admin role renounced while configuration is broken | No one can fix settings such as a wrong return lane format, so cross-chain refunds that depend on them stay blocked. Only local recovery remains, and only for messages that set a local refund address. | Verify the configuration with test messages before any admin change, and keep at least one `DEFAULT_ADMIN_ROLE` holder. | ## Customizing the adapter If your vault does not fit, fork the adapter rather than modifying the vault. See [Use it as-is or customize it](/solutions/cross-chain-vault-adapter#use-it-as-is-or-customize-it) for when to fork. - **There are no hooks to override.** `processMessage` and `_processTarget` are not `virtual`. Edit them in your copy of [`CrossChainERC4626Adapter.sol`](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/src/ccip/CrossChainERC4626Adapter.sol). - **Keep the safety checks.** Keep the router check in `ccipReceive`, the self-call that isolates `processMessage` in a `try`/`catch`, failed-message storage with both recovery paths, and the beneficiary address checks. The failure handler, the `catch` path in `ccipReceive`, must never revert on any input, or tokens can be stranded as in the malformed payload case above. - **Keep the compiler settings.** The audited build uses solc 0.8.24, `via_ir`, `optimizer_runs = 1`, and `evm_version = cancun`, as pinned in [`foundry.toml`](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/foundry.toml). - **A fork is not the audited code.** The audit covers the contracts in `src/ccip` as published. Any change to the source or the compiler settings changes the bytecode, so get your fork its own security review before you deploy it. ## Versions covered This page describes the following versions. The contracts in `src/ccip` are audited. | Component | Version | | --------------------------------- | ------------------------------------------------------------------------------------------------------- | | `CrossChainERC4626Adapter` | 1.0.0 (`typeAndVersion` is `CrossChainERC4626Adapter 1.0.0`) | | `CrossChainERC4626AdapterFactory` | 1.0.0 (`typeAndVersion` is `CrossChainERC4626AdapterFactory 1.0.0`) | | `@chainlink/contracts-ccip` | 2.0.0 | | `@chainlink/contracts` | 1.5.0 (`ITypeAndVersion` only) | | OpenZeppelin Contracts | 5.0.2 (`AccessControlEnumerable`, `IERC4626`, `ReentrancyGuard`) and 4.8.3 (`IERC20`, `SafeERC20`) | | Compiler | solc 0.8.24, `via_ir`, `optimizer_runs = 1`, `evm_version = cancun` (requires `PUSH0`) | | CCIP lanes | CCIP 1.6 lanes (`GenericExtraArgsV2` return legs) and CCIP 2.0 lanes (`GenericExtraArgsV3` return legs) | | Hub chains | EVM chains with the Shanghai upgrade or later | For exact signatures, events, and errors, continue to [Adapter contract](/solutions/cross-chain-vault-adapter/reference/adapter-contract) and [Factory contract](/solutions/cross-chain-vault-adapter/reference/factory-contract). ## Disclaimer Please note, this repo contains community examples only — these are not Chainlink products or services and are not supported or maintained by Chainlink. This code represents an example of using a Chainlink product or service, and is intended for demonstration and educational purposes only. It is provided “AS IS” and “AS AVAILABLE” without warranties of any kind, may not have been audited, and may omit checks or error handling. Each party intending to use this example code does so entirely at their own risk and must perform its own audits, security and code review, key management, and testing before any production deployment and ensure the operation and performance of such code matches expectations. Neither Chainlink Labs nor the Chainlink Foundation deploys, operates, monitors, maintains or endorses any deployment of this code. Note that this is not a Chainlink product, feature or service, and there are no commitments made with respect to the code, including compatibility with future Chainlink releases. You should not rely on this code without first conducting your own technical, engineering, and security review. This code is also outside the scope of any Chainlink bug bounty programs. Neither Chainlink Labs, the Chainlink Foundation, nor Chainlink node operators are responsible for outcomes due to errors in this example or how it is deployed or operated, or liable for any resulting claims or damages. Use of the Chainlink Network is subject to the Chainlink Foundation [Terms of Service](https://chain.link/terms), which provides important information and disclosures. By using this code, you acknowledge and agree to these terms. --- # Adapter contract Source: https://docs.chain.link/solutions/cross-chain-vault-adapter/reference/adapter-contract Use this reference to check the exact signatures, access rules, revert conditions, and events of `CrossChainERC4626Adapter`. 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. The adapter receives CCIP token transfers on the hub chain, deposits the asset into or redeems shares from an allowlisted ERC-4626 vault, and delivers the output on the hub chain or bridges it back to the source chain. For the same request explained step by step, read [How it works](/solutions/cross-chain-vault-adapter/overview/how-it-works) and [Failures and recovery](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery). This contract provides: - a CCIP receiver that runs the vault interaction in an isolated self-call and records failures instead of reverting - deposit and redeem routing, selected by the token the message carries - local delivery on the hub chain or return to the source chain, with a flat adapter fee on returns - cross-chain refund and local recovery for failed messages - per-source-chain [Cross-Chain Verifier (CCV)](/ccip/concepts/ccvs/overview) and finality settings for CCIP 2.0 lanes - configuration split across `DEFAULT_ADMIN_ROLE`, `FEE_SETTER_ROLE`, and `FEE_COLLECTOR_ROLE` > **NOTE: Who calls the adapter** > > The vault team deploys the adapter through `CrossChainERC4626AdapterFactory` on the hub chain. The CCIP Router calls > `ccipReceive` to deliver each message. ## Usage boundary - Only the CCIP Router set at construction (`ROUTER`) can call `ccipReceive`. Any other caller gets `InvalidRouter`. - `processMessage` is `external` only so that `ccipReceive` can wrap it in `try`/`catch`. Any caller other than the adapter itself gets `OnlySelf`. - The CCIP OffRamp calls `supportsInterface` to confirm the adapter is a CCIP receiver. On CCIP 2.0 lanes it also reads `getCCVsAndFinalityConfig` before it delivers a message. - Users do not call the adapter to deposit or redeem. They send a CCIP message from the source chain to the adapter's address, with the payload described in [Message format](#message-format). - Users and integrators call the [integrator views](#integrator-views) and the two [recovery functions](#recovery-functions). Anyone can call `refundFailedMessage`. Only the local refund address stored for a message can call `recoverFailedMessageLocally`. - Role holders call the [configuration functions](#configuration-functions) and the [fee functions](#fee-functions). - Deploy the adapter through the [factory](/solutions/cross-chain-vault-adapter/reference/factory-contract) instead of calling the constructor directly. See [Deploy the adapter](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter). - The message-processing logic has no `virtual` hooks. To change it, fork the contract and edit `processMessage` and its private helpers. See [Use it as-is or customize it](/solutions/cross-chain-vault-adapter#use-it-as-is-or-customize-it). ## Contract Source: [`src/ccip/CrossChainERC4626Adapter.sol`](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/src/ccip/CrossChainERC4626Adapter.sol) | Property | Value | | ---------------- | ----------------------------------------------------------------------- | | Contract | `CrossChainERC4626Adapter` | | `typeAndVersion` | `CrossChainERC4626Adapter 1.0.0` | | License | MIT | | Audit status | Audited | | Compiler | solc 0.8.24, `via_ir` enabled, optimizer runs `1`, EVM version `cancun` | | Minimum EVM | Shanghai upgrade (`PUSH0`) | The audited bytecode is identical to a shanghai build, and `PUSH0` is the only opcode it uses that pre-Shanghai chains lack. Deploy the adapter only on chains that support the Shanghai upgrade. Recompiling with other settings produces bytecode that differs from the audited build. The pinned settings are in [`foundry.toml`](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/foundry.toml). The contract imports these dependencies: | Package | Version | Imports | | --------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------- | | `@chainlink/contracts-ccip` | 2.0.0 | `Client`, `ExtraArgsCodec`, `FinalityCodec`, `IRouterClient`, `IAny2EVMMessageReceiver`, `IAny2EVMMessageReceiverV2` | | `@chainlink/contracts` | 1.5.0 | `ITypeAndVersion` | | OpenZeppelin Contracts | 5.0.2 | `AccessControlEnumerable`, `ReentrancyGuard`, `IERC4626` | | OpenZeppelin Contracts | 4.8.3 | `IERC20`, `SafeERC20` | ## Inheritance - [`IAny2EVMMessageReceiverV2`](/ccip/evm/api-reference/v2.0.0/i-any2-evm-message-receiver-v2): `ccipReceive` and `getCCVsAndFinalityConfig` - `AccessControlEnumerable` (OpenZeppelin 5.0.2): roles and role enumeration. See [Roles](#roles). - `ReentrancyGuard` (OpenZeppelin 5.0.2): the `nonReentrant` modifier on `refundFailedMessage`, `recoverFailedMessageLocally`, `withdrawFee`, and `recoverNative` - [`ITypeAndVersion`](/ccip/evm/api-reference/v2.0.0/i-type-and-version): `typeAndVersion` The adapter does not inherit CCIP's `CCIPReceiver` base contract. It implements the receiver interface and its own router check. ## Constructor A direct deployment runs this constructor: ```solidity constructor(address router_, address defaultAdmin, address feeSetter, address feeCollector) ``` | Parameter | Type | Description | | -------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `router_` | `address` | CCIP Router on the hub chain, stored as the immutable `ROUTER`. Get the address from the CCIP Directory for [testnet](/ccip/directory/testnet) or [mainnet](/ccip/directory/mainnet). | | `defaultAdmin` | `address` | Receives `DEFAULT_ADMIN_ROLE`, `FEE_SETTER_ROLE`, and `FEE_COLLECTOR_ROLE`. | | `feeSetter` | `address` | Receives `FEE_SETTER_ROLE`. | | `feeCollector` | `address` | Receives `FEE_COLLECTOR_ROLE`. | **Reverts with:** - `InvalidRouter(address(0))` when `router_` is the zero address. - `InvalidAdmin()` when `defaultAdmin` is the zero address. - `InvalidFeeSetter()` when `feeSetter` is the zero address. - `InvalidFeeCollector()` when `feeCollector` is the zero address. **Emits:** - `RoleGranted` for each new role grant: three for `defaultAdmin`, plus one each for `feeSetter` and `feeCollector` when they differ from `defaultAdmin`. The constructor checks only that `router_` is non-zero. It does not verify that the address is a CCIP Router, and `ROUTER` cannot change after deployment. A wrong router means deploying a new adapter. A new adapter starts with deposits and redeems disabled, no chain configured, no vault allowlisted, no adapter fee, no CCV or inbound finality settings, the `UNSET` (legacy) return format on every lane, and no native gas token balance. Deploy through the [factory](/solutions/cross-chain-vault-adapter/reference/factory-contract) instead. It sets chain types, the vault allowlist entry, the deposit and redeem switches, and adapter fees in the same transaction, then grants each role only to its configured address. Through the factory, `defaultAdmin` receives the fee roles only if you also set it as `feeSetter` or `feeCollector`. See [Role handoff](/solutions/cross-chain-vault-adapter/reference/factory-contract#role-handoff). ## Roles The adapter defines two roles and inherits `DEFAULT_ADMIN_ROLE`: | Role | Constant | Value | Granted by | Can call | | ------------- | -------------------- | --------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Admin | `DEFAULT_ADMIN_ROLE` | `0x00` | Constructor (`defaultAdmin`), factory (`defaultAdmin`), or an existing admin | `setChainType`, `setTargetEnabled`, `setProcessingEnabled`, `setCCVsConfig`, `setInboundFinality`, `setEvmReturnLaneFormat`, `setEvmReturnRequestedFinality`, `recoverNative`, and `grantRole` and `revokeRole` for all three roles | | Fee setter | `FEE_SETTER_ROLE` | `keccak256("FEE_SETTER_ROLE")` | Constructor (`feeSetter` and `defaultAdmin`), factory (`feeSetter` only), or an admin | `setAssetFee`, `setAssetFees` | | Fee collector | `FEE_COLLECTOR_ROLE` | `keccak256("FEE_COLLECTOR_ROLE")` | Constructor (`feeCollector` and `defaultAdmin`), factory (`feeCollector` only), or an admin | `withdrawFee` | The adapter inherits role management from OpenZeppelin [`AccessControlEnumerable`](https://docs.openzeppelin.com/contracts/5.x/api/access#AccessControlEnumerable): `grantRole`, `revokeRole`, `renounceRole`, `hasRole`, `getRoleAdmin`, `getRoleMember`, and `getRoleMemberCount`. The adapter never changes role admins, so `DEFAULT_ADMIN_ROLE` is the admin of all three roles. Use `getRoleMemberCount` and `getRoleMember` to list the current holders of a role. Role changes take effect in a single step. There is no pending-admin acceptance and no delay, so granting `DEFAULT_ADMIN_ROLE` to a wrong address hands over control in that transaction. `renounceRole(role, callerConfirmation)` requires `callerConfirmation` to equal the caller. If the last holder of `DEFAULT_ADMIN_ROLE` renounces it or has it revoked, no account can call the admin functions or change roles again. Fee setters and fee collectors keep their roles. ## Message format Every message sent to the adapter must carry exactly one token and a `data` field that decodes to `Payload`. For how a user builds the full CCIP message on the source chain, see [What a user sends](/solutions/cross-chain-vault-adapter/overview/how-it-works#what-a-user-sends). The adapter decodes `message.data` into this struct, shown here without its source comments: ```solidity struct Payload { address target; bytes32 beneficiary; uint256 minimumOut; uint256 deliveryAndRefund; } ``` | Field | Type | Description | | ------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `target` | `address` | Vault to call. Must be allowlisted with [`setTargetEnabled`](#settargetenabled), or processing fails with `InvalidTarget`. | | `beneficiary` | `bytes32` | Recipient of the output: on the hub chain for local delivery, on the source chain for a return. For an EVM address, left-pad it to 32 bytes; if the upper 96 bits are not zero, processing fails with `InvalidEVMAddress`. | | `minimumOut` | `uint256` | Smallest acceptable output after the adapter fee, in output units: shares for a deposit, asset for a redemption. Below it, processing fails with `MinimumOutputNotMet`. `0` disables the check, but a zero output still fails with `NoOutputReceived`. | | `deliveryAndRefund` | `uint256` | Delivery choice and local refund address, packed into one word as shown in the next table. | `deliveryAndRefund` uses this bit layout: | Bits | Field | Meaning | | ---------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | 0 | `returnToSourceChain` | `1` bridges the output back to `message.sourceChainSelector`. `0` delivers it to `beneficiary` on the hub chain. | | 1 to 160 | `localRefundAddress` | Hub-chain address that can call [`recoverFailedMessageLocally`](#recoverfailedmessagelocally) if processing fails. `address(0)` disables local recovery. | | 161 to 255 | Unused | Ignored on decode and not validated. | The adapter enforces these rules on every message: - `message.destTokenAmounts` must hold exactly one entry. Otherwise processing fails with `InvalidTokenCount`. - `message.data` must be exactly `CCIP_MESSAGE_PAYLOAD_LENGTH` (128) bytes, the length of `abi.encode` over the four static fields. Otherwise processing fails with `InvalidPayloadLength`. - The token selects the operation. The vault's `asset()` means deposit. The vault address, which is also the share token, means redeem. Any other token fails with `InvalidTargetToken`. This Solidity snippet builds `message.data` the same way as the `_encodePayload` and `_packDeliveryAndRefund` helpers in the repository's [adapter tests](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/test/ccip/CrossChainERC4626Adapter.t.sol): ```solidity uint256 deliveryAndRefund = (uint256(uint160(localRefundAddress)) << 1) | (returnToSourceChain ? 1 : 0); bytes memory data = abi.encode( vault, // target bytes32(uint256(uint160(beneficiary))), // beneficiary, left-padded EVM address minimumOut, deliveryAndRefund ); ``` In TypeScript, encode the same four values as the ABI tuple `(address, bytes32, uint256, uint256)` with viem or ethers. > **CAUTION: Validate the payload before you send** > > Check every message against the payload checklist in [What a user > sends](/solutions/cross-chain-vault-adapter/overview/how-it-works#what-a-user-sends), including a non-zero > `localRefundAddress`, which cannot be added after the message is sent. A malformed payload can strand funds > permanently, as explained in [Failures with no recovery > path](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#failures-with-no-recovery-path). ## Types All types below are declared inside `CrossChainERC4626Adapter`. Messages and token amounts use `Client.Any2EVMMessage` and `Client.EVMTokenAmount` from the CCIP [`Client`](/ccip/evm/api-reference/v2.0.0/client) library. ### ChainType The adapter records a chain family for each remote chain selector with this enum: ```solidity enum ChainType { NONE, EVM, SVM } ``` | Value | Meaning | | ---------- | ------------------------------------------------------------------------------------------------------------------------------ | | `NONE` (0) | Selector not configured. Messages from it fail with `InvalidChain`, and `preview` reverts for it when the amount is not zero. | | `EVM` (1) | EVM chain family. Return legs and refunds to this selector use an EVM receiver encoding and the lane's EVM `extraArgs` format. | | `SVM` (2) | Solana Virtual Machine chain family. | ### EvmReturnExtraArgsFormat The adapter encodes `extraArgs` for return legs and refunds to an EVM chain according to this enum, set per destination with [`setEvmReturnLaneFormat`](#setevmreturnlaneformat): ```solidity enum EvmReturnExtraArgsFormat { UNSET, LEGACY_EXTRA_ARGS_V2, GENERIC_EXTRA_ARGS_V3_BASIC } ``` | Value | Encoding | Use on | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | | `UNSET` (0) | Same as `LEGACY_EXTRA_ARGS_V2`. This is the default and cannot be written with `setEvmReturnLaneFormat`. | CCIP 1.6 lanes | | `LEGACY_EXTRA_ARGS_V2` (1) | `Client.GenericExtraArgsV2` with `gasLimit` 0 and `allowOutOfOrderExecution` true | CCIP 1.6 lanes | | `GENERIC_EXTRA_ARGS_V3_BASIC` (2) | `ExtraArgsCodec._getBasicEncodedExtraArgsV3(0, requestedFinality)`, with the finality from `evmReturnRequestedFinality`. See [`ExtraArgsCodec`](/ccip/evm/api-reference/v2.0.0/extra-args-codec). | CCIP 2.0 lanes | ### ErrorCode The adapter tracks the processing outcome of each inbound message ID with this enum: ```solidity enum ErrorCode { NONE, BASIC, RESOLVED } ``` | Value | Meaning | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `NONE` (0) | Not received yet, or processed successfully. A `MessageSucceeded` event for the message ID tells the two apart. | | `BASIC` (1) | Processing failed. The adapter holds the inbound tokens, and a cross-chain refund or local recovery is available. | | `RESOLVED` (2) | A failed message was refunded cross-chain or recovered locally. A `MessageRefunded` or `MessageRecoveredLocally` event for the message ID tells which. | ### Payload `Payload` is the struct encoded in `message.data`. See [Message format](#message-format). ### FailedMessageRecord When processing fails, the adapter stores this record for the message ID: ```solidity struct FailedMessageRecord { uint64 sourceChainSelector; bytes sender; Client.EVMTokenAmount[] destTokenAmounts; address localRefundAddress; } ``` | Field | Type | Description | | --------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sourceChainSelector` | `uint64` | Chain the message came from. Cross-chain refunds go back to this chain. | | `sender` | `bytes` | Original `message.sender`. CCIP encodes an EVM sender as 32 bytes. Cross-chain refunds go to this address. | | `destTokenAmounts` | `Client.EVMTokenAmount[]` | Tokens and amounts the adapter received and now holds. | | `localRefundAddress` | `address` | Decoded from `deliveryAndRefund` when the payload is exactly 128 bytes, otherwise `address(0)`. The only address that can call `recoverFailedMessageLocally`. | The record does not include `message.data`. Read it with [`getFailedMessageRecord`](#getfailedmessagerecord). ### CCVConfig The adapter stores its CCIP 2.0 verifier policy per source chain in this struct: ```solidity struct CCVConfig { address[] requiredCCVs; address[] optionalCCVs; uint8 optionalThreshold; } ``` | Field | Type | Description | | ------------------- | ----------- | ------------------------------------------------------------------------------------------ | | `requiredCCVs` | `address[]` | CCVs that must all attest to a message. `address(0)` stands for the lane's default CCVs. | | `optionalCCVs` | `address[]` | CCVs from which at least `optionalThreshold` must attest. | | `optionalThreshold` | `uint8` | Number of optional CCVs that must attest. Greater than 0 when `optionalCCVs` is non-empty. | Set it with [`setCCVsConfig`](#setccvsconfig) and read it with [`getCCVsAndFinalityConfig`](#getccvsandfinalityconfig). ## State variables Every public state variable has a getter with the same name. The adapter declares these: | Getter | Returns | Description | | ---------------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `ROUTER()` | `address` | Immutable CCIP Router. The only caller allowed into `ccipReceive`, and the router used for `getFee` and `ccipSend` on return legs and refunds. | | `typeAndVersion()` | `string` | Constant `"CrossChainERC4626Adapter 1.0.0"`. | | `DEFAULT_ADMIN_ROLE()` | `bytes32` | Inherited constant `0x00`. | | `FEE_SETTER_ROLE()` | `bytes32` | Constant `keccak256("FEE_SETTER_ROLE")`. | | `FEE_COLLECTOR_ROLE()` | `bytes32` | Constant `keccak256("FEE_COLLECTOR_ROLE")`. | | `CCIP_MESSAGE_PAYLOAD_LENGTH()` | `uint256` | Constant `128`. Required length of `message.data`. | | `depositsEnabled()` | `bool` | Whether deposits are processed, for every vault on the adapter. | | `redeemsEnabled()` | `bool` | Whether redemptions are processed, for every vault on the adapter. | | `inboundFinality(uint64 sourceChainSelector)` | `bytes4` | Allowed finality for messages from the source chain on CCIP 2.0 lanes. Default `0x00000000` accepts only messages that wait for finality. | | `chains(uint64 chainSelector)` | `ChainType` | Chain family of a remote selector. `NONE` means not configured. | | `enabledTargets(address target)` | `bool` | Whether a vault is allowlisted. | | `assetFees(uint64 destinationChainSelector, address bridgedToken)` | `uint256` | Adapter fee in the asset's smallest units, keyed by the return chain and the token bridged back. | | `collectedFees(address asset)` | `uint256` | Adapter fees accrued and not yet withdrawn. | | `refundChainFamilySnapshot(bytes32 messageId)` | `ChainType` | Chain family recorded when a message failed. Cross-chain refunds use it instead of the current `chains` value. Deleted when the message resolves. | | `messageErrorCode(bytes32 messageId)` | `ErrorCode` | Processing outcome of an inbound message. | | `evmReturnExtraArgsFormat(uint64 destinationChainSelector)` | `EvmReturnExtraArgsFormat` | Return and refund `extraArgs` format for a destination chain. | | `evmReturnRequestedFinality(uint64 destinationChainSelector, address token)` | `bytes4` | Requested finality for return legs and refunds of `token` to a destination, used only on `GENERIC_EXTRA_ARGS_V3_BASIC` lanes. | `ccvConfigs` and `failedMessageRecords` are `internal`. Read them through [`getCCVsAndFinalityConfig`](#getccvsandfinalityconfig) and [`getFailedMessageRecord`](#getfailedmessagerecord). The adapter accepts the native gas token through `receive() external payable`. That balance pays the CCIP fees for return legs. The admin withdraws it with [`recoverNative`](#recovernative). ## Receiver functions ### ccipReceive Receives a message from the CCIP Router and processes it in an isolated self-call. ```solidity function ccipReceive(Client.Any2EVMMessage calldata message) external onlyRouter ``` > Calls `this.processMessage(message)` inside `try`/`catch`. > > - On success, emits `MessageSucceeded`. `messageErrorCode` stays `NONE`. > - On a revert, sets `messageErrorCode` to `BASIC`, stores a [`FailedMessageRecord`](#failedmessagerecord), records the source chain's family in `refundChainFamilySnapshot`, and emits `MessageFailed`. If `chains` has no entry for the source chain, the family is inferred from the sender's format. The one exception, a malformed `target` word, is listed under **Reverts with**. > > When processing fails, every effect of `processMessage` rolls back: the vault call, the fee, and any outbound send. The adapter keeps the inbound tokens. CCIP execution still succeeds, so the [CCIP Explorer](https://ccip.chain.link) shows the message as Success. The `catch` is bare, so the adapter does not store or emit the revert reason. Nothing retries the message, and re-enabling a vault or chain does not reprocess it. See [Why the CCIP Explorer shows Success](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#why-the-ccip-explorer-shows-success). **Access:** `ROUTER` only. **Parameters:** | Parameter | Type | Description | | --------- | -------------------------------- | --------------------------------------------------------------------------------------- | | `message` | `Client.Any2EVMMessage calldata` | Inbound message: message ID, source chain selector, sender, data, and delivered tokens. | **Returns:** none. **Reverts with:** - `InvalidRouter(msg.sender)` when the caller is not `ROUTER`. - Out of gas when the gas limit set on the source chain does not cover the call. CCIP marks the message failed. Recover it with [manual execution](/ccip/concepts/manual-execution) at a higher gas limit. - A decode revert in the `catch` block when a 128-byte payload has a `target` word with non-zero upper 12 bytes. The message can never execute. See [Failures with no recovery path](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#failures-with-no-recovery-path). When `ccipReceive` reverts, CCIP reverts the token transfer and marks the message failed at the CCIP level. See [Failures the adapter can't catch](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#failures-the-adapter-cant-catch). **Emits:** - `MessageSucceeded(messageId)` after the events from a successful `processMessage`. - `MessageFailed(messageId)` when `processMessage` reverts. *** ### processMessage Validates an inbound message, calls the vault, and delivers the output. ```solidity function processMessage(Client.Any2EVMMessage calldata message) external onlySelf onlyValidChain(message.sourceChainSelector) ``` > Runs in this order: > > 1. Requires the caller to be the adapter, and `chains[message.sourceChainSelector]` to be configured (not `NONE`). > 2. Requires exactly one entry in `message.destTokenAmounts`. > 3. Requires `message.data` to be exactly 128 bytes, then decodes it as `Payload`. > 4. Requires `enabledTargets[payload.target]`. > 5. Requires a non-zero token amount. > 6. Reads `asset()` from the vault and routes by token: > - **Asset (deposit):** requires `depositsEnabled`. When returning to the source chain, subtracts the adapter fee from the input. Calls `deposit(assets, address(this))` with the remaining amount. > - **Share token (redeem):** requires `redeemsEnabled`. Calls `redeem(shares, address(this), address(this))`. When returning to the source chain, subtracts the adapter fee from the redeemed assets. > 7. Requires a non-zero output of at least `minimumOut`, then emits `TargetProcessed`. > 8. Delivers the output. With `returnToSourceChain` set, it sends a CCIP message to `beneficiary` on `message.sourceChainSelector`. Otherwise it transfers the output to `beneficiary` on the hub chain and emits `LocalTokenDelivered`. > > The vault sees the adapter as the caller, receiver, and owner. The adapter fee is read from `assetFees[message.sourceChainSelector][bridgedToken]` when the message executes. `bridgedToken` is the share token for a deposit return and the asset for a redemption return. Fees accrue in `collectedFees[asset]`. Local delivery pays no adapter fee. The adapter pays each return leg's CCIP fee from its own native gas token balance. A return leg is a token-only CCIP message, and for an EVM destination it has these fields: | Field | Value | | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `receiver` | `abi.encode(beneficiary)` | | `data` | Empty | | `tokenAmounts` | One entry: the output token and the output amount after the adapter fee | | `extraArgs` | Built from the adapter's configuration, never from message data. `GenericExtraArgsV3` with gas limit 0 and `evmReturnRequestedFinality[destination][token]` on a `GENERIC_EXTRA_ARGS_V3_BASIC` lane; otherwise `GenericExtraArgsV2` with gas limit 0 and out-of-order execution allowed. | | `feeToken` | `address(0)`, so the CCIP fee is paid in the native gas token, never LINK | **Access:** the adapter itself (`onlySelf`). **Parameters:** | Parameter | Type | Description | | --------- | -------------------------------- | ---------------------------------------------- | | `message` | `Client.Any2EVMMessage calldata` | Inbound message, forwarded from `ccipReceive`. | **Returns:** none. **Reverts with** (when called from `ccipReceive`, every revert below except `OnlySelf` and the decode revert becomes a failed message): - `OnlySelf()` when any address other than the adapter calls it. - `InvalidChain(sourceChainSelector)` when the source chain is not configured. - `InvalidTokenCount(tokenCount)` when the message does not carry exactly one token. - `InvalidPayloadLength(length, 128)` when `message.data` is not 128 bytes. - A decode revert without error data when the payload is 128 bytes but `target` does not fit in 160 bits. This case does not become a failed message. See [`ccipReceive`](#ccipreceive) and [Failures with no recovery path](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#failures-with-no-recovery-path). - `InvalidTarget(target)` when the vault is not allowlisted. - `AmountIsZero()` when the token amount is zero. - `DepositsDisabled()` or `RedeemsDisabled()` when that path is switched off. - `InvalidTargetToken(target, token)` when the token is neither the vault's asset nor its share token. - `FeeExceedsAmount(amount, fee)` when the adapter fee is greater than or equal to the deposit input or the redeemed assets. - `NoOutputReceived()` when the vault returns zero shares or zero assets. - `MinimumOutputNotMet(minimumOut, actualOut)` when the output after the fee is below `minimumOut`. - `InvalidEVMAddress(beneficiary)` when the upper 96 bits of `beneficiary` are not zero. - `InsufficientNativeBalance(requiredFee, availableBalance)` when the adapter's native gas token balance is below the router fee for the return leg. - Errors from the vault (for example, a deposit limit), the tokens, the CCIP Router, or a token pool (for example, a rate limit on the return lane). **Emits:** - `TargetProcessed` after the vault call. - `LocalTokenDelivered` for local delivery, or `MessageSent` for a return leg. These events roll back with the rest of `processMessage` when a later step reverts. *** ### getCCVsAndFinalityConfig Returns the verifier and finality policy that CCIP 2.0 OffRamps apply to messages from a source chain. ```solidity function getCCVsAndFinalityConfig(uint64 sourceChainSelector, bytes calldata) external view virtual returns ( address[] memory requiredCCVs, address[] memory optionalCCVs, uint8 optionalThreshold, bytes4 allowedFinalityConfig ) ``` > Returns the stored `CCVConfig` and `inboundFinality` for `sourceChainSelector`. The second parameter, the sender, is unnamed and ignored, so every sender on a source chain gets the same policy. > > On CCIP 2.0 lanes the OffRamp calls this function before `ccipReceive`. If a message lacks the required CCV attestations, or requests a finality that `allowedFinalityConfig` does not allow, the message fails at the CCIP level and the adapter never sees it. CCIP 1.6 lanes do not call this function. > > Empty CCV lists with a zero threshold make the OffRamp use the lane's default CCVs. An `allowedFinalityConfig` of `0x00000000`, the default, accepts only messages that wait for finality. **Access:** anyone (view). **Parameters:** | Parameter | Type | Description | | --------------------- | ---------------- | ------------------------------------ | | `sourceChainSelector` | `uint64` | Source chain of the inbound message. | | (unnamed) | `bytes calldata` | Sender on the source chain. Ignored. | **Returns:** | Name | Type | Description | | ----------------------- | ------------------ | ---------------------------------------------------------------------- | | `requiredCCVs` | `address[] memory` | CCVs that must all attest. | | `optionalCCVs` | `address[] memory` | Optional CCVs. | | `optionalThreshold` | `uint8` | Number of optional CCVs that must attest. | | `allowedFinalityConfig` | `bytes4` | `inboundFinality[sourceChainSelector]`, in the `FinalityCodec` layout. | **Reverts with:** none. **Emits:** none. *** ### supportsInterface Reports which interfaces the adapter implements, per ERC-165. ```solidity function supportsInterface(bytes4 interfaceId) public view virtual override(AccessControlEnumerable) returns (bool supported) ``` > Returns `true` for `IAny2EVMMessageReceiver`, `IAny2EVMMessageReceiverV2`, `IAccessControlEnumerable`, `IAccessControl`, and `IERC165`. The CCIP OffRamp uses this check to decide whether to call `ccipReceive`, and on CCIP 2.0 lanes whether to read `getCCVsAndFinalityConfig`. **Access:** anyone (view). **Parameters:** | Parameter | Type | Description | | ------------- | -------- | ----------------------------- | | `interfaceId` | `bytes4` | ERC-165 interface identifier. | **Returns:** | Name | Type | Description | | ----------- | ------ | --------------------------------------------- | | `supported` | `bool` | `true` if the adapter supports the interface. | **Reverts with:** none. **Emits:** none. ## Integrator views ### preview Simulates a deposit or redemption, including the adapter fee, so a UI can show the expected output. ```solidity function preview( address token, address vaultTarget, uint256 amount, bool returnToSourceChain, uint64 assetFeeDestinationChainSelector ) external view returns (uint256 received) ``` > Call it on the hub chain with the source chain's selector. It follows the routing and fee rules of `processMessage` and calls the vault's `previewDeposit` or `previewRedeem`. > > - Returns `0` instead of reverting when `amount` is `0`, when the adapter fee is greater than or equal to the amount (deposit) or the redeemed assets (redemption), or when the vault preview returns `0`. > - Requires `assetFeeDestinationChainSelector` to be configured in `chains`, even when `returnToSourceChain` is `false`. > - Does not apply `minimumOut`. Vault state and the adapter fee can change before the message executes, so derive `minimumOut` from this value with a tolerance. See [Slippage protection](/solutions/cross-chain-vault-adapter/overview/how-it-works#slippage-protection). > - Does not catch vault reverts. If `asset()`, `previewDeposit`, or `previewRedeem` reverts, `preview` reverts. **Access:** anyone (view). **Parameters:** | Parameter | Type | Description | | ---------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------- | | `token` | `address` | Inbound token: the vault's asset for a deposit, or the vault (share token) address for a redemption. | | `vaultTarget` | `address` | Allowlisted vault, as in `Payload.target`. | | `amount` | `uint256` | Inbound amount, in `token` units. | | `returnToSourceChain` | `bool` | `true` applies the adapter fee for a return to the source chain. `false` simulates local delivery, with no adapter fee. | | `assetFeeDestinationChainSelector` | `uint64` | Source chain selector of the message. Must be configured in `chains`. Also keys `assetFees` when `returnToSourceChain` is `true`. | **Returns:** | Name | Type | Description | | ---------- | --------- | ------------------------------------------------------------------------------------------------------------------------ | | `received` | `uint256` | Expected output after the adapter fee: shares for a deposit, asset for a redemption. `0` when the request is not viable. | **Reverts with:** - `InvalidTarget(vaultTarget)` when the vault is not allowlisted. This check runs before the zero-amount check. - `InvalidChain(assetFeeDestinationChainSelector)` when the selector is not configured and `amount` is not zero. - `DepositsDisabled()` or `RedeemsDisabled()` when that path is switched off. - `InvalidTargetToken(vaultTarget, token)` when `token` is neither the asset nor the share token. - Errors from the vault's `asset()`, `previewDeposit`, or `previewRedeem`. **Emits:** none. *** ### checkRefundEligibility Reports whether a failed message can be refunded cross-chain, and estimates the fee. ```solidity function checkRefundEligibility(bytes32 messageId) external view returns (bool canRefund, bytes32 originalSender, address token, uint256 tokenAmount, uint256 requiredFee) ``` > Returns `canRefund = false` with zeroed fields when the message is not `BASIC`, when the stored sender is not exactly 32 bytes, or when no inbound token amount is positive. Otherwise it returns `canRefund = true`, the original sender, the first token with a non-zero amount, and `requiredFee`: the sum of one router `getFee` quote per non-zero token. > > This view can revert. After the early checks it calls the router's `getFee`, which reverts when, for example, the router has no lane back to the source chain, the configured return format does not match the lane, a token pool does not allow the requested finality, or the token has no pool on that lane. Treat a revert as "not refundable right now". > > `requiredFee` is an estimate. Send more than this as `msg.value` to `refundFailedMessage`; the adapter returns the excess. **Access:** anyone (view). **Parameters:** | Parameter | Type | Description | | ----------- | --------- | --------------------------------- | | `messageId` | `bytes32` | ID of the failed inbound message. | **Returns:** | Name | Type | Description | | ---------------- | --------- | ------------------------------------------------------------------------------------ | | `canRefund` | `bool` | Whether a cross-chain refund is possible now, when the call does not revert. | | `originalSender` | `bytes32` | Sender of the original message. The refund goes to this address on the source chain. | | `token` | `address` | First inbound token with a non-zero amount. | | `tokenAmount` | `uint256` | Amount of `token`. | | `requiredFee` | `uint256` | Estimated total fee for all refund sends, in the native gas token. | **Reverts with:** - `InvalidEVMAddress(originalSender)` when the refund goes to an EVM chain and the upper 96 bits of the stored sender are not zero. - Router, OnRamp, and token pool errors from `getFee`. See [Errors from dependencies](#errors-from-dependencies). **Emits:** none. *** ### estimateRefundFee Estimates the total fee, in the native gas token, to refund a failed message cross-chain. ```solidity function estimateRefundFee(bytes32 messageId) external view returns (uint256) ``` > Sums one router `getFee` quote per inbound token with a non-zero amount, using the current router state. The estimate can be lower than the cost at execution, because `refundFailedMessage` re-quotes each send and fees can change in between. Pad `msg.value` above this value. **Access:** anyone (view). **Parameters:** | Parameter | Type | Description | | ----------- | --------- | --------------------------------- | | `messageId` | `bytes32` | ID of the failed inbound message. | **Returns:** | Type | Description | | --------- | ------------------------------------------------------------------------------ | | `uint256` | Estimated total fee for all refund sends, in the hub chain's native gas token. | **Reverts with:** - `MessageNotFailed(messageId)` when the message is not `BASIC`. - `NoRefundableTokenAmounts()` when no inbound token amount is positive. - `InvalidSenderAddressFormat()` when the stored sender is not exactly 32 bytes. - `InvalidEVMAddress(originalSender)` when the refund goes to an EVM chain and the upper 96 bits of the stored sender are not zero. - Router, OnRamp, and token pool errors from `getFee`. **Emits:** none. *** ### checkLocalRecoveryEligibility Reports whether a failed message can be recovered on the hub chain, and by which address. ```solidity function checkLocalRecoveryEligibility(bytes32 messageId) external view returns (bool canRecover, address localRefundAddress, address token, uint256 tokenAmount) ``` > Returns `canRecover = false` with zeroed fields when the message is not `BASIC`, when no local refund address was stored, or when no inbound token amount is positive. Otherwise it returns `true`, the local refund address, and the first token with a non-zero amount. It does not check the caller, and it never reverts. **Access:** anyone (view). **Parameters:** | Parameter | Type | Description | | ----------- | --------- | --------------------------------- | | `messageId` | `bytes32` | ID of the failed inbound message. | **Returns:** | Name | Type | Description | | -------------------- | --------- | ------------------------------------------------------------- | | `canRecover` | `bool` | Whether the local refund address can recover the message now. | | `localRefundAddress` | `address` | The only address that can call `recoverFailedMessageLocally`. | | `token` | `address` | First inbound token with a non-zero amount. | | `tokenAmount` | `uint256` | Amount of `token`. | **Reverts with:** none. **Emits:** none. *** ### getFailedMessageRecord Returns the stored record of a failed message. ```solidity function getFailedMessageRecord(bytes32 messageId) external view returns (FailedMessageRecord memory record) ``` > The record exists only while `messageErrorCode[messageId]` is `BASIC`. For a message that never failed, or one that was refunded or recovered, the call returns a record with zero values. Read `messageErrorCode` to tell these states apart. **Access:** anyone (view). **Parameters:** | Parameter | Type | Description | | ----------- | --------- | -------------------------- | | `messageId` | `bytes32` | ID of the inbound message. | **Returns:** | Name | Type | Description | | -------- | ---------------------------- | -------------------------------------------------- | | `record` | `FailedMessageRecord memory` | See [`FailedMessageRecord`](#failedmessagerecord). | **Reverts with:** none. **Emits:** none. ## Recovery functions ### refundFailedMessage Sends the inbound tokens of a failed message back to the original sender on the source chain. ```solidity function refundFailedMessage(bytes32 messageId) external payable nonReentrant ``` > Anyone can call it, and the caller pays the CCIP fees through `msg.value`. The recipient comes from the stored record, not from the caller. It is the original `message.sender` on `sourceChainSelector`. It is not the beneficiary and not the local refund address. > > The function: > > 1. Requires the message to be `BASIC`, at least one non-zero token amount, and a sender of exactly 32 bytes. > 2. Sets the message to `RESOLVED` and deletes its record and chain family snapshot. > 3. Sends each non-zero token in its own token-only CCIP message, with the same fields as a return leg (see [`processMessage`](#processmessage)). It uses the chain family recorded at failure time, the lane's return format, and `evmReturnRequestedFinality` for the inbound token: the asset for a failed deposit, the share token for a failed redemption. > 4. Requires `msg.value` to cover the total fee paid, then sends any excess `msg.value` back to the caller. > > The adapter's own native gas token balance is not consumed. If any step reverts, the whole transaction reverts and the message stays `BASIC`. If the original sender is a contract, the tokens arrive at that contract's address on the source chain, and the contract is not called. **Access:** anyone. **Parameters:** | Parameter | Type | Description | | ----------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `messageId` | `bytes32` | ID of the failed inbound message. | | `msg.value` | native gas token | Fee for all refund sends. Start from [`estimateRefundFee`](#estimaterefundfee) or [`checkRefundEligibility`](#checkrefundeligibility) and add a margin. | **Returns:** none. **Reverts with:** - `MessageNotFailed(messageId)` when the message is not `BASIC`: it was never received, it succeeded, or it is already resolved. - `NoRefundableTokenAmounts()` when no inbound token amount is positive. - `InvalidSenderAddressFormat()` when the stored sender is not exactly 32 bytes. - `InvalidEVMAddress(originalSender)` when the refund goes to an EVM chain and the upper 96 bits of the stored sender are not zero. - `InsufficientNativeBalance(requiredFee, availableBalance)` when the adapter's native gas token balance, which includes `msg.value`, is below the router fee for a send. - `InsufficientRecoveryFee(requiredFee, providedFee)` when `msg.value` is below the total fee paid. - `RefundFailed()` when sending the excess `msg.value` back to the caller fails. - `ReentrancyGuardReentrantCall()` on a reentrant call. - Router, OnRamp, and token pool errors from `getFee` or `ccipSend`. **Emits:** - `MessageSent` for each token sent. - `MessageRefunded(messageId, sourceChainSelector, originalSender)`. *** ### recoverFailedMessageLocally Transfers the inbound tokens of a failed message to its local refund address on the hub chain. ```solidity function recoverFailedMessageLocally(bytes32 messageId) external nonReentrant ``` > Only the `localRefundAddress` stored in the failed-message record can call it. It costs no CCIP fee, and the function is not `payable`. It sets the message to `RESOLVED`, deletes the record and chain family snapshot, transfers each non-zero inbound token to the caller, and emits `MessageRecoveredLocally`. The tokens are the ones that arrived: the asset for a failed deposit, shares for a failed redemption. > > The local refund address comes from the payload's `deliveryAndRefund` field and cannot be set or changed afterward. Whichever recovery function succeeds first resolves the message; the other then reverts with `MessageNotFailed`. See [Local recovery](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#local-recovery). **Access:** the stored `localRefundAddress` only. **Parameters:** | Parameter | Type | Description | | ----------- | --------- | --------------------------------- | | `messageId` | `bytes32` | ID of the failed inbound message. | **Returns:** none. **Reverts with:** - `MessageNotFailed(messageId)` when the message is not `BASIC`. - `NoLocalRefundAddress(messageId)` when no local refund address was stored. - `UnauthorizedLocalRefund(caller, localRefundAddress)` when the caller is not the stored local refund address. - `NoRefundableTokenAmounts()` when no inbound token amount is positive. - `ReentrancyGuardReentrantCall()` on a reentrant call. - Token transfer errors. **Emits:** - `MessageRecoveredLocally(messageId, localRefundAddress)`. ## Configuration functions ### setChainType Configures how the adapter treats a remote chain selector. ```solidity function setChainType(uint64 chainSelector, ChainType chainType) external onlyRole(DEFAULT_ADMIN_ROLE) ``` > Writes `chains[chainSelector]`. The adapter processes inbound messages only from selectors set to a value other than `NONE`, and uses the value to encode return legs to that selector. Set `EVM` for an EVM source chain, such as Arbitrum Sepolia (selector `3478487238524512106`) for an adapter on Ethereum Sepolia. > > Setting a selector to `NONE` does not stop CCIP delivery. Messages that arrive afterward fail with `InvalidChain` and become failed messages that users must refund or recover. Cross-chain refunds of earlier failures use the chain family recorded at failure time. The function does not validate the selector. **Access:** `DEFAULT_ADMIN_ROLE`. **Parameters:** | Parameter | Type | Description | | --------------- | ----------- | --------------------------------------------------- | | `chainSelector` | `uint64` | CCIP chain selector of the remote chain. | | `chainType` | `ChainType` | `EVM`, `SVM`, or `NONE` to reject messages from it. | **Returns:** none. **Reverts with:** - `AccessControlUnauthorizedAccount(account, neededRole)` when the caller lacks the role. **Emits:** - `ChainTypeSet(chainSelector, chainType)`. *** ### setTargetEnabled Adds a vault to the allowlist or removes it. ```solidity function setTargetEnabled(address target, bool enabled) external onlyRole(DEFAULT_ADMIN_ROLE) ``` > Writes `enabledTargets[target]`. The payload's `target` selects the vault, so one adapter can serve several vaults. The function accepts any non-zero address and does not check that it is an ERC-4626 vault. Enable only vaults whose asset and share token behave as standard ERC-20 tokens, without fee-on-transfer or rebasing. See [Vault and token compatibility](/solutions/cross-chain-vault-adapter/reference/limitations#vault-and-token-compatibility). > > Disabling a vault does not stop delivery. Messages that arrive for it fail with `InvalidTarget` and become failed messages. **Access:** `DEFAULT_ADMIN_ROLE`. **Parameters:** | Parameter | Type | Description | | --------- | --------- | --------------------------------------- | | `target` | `address` | Vault address. | | `enabled` | `bool` | `true` to allowlist, `false` to remove. | **Returns:** none. **Reverts with:** - `InvalidTarget(address(0))` when `target` is the zero address. - `AccessControlUnauthorizedAccount(account, neededRole)` when the caller lacks the role. **Emits:** - `TargetEnabled(target, enabled)`. *** ### setProcessingEnabled Switches deposits and redemptions on or off for every vault on the adapter. ```solidity function setProcessingEnabled(bool depositsEnabled_, bool redeemsEnabled_) external onlyRole(DEFAULT_ADMIN_ROLE) ``` > Sets `depositsEnabled` and `redeemsEnabled`. Both start as `false` on a directly deployed adapter. > > This is not a pause. CCIP keeps delivering, and messages that arrive while their path is off, including messages already in flight, fail with `DepositsDisabled` or `RedeemsDisabled` and become failed messages. Switching the path back on does not reprocess them. See [What counts as a failure](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#what-counts-as-a-failure). **Access:** `DEFAULT_ADMIN_ROLE`. **Parameters:** | Parameter | Type | Description | | ------------------ | ------ | ------------------------------- | | `depositsEnabled_` | `bool` | Whether to process deposits. | | `redeemsEnabled_` | `bool` | Whether to process redemptions. | **Returns:** none. **Reverts with:** - `AccessControlUnauthorizedAccount(account, neededRole)` when the caller lacks the role. **Emits:** - `ProcessingEnabledSet(depositsEnabled, redeemsEnabled)`. *** ### setCCVsConfig Sets the CCV policy for messages from a source chain on CCIP 2.0 lanes. ```solidity function setCCVsConfig( uint64 sourceChainSelector, address[] calldata requiredCCVs, address[] calldata optionalCCVs, uint8 optionalThreshold_ ) external onlyRole(DEFAULT_ADMIN_ROLE) ``` > Replaces the stored `CCVConfig` for `sourceChainSelector`, which `getCCVsAndFinalityConfig` returns to the OffRamp. CCIP 1.6 lanes ignore it. Empty lists with a zero threshold mean the lane's default CCVs. In `requiredCCVs`, `address(0)` stands for the lane's default CCVs. > > The function validates the lists: > > - `optionalThreshold_` must be greater than 0 when `optionalCCVs` is not empty, and no greater than `optionalCCVs.length`. > - No address can appear twice in one list, or in both lists. > - `optionalCCVs` cannot contain `address(0)`. > > It does not check that the CCVs exist or serve the lane. If a required CCV never attests, messages from that source chain fail at the CCIP level before they reach the adapter. Fix the configuration, then retry them with manual execution. **Access:** `DEFAULT_ADMIN_ROLE`. **Parameters:** | Parameter | Type | Description | | --------------------- | -------------------- | ----------------------------------------- | | `sourceChainSelector` | `uint64` | Source chain the policy applies to. | | `requiredCCVs` | `address[] calldata` | CCVs that must all attest. | | `optionalCCVs` | `address[] calldata` | Optional CCVs. | | `optionalThreshold_` | `uint8` | Number of optional CCVs that must attest. | **Returns:** none. **Reverts with:** - `OptionalCCVsRequirePositiveThreshold(optionalCCVCount)` when `optionalCCVs` is not empty and the threshold is 0. - `InvalidOptionalThreshold(optionalThreshold, optionalCCVCount)` when the threshold is greater than the number of optional CCVs. - `DuplicateCCV(ccv)` when an address repeats within or across the lists. - `InvalidOptionalCCV()` when `optionalCCVs` contains `address(0)`. - `AccessControlUnauthorizedAccount(account, neededRole)` when the caller lacks the role. **Emits:** - `CCVsConfigSet(sourceChainSelector, requiredCCVs, optionalCCVs, optionalThreshold)`. *** ### setInboundFinality Sets which requested finality the adapter accepts for messages from a source chain on CCIP 2.0 lanes. ```solidity function setInboundFinality(uint64 sourceChainSelector, bytes4 allowedFinalityConfig) external onlyRole(DEFAULT_ADMIN_ROLE) ``` > Writes `inboundFinality[sourceChainSelector]`, which `getCCVsAndFinalityConfig` returns as `allowedFinalityConfig`. CCIP 2.0 OffRamps compare each message's requested finality against it; CCIP 1.6 lanes ignore it. The default, `0x00000000`, accepts only messages that wait for finality, so [faster-than-finality](/ccip/concepts/execution-latency/ftf-dapps) messages from that source chain fail at the CCIP level. > > The value uses the `FinalityCodec` layout. Bit 16 is the `safe` tag flag, and the low 16 bits are a block depth. A message that requests the `safe` tag is accepted when the flag is set. A message that requests N blocks is accepted when the configured depth is non-zero and N is at least that depth. A message that waits for finality is always accepted. The function performs no validation. **Access:** `DEFAULT_ADMIN_ROLE`. **Parameters:** | Parameter | Type | Description | | ----------------------- | -------- | ------------------------------------------------ | | `sourceChainSelector` | `uint64` | Source chain the setting applies to. | | `allowedFinalityConfig` | `bytes4` | Allowed finality, in the `FinalityCodec` layout. | **Returns:** none. **Reverts with:** - `AccessControlUnauthorizedAccount(account, neededRole)` when the caller lacks the role. **Emits:** - `InboundFinalitySet(sourceChainSelector, allowedFinalityConfig)`. *** ### setEvmReturnLaneFormat Sets the `extraArgs` format for return legs and refunds to an EVM destination chain. ```solidity function setEvmReturnLaneFormat(uint64 destinationChainSelector, EvmReturnExtraArgsFormat format) external onlyRole(DEFAULT_ADMIN_ROLE) ``` > `destinationChainSelector` is the source chain of the inbound messages whose outputs and refunds go back to it. Set `GENERIC_EXTRA_ARGS_V3_BASIC` on CCIP 2.0 lanes, such as Arbitrum Sepolia for an adapter on Ethereum Sepolia. Leave the format unset or set `LEGACY_EXTRA_ARGS_V2` on CCIP 1.6 lanes. > > Setting `GENERIC_EXTRA_ARGS_V3_BASIC` on a CCIP 1.6 lane makes return legs and refunds revert at the router. Return-to-source messages then become failed messages, and cross-chain refunds to that chain revert until the format is corrected. > > `UNSET` cannot be written. To return to the legacy encoding, set `LEGACY_EXTRA_ARGS_V2`. **Access:** `DEFAULT_ADMIN_ROLE`. **Parameters:** | Parameter | Type | Description | | -------------------------- | -------------------------- | -------------------------------------------------------- | | `destinationChainSelector` | `uint64` | Chain that return legs and refunds go to. | | `format` | `EvmReturnExtraArgsFormat` | `LEGACY_EXTRA_ARGS_V2` or `GENERIC_EXTRA_ARGS_V3_BASIC`. | **Returns:** none. **Reverts with:** - `InvalidEvmReturnExtraArgsFormat()` when `format` is `UNSET`. - `AccessControlUnauthorizedAccount(account, neededRole)` when the caller lacks the role. **Emits:** - `EvmReturnLaneFormatSet(destinationChainSelector, format)`. *** ### setEvmReturnRequestedFinality Sets the finality that return legs and refunds of one token request on a CCIP 2.0 lane. ```solidity function setEvmReturnRequestedFinality( uint64 destinationChainSelector, address token, bytes4 requestedFinalityForV3 ) external onlyRole(DEFAULT_ADMIN_ROLE) ``` > `token` is the token bridged on that leg. It is the share token for deposit returns and failed-redemption refunds, and the asset for redemption returns and failed-deposit refunds. > > A non-zero value requires the lane format for `destinationChainSelector` to already be `GENERIC_EXTRA_ARGS_V3_BASIC`. On any other format, passing `0x00000000` clears the stored value. On a V3 lane, the value must select exactly one mode. The token pools on the lane must allow the requested finality. If they do not, the router rejects the send, return legs become failed messages, and refunds revert. The requested finality takes one of these values: | Value | Meaning | | ---------------------------- | --------------------------------------- | | `0x00000000` | Wait for finality. This is the default. | | `0x00010000` | Wait for the `safe` tag. | | `0x00000001` to `0x0000FFFF` | Wait for that many blocks. | Other single flags in bits 17 to 31 pass the adapter's validation, but token pools and CCVs reject modes they do not implement. **Access:** `DEFAULT_ADMIN_ROLE`. **Parameters:** | Parameter | Type | Description | | -------------------------- | --------- | -------------------------------------------------- | | `destinationChainSelector` | `uint64` | Chain that return legs and refunds go to. | | `token` | `address` | Token bridged on the leg. Must be non-zero. | | `requestedFinalityForV3` | `bytes4` | Requested finality, in the `FinalityCodec` layout. | **Returns:** none. **Reverts with:** - `InvalidTarget(address(0))` when `token` is the zero address. - `UnexpectedRequestedFinalityForLegacyFormat(requestedFinalityForV3)` when the value is non-zero and the lane format is not `GENERIC_EXTRA_ARGS_V3_BASIC`. - `RequestedFinalityCanOnlyHaveOneMode(encodedFinality)` from `FinalityCodec` when the value combines a flag with a block depth or sets more than one flag. - `AccessControlUnauthorizedAccount(account, neededRole)` when the caller lacks the role. **Emits:** - `EvmReturnRequestedFinalitySet(destinationChainSelector, token, requestedFinalityForV3)`, with `0x00000000` when the call clears the value on a non-V3 lane. *** ### recoverNative Sends the adapter's entire native gas token balance to a recipient. ```solidity function recoverNative(address recipient) external nonReentrant onlyRole(DEFAULT_ADMIN_ROLE) ``` > There is no amount parameter, so the call always sends the full balance. Afterward the adapter cannot pay CCIP fees, so return-to-source messages fail with `InsufficientNativeBalance` until someone funds it again. **Access:** `DEFAULT_ADMIN_ROLE`. **Parameters:** | Parameter | Type | Description | | ----------- | --------- | -------------------------------------- | | `recipient` | `address` | Receives the native gas token balance. | **Returns:** none. **Reverts with:** - `InvalidRecipient()` when `recipient` is the zero address. - `AmountIsZero()` when the balance is zero. - `RecoverNativeFailed()` when the transfer to `recipient` fails. - `ReentrancyGuardReentrantCall()` on a reentrant call. - `AccessControlUnauthorizedAccount(account, neededRole)` when the caller lacks the role. **Emits:** - `NativeRecovered(recipient, amount)`. ## Fee functions ### setAssetFee Sets the adapter fee for returns of one token to one chain. ```solidity function setAssetFee(uint64 destinationChainSelector, address bridgedToken, uint256 fee) external onlyRole(FEE_SETTER_ROLE) ``` > Writes `assetFees[destinationChainSelector][bridgedToken]`. The adapter fee is a flat amount in the asset's smallest units on both paths. It applies only when the output returns to the source chain. > > - **Deposit return:** the key is the share token (the vault address). The fee is taken from the inbound asset before the deposit. > - **Redemption return:** the key is the asset. The fee is taken from the redeemed assets. > > There is no upper bound. A fee greater than or equal to the amount makes those messages fail with `FeeExceedsAmount`. The fee is read when a message executes, so a change applies to messages already in flight. You can set a fee before configuring the chain with `setChainType`. Set `0` to remove a fee. See [Fees](/solutions/cross-chain-vault-adapter/overview/how-it-works#fees). **Access:** `FEE_SETTER_ROLE`. **Parameters:** | Parameter | Type | Description | | -------------------------- | --------- | ---------------------------------------------------------------------------------------- | | `destinationChainSelector` | `uint64` | Chain the output returns to, which is the source chain of the inbound message. | | `bridgedToken` | `address` | Token bridged back, which is the share token for deposits and the asset for redemptions. | | `fee` | `uint256` | Fee in the asset's smallest units. | **Returns:** none. **Reverts with:** - `InvalidTarget(address(0))` when `bridgedToken` is the zero address. - `AccessControlUnauthorizedAccount(account, neededRole)` when the caller lacks the role. **Emits:** - `AssetFeeSet(destinationChainSelector, bridgedToken, fee)`. *** ### setAssetFees Sets several adapter fees in one call. ```solidity function setAssetFees( uint64[] calldata destinationChainSelectors, address[] calldata bridgedTokens, uint256[] calldata fees ) external onlyRole(FEE_SETTER_ROLE) ``` > Each index applies one `setAssetFee` row with the same validation. A zero token in any row reverts the whole call. **Access:** `FEE_SETTER_ROLE`. **Parameters:** | Parameter | Type | Description | | --------------------------- | -------------------- | ------------------------------------------- | | `destinationChainSelectors` | `uint64[] calldata` | Return chain per row. | | `bridgedTokens` | `address[] calldata` | Token bridged back per row. | | `fees` | `uint256[] calldata` | Fee per row, in the asset's smallest units. | **Returns:** none. **Reverts with:** - `FeeConfigLengthMismatch()` when the three arrays differ in length. - `InvalidTarget(address(0))` when any `bridgedTokens` entry is the zero address. - `AccessControlUnauthorizedAccount(account, neededRole)` when the caller lacks the role. **Emits:** - `AssetFeeSet(destinationChainSelector, bridgedToken, fee)` for each row. *** ### withdrawFee Transfers accrued adapter fees to a recipient. ```solidity function withdrawFee(address asset, address recipient, uint256 amount) external nonReentrant onlyRole(FEE_COLLECTOR_ROLE) ``` > Reduces `collectedFees[asset]` by `amount` and transfers that amount. Partial withdrawals are allowed. The function withdraws only fees the adapter has accounted for. The adapter has no general ERC-20 sweep, so tokens sent to it directly, outside a CCIP message, cannot be recovered. **Access:** `FEE_COLLECTOR_ROLE`. **Parameters:** | Parameter | Type | Description | | ----------- | --------- | -------------------------------------- | | `asset` | `address` | Asset whose fees to withdraw. | | `recipient` | `address` | Receives the fees. | | `amount` | `uint256` | Amount, in the asset's smallest units. | **Returns:** none. **Reverts with:** - `InvalidRecipient()` when `recipient` is the zero address. - `AmountIsZero()` when `amount` is zero. - `InsufficientFeeBalance(availableBalance, requestedAmount)` when `amount` exceeds `collectedFees[asset]`. - Token transfer errors, for example when the adapter holds less of the asset than its accounting allows. - `ReentrancyGuardReentrantCall()` on a reentrant call. - `AccessControlUnauthorizedAccount(account, neededRole)` when the caller lacks the role. **Emits:** - `FeeWithdrawn(asset, recipient, amount)`. ## Events The adapter declares these 17 events: ```solidity event MessageSent( bytes32 indexed messageId, uint64 indexed destinationChainSelector, ChainType indexed chainType, bytes32 beneficiary, address token, uint256 amount, uint256 fee ); event MessageSucceeded(bytes32 indexed messageId); event MessageFailed(bytes32 indexed messageId); event MessageRefunded( bytes32 indexed messageId, uint64 indexed destinationChainSelector, bytes32 indexed beneficiary ); event MessageRecoveredLocally(bytes32 indexed messageId, address indexed localRefundAddress); event TargetProcessed( bytes32 indexed messageId, address indexed target, address indexed inputToken, address outputToken, uint256 inputAmount, uint256 outputAmount ); event TargetEnabled(address indexed target, bool enabled); event ChainTypeSet(uint64 indexed chainSelector, ChainType chainType); event ProcessingEnabledSet(bool depositsEnabled, bool redeemsEnabled); event AssetFeeSet(uint64 indexed destinationChainSelector, address indexed bridgedToken, uint256 fee); event CCVsConfigSet( uint64 indexed sourceChainSelector, address[] requiredCCVs, address[] optionalCCVs, uint8 optionalThreshold ); event InboundFinalitySet(uint64 indexed sourceChainSelector, bytes4 allowedFinalityConfig); event EvmReturnLaneFormatSet(uint64 indexed destinationChainSelector, EvmReturnExtraArgsFormat format); event EvmReturnRequestedFinalitySet( uint64 indexed destinationChainSelector, address indexed token, bytes4 requestedFinalityForV3 ); event FeeWithdrawn(address indexed asset, address indexed recipient, uint256 amount); event NativeRecovered(address indexed recipient, uint256 amount); event LocalTokenDelivered( bytes32 indexed messageId, address indexed token, address indexed beneficiary, uint256 amount ); ``` | Event | Emitted when | Use it to | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `MessageSucceeded` | `processMessage` completes inside `ccipReceive`. | Mark an inbound message as processed. | | `MessageFailed` | `processMessage` reverts and the adapter records a failed message. | Alert on failures and offer the user a cross-chain refund or local recovery. The CCIP Explorer still shows Success. | | `TargetProcessed` | The vault deposit or redemption completes, before delivery. | Record the inbound amount and the output after the adapter fee for each message. | | `LocalTokenDelivered` | The output is transferred to the beneficiary on the hub chain. | Confirm local delivery. | | `MessageSent` | The adapter sends a CCIP message: a return leg, or one leg of a cross-chain refund. | Track the outbound message ID on the CCIP Explorer and the fee paid in the native gas token. `messageId` here is the outbound ID. | | `MessageRefunded` | `refundFailedMessage` completes. `destinationChainSelector` is the original source chain and `beneficiary` the original sender. | Close a failed message as refunded. | | `MessageRecoveredLocally` | `recoverFailedMessageLocally` completes. | Close a failed message as recovered on the hub chain. | | `TargetEnabled` | `setTargetEnabled` runs. | Review the vault allowlist. | | `ChainTypeSet` | `setChainType` runs. | Review which source chains are configured. | | `ProcessingEnabledSet` | `setProcessingEnabled` runs. | Warn users before they send a request that will fail. | | `AssetFeeSet` | `setAssetFee` runs, or once per row of `setAssetFees`. | Update the adapter fee shown in a UI. | | `CCVsConfigSet` | `setCCVsConfig` runs. | Review the CCV policy per source chain. | | `InboundFinalitySet` | `setInboundFinality` runs. | Review which faster-than-finality modes are accepted. | | `EvmReturnLaneFormatSet` | `setEvmReturnLaneFormat` runs. | Review return formats per lane. | | `EvmReturnRequestedFinalitySet` | `setEvmReturnRequestedFinality` runs. | Review return finality per lane and token. | | `FeeWithdrawn` | `withdrawFee` runs. | Reconcile fee withdrawals. | | `NativeRecovered` | `recoverNative` runs. | Raise an alert, because the adapter can no longer pay for return legs until it is funded again. | `TargetProcessed`, `LocalTokenDelivered`, and the return-leg `MessageSent` are emitted inside `processMessage`. When processing fails they roll back, so a failed message emits only `MessageFailed`. On success, the inbound message's `TargetProcessed` and `MessageSucceeded` appear in the same transaction as the return leg's `MessageSent`, which links the inbound and outbound message IDs. The adapter also inherits `RoleGranted` and `RoleRevoked` from OpenZeppelin `AccessControl`. Watch them for role changes. `RoleAdminChanged` is part of the ABI, but the adapter never emits it because it never changes a role's admin. ## Errors ### Adapter errors The adapter declares these 35 custom errors: ```solidity error InvalidRouter(address router); error InvalidAdmin(); error InvalidFeeSetter(); error InvalidFeeCollector(); error InvalidRecipient(); error InvalidChain(uint64 chainSelector); error OnlySelf(); error AmountIsZero(); error InvalidEVMAddress(bytes32 beneficiary); error InvalidSenderAddressFormat(); error InsufficientNativeBalance(uint256 requiredFee, uint256 availableBalance); error InsufficientRecoveryFee(uint256 requiredFee, uint256 providedFee); error InsufficientFeeBalance(uint256 availableBalance, uint256 requestedAmount); error MessageNotFailed(bytes32 messageId); error InvalidTarget(address target); error InvalidTargetToken(address target, address token); error InvalidTokenCount(uint256 tokenCount); error InvalidPayloadLength(uint256 length, uint256 expected); error FeeConfigLengthMismatch(); error InvalidEvmReturnExtraArgsFormat(); error UnexpectedRequestedFinalityForLegacyFormat(bytes4 requestedFinalityForV3); error InvalidOptionalThreshold(uint8 optionalThreshold, uint256 optionalCCVCount); error OptionalCCVsRequirePositiveThreshold(uint256 optionalCCVCount); error DuplicateCCV(address ccv); error InvalidOptionalCCV(); error DepositsDisabled(); error RedeemsDisabled(); error FeeExceedsAmount(uint256 amount, uint256 fee); error MinimumOutputNotMet(uint256 minimumOut, uint256 actualOut); error NoOutputReceived(); error RefundFailed(); error RecoverNativeFailed(); error NoRefundableTokenAmounts(); error NoLocalRefundAddress(bytes32 messageId); error UnauthorizedLocalRefund(address caller, address localRefundAddress); ``` "Becomes a failed message" is yes when the error is raised while `processMessage` handles an inbound message, because `ccipReceive` catches it and records a failed message. Errors raised by views, recovery functions, and setters revert the call directly. | Error | Raised by | Meaning | Becomes a failed message | | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------- | | `InvalidRouter` | Constructor, `ccipReceive` | The router is the zero address at construction, or the caller of `ccipReceive` is not `ROUTER`. | No | | `InvalidAdmin` | Constructor | `defaultAdmin` is the zero address. The factory reuses this error. | No | | `InvalidFeeSetter` | Constructor | `feeSetter` is the zero address. The factory reuses this error. | No | | `InvalidFeeCollector` | Constructor | `feeCollector` is the zero address. The factory reuses this error. | No | | `InvalidRecipient` | `withdrawFee`, `recoverNative` | The recipient is the zero address. | No | | `InvalidChain` | `processMessage`, `preview` | The source chain selector is `NONE` in `chains`. | Yes from `processMessage` | | `OnlySelf` | `processMessage` | An address other than the adapter called `processMessage`. | No | | `AmountIsZero` | `processMessage`, `withdrawFee`, `recoverNative` | The inbound amount, the withdrawal amount, or the native gas token balance is zero. | Yes from `processMessage` | | `InvalidEVMAddress` | `processMessage`, `refundFailedMessage`, `estimateRefundFee`, `checkRefundEligibility` | The beneficiary or the original sender has non-zero upper 96 bits. | Yes from `processMessage` | | `InvalidSenderAddressFormat` | `refundFailedMessage`, `estimateRefundFee` | The stored sender is not exactly 32 bytes. | No | | `InsufficientNativeBalance` | `processMessage` (return leg), `refundFailedMessage` | The native gas token balance available for a send is below the router fee. | Yes from `processMessage` | | `InsufficientRecoveryFee` | `refundFailedMessage` | `msg.value` is below the total fee paid for the refund. | No | | `InsufficientFeeBalance` | `withdrawFee` | `amount` exceeds `collectedFees[asset]`. | No | | `MessageNotFailed` | `refundFailedMessage`, `recoverFailedMessageLocally`, `estimateRefundFee` | The message is not `BASIC`. | No | | `InvalidTarget` | `processMessage`, `preview`, `setTargetEnabled`, `setAssetFee`, `setAssetFees`, `setEvmReturnRequestedFinality` | The vault is not allowlisted, or a setter received the zero address. | Yes from `processMessage` | | `InvalidTargetToken` | `processMessage`, `preview` | The token is neither the vault's asset nor its share token. | Yes from `processMessage` | | `InvalidTokenCount` | `processMessage` | The message does not carry exactly one token. | Yes | | `InvalidPayloadLength` | `processMessage` | `message.data` is not 128 bytes. | Yes | | `FeeConfigLengthMismatch` | `setAssetFees` | The three arrays differ in length. | No | | `InvalidEvmReturnExtraArgsFormat` | `setEvmReturnLaneFormat` | The format is `UNSET`. | No | | `UnexpectedRequestedFinalityForLegacyFormat` | `setEvmReturnRequestedFinality` | A non-zero finality was set for a lane whose format is not `GENERIC_EXTRA_ARGS_V3_BASIC`. | No | | `InvalidOptionalThreshold` | `setCCVsConfig` | The threshold is greater than the number of optional CCVs. | No | | `OptionalCCVsRequirePositiveThreshold` | `setCCVsConfig` | Optional CCVs were given with a threshold of 0. | No | | `DuplicateCCV` | `setCCVsConfig` | An address repeats within or across the CCV lists. | No | | `InvalidOptionalCCV` | `setCCVsConfig` | `optionalCCVs` contains the zero address. | No | | `DepositsDisabled` | `processMessage`, `preview` | Deposits are switched off. | Yes from `processMessage` | | `RedeemsDisabled` | `processMessage`, `preview` | Redemptions are switched off. | Yes from `processMessage` | | `FeeExceedsAmount` | `processMessage` | The adapter fee is greater than or equal to the deposit input or the redeemed assets. `preview` returns `0` instead. | Yes | | `MinimumOutputNotMet` | `processMessage` | The output after the adapter fee is below `minimumOut`. | Yes | | `NoOutputReceived` | `processMessage` | The vault returned zero shares or zero assets. | Yes | | `RefundFailed` | `refundFailedMessage` | Sending the excess `msg.value` back to the caller failed. | No | | `RecoverNativeFailed` | `recoverNative` | The native gas token transfer to the recipient failed. | No | | `NoRefundableTokenAmounts` | `refundFailedMessage`, `recoverFailedMessageLocally`, `estimateRefundFee` | No inbound token amount is positive. | No | | `NoLocalRefundAddress` | `recoverFailedMessageLocally` | No local refund address was stored for the message. | No | | `UnauthorizedLocalRefund` | `recoverFailedMessageLocally` | The caller is not the stored local refund address. | No | ### Errors from dependencies Callers can also see these errors from inherited contracts, libraries, and CCIP contracts: | Error | Source | Raised when | | ---------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `AccessControlUnauthorizedAccount(address account, bytes32 neededRole)` | OpenZeppelin `AccessControl` | The caller lacks the role for a configuration or fee function, or the admin role for `grantRole` or `revokeRole`. | | `AccessControlBadConfirmation()` | OpenZeppelin `AccessControl` | `renounceRole` is called with an account other than the caller. | | `ReentrancyGuardReentrantCall()` | OpenZeppelin `ReentrancyGuard` | `refundFailedMessage`, `recoverFailedMessageLocally`, `withdrawFee`, or `recoverNative` is re-entered. | | `RequestedFinalityCanOnlyHaveOneMode(bytes4 encodedFinality)` | `FinalityCodec`, in `setEvmReturnRequestedFinality` | The requested finality combines a flag with a block depth or sets more than one flag. | | `UnsupportedDestinationChain(uint64 destChainSelector)` | CCIP Router, in `getFee` and `ccipSend` | The hub-chain router has no lane to the return or refund destination. | | `BadARMSignal()` | CCIP Router, in `ccipSend` | The hub chain is cursed through the RMN contract, so sends revert until the curse is lifted. | | `InvalidRequestedFinality(bytes4 requestedFinality, bytes4 allowedFinality)` | `FinalityCodec`, raised by a token pool in `getFee` or `ccipSend` | The token pool does not allow the return leg's requested finality. | OnRamps, fee quoters, token pools, vaults, and tokens can raise other errors during a send or vault call. `SafeERC20` from OpenZeppelin 4.8.3 reverts with string messages such as `SafeERC20: ERC20 operation did not succeed`. Inside `processMessage`, all of these are caught and the message becomes a failed message. In refund functions and views, they revert the call. ## Security notes **Processing failures are caught.** A revert in `processMessage` does not revert CCIP execution, except for the malformed payload described below. The adapter keeps the tokens as a failed message, and the CCIP Explorer shows Success. Detect failures with `MessageFailed` or `messageErrorCode`, not with the CCIP Explorer status. Nothing refunds or retries a failed message automatically. **No sender allowlist.** Any sender on a chain configured in `chains` can deposit into or redeem from any allowlisted vault. `getCCVsAndFinalityConfig` ignores the sender. The adapter gates requests only by source chain, vault, and the deposit and redeem switches. The vault sees the adapter as its depositor, so a vault that allowlists depositors admits every cross-chain user once it allowlists the adapter. **Admin powers.** `DEFAULT_ADMIN_ROLE` holders can change chain types, the vault allowlist, the deposit and redeem switches, CCV and finality policy, and return formats; withdraw the entire native gas token balance; and grant or revoke every role. `FEE_SETTER_ROLE` holders can change the adapter fee, which applies to messages already in flight; `minimumOut` is the user's protection against that. Every change takes effect in the transaction that makes it. No role can transfer the tokens of a failed message. Only `refundFailedMessage` and `recoverFailedMessageLocally` move them, and `withdrawFee` is limited to `collectedFees`. **Reentrancy.** `refundFailedMessage`, `recoverFailedMessageLocally`, `withdrawFee`, and `recoverNative` use `nonReentrant`. Both recovery functions mark the message `RESOLVED` and delete its record before they move tokens. **The adapter pays CCIP fees only in the native gas token.** Return legs pay the CCIP fee from the adapter's native gas token balance, never in LINK. When the balance is below the fee, return-to-source messages fail with `InsufficientNativeBalance`. Cross-chain refunds are paid from the caller's `msg.value`. The sender pays the source-chain CCIP fee in the native gas token or LINK. **Outputs return only to the source chain.** Return legs and refunds go to the inbound message's source chain. The adapter cannot route output to a third chain. Refunds go to the original sender, so a refund for a message sent through a contract arrives at that contract. Return-leg `extraArgs` come from the adapter's configuration, never from message data. **Malformed payloads.** A hand-built payload can make `ccipReceive` itself revert and strand the tokens permanently, as explained in [Failures with no recovery path](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#failures-with-no-recovery-path). **Token-only messages.** A CCIP message with empty `data` and a gas limit of 0 is a token-only transfer. The OffRamp delivers the tokens to the adapter without calling `ccipReceive`, so the adapter records nothing and has no function that returns them. An ERC-20 transfer sent straight to the adapter ends up in the same state. See [When funds can get stuck](/solutions/cross-chain-vault-adapter/reference/limitations#when-funds-can-get-stuck). **Forks.** If you fork the adapter, keep the router check in `ccipReceive`, the self-call isolation of `processMessage`, failed-message storage and both recovery paths, and the beneficiary address checks. The failure handler, the `catch` path in `ccipReceive`, must never revert on any input. See [Customizing the adapter](/solutions/cross-chain-vault-adapter/reference/limitations#customizing-the-adapter). ## Related contracts - [Factory contract](/solutions/cross-chain-vault-adapter/reference/factory-contract): deploys and configures adapters - [Limitations](/solutions/cross-chain-vault-adapter/reference/limitations): what the adapter does not do - [`IAny2EVMMessageReceiverV2`](/ccip/evm/api-reference/v2.0.0/i-any2-evm-message-receiver-v2): the CCIP 2.0 receiver interface the adapter implements - [`Client`](/ccip/evm/api-reference/v2.0.0/client): CCIP message structs and `GenericExtraArgsV2` - [`ExtraArgsCodec`](/ccip/evm/api-reference/v2.0.0/extra-args-codec): `GenericExtraArgsV3` encoding for return legs on CCIP 2.0 lanes - [`CCIPReceiver`](/ccip/evm/api-reference/v2.0.0/ccip-receiver): CCIP's base receiver, which the adapter does not inherit - [`CCVConfigValidation`](/ccip/evm/api-reference/v2.0.0/ccv-config-validation): CCV list rules applied by CCIP - [OpenZeppelin `AccessControlEnumerable`](https://docs.openzeppelin.com/contracts/5.x/api/access#AccessControlEnumerable): role management - [EIP-4626](https://eips.ethereum.org/EIPS/eip-4626): the tokenized vault standard ## Disclaimer Please note, this repo contains community examples only — these are not Chainlink products or services and are not supported or maintained by Chainlink. This code represents an example of using a Chainlink product or service, and is intended for demonstration and educational purposes only. It is provided “AS IS” and “AS AVAILABLE” without warranties of any kind, may not have been audited, and may omit checks or error handling. Each party intending to use this example code does so entirely at their own risk and must perform its own audits, security and code review, key management, and testing before any production deployment and ensure the operation and performance of such code matches expectations. Neither Chainlink Labs nor the Chainlink Foundation deploys, operates, monitors, maintains or endorses any deployment of this code. Note that this is not a Chainlink product, feature or service, and there are no commitments made with respect to the code, including compatibility with future Chainlink releases. You should not rely on this code without first conducting your own technical, engineering, and security review. This code is also outside the scope of any Chainlink bug bounty programs. Neither Chainlink Labs, the Chainlink Foundation, nor Chainlink node operators are responsible for outcomes due to errors in this example or how it is deployed or operated, or liable for any resulting claims or damages. Use of the Chainlink Network is subject to the Chainlink Foundation [Terms of Service](https://chain.link/terms), which provides important information and disclosures. By using this code, you acknowledge and agree to these terms. --- # Factory contract Source: https://docs.chain.link/solutions/cross-chain-vault-adapter/reference/factory-contract 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. `CrossChainERC4626AdapterFactory` deploys a `CrossChainERC4626Adapter` on the hub chain, applies its initial configuration, and hands its roles to your accounts in one transaction. This contract provides: - deployment of an adapter bound to the CCIP Router you pass in - the initial chain types, vault allowlist entry, deposit and redeem switches, and adapter fees - a role handoff that leaves the factory with no roles on the adapter > **NOTE: Who calls the factory** > > The vault team calls `deploy` once per adapter, on the hub chain. Users and integrators never call the factory. They > send CCIP messages to the deployed adapter, as described in [How it > works](/solutions/cross-chain-vault-adapter/overview/how-it-works). To deploy an adapter for your vault, see [Deploy > the adapter](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter). ## Usage boundary - Anyone can call `deploy`. The factory has no owner, allowlist, or fee. - After `deploy` returns, the factory holds no role on the adapter and cannot change it. - The factory keeps no registry of adapters. The `AdapterDeployed` event is the only on-chain record that links an adapter to this factory. - The factory sets the initial configuration only. Send later changes to the adapter directly, from the account that holds the required role. See the [configuration functions](/solutions/cross-chain-vault-adapter/reference/adapter-contract#configuration-functions) on the adapter. ## Contract [`src/ccip/CrossChainERC4626AdapterFactory.sol`](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/src/ccip/CrossChainERC4626AdapterFactory.sol) - Version: `CrossChainERC4626AdapterFactory 1.0.0`, returned by [`typeAndVersion`](#typeandversion). - The factory and the adapter are audited. - Both are compiled with solc 0.8.24 for the cancun EVM version, and their bytecode uses the `PUSH0` opcode. Deploy them only on chains that support the Shanghai upgrade. ## Deployed addresses | Network | Address | | ---------------- | -------------------------------------------- | | Ethereum mainnet | `0x1b79038DCCbeE0E406eA0c1bA758A2cF696b38D4` | | Ethereum Sepolia | `0x12A0f45738F3DDA931C4D3ECd1b267355e587921` | You can deploy your own factory with the repository's `pnpm ccip:deploy-factory` script. ## Inheritance - [`ITypeAndVersion`](/ccip/evm/api-reference/v2.0.0/i-type-and-version) ## Functions ### deploy Deploys and configures a `CrossChainERC4626Adapter` in one transaction. ```solidity function deploy(DeploymentConfig calldata config) external returns (address adapterAddress) ``` > The factory holds all three roles while it applies `config`, then grants each role to its configured account and renounces its own. Any revert undoes the whole transaction, so no adapter is created. The numbered steps after **Emits** list the exact order. > > `deploy` leaves the CCIP 2.0 settings at their defaults and does not fund the adapter. See [Security notes](#security-notes). **Access:** anyone. **Parameters:** | Parameter | Type | Description | | --------- | -------------------------------------------------- | ------------------------------------------------------------------------------------- | | `config` | [`DeploymentConfig`](#deploymentconfig) `calldata` | Router, role accounts, vault, processing switches, and batches of chain and fee rows. | **Returns:** | Name | Type | Description | | ---------------- | --------- | -------------------------------- | | `adapterAddress` | `address` | Address of the deployed adapter. | **Reverts with:** - `InvalidAdmin`, `InvalidFeeSetter`, or `InvalidFeeCollector` when the matching role account is the zero address. - `InvalidRouter(address(0))` from the adapter constructor when `config.router` is the zero address. - `InvalidTarget(address(0))` from the adapter's `setAssetFee` when a `FeeConfig` row has a zero `bridgedToken`. **Emits:** - `AdapterDeployed` from the factory. - The adapter's own events from the configuration calls: `ChainTypeSet` per chain row, `TargetEnabled` when `vaultTarget` is set, `ProcessingEnabledSet`, and `AssetFeeSet` per fee row. See the adapter's [events](/solutions/cross-chain-vault-adapter/reference/adapter-contract#events). - OpenZeppelin `RoleGranted` and `RoleRevoked` events from the adapter for the role handoff. `deploy` runs these steps in order: 1. Reverts if `defaultAdmin`, `feeSetter`, or `feeCollector` is the zero address. These checks run before the adapter constructor, so a call with a zero router and a zero admin reverts with `InvalidAdmin`. 2. Deploys the adapter with `CREATE`, passing `config.router` and the factory's own address as admin, fee setter, and fee collector. The factory now holds all three roles. 3. Calls `setChainType` once for each `chainConfigs` row. 4. Calls `setTargetEnabled(vaultTarget, targetEnabled)` only when `vaultTarget` is not the zero address. Otherwise it skips the call and ignores `targetEnabled`. 5. Calls `setProcessingEnabled(depositsEnabled, redeemsEnabled)`. This call always runs. 6. Calls `setAssetFee` once for each `feeConfigs` row. 7. Hands off the roles, as described in [Role handoff](#role-handoff). 8. Emits `AdapterDeployed` and returns the adapter address. Rows apply in array order. A later row for the same chain selector, or the same selector and token pair, overwrites an earlier one. *** ### typeAndVersion Returns the factory's type and version, declared as a public constant: ```solidity string public constant override typeAndVersion = "CrossChainERC4626AdapterFactory 1.0.0"; ``` **Access:** anyone. **Returns:** `string`, the contract type and version. The repository's `pnpm ccip:deploy` script checks this value before it deploys through an existing factory. ## Types The three structs are declared in the factory: ```solidity struct ChainConfig { uint64 chainSelector; CrossChainERC4626Adapter.ChainType chainType; } struct FeeConfig { uint64 destinationChainSelector; address bridgedToken; uint256 fee; } struct DeploymentConfig { address router; address defaultAdmin; address feeSetter; address feeCollector; address vaultTarget; bool targetEnabled; bool depositsEnabled; bool redeemsEnabled; ChainConfig[] chainConfigs; FeeConfig[] feeConfigs; } ``` ### DeploymentConfig | Field | Type | Description | | ----------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `router` | `address` | CCIP Router on the hub chain. It becomes the adapter's immutable `ROUTER`, the only caller allowed to deliver messages. Must not be zero. Get the address from the CCIP Directory for [testnet](/ccip/directory/testnet) or [mainnet](/ccip/directory/mainnet). | | `defaultAdmin` | `address` | Receives `DEFAULT_ADMIN_ROLE`. Must not be zero. | | `feeSetter` | `address` | Receives `FEE_SETTER_ROLE`. Must not be zero. | | `feeCollector` | `address` | Receives `FEE_COLLECTOR_ROLE`. Must not be zero. | | `vaultTarget` | `address` | ERC-4626 vault to allowlist. Pass the zero address to skip. | | `targetEnabled` | `bool` | Value passed to `setTargetEnabled` for `vaultTarget`. Ignored when `vaultTarget` is zero. | | `depositsEnabled` | `bool` | Whether the adapter processes deposits. | | `redeemsEnabled` | `bool` | Whether the adapter processes redemptions. | | `chainConfigs` | `ChainConfig[]` | Source chains to configure. Can be empty. | | `feeConfigs` | `FeeConfig[]` | Adapter fee rows. Can be empty. | ### ChainConfig | Field | Type | Description | | --------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `chainSelector` | `uint64` | CCIP chain selector of a source chain. | | `chainType` | `CrossChainERC4626Adapter.ChainType` | `NONE` (0) disables the chain, `EVM` (1) is an EVM chain, `SVM` (2) is the Solana Virtual Machine chain family. Messages from a chain whose type is `NONE` become [failed messages](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery) in the adapter. | ### FeeConfig Each row becomes one `setAssetFee` call. The code calls the adapter fee the asset fee. | Field | Type | Description | | -------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `destinationChainSelector` | `uint64` | Chain the output returns to, which is the source chain of the inbound message. | | `bridgedToken` | `address` | Token bridged back, which is the share token (the vault address) for deposit returns and the asset for redeem returns. Must not be zero. | | `fee` | `uint256` | Flat adapter fee in the asset's smallest units. The adapter charges it only when the user asks for return to the source chain. | The fee is staged even when the chain in that row has no chain type yet. ### Example configuration The following JSON illustrates the fields of a `DeploymentConfig` for an adapter on Ethereum Sepolia that accepts messages from Arbitrum Sepolia, with deposits and redemptions enabled and an adapter fee on both return legs. No repository script reads this JSON. Instead, `pnpm ccip:deploy` builds the struct from environment variables. Chain selectors and fees are strings because `uint64` and `uint256` values can exceed the JavaScript safe integer range. ```json { "router": "0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59", "defaultAdmin": "", "feeSetter": "", "feeCollector": "", "vaultTarget": "", "targetEnabled": true, "depositsEnabled": true, "redeemsEnabled": true, "chainConfigs": [{ "chainSelector": "3478487238524512106", "chainType": 1 }], "feeConfigs": [ { "destinationChainSelector": "3478487238524512106", "bridgedToken": "", "fee": "1000000000000000" }, { "destinationChainSelector": "3478487238524512106", "bridgedToken": "", "fee": "1000000000000000" } ] } ``` - `0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59` is the CCIP Router on Ethereum Sepolia, and `3478487238524512106` is the Arbitrum Sepolia chain selector. - Replace ``, ``, and the three role placeholders with your own addresses. - A `fee` of `1000000000000000` is 0.001 of an 18-decimal asset. It matches the 0.001 CCIP-BnM adapter fee in [Deploy the adapter](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter#configure-the-deployment). - The first fee row applies when shares return to Arbitrum Sepolia. That requires the share token to be a [CCIP cross-chain token (CCT)](/ccip/concepts/cross-chain-token/overview) on both chains. ## Events The factory declares one event: ```solidity event AdapterDeployed( address indexed adapter, address indexed router, address indexed defaultAdmin, address feeSetter, address feeCollector, address vaultTarget, bool targetEnabled, bool depositsEnabled, bool redeemsEnabled ); ``` | Field | Type | Indexed | Description | | ----------------- | --------- | ------- | ---------------------------------------------------------------- | | `adapter` | `address` | Yes | Address of the new adapter. | | `router` | `address` | Yes | `config.router`. | | `defaultAdmin` | `address` | Yes | Account that received `DEFAULT_ADMIN_ROLE`. | | `feeSetter` | `address` | No | Account that received `FEE_SETTER_ROLE`. | | `feeCollector` | `address` | No | Account that received `FEE_COLLECTOR_ROLE`. | | `vaultTarget` | `address` | No | `config.vaultTarget`, or the zero address if none was set. | | `targetEnabled` | `bool` | No | `config.targetEnabled`, emitted even when `vaultTarget` is zero. | | `depositsEnabled` | `bool` | No | `config.depositsEnabled`. | | `redeemsEnabled` | `bool` | No | `config.redeemsEnabled`. | The chain and fee rows are not in this event. They appear as the adapter's `ChainTypeSet` and `AssetFeeSet` events in the same transaction. ## Errors The factory declares no errors. It reverts with errors declared in `CrossChainERC4626Adapter`, listed on the adapter's [errors](/solutions/cross-chain-vault-adapter/reference/adapter-contract#errors): | Error | Raised by | When | | ------------------------------- | --------------------- | -------------------------------------------- | | `InvalidAdmin()` | Factory | `defaultAdmin` is the zero address. | | `InvalidFeeSetter()` | Factory | `feeSetter` is the zero address. | | `InvalidFeeCollector()` | Factory | `feeCollector` is the zero address. | | `InvalidRouter(address router)` | Adapter constructor | `router` is the zero address. | | `InvalidTarget(address target)` | Adapter `setAssetFee` | A `FeeConfig` row has a zero `bridgedToken`. | ## Role handoff The factory hands off the roles in this order: 1. The adapter constructor grants `DEFAULT_ADMIN_ROLE`, `FEE_SETTER_ROLE`, and `FEE_COLLECTOR_ROLE` to the factory. 2. The factory applies the configuration. `setAssetFee` needs `FEE_SETTER_ROLE`; the other calls need `DEFAULT_ADMIN_ROLE`. 3. The factory grants `DEFAULT_ADMIN_ROLE` to `defaultAdmin`, `FEE_SETTER_ROLE` to `feeSetter`, and `FEE_COLLECTOR_ROLE` to `feeCollector`. 4. The factory renounces `FEE_COLLECTOR_ROLE`, then `FEE_SETTER_ROLE`, then `DEFAULT_ADMIN_ROLE`. Each role ends with exactly one holder, unless you pass the same account for several roles. Deploying the adapter with its constructor gives a different result: | Role | Through the factory | Through the constructor | | -------------------- | ------------------- | --------------------------------- | | `DEFAULT_ADMIN_ROLE` | `defaultAdmin` | `defaultAdmin` | | `FEE_SETTER_ROLE` | `feeSetter` only | `feeSetter` and `defaultAdmin` | | `FEE_COLLECTOR_ROLE` | `feeCollector` only | `feeCollector` and `defaultAdmin` | `DEFAULT_ADMIN_ROLE` is the admin of both fee roles, so `defaultAdmin` can grant or revoke them later. The adapter uses OpenZeppelin [`AccessControlEnumerable`](https://docs.openzeppelin.com/contracts/5.x/api/access#AccessControlEnumerable), so grant, revoke, and renounce each take effect in one step, with no two-step transfer. If the last `DEFAULT_ADMIN_ROLE` holder renounces it, the adapter's configuration is locked permanently. Use a multisig for `defaultAdmin`. Do not pass the factory's own address for any role. The factory renounces its roles in step 4, so that role would end with no holder. ## Security notes **Permissionless deployment.** Anyone can deploy an adapter that points at any router and any vault. An `AdapterDeployed` event from this factory does not show who controls the adapter. Before you trust an adapter, check its role holders with `getRoleMember` and its `ROUTER`. **Address derivation.** The factory deploys with `CREATE`, so the adapter address depends on the factory address and its nonce. Each call to `deploy` from anyone increments the nonce. Read the address from the return value or the `AdapterDeployed` event instead of precomputing it. Do not assume an adapter has the same address on two chains. **No input validation beyond zero checks.** The factory does not check that `router` has code or that `vaultTarget` is an ERC-4626 vault. The repository's `pnpm ccip:deploy` script checks both before it broadcasts. The router must have code, and the vault must return a non-zero `asset()`. `ROUTER` is immutable, so a wrong router means deploying a new adapter. A `defaultAdmin` you do not control also means deploying a new adapter, because nobody else can change the configuration. **Settings the factory does not apply.** The factory does not configure CCIP 2.0 settings and does not fund the adapter. After deployment, the `defaultAdmin` account sets these on the adapter: - the return format for each CCIP 2.0 lane, with [`setEvmReturnLaneFormat`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#setevmreturnlaneformat) - the requested finality of return legs, with [`setEvmReturnRequestedFinality`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#setevmreturnrequestedfinality), if you change the default - the inbound finality, with [`setInboundFinality`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#setinboundfinality), if you change the default - the Cross-Chain Verifier (CCV) policy, with [`setCCVsConfig`](/solutions/cross-chain-vault-adapter/reference/adapter-contract#setccvsconfig), if the lane's default verifiers are not enough Anyone can also send the native gas token to the adapter, which pays the CCIP fee for return legs. A new adapter holds none. The repository's `pnpm ccip:configure` script applies these settings and the funding. For what each setting does and which lanes need it, see [CCIP 2.0 lanes](/solutions/cross-chain-vault-adapter/overview/how-it-works#ccip-20-lanes). For an example that sets the return format and funds the adapter, see [Deploy the adapter](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter#fund-the-adapter). ## Related contracts - [Adapter contract](/solutions/cross-chain-vault-adapter/reference/adapter-contract): the `CrossChainERC4626Adapter` that `deploy` creates - [`ITypeAndVersion`](/ccip/evm/api-reference/v2.0.0/i-type-and-version) - [`IAny2EVMMessageReceiverV2`](/ccip/evm/api-reference/v2.0.0/i-any2-evm-message-receiver-v2): the receiver interface the adapter implements - [`ExtraArgsCodec`](/ccip/evm/api-reference/v2.0.0/extra-args-codec): encodes the `GenericExtraArgsV3` return format - [OpenZeppelin `AccessControlEnumerable`](https://docs.openzeppelin.com/contracts/5.x/api/access#AccessControlEnumerable): role management on the adapter ## Disclaimer Please note, this repo contains community examples only — these are not Chainlink products or services and are not supported or maintained by Chainlink. This code represents an example of using a Chainlink product or service, and is intended for demonstration and educational purposes only. It is provided “AS IS” and “AS AVAILABLE” without warranties of any kind, may not have been audited, and may omit checks or error handling. Each party intending to use this example code does so entirely at their own risk and must perform its own audits, security and code review, key management, and testing before any production deployment and ensure the operation and performance of such code matches expectations. Neither Chainlink Labs nor the Chainlink Foundation deploys, operates, monitors, maintains or endorses any deployment of this code. Note that this is not a Chainlink product, feature or service, and there are no commitments made with respect to the code, including compatibility with future Chainlink releases. You should not rely on this code without first conducting your own technical, engineering, and security review. This code is also outside the scope of any Chainlink bug bounty programs. Neither Chainlink Labs, the Chainlink Foundation, nor Chainlink node operators are responsible for outcomes due to errors in this example or how it is deployed or operated, or liable for any resulting claims or damages. Use of the Chainlink Network is subject to the Chainlink Foundation [Terms of Service](https://chain.link/terms), which provides important information and disclosures. By using this code, you acknowledge and agree to these terms.