Destination calls
Bridge and then do something on the destination chain in one user action, such as supplying to Aave, buying an item or topping up a game contract. Calls are simulated before you get a quote.
Destination calls let a quote include contract calls that run on the destination chain with the bridged output, in the same user action. The user signs once on the source chain. The provider delivers the output to its executor contract, which makes your calls in order. Typical uses: bridge USDC from Base and supply it to Aave on Arbitrum for the user, buy an NFT, or top up a game or subscription contract.
Send the calls with POST /v1/quote. Either pass raw calls, or pass an action preset that LayerHop encodes for you.
How it works#
- You ask for an exact output:
dstAmountis the amount ofdstTokenyour calls receive. The quote reportssrcAmount, which is what the sender pays, fees included. - Only providers that run destination calls are asked. Today that is Relay; the other providers are skipped for these requests.
- Before LayerHop returns a quote, it simulates your calls on the destination chain with
eth_simulateV1(read-only, public mainnet state). The simulation runs them from the provider's executor after giving it exactlydstAmountof the output token. If any call would revert, the request fails withCALLS_REVERTED, and the message names the call and the revert reason. If the simulation can't run, you getPROVIDER_ERRORand no quote. LayerHop never returns a calls quote that has not been simulated. - The user signs the source transaction as usual (
POST /v1/orders, approval first for ERC-20 inputs). On the destination chain the executor receives the output and runs the calls. - If a call reverts at fill time anyway (for example the state changed after the quote), the provider refunds the sender.
Our fee applies as on any other quote (fees.ourFeeBps), and partner fees work the same way.
Trust model: read this first#
msg.senderis the executor, not the user. Each quote shows the executor incalls.executor(for Relay, its ERC20Router0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f). A contract that creditsmsg.senderwould credit the executor, and the user would lose the funds. Targets must take the beneficiary as a parameter and creditrecipient, as Aavesupply(asset, amount, onBehalfOf, referralCode)does.- ERC-20 outputs need an approval call. The executor holds the tokens, so the first call must be
approve(target, dstAmount)ondstToken, followed by the call that spends them. - Spend exactly
dstAmount. Tokens left in the executor after your calls do not reach anyone you control. The simulation reports any remainder incalls.simulation.leftover. It should be"0". - Only actions that benefit the user. Anyone can make the executor call anything, so never design a target that lets the executor withdraw, sell or cancel on someone's behalf.
- Blocked patterns. LayerHop rejects calls that move or authorize someone else's assets:
transferFrom,safeTransferFromandsafeBatchTransferFrom(ERC-20, ERC-721 and ERC-1155),permit(EIP-2612 and DAI), any call to Permit2, and calls to the zero address or precompiles.
Limits#
| Rule | Value |
|---|---|
| Destination | EVM chains only. Not Solana or Hyperliquid. Cross-chain only (srcChain ≠ dstChain). |
| Calls | 1 to 8, run in order. |
| Calldata | At most 10,000 bytes per call and 24,000 bytes in total. Hex with whole bytes. |
value |
Wei, only when dstToken is the native coin. The values together must not exceed dstAmount. |
| Amount | Send dstAmount, not amount. |
| Not combinable with | gasOnArrival / gasOnArrivalUsd, and calls together with action. |
Full example: bridge USDC and supply it to Aave#
Base USDC to Arbitrum, supplying exactly 10 USDC to Aave v3 for the user, so the aUSDC goes to recipient. The aave-v3-supply preset builds two calls: approve(pool, dstAmount) on dstToken, then supply(dstToken, dstAmount, recipient, 0) on the chain's Aave v3 Pool. Presets are available on Ethereum, Optimism, BNB Chain, Polygon, Base and Arbitrum. On each of these chains the Pool address was read from Aave's PoolAddressesProvider on chain, and a supply from the executor was simulated against mainnet.
curl -X POST "https://api.layerhop.com/v1/quote" \
-H "content-type: application/json" -H "x-api-key: $LAYERHOP_KEY" \
-d '{
"srcChain": "base", "srcToken": "USDC",
"dstChain": "arbitrum", "dstToken": "USDC", "dstAmount": "10000000",
"sender": "0xYourUser", "recipient": "0xYourUser",
"action": { "type": "aave-v3-supply" }
}'The response is a normal quote with srcAmount and calls added. This one is from a real read-only quote with a 25 bps fee:
{
"quoteId": "q_713598a2-7493-4cf5-8412-7e44015c9c88",
"provider": "relay",
"dstAmount": "10000000",
"dstAmountMin": "10000000",
"srcAmount": "10083608",
"fees": { "ourFeeBps": 25, "providerFeeUsd": 0.0586, "gasUsd": 0.001194 },
"calls": {
"items": [
{ "to": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", "data": "0x095ea7b3…", "value": "0" },
{ "to": "0x794a61358d6845594f94dc1db02a252b5b4814ad", "data": "0x617ba037…", "value": "0" }
],
"executor": "0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f",
"executed": true,
"simulation": { "ok": true, "gasUsed": "288491", "leftover": "0" }
}
}The user pays 10.083608 USDC on Base, and 10 USDC is supplied to Aave on Arbitrum for them. Build and send it like any quote. With the TypeScript SDK:
import { LayerHop } from "@layerhop/sdk";
const lh = new LayerHop({ apiKey: process.env.LAYERHOP_KEY });
const { best } = await lh.quote({
srcChain: "base", srcToken: "USDC",
dstChain: "arbitrum", dstToken: "USDC", dstAmount: "10000000", // exactly 10 USDC into Aave
sender: user, recipient: user,
action: { type: "aave-v3-supply" },
});
console.log(`Pay ${best.srcAmount} on Base; calls simulated: ${best.calls?.simulation?.ok}`);
const tx = await lh.buildTx(best.quoteId); // EVM tx, with `approval` first for USDC
// approve tx.approval.spender for tx.approval.amount, then send { to, data, value } from `user`Raw calls#
The same deposit written out by hand with viem shows the pattern for any target: approve the output token, then call the target with recipient as the beneficiary.
import { encodeFunctionData, parseAbi } from "viem";
const USDC = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"; // Arbitrum USDC
const POOL = "0x794a61358D6845594F94dc1DB02A252b5b4814aD"; // Aave v3 Pool, Arbitrum
const amount = 10_000_000n;
const abi = parseAbi([
"function approve(address spender, uint256 amount) returns (bool)",
"function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode)",
]);
const { best } = await lh.quote({
srcChain: "base", srcToken: "USDC", dstChain: "arbitrum", dstToken: "USDC",
dstAmount: amount.toString(), sender: user, recipient: user,
calls: [
{ to: USDC, data: encodeFunctionData({ abi, functionName: "approve", args: [POOL, amount] }) },
{ to: POOL, data: encodeFunctionData({ abi, functionName: "supply", args: [USDC, amount, user, 0] }) },
],
});For a native output (for example ETH on Arbitrum), set value on the call instead of approving, as in { to: game, data: topUp(user), value: dstAmount }.
If the calls would fail, nothing is quoted:
{
"error": {
"code": "CALLS_REVERTED",
"message": "Destination call 1 (to 0x794a61358d6845594f94dc1db02a252b5b4814ad) would revert: Error(ERC20: transfer amount exceeds balance)"
}
}Availability and test mode#
Live-mode destination calls are switched on per deployment. Until then, a live request with calls or action gets FORBIDDEN, while test keys always work.
With an lh_test_ key the quote comes from the sandbox: srcAmount is priced from USD prices and the transaction is a placeholder. Your calls are still validated and simulated against real mainnet state, so test mode catches calls that would revert.
Related#
- Gas on arrival: deliver native gas instead of making calls.
- Error handling and
CALLS_REVERTED. - API reference.
Last updated