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

# Bridge all

# Bridge API — Full Reference (Alpha Preview)

> **Alpha Preview.** Endpoints, response shape, contract addresses, and supported
> routes may change without notice. Don't depend on this API for
> production-critical flows until it leaves alpha.

This is a single-file mirror of the Mobula Bridge API reference. It bundles the
core endpoints (`/quote`, `/status/:id` + `/status/:id/wait`, `/routes`, plus the
read-only `/history` and `/explorer/*` feeds) into one markdown blob suitable for
copy-paste or LLM context. Source of truth remains the per-endpoint pages and the
controller at `apps/api/src/controllers/v2/bridge/BridgeController.ts`. The
canonical client integration is `apps/bridgescan2` (`src/services/bridge-api.ts`,
`src/pages/BridgePage.tsx`, `src/dev/runBridgeRoute.ts`).

Base URL: `https://api.mobula.io/api/2/bridge` (production) or
`https://demo-api.mobula.io/api/2/bridge` (rate-limited).

### Authentication

Every bridge endpoint is authenticated. Pass your Mobula API key as a query
parameter — `?apiKey=YOUR_API_KEY` — on **every** request, exactly as the
reference client does (it appends `apiKey` to all `/api/2` URLs). This applies on
`demo-api.mobula.io` too; the demo host is only rate-limited, not key-free.
`/quote` rejects a missing key with `{ "error": "Missing required parameter:
apiKey" }`; the other routes are gated at the framework level and return a `402`
challenge without a key. Do **not** pass the key as an `Authorization` header —
the controller reads it from the query string.

### The integration loop differs by origin chain

The signing requirements depend on which chain the funds leave from:

* **EVM and HyperLiquid origins (signature-gated):**
  `GET /quote` (unsigned) → **sign the returned `typedData`** (EIP-712) →
  `GET /quote` again with the signature (the *confirm* call — persists the
  prediction the solver reads) → broadcast the `deposit` → `GET
  /status/{intentId}/wait` until terminal.
* **Solana origin (memo-driven, no signature):**
  `GET /quote` → broadcast the `deposit` (the memo carries the intent) →
  `GET /status/{intentId}/wait` until terminal.

