# Bridge

Requires `bridge:read` for routes, quotes and transfer reads. Building and
submitting a bridge needs **both** `bridge:read` and `transactions:write` -
holding `transactions:write` for staking or stocks is not enough on its own.
Both can come through the [`all:read` / `all:write` wildcards](/authentication#wildcard-scopes).

The bridge moves a user's token from one chain to another - for example USDC
from Base to BNB Chain. The user signs **once**, on the source chain, and needs
no native gas token on either side: the batch is sponsored, and the tokens are
delivered on the destination without the wallet doing anything.

| Endpoint | Scope | Returns |
| --- | --- | --- |
| `GET …/bridge/routes` | `bridge:read` | Every direction on offer, with live limits |
| `POST …/bridge/quote` | `bridge:read` | What an amount would deliver, and what it costs |
| `GET …/bridge/fee-estimate` | `bridge:read` | The approximate gas fee on an amount |
| `POST …/bridge/construct/transaction/batch` | `bridge:read` + `transactions:write` | An unsigned bridge |
| `POST …/bridge/transaction/batch/send` | `bridge:read` + `transactions:write` | Submits a signed bridge |
| `GET …/bridge/transfers` | `bridge:read` | A user's transfers, newest first |
| `GET …/bridge/transfers/{transferId}` | `bridge:read` | One transfer |

Every transfer status change is also pushed to you as a
[`bridge_transfer_*` callback](/callbacks#bridge-events), on the same endpoint
as your portfolio callbacks.

## Routes

```bash
curl https://stg-s2s.restake.gg/api/v1/s2s/bridge/routes \
  -H "authorization: Bearer $ACCESS_TOKEN"
```

```json title="Response"
{
  "data": [
    {
      "routeId": "5c0a…",
      "provider": "across",
      "from": { "symbol": "USDC", "chain": "BASE", "chainId": 8453, "contractAddress": "0x8335…2913", "decimals": 6 },
      "to": { "symbol": "USDC", "chain": "BSC", "chainId": 56, "contractAddress": "0x8ac7…580d", "decimals": 18 },
      "limits": {
        "minAmount": "0.5",
        "maxAmount": "132600.0",
        "minAmountRaw": "500000",
        "maxAmountRaw": "132600000000",
        "fetchedAt": "2026-09-24T10:00:00.000Z"
      },
      "targets": {
        "bridge": "0x09aea4b2242abC8bb4BB78D537A67a245A7bEC64",
        "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
        "treasury": "0x…"
      }
    }
  ]
}
```

Each **direction** is its own route with its own `routeId`: Base → BNB Chain and
BNB Chain → Base are two entries, and either can be paused on its own. Treat
this list as the source of truth for what is on offer - do not hard-code a
`routeId`, and do not assume a direction exists because its reverse does.

**The two sides can have different decimals.** USDC is 6 decimals on Base and
18 on BNB Chain. Every amount in this API is therefore given twice - a decimal
string in that token's own units (`"100.0"`) and the base-unit integer as a
string (`"100000000"`) - and a raw amount must never be carried from one side
to the other.

**Limits are live.** They come from the bridge's relayer liquidity at the time
of the call (cached for up to a minute) and move with it. `maxAmount` is the
largest amount that fills at the quoted speed, in source-token units. When the
bridge cannot be reached, `limits` is `null` and quote and construct on that
route answer `503` until it can.

`targets` lists every contract a batch on this route calls on the source chain:
the bridge's deposit contract, the source token, and the treasury the gas fee is
paid to. See [Verifying a batch](#verifying-a-batch).

## Quoting

```bash
curl -X POST https://stg-s2s.restake.gg/api/v1/s2s/bridge/quote \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H 'content-type: application/json' \
  -d '{ "userId": "8f2b1c4e-…", "routeId": "5c0a…", "amount": 100 }'
```

```json title="Response"
{
  "data": {
    "routeId": "5c0a…",
    "amountIn": "100.0",
    "amountInRaw": "100000000",
    "amountOut": "99.9859",
    "amountOutRaw": "99985900000000000000",
    "providerFee": "0.0141",
    "providerFeeRaw": "14100",
    "gasFee": "0.1",
    "gasFeeRaw": "100000",
    "gasFeeToken": "USDC",
    "etaSec": 2,
    "expiresAt": "2026-09-24T11:00:00.000Z",
    "limits": { "minAmount": "0.5", "maxAmount": "132600.0", "…": "…" }
  }
}
```

`amount` is in **source-token units**: `100` is 100 USDC. A quote takes a
`userId` because the price is for that user's wallet.

There are two costs, and they are paid differently:

- **`providerFee`** is what the bridge keeps. It is taken out of the amount:
  the user deposits `amountIn` and receives `amountOut`.
- **`gasFee`** covers the sponsored gas, and is charged **on top of** the
  amount, in `gasFeeToken` on the source chain. It is priced from the source
  chain's current gas price. The wallet must hold `amountIn + gasFee`, so a
  "max" button should construct with `maxAmount: true` rather than send the
  balance.

There is no platform fee on a bridge.

A quote is indicative. Construct re-quotes rather than trusting it, so the
numbers returned beside the batch are the ones that count.

## Estimating the gas fee (`fee-estimate`)

`GET …/fee-estimate?routeId=…&amount=…` answers the gas fee on an amount and
`finalAmount`: `amount` less that fee. For a wallet holding `amount`,
`finalAmount` is about what `maxAmount` will bridge. It reads no wallet and does
not call the bridge, so it carries no `providerFee`; [quote](#quoting) for that.
The response has the same shape as on
[staking](/staking#estimating-the-fee-fee-estimate), with `feePercent` and
`systemFeeUsd` always `0`.

```bash
curl "https://stg-s2s.restake.gg/api/v1/s2s/bridge/fee-estimate?routeId=5c0a…&amount=100" \
  -H "authorization: Bearer $ACCESS_TOKEN"
```

## The bridge flow

The same three steps as [staking](/staking#the-transaction-flow) and
[stocks](/stocks#the-trade-flow), then tracking:

1. `POST …/bridge/construct/transaction/batch` returns an unsigned EIP-7702
   batch user operation on the **source** chain, an `intentId`, a `transferId`
   and the quote it was built on.
2. The user's wallet signs `bundle.userOpHash` and, when `bundle.authorization`
   is not `null`, the EIP-7702 authorization.
3. `POST …/bridge/transaction/batch/send` returns the signatures with the
   `intentId`, and the API submits the operation.
4. Track the transfer by `transferId` - through callbacks, or by reading it.

### 1. Build

```bash
curl -X POST https://stg-s2s.restake.gg/api/v1/s2s/bridge/construct/transaction/batch \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H 'content-type: application/json' \
  -d '{ "userId": "8f2b1c4e-…", "routeId": "5c0a…", "amount": 100 }'
```

```json title="Response"
{
  "data": {
    "transferId": "d41e…",
    "intentId": "b7c1…",
    "bundle": {
      "chainId": 8453,
      "entryPoint": "0x…",
      "delegate": "0x…",
      "calls": [
        { "target": "0x8335…2913", "value": "0x0", "data": "0x095ea7b3…" },
        { "target": "0x09ae…EC64", "value": "0x0", "data": "0xad5425c6…" },
        { "target": "0x8335…2913", "value": "0x0", "data": "0xa9059cbb…" }
      ],
      "userOp": { "sender": "0x…", "…": "…" },
      "userOpHash": "0x…",
      "authorization": { "chainId": 8453, "address": "0x…", "nonce": 0 }
    },
    "quote": { "amountOut": "99.9859", "gasFee": "0.1", "expiresAt": "…", "…": "…" }
  }
}
```

Send `"maxAmount": true` instead of `amount` to bridge the largest amount the
wallet can afford: the source balance less the gas fee, capped at the route's
live `maxAmount`. A tiny remainder stays in the wallet, so a gas price that
rises before the batch lands cannot make it unaffordable. `quote.amountIn` in
the response is the amount used. A maximum below the route's `minAmount` is a
`400`, as for any amount.

Construct makes two checks a quote does not, both before anything is signed:

- the wallet holds `amount + gasFee` of the source token - otherwise a `400`
  naming both figures;
- the bridge's fee is within the ceiling we set for the route - otherwise a
  `503`; try again shortly.

### 2. Sign

Exactly as for staking and stocks:

- `signature` - EIP-191 over the 32 bytes of `bundle.userOpHash`, prefixed with
  `0xff00`.
- `authorization` - the EIP-7702 tuple signed as `{ r, s, yParity }`, using
  `bundle.authorization`. Note its `chainId` is the **source** chain. Only when
  `bundle.authorization` is not `null`; for a wallet already delegated on the
  source chain it is `null`, and the submit body leaves `authorization` out.

[Signing with Privy](/privy) and [Signing with Wallet Core](/wallet-core) cover
both signatures in code.

### 3. Submit

```bash
curl -X POST https://stg-s2s.restake.gg/api/v1/s2s/bridge/transaction/batch/send \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H 'content-type: application/json' \
  -d '{
    "userId": "8f2b1c4e-…",
    "intentId": "b7c1…",
    "signature": "0x…",
    "authorization": { "r": "0x…", "s": "0x…", "yParity": 0 }
  }'
```

```json title="Response"
{ "data": { "userOpHash": "0xa1b2…", "transferId": "d41e…" } }
```

Submit **before `quote.expiresAt`** - a bridge intent lives only as long as its
quote is fillable, a few minutes at most. A late or repeated submit is a `400`;
build and sign a new one. Only an intent built by the bridge construct is
accepted here, and a bridge must be submitted here, not through the staking or
stocks submit endpoint.

### 4. Track

```bash
curl "https://stg-s2s.restake.gg/api/v1/s2s/bridge/transfers/d41e…?userId=8f2b1c4e-…" \
  -H "authorization: Bearer $ACCESS_TOKEN"
```

```json title="Response"
{
  "data": {
    "transferId": "d41e…",
    "userId": "8f2b1c4e-…",
    "routeId": "5c0a…",
    "status": "delivered",
    "provider": "across",
    "providerRef": "3141592",
    "source": { "chain": "BASE", "chainId": 8453, "token": "USDC", "tokenAddress": "0x…", "decimals": 6, "amount": "100.0", "amountRaw": "100000000" },
    "destination": { "chain": "BSC", "chainId": 56, "token": "USDC", "tokenAddress": "0x…", "decimals": 18, "amount": "99.9859", "amountRaw": "99985900000000000000" },
    "providerFee": "0.0141",
    "providerFeeRaw": "14100",
    "gasFee": "0.1",
    "gasFeeRaw": "100000",
    "gasFeeToken": "USDC",
    "srcTxHash": "0x…",
    "dstTxHash": "0x…",
    "refundTxHash": null,
    "failureReason": null,
    "createdAt": "2026-09-24T10:00:00.000Z",
    "updatedAt": "2026-09-24T10:00:09.000Z"
  }
}
```

This body is exactly what every bridge callback carries.

`GET …/bridge/transfers?userId=…` lists a user's transfers, newest first.
Filter with `status=`, and page with `limit` (up to 100) and the `nextCursor`
of the previous page passed back as `cursor`. `nextCursor` is `null` on the
last page.

## Statuses

| Status | Meaning | Callback |
| --- | --- | --- |
| `pending` | Built; the wallet has not signed | - |
| `submitted` | The signed operation reached the bundler | `bridge_transfer_submitted` |
| `source_confirmed` | The deposit landed on the source chain; the gas fee is paid | `bridge_transfer_source_confirmed` |
| `delivered` | The tokens arrived on the destination - usually seconds later | `bridge_transfer_delivered` |
| `refund_pending` | Nobody filled the deposit before its deadline; it is being refunded | `bridge_transfer_refund_pending` |
| `refunded` | The refund landed back on the **source** chain | `bridge_transfer_refunded` |
| `failed` | The operation reverted or never reached the bundler. Nothing moved | `bridge_transfer_failed` |
| `expired` | Built but never signed in time | - |

`delivered`, `refunded`, `failed` and `expired` are final.

**A refund is slow and lands on the source chain.** It is paid back to the same
wallet on the chain the tokens left from, in a later settlement by the bridge
- hours, not seconds. The money is never lost, but tell the user where and when
to expect it.

## Verifying a batch

Every call in a bridge batch is plain, decodable calldata, so you can check a
batch before the user signs it without trusting our API. On the source chain,
in this order, and nothing else:

1. `approve(bridge, amount)` on the source token - for the exact amount, never
   unlimited;
2. the bridge deposit on `targets.bridge`, with the user's own wallet as both
   depositor and recipient, the input and output token and amount, and the
   destination chain id all in the calldata, and no attached message;
3. `transfer(treasury, gasFee)` on the source token - omitted when no gas fee
   is charged.

Every target is in the route's `targets`, so a verifier can allowlist them. A
contract-based verifier and its documentation are on the way and will be linked
here.

## Failure modes

| Status | Cause |
| --- | --- |
| `400` | The route is paused; the amount is below `minAmount` or above `maxAmount` (the message names the limit); the user has no linked wallet; the wallet does not hold `amount + gasFee` ([`INSUFFICIENT_BALANCE`](/errors#insufficient-balance)); or the intent was already submitted or has expired |
| `403` | Your token lacks `bridge:read`, or `transactions:write` on construct or submit |
| `404` | No such route or transfer - or no such user, or a user or transfer belonging to another partner |
| `503` | The bridge could not be reached, its fee is above the route's ceiling right now, or the quote expires too soon to sign. Retry shortly |
