> ## Documentation Index
> Fetch the complete documentation index at: https://docs.li.fi/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a cross-chain flow

> Bridge USDC from Arbitrum and deposit it into Aave on Base with one signature: write the destination flow, add a continuation.settle node, read the escrow, submit, and track it.

This guide builds a flow that no single transaction can run. The user signs one transaction on Arbitrum. That transaction bridges USDC to Base, into an escrow. When the USDC lands, LI.FI's settlement service runs the **destination flow**: it deposits the USDC into Aave on Base, and the user receives aBasUSDC. The user needs no gas on Base.

The guide uses short fragments. The same flow as one runnable file, with the submit and tracking loops, is the [Bridge and deposit](https://docs.li.fi/composer/composer-api/recipes/bridge-and-deposit.md) recipe. For the model behind it, read [Cross-chain flows](https://docs.li.fi/composer/composer-api/concepts/cross-chain-flows.md). For every config field, rule and error, see [`continuation.settle`](https://docs.li.fi/composer/composer-api/ops/continuation-settle).

## Prerequisites

* The Composer SDK and a server-side API key in `LIFI_API_KEY`; see [Before you start](https://docs.li.fi/composer/composer-api/overview.md#before-you-start). This guide also uses viem:

  ```bash theme={"system"}
  npm install @lifi/composer-sdk @lifi/compose-spec viem
  ```

* A destination chain from the [served chains](https://docs.li.fi/composer/composer-api/concepts/cross-chain-flows.md#availability). The escrow lives on the bridge's destination chain, and the outcome must land on that same chain. Here both are Base.

> [!WARNING]
>
> **Mainnet only.** Cross-chain flows are served on mainnets only, so this guide sends real transactions. Use a small amount, and a wallet in `PRIVATE_KEY` that holds USDC and gas on Arbitrum.

## 1. Write the destination flow

The destination flow runs later, on Base, from the escrow. Build it like any other flow, on the destination chain, with exactly one input: the token the bridge delivers. Then pass `destFlow.build()` to the settle node.

```ts theme={"system"}
import { createComposeSdk, guards, materialisers, resources } from '@lifi/composer-sdk';

const ARBITRUM = 42161;
const BASE = 8453;
const ARB_USDC = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831';
const BASE_USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const A_BAS_USDC = '0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB'; // Aave V3 aUSDC on Base

const sdk = createComposeSdk({
  baseUrl: 'https://composer.li.quest',
  apiKey: process.env.LIFI_API_KEY!,
});

// The destination flow: runs later, from the escrow on Base. The zap deposits
// the USDC into Aave and leaves the aBasUSDC in the destination flow's proxy.
const destFlow = sdk.flow(BASE, {
  name: 'deposit-on-base',
  inputs: { funding: resources.erc20(BASE_USDC, BASE) },
});
destFlow.lifi.zap('deposit', {
  bind: { amountIn: destFlow.inputs.funding },
  config: { resourceOut: resources.erc20(A_BAS_USDC, BASE) },
  // The guard proves the minimum received, which is checked on chain.
  guards: [guards.slippage({ port: 'amountOut', bps: 50 })],
});
```

A destination flow runs in the escrow's execution context: a proxy that LI.FI's executor controls, funded from the escrow. It never runs in the user's proxy. The compiler checks these rules:

* **Exactly one input**, a resource input in the funding token on the destination chain. The settlement service binds the escrow's balance to it.
* **Leave every outcome in the destination flow's proxy.** In a destination flow, `destFlow.context.executionAddress` is the escrow's execution context, not the user's proxy. Where a node needs a receiver, bind `destFlow.context.executionAddress`. Never bind `destFlow.context.sender`: in a destination flow it is LI.FI's executor, and the compile fails with `leg_binds_sender`.
* **The settle node decides where outcomes go.** You set the outcome's `destination` and the `refund` address in the settle node's `config`. The compiler then appends transfers to the destination flow (an [epilogue](https://docs.li.fi/composer/composer-api/concepts/sweeping-and-amounts.md#the-flow-epilogue)): the outcome goes to the settlement contract, which checks the minimum received and forwards it to `destination`, and every leftover goes to `refund`. Do not send to `destination` from the destination flow, and do not give it an epilogue of its own.
* **No nested `continuation.settle`.**
* **An empty destination flow is valid.** One with no nodes delivers its funding unchanged: set the outcome token to the funding token.

Not every op is allowed in a destination flow; see [which ops a destination flow can run](https://docs.li.fi/composer/composer-api/ops/continuation-settle.md#which-ops-a-destination-flow-can-run). The compiler proves the destination flow at the minimum funding, the smallest balance that starts a run. A failure inside it is reported on the settle node, with a message that starts with `continuation leg of '<id>' failed at the funding floor`.

## 2. Bridge into the escrow

The source flow runs on Arbitrum. It bridges the USDC to Base and binds the bridge's output to the settle node. The bridge has no `destination`, so it delivers into the settle node's escrow on Base. The compiler infers this funding from the binding, so the settle node sets no `funding`.

```ts theme={"system"}
const owner = '0xYourAddress'; // the signer that sends the transactions in step 4

const source = sdk.flow(ARBITRUM, {
  name: 'bridge-and-deposit',
  inputs: { amountIn: resources.erc20(ARB_USDC, ARBITRUM) },
});
const bridge = source.lifi.bridge('bridge', {
  bind: { amountIn: source.inputs.amountIn },
  // No `destination`: the bridge delivers into the settle node's escrow on Base.
  config: { resourceOut: resources.erc20(BASE_USDC, BASE) },
});
source.continuation.settle('settle', {
  bind: { input: bridge.amountOut }, // funded by the bridge: omit `funding`
  config: {
    outcomes: [{ token: A_BAS_USDC, destination: owner, chainId: BASE }], // no minAmount: the guard proves it
    continuationFlow: destFlow.build(),
    // At least max(3 × the bridge's ETA, 30 minutes) away.
    validity: { expiresAtMs: Date.now() + 60 * 60 * 1000 },
    // Receives the escrow's balance if the destination flow doesn't run.
    // Must be an address you control on Base.
    refund: owner,
  },
});
```

The outcome token is what the destination flow produces (aBasUSDC), not the funding token (USDC). The settle node takes no guards. Omit `outcomes[0].minAmount` when the destination flow proves a minimum, as the zap's slippage guard does here; the op reference explains [how the minimum received is set](https://docs.li.fi/composer/composer-api/ops/continuation-settle.md#minimum-received).

## 3. Compile and read the escrow

```ts theme={"system"}
const result = await source.compile({
  signer: owner,
  inputs: { amountIn: materialisers.directDeposit({ amount: '1000000000' }) }, // 1,000 USDC
  // Returns anything the source flow leaves on the proxy, such as the bridge's unspentIn.
  sweepTo: source.context.sender,
});
if (result.status !== 'success') {
  throw new Error(`compile did not succeed: ${result.error.message}`);
}

const escrow = result.settlements?.settle?.escrow;
if (escrow === undefined) throw new Error('no escrow registered');
console.log('escrow', escrow.chainId, escrow.address); // chainId 8453: the escrow is on Base
console.log('runs once the balance reaches', escrow.floor, 'USDC base units');
console.log('refundable after', new Date(escrow.expiresAtMs).toISOString());

const outcome = result.outputs['settle.output'];
console.log('minimum received (enforced on chain):', outcome?.amount.minimum?.value);
console.log('expected outcome (not enforced):', outcome?.amount.estimate?.value);
```

The response adds `settlements.settle.escrow`, where the funds go, and `outputs['settle.output']`, the minimum and expected outcome. [Flow wire format: `settlements`](https://docs.li.fi/composer/composer-api/reference/flow-wire-format#settlements) describes every field.

* **`escrow`.** Every compile returns a new escrow address; never reuse one. Trust `escrow.expiresAtMs`, not the expiry you authored: the settlement service may shorten it. `escrow.floor` is the minimum funding, derived from the bridge's quote.
* **`amount.minimum`** is the **minimum received**, the only guaranteed amount: the settlement contract reverts a run that would deliver less. `amount.estimate` is the expected outcome; nothing enforces it. Show users the minimum as the guarantee and the estimate as the likely result.
* **`actions`** holds the approval of Arbitrum USDC, if the proxy needs one, and `execute`. Both run on Arbitrum. There is no `fundEscrow` action, because the bridge funds the escrow. `outputs['bridge.amountOut']` reports `role: 'consumed'` and `consumedBy: 'settle'`: the bridged USDC goes into the escrow, not to the user.
* **`result.fill.validUntilMs`**, when present, is the latest time to submit the user's transaction. It is never later than the escrow's expiry and is not enforced on chain.

If you compile with `simulationPolicy: 'allow-revert'`, a simulated revert returns `status: 'partial'` (HTTP 206) instead of throwing. The compile registers the escrow before it simulates the source transaction, so a partial response can carry `settlements` and `actions`. Never fund an escrow from a partial response; compile again instead.

## 4. Submit

Send every action in order, each on its own `transactionRequest.chainId`. This is the same loop as in [Build a same-chain flow](https://docs.li.fi/composer/composer-api/guides/build-a-flow.md#wire-in-a-signer), with a check that every action runs on Arbitrum. It uses viem with a private key; use your own wallet stack in production.

```ts theme={"system"}
import { createPublicClient, createWalletClient, http, type Address, type Hex } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { arbitrum } from 'viem/chains';

if (result.fill?.validUntilMs !== undefined && Date.now() > result.fill.validUntilMs) {
  throw new Error('quote expired: compile again');
}

const account = privateKeyToAccount(process.env.PRIVATE_KEY as Hex);
const wallet = createWalletClient({ account, chain: arbitrum, transport: http() });
const publicClient = createPublicClient({ chain: arbitrum, transport: http() });

let sourceTxHash: Hex | undefined;
for (const action of result.actions ?? []) {
  if (action.kind !== 'transaction') continue;
  const tx = action.transactionRequest;
  if (tx.chainId !== ARBITRUM) throw new Error(`action ${action.purpose} targets chain ${tx.chainId}`);
  const hash = await wallet.sendTransaction({
    to: tx.to as Address,
    data: tx.data as Hex,
    value: BigInt(tx.value),
    gas: tx.gasLimit === undefined ? undefined : BigInt(tx.gasLimit),
  });
  const receipt = await publicClient.waitForTransactionReceipt({ hash });
  if (receipt.status !== 'success') throw new Error(`${action.purpose} reverted: ${hash}`);
  if (action.purpose === 'execute') sourceTxHash = hash;
}
```

In this example `owner` must be `account.address`. After a failure, compile again and use the new response. Never fund an escrow from an earlier response.

## 5. Track the bridge and the escrow

Two services report progress after the `execute` transaction is mined:

* **The bridge.** Poll the LI.FI API's [`GET /v1/status`](https://docs.li.fi/api-reference/check-the-status-of-a-cross-chain-transfer) with `txHash=<sourceTxHash>&fromChain=42161&toChain=8453` and your API key. It reports `PENDING`, then `DONE` or `FAILED`.
* **The escrow.** Poll [`GET /intent-factory/status`](https://docs.li.fi/composer/composer-api/reference/escrow-status) with the escrow's `chainId` and `address`:

  ```bash theme={"system"}
  curl -s "https://composer.li.quest/intent-factory/status?chainId=8453&address=<escrow.address>" \
    -H "x-lifi-api-key: $LIFI_API_KEY"
  ```

`COMPLETED` means the aBasUSDC reached the user. An escrow at `READY` or later means the bridge has delivered, even if LI.FI still reports `PENDING`. If the bridge transfer `FAILED`, the escrow never receives funds and stays `EXPIRED` after its expiry. A bridge refund on Arbitrum goes to the user's execution proxy, not the wallet; [Track the delivery](https://docs.li.fi/composer/composer-api/ops/lifi-bridge.md#track-the-delivery) shows how to recover it. If the USDC lands but the destination flow doesn't run, the USDC is refunded to `refund` automatically. [Escrow status](https://docs.li.fi/composer/composer-api/reference/escrow-status.md#what-to-show-your-user) maps every status to what to show your user, and has a polling function.

## Other ways to fund the escrow

A bridge isn't the only way to fund an escrow. The two other modes fund an escrow on the flow's own chain, so the destination flow runs later on that same chain. [Same-chain continuations](https://docs.li.fi/composer/composer-api/concepts/cross-chain-flows.md#same-chain-continuations) explains when to pick each. Each example below is a separate flow, not a change to the running example.

* **Pay by transfer.** Set `funding: 'external'` and hand the returned `fundEscrow` transfer to whoever pays.
* **Exact payment on one chain.** Set `exact: true`, and bind the settle node's `input` to a declared input or an upstream output instead of a bridge's `amountOut`. The user's transaction then funds an escrow on the same chain, and the escrow runs only the destination flow's compiled bytes. The cross-chain version of this pattern is [Pay cross-chain](https://docs.li.fi/composer/composer-api/recipes/pay-cross-chain.md).

### Pay by transfer

With `funding: 'external'`, the source flow does not touch the funds. The response carries a `fundEscrow` action: a plain transfer to the escrow that any wallet can submit, for example a payer who scans a QR code. This flow escrows 100 USDC on Base for an empty destination flow, which delivers the USDC to `owner` unchanged:

```ts theme={"system"}
const deliverFlow = sdk.flow(BASE, {
  name: 'deliver-on-base',
  inputs: { funding: resources.erc20(BASE_USDC, BASE) },
}); // no nodes: the escrow delivers the USDC it receives

const deposit = sdk.flow(BASE, {
  name: 'deposit-by-transfer',
  inputs: { amount: resources.erc20(BASE_USDC, BASE) },
});
deposit.continuation.settle('settle', {
  bind: { input: deposit.inputs.amount },
  config: {
    funding: 'external',
    outcomes: [{ token: BASE_USDC, destination: owner, chainId: BASE }],
    continuationFlow: deliverFlow.build(),
    validity: { expiresAtMs: Date.now() + 60 * 60 * 1000 },
    refund: owner, // set it to the payer when you know it: an unrun escrow refunds this address
  },
});

// A plain amount, not a materialiser.
const depositResult = await deposit.compile({ signer: owner, inputs: { amount: '100000000' } }); // 100 USDC
if (depositResult.status !== 'success') throw new Error(depositResult.error.message);

const fund = depositResult.actions?.find((a) => a.purpose === 'fundEscrow' && a.nodeId === 'settle');
if (fund?.kind !== 'transaction') throw new Error('no fundEscrow action');
console.log(fund.transactionRequest); // USDC transfer(escrow, 100000000) on Base
```

The settle node is the only node in this flow, so `depositResult.transactionRequest` is absent and `actions` holds only the `fundEscrow` transaction. Track the escrow as in [step 5](#5-track-the-bridge-and-the-escrow).

### Optional: verify the binding before funding

An exact destination flow adds `settlements[id].binding`, which discloses what the escrow will run; [Flow wire format: `settlements`](https://docs.li.fi/composer/composer-api/reference/flow-wire-format#settlements) describes each field. The compile already checks that `payload` matches `payloadHash`, so this step is not mandatory. We recommend recomputing the hash yourself before anyone funds the escrow. Run this on the `result` of a compile with `exact: true`, such as the one in [Pay cross-chain](https://docs.li.fi/composer/composer-api/recipes/pay-cross-chain.md):

```ts theme={"system"}
import { keccak256, type Hex } from 'viem';

const binding = result.settlements?.settle?.binding;
if (binding === undefined || keccak256(binding.payload as Hex) !== binding.payloadHash.toLowerCase()) {
  throw new Error('do not fund: the binding does not match its hash');
}
```

What an exact destination flow cannot check for you:

* The compile checks that `payload` wraps the compiled destination flow and that its hash matches. It cannot prove that the settlement service put that hash in the escrow's constraint. You also cannot check yet how the escrow address is derived; a way to check it is planned.
* The bytes are fixed at compile time. An on-chain assumption that stops holding before the run (a cap that fills up, an epoch that ends) makes the run revert, and the escrow is refunded.
* **No outcome, no minimum.** With `outcomes: []`, only the executor, the payload hash and the spending cap constrain the run on chain. Nothing checks what the destination flow achieves. To check it yourself, decode `calldata` before funding; for an on-chain check of the effect, add an `invariant.*` op to the destination flow. A zero-outcome exact destination flow has no `settle.output` entry.

> [!WARNING]
>
> **Double payment.** The escrow runs the payment whenever the destination flow runs, up to the escrow's expiry. If the payee is paid another way in that window, the exact destination flow pays again, unless the contract it calls rejects duplicates. Keep the expiry short.

## Errors you might see

`source.compile` throws a `ComposeError`. Settle-node authoring errors carry a `fix` field that names the one edit that clears the error.

```ts theme={"system"}
import { isComposeDeliveryError, isComposeError } from '@lifi/composer-sdk';

try {
  await source.compile({
    signer: owner,
    inputs: { amountIn: materialisers.directDeposit({ amount: '1000000000' }) },
    sweepTo: source.context.sender,
  });
} catch (e) {
  if (isComposeDeliveryError(e)) {
    // The bridge quote no longer fits the escrow. Compile again: each compile quotes afresh.
    console.error(e.delivery.reason, e.message);
  } else if (isComposeError(e)) {
    console.error(e.kind, e.status, e.path, e.port, e.fix ?? e.message);
  }
  throw e;
}
```

The most common ones in this guide: `delivery_error` with `floor_drift` (compile again) or `eta_exceeds_expiry` (set a later expiry), and `validation_error` with `forbidden_operation` (replace the destination flow's op). [`continuation.settle` errors](https://docs.li.fi/composer/composer-api/ops/continuation-settle#errors) lists every settle-node error, its cause and its fix.

## Next steps

> [card] [Bridge and deposit](https://docs.li.fi/composer/composer-api/recipes/bridge-and-deposit.md)
>
> This guide as one runnable file, with polling.

> [card] [Pay cross-chain](https://docs.li.fi/composer/composer-api/recipes/pay-cross-chain.md)
>
> An exact destination flow that pays two recipients.

> [card] [Escrow status](https://docs.li.fi/composer/composer-api/reference/escrow-status.md)
>
> Status values, what to show your user, and a polling function.

> [card] [continuation.settle](https://docs.li.fi/composer/composer-api/ops/continuation-settle.md)
>
> Every config field, rule and error.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.