The signed-confirm step is mandatory for EVM/HL — see
[EIP-712 signed-confirm flow](#eip-712-signed-confirm-flow-evmhl-origins). If you
broadcast an EVM/HL deposit **without** confirming the signature first, the solver
has no prediction row, can't resolve your destination token/recipient, and the
deposit is refunded.

***

## Bridge Quote (`GET /api/2/bridge/quote`)

Returns an `intentId`, the estimated output and fee breakdown, the EIP-712
`typedData` to sign (EVM/HL), and ready-to-broadcast `deposit` instructions. The
Mobula solver detects the deposit (flash blocks on Base, gRPC on Solana) and fills
on the destination chain.

Typical end-to-end latency: \~500 ms for Base ↔ Solana, \~1–3 s elsewhere.

### Query parameters

| Name                 | Required          | Notes                                                                                                                                                                                                                                                                                                                                                                                      |
| -------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `apiKey`             | yes               | Your Mobula API key. Required on every call (see Authentication).                                                                                                                                                                                                                                                                                                                          |
| `originChainId`      | yes               | One of the supported chain IDs (see Bridge Routes below).                                                                                                                                                                                                                                                                                                                                  |
| `destinationChainId` | yes               | Same set. When equal to `originChainId`, the endpoint short-circuits to the Swap API — see [Same-chain quotes](#same-chain-quotes).                                                                                                                                                                                                                                                        |
| `walletAddress`      | yes               | **Destination recipient.** Format-validated against the destination chain: EVM regex for `evm:*` and `hl:mainnet`, Base58 for `solana:solana`.                                                                                                                                                                                                                                             |
| `amount`             | yes (cross-chain) | Decimal human units (`"0.05"`, not wei). Must be finite, positive, ≤ `1e15`. Not required when source and destination are the same chain.                                                                                                                                                                                                                                                  |
| `originToken`        | no                | Omit, or pass `0x0000…0000` / `0xeeee…eeee` for the native token. EVM addresses are checksummed server-side.                                                                                                                                                                                                                                                                               |
| `destinationToken`   | no                | Same rules. When omitted on a Solana destination, the API substitutes wSOL (`So11111111111111111111111111111111111111112`).                                                                                                                                                                                                                                                                |
| `senderAddress`      | conditional       | The origin-side signer/holder. **Required** for (1) Solana SPL bridges (to build the ATA + SPL transfer + memo) and (2) EVM/HL origins whose destination is **not** EVM (e.g. EVM→Solana), where `walletAddress` is a base58 recipient and can't double as the EVM signer. Optional for EVM→EVM / HL→EVM, where it defaults to `walletAddress`. On EVM origins it must be a `0x…` address. |
| `slippage`           | no                | Percent. Default `1`, valid range `0`–`50`. See `recommendedSlippage` in the response.                                                                                                                                                                                                                                                                                                     |
| `signature`          | confirm only      | EIP-712 signature over `typedData`. Present only on the second (confirm) call for EVM/HL origins.                                                                                                                                                                                                                                                                                          |
| `intentId`           | confirm only      | Echo the `intentId` from the unsigned response.                                                                                                                                                                                                                                                                                                                                            |
| `deadline`           | confirm only      | Echo the `deadline` from the unsigned response.                                                                                                                                                                                                                                                                                                                                            |
| `minAmountOut`       | confirm only      | Echo `typedData.message.minAmountOut` from the unsigned response.                                                                                                                                                                                                                                                                                                                          |

`customerId` is **not** accepted on quote requests — pass `apiKey` instead
(supplying `customerId` returns an error).

Most validation errors come back as `{ "error": "..." }` with **HTTP 200** —
always check the `error` key before reading `data`. A few failures use real HTTP
error codes: unknown/revoked `apiKey` → `404`, signature that recovers to the
wrong address / bad signature → `400`, prediction-persist failure → `503`.

### Response

```jsonc theme={null}
{
  "data": {
    "intentId": "a3b4ba1-e34523c-324",
    "deadline": 1750000000,            // unix seconds; signature/intent validity (≈2h)
    "signatureRequired": true,          // true for EVM + HyperLiquid, false for Solana
    "typedData": { /* EIP-712 payload to sign — EVM/HL only, see below */ },
    "prediction": { "persisted": false }, // false on an unsigned EVM/HL quote; true after confirm / for Solana
    "estimatedAmountOut": "0.68421052",
    "estimatedAmountOutUsd": "1.99",
    "recommendedSlippage": 1.5,          // % we suggest signing with (implied spread + buffer), or null
    "fees": {
      "bridgeFeeBps": 0,
      "bridgeFeeUsd": "0.0000",          // Mobula protocol fee (currently 0 bps)
      "destFillGasUsd": "0.0800",        // gas the solver spends filling on the destination
      "originRefundGasUsd": "0.0200",    // origin-side refund-gas reserve (charged every time)
      "gasFeeUsd": "0.1000",             // destFillGasUsd + originRefundGasUsd
      "totalFeeUsd": "0.1000"            // bridgeFeeUsd + gasFeeUsd
    },
    "estimatedTimeMs": 1000,
    "maxTradeUsd": 10000,
    "steps": [ /* optional, see below */ ],
    "deposit": { /* one of evm | solana | hl */ }
  }
}
```

* `intentId` is the user-facing handle, format `xxxxxxx-xxxxxxx-xxx` (lowercase
  hex, \~68 bits of entropy). Pass it to `GET /status/:id` or `/status/:id/wait`.
  EVM deposits also emit an on-chain `bytes32` intent ID; both resolve in
  `/status/:id`, so use whichever you have.
* `signatureRequired` / `typedData` / `deadline` drive the
  [signed-confirm flow](#eip-712-signed-confirm-flow-evmhl-origins). They are
  populated for EVM and HyperLiquid origins; for Solana, `signatureRequired` is
  `false` and `prediction.persisted` is already `true` on the first call.
* `prediction.persisted` tells you whether the solver can already resolve this
  deposit. On an **unsigned EVM/HL** quote it is `false` — do not broadcast yet.
  After the confirm call (or on any Solana quote) it is `true`.
* `recommendedSlippage` is derived from this quote's implied spread
  (`(1 − outUsd/inUsd)×100`) plus a `0.5%` drift buffer, rounded up to `0.1` and
  clamped to `[1, 50]`; `null` when USD prices are unavailable. Because the solver
  refunds any fill below the signed `minAmountOut` (failure code `slippage`),
  signing with at least this value avoids near-guaranteed refunds.
* `fees` are all **real, deducted** amounts — there is no placeholder. The user's
  output is `amountInUsd − bridgeFeeUsd − destFillGasUsd − originRefundGasUsd`. The
  origin refund-gas reserve is charged on every bridge (a small insurance premium)
  so the solver isn't pushed into the red when a refund is required.
* `steps` lists the on-chain transactions for **EVM origins**:
  `[{type:'bridge'}]` for native, `[{type:'approve'},{type:'bridgeToken'}]` for
  direct-bridge tokens, `[{type:'approve'},{type:'swapAndBridge'}]` for other
  ERC-20s. Solana and HyperLiquid deposits omit `steps`.
* `maxTradeUsd` is `$10,000`. Amounts above that return
  `"Amount $X exceeds maximum trade of $10000"`.

### EIP-712 signed-confirm flow (EVM/HL origins)

For `evm:*` and `hl:mainnet` origins, the unsigned `/quote` call does **not**
persist the prediction row the solver needs. You must sign the returned
`typedData` and call `/quote` a second time with the signature so the server can
verify it recovers to the signer and only then write the prediction. This stops an
attacker from rewriting your destination token between quote and deposit.

The `typedData` returned looks like:

```jsonc theme={null}
{
  "domain": { "name": "Mobula Bridge", "version": "1", "chainId": 8453 },
  "types": {
    "BridgeIntent": [
      { "name": "intentId", "type": "string" },
      { "name": "sender", "type": "address" },
      { "name": "originChainId", "type": "string" },
      { "name": "originToken", "type": "string" },
      { "name": "amountIn", "type": "uint256" },
      { "name": "destinationChainId", "type": "string" },
      { "name": "destinationToken", "type": "string" },
      { "name": "recipient", "type": "bytes32" },
      { "name": "minAmountOut", "type": "uint256" },
      { "name": "deadline", "type": "uint64" }
    ]
  },
  "primaryType": "BridgeIntent",
  "message": { /* the fields above, filled in */ }
}
```

Steps:

1. **Sign** `typedData` with `eth_signTypedData_v4`. viem omits the `EIP712Domain`
   type from `types`; inject it client-side
   (`name:string, version:string, chainId:uint256`) before signing.
2. **Confirm:** call `GET /quote` again with the **same** base params
   (`originChainId`, `destinationChainId`, `amount`, `walletAddress`,
   `originToken`, `destinationToken`, `slippage`, `senderAddress`) **plus**
   `signature`, `intentId`, `deadline`, and `minAmountOut`
   (= `typedData.message.minAmountOut`). The server verifies the signature
   recovers to the signer (`senderAddress` on EVM, or `walletAddress` for
   EVM→EVM), persists the prediction, and returns `prediction.persisted: true`.
3. **Broadcast** the `deposit`:
   * **Native and direct-bridge token** routes compute `minAmountOut`
     deterministically, so the `deposit` from the unsigned call already matches the
     signed value — you may broadcast it in parallel with the confirm round-trip.
   * **Swap-and-bridge** routes embed aggregator calldata that goes stale, so the
     confirm call re-quotes it; use the `deposit` returned by the **confirm** call.

**HyperLiquid specifics.** HL signs all actions under Ethereum mainnet, so the
`typedData.domain.chainId` is `1` (not `999`) — switch the wallet to chain `1`
before signing. The flow is two signatures: (1) the EIP-712 `BridgeIntent`
confirm, then (2) the HL `spotSend` transfer to the solver.

**Solana** origins skip all of this: there is no `typedData`, `signatureRequired`
is `false`, and the intent is carried in the deposit memo.

### `deposit` shapes

The shape depends on `originChainId`. Sign and broadcast whichever one is present
(for EVM/HL, only after the confirm step above).

#### EVM origin (`deposit.evm`)

```jsonc theme={null}
{
  "to": "0xa834E70303322D86E5DaE95ee47E9c6a073d9812",
  "data": "0x...",
  "value": "50000000000000000",      // wei; "0" for token paths
  "chainId": 8453,
  // approval fields appear ONLY on token paths (direct-bridge / swap-and-bridge):
  "approvalAddress": "0x...",
  "approvalToken": "0x...",
  "approvalAmount": "115792...255"
}
```

Three code paths:

* **Native ETH/BNB/POL** — single `bridge()` call on `MobulaBridge`. `value` is
  the raw amount in wei. No approval fields. `steps` has one `bridge` entry.
* **Direct-bridge tokens** — `approve` to `MobulaBridge`, then `bridgeToken()`.
  `value` is `"0"`. The direct-bridge set is USDC on all four chains, plus Base's
  bridged **USDbC** and **USDT** on Arbitrum and Polygon. (BSC is USDC-only.)
* **Any other ERC-20** — `approve` to `SwapBridgeHelper`, then `swapAndBridge()`,
  which atomically swaps to native and bridges in one TX. The embedded swap
  calldata is validated server-side to start with the `MobulaRouter.executeRoute`
  selector (`0xa564dfa4`); otherwise the quote returns
  `"Swap quote failed: invalid calldata selector"`.

`MobulaBridge` (MobulaBridgeV2, deployed 2026-06-04) is the **same proxy address
on every EVM chain**:

| Chain                  | Bridge contract                              |
| ---------------------- | -------------------------------------------- |
| `evm:8453` (Base)      | `0xa834E70303322D86E5DaE95ee47E9c6a073d9812` |
| `evm:56` (BSC)         | `0xa834E70303322D86E5DaE95ee47E9c6a073d9812` |
| `evm:42161` (Arbitrum) | `0xa834E70303322D86E5DaE95ee47E9c6a073d9812` |
| `evm:137` (Polygon)    | `0xa834E70303322D86E5DaE95ee47E9c6a073d9812` |

`SwapBridgeHelper` (the approve spender for the swap-and-bridge path) differs per
chain:

| Chain                  | Helper contract                              |
| ---------------------- | -------------------------------------------- |
| `evm:8453` (Base)      | `0x8E0E5f83d0B011250B47Acf5bE93B060e7852C4b` |
| `evm:56` (BSC)         | `0x61D744cCDEB1e436Fa609e8231104AC0a0B235a7` |
| `evm:42161` (Arbitrum) | `0x61D744cCDEB1e436Fa609e8231104AC0a0B235a7` |
| `evm:137` (Polygon)    | `0xB75195B66FA0fd5BB5240E61cD45fbceFC0C86b3` |

Approval handling: `approvalAmount` is always `MAX_UINT256`, so a single approve
per (token, spender) is enough forever. Skip the `approve` step only if the
current on-chain allowance already covers the spend. **Approve the spender named
in the step** (`MobulaBridge` for direct tokens, `SwapBridgeHelper` for swaps) —
they differ.

#### Solana origin (`deposit.solana`)

Two shapes depending on token:

* **Native SOL** — `{ to, amount, memo }`. Build a `SystemProgram.transfer` for
  `amount` lamports to `to` (the solver address from the response), then add a memo
  instruction (program `MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr`) whose data is
  the `memo` string. The memo is a JSON blob the Solana listener parses to recover
  `intentId`, `destinationChainId`, `recipient`, `destinationToken`, and
  `minAmountOut`.
* **SPL** — `{ type: "spl-transfer", serializedTx }`. The controller has already
  built the full versioned transaction (SPL transfer + ATA creation if missing +
  memo). Just `VersionedTransaction.deserialize`, sign, and send.

#### HyperLiquid origin (`deposit.hl`)

```json theme={null}
{
  "type": "spotSend",
  "to": "0x74CfC17edF89aD6134c04c446c3Be6dD288F0B8d",
  "token": "USDH:0x54e00a5988577cb0b0c9ab0cb6ef7f4b",
  "amount": "1.0"
}
```

Submit a `spotSend` action to the solver L1 address using your HL signer, **after**
the EIP-712 confirm step. The `to` address is the solver's, set from a server-side
env var — **use the `to` returned in the response, don't hardcode it** (the value
above is illustrative). The `token` field uses HL's `SYMBOL:tokenId` format.

### Same-chain quotes

When `originChainId === destinationChainId`, the controller short-circuits into a
swap-wrapper. The response is the **raw Swap API response**, not the bridge shape
above: top-level `evm`/`solana` transaction fields, **no** `intentId`, `typedData`,
or `deposit`. The `apiKey` is still validated. Approval amounts are overridden to
`MAX_UINT256` server-side.

Branch on the response: `data.deposit` present → bridge flow; otherwise → swap
flow (execute the top-level `evm`/`solana` tx directly, no status polling).

### Side effects (prediction rows)

A `/quote` call persists a row into `misc.bridge_intent_predictions` that the
destination listener reads to resolve `destinationToken`, `recipient`, and
`slippage` — so you don't encode those into the EVM deposit TX. **When** the row is
written differs by origin:

* **Solana origin:** every unsigned `/quote` inserts a fresh row, keyed per
  `intentId` (memo-driven; each in-flight intent gets its own row).
* **EVM/HL origin:** the row is written **only on the signed confirm call**, and
  upserts the *latest* prediction per `(sender, originChainId, destinationChainId)`.
  An unsigned EVM/HL `/quote` writes nothing (`prediction.persisted: false`).

***

## Bridge Status (`GET /api/2/bridge/status/{id}`) and Wait (`GET /api/2/bridge/status/{id}/wait`)

Two endpoints share this section:

* `GET /api/2/bridge/status/{id}` — returns the current row immediately.
* `GET /api/2/bridge/status/{id}/wait` — long-polls server-side until the intent
  reaches a terminal state (or the timeout elapses).

### Path parameter

`id` accepts **any** of these — pass whichever you have:

* `intentId` from `/quote` (format `xxxxxxx-xxxxxxx-xxx`).
* The on-chain `bytes32` intent ID (EVM only, emitted by `MobulaBridge`).
* The deposit TX hash.
* The fill TX hash.

(The lookup matches `intent_id`, `onchain_intent_id`, `deposit_tx_hash`, and
`fill_tx_hash`.) If nothing matches, the response is **not** an error — it's:

```json theme={null}
{ "data": { "id": "...", "status": "pending", "message": "Intent not found — deposit may still be processing" } }
```

This is the expected state right after the deposit is broadcast but before the
chain listener has indexed it. Keep polling.

### Response

```jsonc theme={null}
{
  "data": {
    "intentId": "a3b4ba1-e34523c-324",
    "status": "filled",
    "failureReason": null,             // structured object on terminal failures
    "originChainId": "evm:8453",
    "destinationChainId": "solana:solana",
    "sender": "0x...",
    "recipient": "...",
    "amountIn": "0.05",
    "amountInUsd": 167.42,
    "amountOut": "0.68421052",
    "amountOutUsd": 165.10,
    "depositTxHash": "0x...",
    "fillTxHash": "5xL...",
    "settleTxHash": null,
    "latencyMs": 487,
    "timestamps": {
      "depositDetected": "2026-05-23T12:00:01.123Z",
      "fillSent": "2026-05-23T12:00:01.500Z",
      "fillConfirmed": "2026-05-23T12:00:01.610Z",
      "settled": null
    },
    "createdAt": "2026-05-23T12:00:00.000Z"
  }
}
```

`latencyMs` is the deposit-detected → fill-confirmed delta. `settleTxHash` /
`timestamps.settled` populate only once the solver has been reimbursed on the
origin chain — that step is async and can lag the user-visible fill. On a slippage
refund, `failureReason.code` is `"slippage"` and a human-readable `message` field
is added telling the user to raise their slippage.

### Status lifecycle

The statuses the solver actually writes:

| Status     | Meaning                                                      | Terminal?            |
| ---------- | ------------------------------------------------------------ | -------------------- |
| `pending`  | Row not yet created, or deposit not yet detected.            | No                   |
| `filling`  | Deposit detected; fill in progress on the destination chain. | No                   |
| `filled`   | Fill confirmed — user has received funds.                    | **Yes** (happy path) |
| `settled`  | Solver reimbursed on the origin chain.                       | Yes                  |
| `refunded` | Fill couldn't complete; user refunded on the origin chain.   | Yes                  |
| `failed`   | Refund also failed — manual intervention.                    | Yes                  |

Notes:

* On deposit detection the solver writes `filling` **directly** — there is no
  intermediate `deposited` status. (`deposited` and `retrying` exist in the
  underlying type enum but the solver does not currently emit them; retries are
  tracked in a separate queue and the intent stays/returns to `filling`.) Treat any
  non-terminal status as "keep waiting," and any unknown status defensively.
* Terminal set: `filled`, `settled`, `failed`, `refunded`. Stop polling on these.

### `GET /status/{id}/wait`

Long-poll variant. Blocks server-side until the intent reaches `filled`,
`settled`, `failed`, or `refunded`, then returns the same shape as `/status/{id}`.
If the window elapses first, you get the **current** (non-terminal) row and should
call again.

#### Query parameter

| Name      | Notes                                             |
| --------- | ------------------------------------------------- |
| `timeout` | Milliseconds. Default `30000`, capped at `60000`. |

#### How it actually waits

The controller hands the intent to a centralized batched poller
(`IntentWaiterService`) that, while any waiter is registered, runs one pass every
**10 ms** across **all** active waiters: a Redis `MGET` of the solver's mirrored
intent-state keys (no replication lag — the fast path), plus a Postgres **primary**
read for anything Redis didn't resolve, matching
`status IN ('filled','settled','failed','refunded')`. The first pass that sees your
row terminal resolves the request. (This replaced an earlier Postgres LISTEN/NOTIFY
implementation that exhausted PG backend connections — there is no `LISTEN` and no
per-request fallback timer.) In practice fills resolve within the
destination-listener latency — typically a few hundred ms — and the `timeout` is
just the upper bound.

#### Recommended client loop

```typescript theme={null}
async function waitUntilDone(intentId: string) {
  while (true) {
    const res = await fetch(
      `${API}/api/2/bridge/status/${intentId}/wait?apiKey=YOUR_API_KEY`,
    );
    const { data } = await res.json();

    if (data.status === "filled" || data.status === "settled") return data;
    if (data.status === "failed" || data.status === "refunded") {
      throw new Error(data.message ?? `Bridge ${data.status}`);
    }
    // pending / filling — re-fire immediately
  }
}
```

Pass the key as the `?apiKey=` query param (not an `Authorization` header). Don't
add a client-side sleep — the server already blocks until something happens; firing
again with no delay keeps one open long-poll waiting for the next state change.

#### Stale-response handling

If you start a new bridge while a previous `/wait` is still in flight, the previous
response will arrive **after** your new `intentId` is active. Track the active
intent ID client-side and discard any `/wait` result that doesn't match it —
otherwise you'll attach an old fill TX to the new attempt.

***

## Bridge Routes (`GET /api/2/bridge/routes`)

Returns the static all-to-all matrix of supported routes. Useful for building a
chain selector or validating a pair before calling `/quote`. No query parameters
beyond `apiKey`.

### Supported chains

| Chain ID        | Chain          | Estimated fill latency |
| --------------- | -------------- | ---------------------- |
| `evm:8453`      | Base           | \~500 ms               |
| `evm:56`        | BSC            | \~3000 ms              |
| `evm:42161`     | Arbitrum       | \~1000 ms              |
| `evm:137`       | Polygon        | \~2000 ms              |
| `solana:solana` | Solana         | \~500 ms               |
| `hl:mainnet`    | HyperLiquid L1 | \~1000 ms              |

The route list is all-to-all across these 6 chains excluding same-chain pairs —
30 entries. Same-chain "bridges" are not in this list; calling `/quote` with
`originChainId === destinationChainId` short-circuits to the Swap API instead.

### Response

```json theme={null}
{
  "data": {
    "routes": [
      {
        "originChainId": "evm:8453",
        "destinationChainId": "solana:solana",
        "estimatedTimeMs": 1000,
        "maxTradeUsd": 10000,
        "feeBps": 0,
        "supportedTokens": "any"
      }
    ]
  }
}
```

Per-route fields:

* `estimatedTimeMs` — sum of origin and destination chain listener latencies from
  the table above (e.g. Base → Solana = 500 + 500 = 1000).
* `maxTradeUsd` — hard cap per intent. Currently `10000` on every route. Amounts
  above this in `/quote` return `"Amount $X exceeds maximum trade of $10000"`.
* `feeBps` — reported as `0` here; this is the public route metadata. The actual
  solver fee is folded into `/quote`'s `estimatedAmountOut` and surfaced under
  `fees`. Always read fees from `/quote`, not this endpoint.
* `supportedTokens` — `"any"` on every route. `/quote` picks the right code path
  (native bridge, direct-bridge token, swap-and-bridge, or SPL transfer) from the
  token you pass.

***

## Other endpoints (read-only)

These power the bridgescan explorer and history views. All require `apiKey`.

### `GET /api/2/bridge/history`

Per-wallet bridge history with cursor pagination. Query params: `wallet`
(required; matches either `sender` or `recipient`), optional `status`,
`originChainId`, `destinationChainId`, `limit` (default `50`), and `cursor`
(opaque, from the previous page's `pagination.nextCursor`). Returns
`{ data: [...], pagination: { limit, pageEntries, nextCursor }, stats: { totalVolumeUsd } }`,
each entry mirroring the `/status` shape (timestamps are epoch-ms here).

### `GET /api/2/bridge/explorer/stats`

Public aggregate counters: `{ data: { volumeSettledUsd, totalOrders } }`.

### `GET /api/2/bridge/explorer/intents`

Most recent intents (up to 400), optionally filtered by `customerId`. Same entry
shape as `/history`, plus a `stats.totalVolumeUsd`.

> `GET /api/2/bridge/explorer/monitor` also exists but is **dev-only** (returns
> `404` unless the server runs with `BRIDGE_ENV=dev`). It dumps full
> `bridge_intents` + `bridge_intent_predictions` rows for the live monitor tab and
> is not part of the public surface.
