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

# Move stablecoins across chains

> Move USDC from one chain to another with an API key: find its chains, quote the move, order it from an organisation wallet and confirm it arrived.

This recipe moves USDC from one chain to the same USDC on another chain, with an API key and no wallet signature. TetraFi executes the move from one of your workspace's organisation wallets. Every step comes from one script, [`samples/http/recipe-cross-chain.sh`](https://github.com/tetrafi/docs/blob/main/samples/http/recipe-cross-chain.sh).

> [!NOTE]
>
> The API may change until launch. Every change is listed in the [Changelog](https://tetrafi.mintlify.site/changelog.md).

## Before you begin

You need `bash`, `curl`, `jq` and `openssl`, and an API key with **Read quotes**, **Submit orders** and **Read orders** ([Authentication](https://tetrafi.mintlify.site/authentication.md)).

The key also needs:

* **Venue trading and request signing.** A move TetraFi executes for you from an organisation wallet granted to the key is a Basic trade, so the key needs the venue trading scope, its signing secret and venue trading switched on ([Trading on your venue accounts](https://tetrafi.mintlify.site/authentication.md#trading-on-your-venue-accounts)).
* **An organisation wallet granted to it.** A workspace owner, or an admin who manages credentials, grants the key the organisation wallets it may use, signed in with a second factor ([Give a key organisation wallets](https://tetrafi.mintlify.site/trade/wallets-and-accounts.md#give-a-key-organisation-wallets)).
* **Funds on the chain you sell from.** The organisation wallet that sells must hold the USDC you move and gas on that chain: it sends the transactions itself.

Then export your environment, as in the [Quickstart](https://tetrafi.mintlify.site/quickstart.md):

```bash theme={null}
export TETRAFI_BASE_URL=https://api.tetrafi.io
export TETRAFI_API_KEY=...            # from your secret manager
export TETRAFI_SIGNING_SECRET=...     # the key's signing secret
export TETRAFI_FROM_CHAIN=...         # the CAIP-2 chain you sell on, from step 1
export TETRAFI_TO_CHAIN=...           # the CAIP-2 chain you buy on, from step 1
```

**1. Find the chains USDC is on**

`GET /api/v1/assets` lists every deployment of each asset. The chains of USDC's `native` variant are the chains you can move it between. The script prints them and checks that both of your chains are among them.

```bash cURL theme={null}
# The chains an asset is on come from GET /assets: every deployment of its native variant.
assets=$(tetrafi GET /api/v1/assets)
chains=$(jq -c --arg a "$asset" '[.assets[] | select(.assetId == $a) | .variants[]
  | select(.kind == "native") | .deployments[].chain] | unique' <<<"$assets")
echo "{\"asset\":\"$asset\",\"chains\":$chains}" >&2
from_chain=${TETRAFI_FROM_CHAIN:-$(jq -r '.[0] // empty' <<<"$chains")}
to_chain=${TETRAFI_TO_CHAIN:-$(jq -r --arg f "$from_chain" 'map(select(. != $f))[0] // empty' <<<"$chains")}
for chain in "$from_chain" "$to_chain"; do
  if ! jq -e --arg c "$chain" 'index($c) != null' <<<"$chains" >/dev/null; then
    jq -nc --arg a "$asset" --arg c "$chain" '{error: "ASSET_NOT_ON_CHAIN", asset: $a, chain: $c}' >&2
    exit 1
  fi
done
```

```json Output on test networks (shortened) theme={null}
{"asset":"USDC","chains":["eip155:11155111","eip155:11155420","eip155:84532","solana:EtWTRABZ…"]}
```

The outputs on this page come from a run on test networks: Ethereum Sepolia (`eip155:11155111`), Optimism Sepolia (`eip155:11155420`), Base Sepolia (`eip155:84532`) and Solana devnet. In production you see main networks, such as Base (`eip155:8453`).

Without `TETRAFI_FROM_CHAIN` and `TETRAFI_TO_CHAIN`, the script takes the first two chains listed, which may not be where your wallet holds USDC.

**2. Quote the move**

`POST /api/v1/quotes` with the same asset on both sides, each side pinned to its chain, and `filters.locations` set to `["crossChain"]` so only moves between the two chains are quoted ([Chains and routes](https://tetrafi.mintlify.site/search/chains-and-routes.md)).

Pick a quote whose `next` is `order`: TetraFi executes it from an organisation wallet, so your key can order it. Its `tradingSource` names the wallet it trades from (`financialAccountId` and `network`), and `fundingModel` says how the move is funded ([Wallets and accounts](https://tetrafi.mintlify.site/trade/wallets-and-accounts.md)). The quote's `eta` is how long delivery is expected to take.

```bash cURL theme={null}
# The same asset on both sides, each side pinned to its chain, and only cross-chain quotes kept.
request=$(jq -nc --arg asset "$asset" --arg amount "$amount" --arg from "$from_chain" --arg to "$to_chain" '{
  sell: {asset: $asset, chain: $from},
  buy: {asset: $asset, chain: $to},
  sellAmount: $amount,
  filters: {locations: ["crossChain"]}
}')
quotes=$(quote "$request")

# Every venue asked, with its outcome. apiKeyOrganisationWalletScopeRequired or onchainWalletNotUsable
# means the key has no organisation wallet granted to it that the venue can use.
jq -c '.sources[] | select(.status != "filteredOut") | {venue: .venue.id, status, reason}' <<<"$quotes" >&2

# next "order": TetraFi executes the move from an organisation wallet, so the key can order it.
# next "preflight", "sign" or "transact": your own wallet would act on-chain (Advanced), not open yet.
jq -c '.quotes[] | {quoteId: .id, venue: .legs[0].venue.id, next: .next.kind,
  sell: "\(.sell.amount) on \(.sell.chain)", buy: "\(.buy.amount) on \(.buy.chain)",
  eta, tradesFrom: (.tradingSource | {kind, financialAccountId, network}), fundingModel}' <<<"$quotes" >&2
quote=$(jq -c 'first(.quotes[] | select(.next.kind == "order")) // empty' <<<"$quotes")
```

```json Output on test networks (shortened) theme={null}
{"venue":"across","status":"answered","reason":null}
{"venue":"cctp","status":"unavailable","reason":"amountBelowMinimum"}
{"quoteId":"q_01M5XS49…","venue":"across","next":"order","sell":"1 on eip155:84532","buy":"0.999501 on eip155:11155111","eta":"PT166S","tradesFrom":{"kind":"wallet","financialAccountId":"4d4670ab-…","network":"eip155:84532"},"fundingModel":"prefunded"}
```

A venue that cannot quote says why in `sources`. `amountBelowMinimum` comes with a `detail` naming the least you can move.

You can send one live quote request every 3 seconds. The script waits `details.retryAfter` once when it gets `429 QUOTE_RATE_LIMITED`.

**3. Place the order**

`POST /api/v1/orders` with the quote id and an `Idempotency-Key` header you generate and keep. A retry with the same key and body returns the first answer instead of moving the funds twice ([Idempotency](https://tetrafi.mintlify.site/trade/idempotency.md)).

A bridge that TetraFi trades from your organisation wallet executes within your limit: an order on a quote whose `minReceiveRequired` is `true` must carry `minReceive`, the least you accept to receive ([Orders](https://tetrafi.mintlify.site/trade/orders.md#venue-quotes-that-execute-within-your-limit)). Without it, the order is refused with `400 MIN_RECEIVE_REQUIRED` and nothing is sent. Cross-chain quotes from such a venue are usually indicative, and the script sends `minReceive` whenever the quote asks for it: the quoted amount less 50 basis points. Change that with `TETRAFI_SLIPPAGE_BPS`.

The script places the order only when you set `TETRAFI_PLACE_ORDER=1`.

```bash cURL theme={null}
# When the quote's minReceiveRequired is true, it executes within your limit: send minReceive, the
# least you accept, here the quoted amount less TETRAFI_SLIPPAGE_BPS (default 50), rounded down to the
# asset's decimals.
decimals=$(jq -r --arg a "$asset" --arg c "$to_chain" '[.assets[] | select(.assetId == $a) | .variants[]
  | select(.kind == "native") | .deployments[] | select(.chain == $c) | .decimals][0]' <<<"$assets")
order_body=$(jq -c --argjson bps "${TETRAFI_SLIPPAGE_BPS:-50}" --argjson d "$decimals" '{quoteId: .id}
  + (if .minReceiveRequired == true then {minReceive: ((.buy.amount | tonumber) * (10000 - $bps) / 10000
      | . * pow(10; $d) | floor | . / pow(10; $d) | tostring)} else {} end)' <<<"$quote")
# One Idempotency-Key per order, stored before you send it: a retry with the same key and body
# returns the first answer instead of moving the funds twice.
idempotency_key=$(openssl rand -hex 16)
placed=$(tetrafi POST /api/v1/orders "$order_body" "Idempotency-Key: $idempotency_key")
# A basicTrade names its venue trade in operation; an advancedTrade names its order in order.
jq -c '{kind, quoteId} + if .kind == "basicTrade" then {operation: (.operation | {connectionId, operationId})}
  else {order: (.order | {orderId})} end' <<<"$placed" >&2
```

The script prints the order response's `kind`, `quoteId` and `operation`. A Basic trade's `operation` holds the `connectionId` and `operationId` you read its status with:

```json Output (shortened) theme={null}
{"kind":"basicTrade","quoteId":"q_01M5XS49…","operation":{"connectionId":"…","operationId":"f1103a3a-…"}}
```

**4. Follow it until it ends**

The order answers as soon as it is accepted. A move executed from an organisation wallet answers `"kind": "basicTrade"`; read it at its `statusUrl` every 10 seconds until `terminal` is `true` ([Order status](https://tetrafi.mintlify.site/trade/order-status.md)).

While the funds are on their way, the status can read `working` or `outcomeUnknown`. Neither is a failure: keep reading, and never place a new order for it. When `next` says `wait`, read again after its `retryAfter`.

```bash cURL theme={null}
# Follow the order at the statusUrl the answer gave until terminal is true, polling every 10
# seconds, or after next.retryAfter when it says to wait. The envelope is the same for a move
# TetraFi executes for you (basicTrade) and an on-chain order (advancedTrade).
status_url=$(jq -r '.statusUrl' <<<"$placed")
for _ in $(seq 1 60); do
  state=$(tetrafi GET "$status_url")
  jq -c '{kind, status, terminal, next}' <<<"$state" >&2
  if jq -e '.terminal == true' <<<"$state" >/dev/null; then break; fi
  wait=$(jq -r '.next | select(.kind == "wait") | .retryAfter | capture("^PT(?<s>[0-9]+)S$").s' <<<"$state" 2>/dev/null || true)
  sleep "${wait:-10}"
done
```

```json Output (shortened) theme={null}
{"kind":"basicTrade","status":"working","terminal":false,"next":null}
{"kind":"basicTrade","status":"settled","terminal":true,"next":null}
```

On test networks, a move from Base Sepolia to Ethereum Sepolia finished about two and a half minutes after the order.

**5. Confirm it arrived**

The venue trade's own detail is under `operation`: `executionStatus` for the trade and `settlementStatus` for any later money movement. When the venue reports amounts, `dispatch.inputSpent` is what you paid and `dispatch.outputReceived` is what you received. A missing or `null` amount is unknown, not zero. `dispatch.venueOrderId`, when present, is the venue's native order id.

`bounds.sell.amount` is the most the trade spends, every fee charged in it included, and `bounds.buy.amount` the least it delivers: the limits you accepted, not the amounts that settled. The public response does not document delivery transaction hashes or your organisation wallet's transaction steps.

```bash cURL theme={null}
# Read the documented amounts and bounds, not transaction details outside the public contract.
# A missing amount is unknown, never zero. Confirm arrival in the destination wallet's app balance.
# A move TetraFi executes for you keeps its detail under operation.
if [[ $(jq -r '.kind' <<<"$state") == basicTrade ]]; then
  jq -c '.operation | {executionStatus, settlementStatus, bounds: (.bounds | {sell, buy}),
    dispatch: (.dispatch | if . == null then null else {inputSpent, outputReceived, venueOrderId} end)}' <<<"$state" >&2
fi
```

```json Output (shortened) theme={null}
{
  "executionStatus": "filled",
  "bounds": {
    "sell": { "asset": "USDC", "amount": "1" },
    "buy": { "asset": "USDC", "amount": "0.994503" }
  },
  "dispatch": { "inputSpent": "1", "outputReceived": "0.999501" }
}
```

To confirm arrival independently, check the receiving wallet's balance on the destination chain in the app. Keep reading the order status if the trade or its money movement is not yet final.

## Run the whole script

```bash theme={null}
TETRAFI_PLACE_ORDER=1 bash samples/http/recipe-cross-chain.sh
```

Progress goes to stderr and the summary to stdout:

```json Output theme={null}
{"from":"eip155:84532","to":"eip155:11155111","quoteId":"q_01M5XS49…","kind":"basicTrade","id":"f1103a3a-…","status":"filled","idempotencyKey":"7870…"}
```

## If no quote can be ordered

When no quote's `next` is `order`, the script stops and its summary lists why, venue by venue:

```json Output (shortened) theme={null}
{"from":"eip155:11155111","to":"eip155:11155420","orderable":false,"ownWallet":[],"reasons":[{"venue":"across-solver-testnet","reason":"onchainWalletNotUsable"},{"venue":"circle-cctp","reason":"onchainWalletNotUsable"},{"venue":"across","reason":"apiKeyOrganisationWalletScopeRequired"},{"venue":"cctp","reason":"apiKeyOrganisationWalletScopeRequired"}]}
```

| What you see | What it means | What to do |
| - | - | - |
| `unavailable` with `apiKeyOrganisationWalletScopeRequired` or `onchainWalletNotUsable` | The key has no organisation wallet granted to it that this venue can use. | Ask a workspace owner to [grant the key organisation wallets](https://tetrafi.mintlify.site/trade/wallets-and-accounts.md#give-a-key-organisation-wallets). |
| `unavailable` with `amountBelowMinimum` | The amount is below what that venue moves. `detail` names the least. | Move at least that amount. |
| A quote whose `next` is `preflight`, `sign` or `transact` | The move would pay from your own wallet on-chain (Advanced), which is not open yet. | Use a quote whose `next` is `order`. |
| `restricted` with `venueTradeScopeRequired` | The key cannot trade on venue accounts. | Set up [venue trading](https://tetrafi.mintlify.site/authentication.md#trading-on-your-venue-accounts) for the key. |

## In the app

On the Trade page, open **Advanced filters** and set **Route** to **Cross-chain**, with a from chain and a to chain.

## Next steps

> [card] [Wallets and accounts](https://tetrafi.mintlify.site/trade/wallets-and-accounts.md)
>
> Choose which organisation wallet trades or receives with source and deliverTo.

> [card] [Order status](https://tetrafi.mintlify.site/trade/order-status.md)
>
> Every status a trade can read, and how often to poll.


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