Build

Destinations and adapters

An adapter turns USDC that the receiver holds into a position for a beneficiary. It is a stateless contract with one function.

IInletAdapter.sol
interface IInletAdapter {
    function deposit(address usdc, uint256 amount, bytes32 beneficiary, bytes calldata data)
        external
        returns (bytes memory result);
}

The receiver approves the adapter for amount, calls deposit, and clears the approval. If the call reverts, the receiver credits the amount to the beneficiary's claimable balance instead, so a broken adapter can never lose funds. The result bytes are emitted in the Executed event and shown by the relayer: share counts, token ids, anything a front end wants to display.

beneficiary is a 32 byte value, the left padded address on EVM chains. data is adapter specific and travels inside the CCTP hook data from Arc, so keep it small.

To be a destination

A protocol needs four things, all of them from the chain it lives on and from its own deposit call. The protocol changes nothing and grants nothing. The adapter does the work.

  1. A chain with a receiver. Any chain with CCTP V2 and native USDC. Today that is Arbitrum Sepolia, Base Sepolia, Unichain Sepolia, Ethereum Sepolia and Monad Testnet. A new chain is a receiver deployment and a hub registration, not protocol work.
  2. Native USDC as the asset. The receiver holds nothing else, so the deposit call has to take USDC itself, not a wrapped or bridged form.
  3. A deposit that credits a third party in one call. The adapter is a contract acting for a user who never touches the destination chain, so the protocol needs an on behalf of parameter, or has to return a transferable token the adapter can forward. Aave's supply, Compound's supplyTo, an ERC 4626 deposit with a receiver and a Uniswap v4 mint to an owner all qualify.
  4. An outcome that is measurable right after the call. Shares, an aToken balance delta, a token id with its liquidity. The adapter compares it with a minimum carried in the intent and reverts on a stale quote, and a revert falls back to claimable USDC.

An ERC 4626 vault over USDC meets all four with no contract work. Anything else is one adapter with one function, registered on the receiver.

Decide what the protocol needs

  • An ERC 4626 vault over USDC on any chain with a receiver, which today means Arbitrum Sepolia, Base Sepolia, Unichain Sepolia, Ethereum Sepolia and Monad Testnet: no contract work. Use erc4626Destination with the vault and the receiver for that chain.
  • Aave V3, Compound III, or a Uniswap v4 pool with USDC on one side: presets exist in the widget.
  • Anything else on those chains: write an adapter.
  • A chain without a receiver: deploy InletReceiver and the generic adapter there with contracts/script/DeployReceiver.s.sol, then ask the hub owner to register the CCTP domain and the receiver with contracts/script/ConfigureHub.s.sol.

Writing one

MyAdapter.sol
contract MyAdapter is IInletAdapter {
    function deposit(address usdc, uint256 amount, bytes32 beneficiary, bytes calldata data)
        external
        returns (bytes memory)
    {
        (address market, uint256 minOut) = abi.decode(data, (address, uint256));
        IERC20(usdc).safeTransferFrom(msg.sender, address(this), amount);
        IERC20(usdc).forceApprove(market, amount);
        uint256 out = IMarket(market).depositFor(address(uint160(uint256(beneficiary))), amount);
        if (out < minOut) revert TooLittle(out, minOut);
        return abi.encode(out);
    }
}
  1. Decode data into what the protocol call needs. Put anything the user should not be able to fake behind a check: the ERC 4626 adapter checks vault.asset() == usdc, the Compound adapter checks comet.baseToken() == usdc, the Aave adapter looks the aToken up from the pool instead of trusting the caller.
  2. Pull the USDC with safeTransferFrom(msg.sender, address(this), amount), approve the protocol, make the call with the beneficiary as the owner of whatever comes out.
  3. Measure what the beneficiary received and compare it to a minimum carried in data, so a stale quote reverts instead of delivering less than promised. A revert here falls back to claimable USDC.
  4. Return something useful in result.
  5. Test it twice: with a mock of the protocol through InletReceiver.receiveAndExecute, and with a fork test against the real testnet contracts. The fork tests in contracts/test/fork skip when the RPC variable is unset, so they never break CI.
  6. Deploy with script/DeployAdapter.s.sol, NAME=<name> RECEIVER=<receiver>, which also registers keccak256("<name>:v1") on the receiver. Add the address to config/deployments.testnet.json and a destination preset to the widget.

Adapter ids are keccak256 of a short name with a version, for example erc4626:v1. The SDK's adapterId("erc4626:v1") produces the same value as the contracts.

Shipped adapters

IdContractAdapter dataResultChecks
erc4626:v1ERC4626Adapter.solabi.encode(address vault, uint256 minShares)abi.encode(uint256 shares)vault.asset() == usdc, shares >= minShares
aave-v3:v1AaveV3Adapter.solabi.encode(address pool, uint256 minATokens)abi.encode(address aToken, uint256 received)aToken read from pool.getReserveData(usdc), aToken balance delta of the beneficiary
compound-v3:v1CompoundV3Adapter.solabi.encode(address comet, uint256 minSupplied)abi.encode(address comet, uint256 gained)comet.baseToken() == usdc, supplyTo(beneficiary, usdc, amount), balance delta
uniswap-v4-lp:v1UniswapV4LpAdapter.solabi.encode(PoolKey key, int24 rangeTicks, uint128 minLiquidity)abi.encode(uint256 tokenId, uint128 liquidity, int24 tickLower, int24 tickUpper)USDC must be one side of the pool; a single sided range just outside the current tick on the USDC side; the position NFT is minted to the beneficiary

The SDK has an encoder for each, and the widget a destination builder for each. The contracts live in contracts/src/adapters in the repository.

Deployed on testnet

ChainCCTP domainReceiverAdapters
Arbitrum Sepolia30x145083628c9dF6980fe2747B286835e7c637ed22ERC 4626 0x912c690f95a381e72F63a378fd906C6294412Fc9, Aave V3 0x9eD3b40bFd249Eb133Ae10b0006afae5d5947736
Base Sepolia60x30695D945039FbBc0C36F595a8B5B54d83a945DeERC 4626 0x6253A9a287803111eD736c0C234de17bBE7672ED, Compound III 0x77D23de84220E4Dc86b6B8c181Be1E49D6a23f7c
Unichain Sepolia100x49de71C101F1768C1ad4132AB815C163608A86d9ERC 4626 0x912c690f95a381e72F63a378fd906C6294412Fc9, Uniswap v4 0x55da7c3B5e99816A7a9cD9dc47e24bfd7B19D6ED
Ethereum Sepolia00x55da7c3B5e99816A7a9cD9dc47e24bfd7B19D6EDERC 4626 0x912c690f95a381e72F63a378fd906C6294412Fc9
Monad Testnet150x38B9bCC43A585C80b7f649b9F98d394F9b321c96ERC 4626 0x912c690f95a381e72F63a378fd906C6294412Fc9

Euler's EVK Vault eUSDC-4 on Ethereum Sepolia is an ERC 4626 vault over Circle USDC reached through the generic adapter. Monad Testnet has no Gateway, so it is a CCTP only destination, with an Inlet demo vault to deposit into. The protocol addresses behind every preset are on the addresses page.