From 27729b34a8acc618054e41b758021d5d48e11f91 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 23:25:31 +0000 Subject: [PATCH] docs(fast-bridge): add Fast Bridge overview, endpoints, errors and how-to Documents the /v1/bridge HTTP API from pimlicolabs/paymaster#819 and the stack below it: supported routes and limits, authorization types, the request lifecycle, POST /execute, POST /execute/sync, GET /status and GET /addresses, rejection and leg error codes, and a how-to for bridging USDC from Base to Arbitrum with a recipient, calls or Permit2. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUrMSxtSGtC5AzeK4kVTpB --- .../payments/bridge-usdc-with-fast-bridge.mdx | 115 ++++++++ .../references/fast-bridge/endpoints.mdx | 244 +++++++++++++++++ docs/pages/references/fast-bridge/errors.mdx | 84 ++++++ docs/pages/references/fast-bridge/index.mdx | 63 +++++ docs/snippets/payments/fast-bridge.ts | 253 ++++++++++++++++++ sidebars/guides.ts | 4 + sidebars/references.ts | 19 ++ vocs.config.ts | 7 + 8 files changed, 789 insertions(+) create mode 100644 docs/pages/guides/how-to/payments/bridge-usdc-with-fast-bridge.mdx create mode 100644 docs/pages/references/fast-bridge/endpoints.mdx create mode 100644 docs/pages/references/fast-bridge/errors.mdx create mode 100644 docs/pages/references/fast-bridge/index.mdx create mode 100644 docs/snippets/payments/fast-bridge.ts diff --git a/docs/pages/guides/how-to/payments/bridge-usdc-with-fast-bridge.mdx b/docs/pages/guides/how-to/payments/bridge-usdc-with-fast-bridge.mdx new file mode 100644 index 00000000..f3094f48 --- /dev/null +++ b/docs/pages/guides/how-to/payments/bridge-usdc-with-fast-bridge.mdx @@ -0,0 +1,115 @@ +--- +title: How to bridge USDC between chains with Fast Bridge +description: Move a user's USDC from one chain to another in seconds with Pimlico Fast Bridge, from a single signed authorization and without gas on either chain. +--- + +# How to bridge USDC between chains with Fast Bridge + +This guide shows you how to move a user's USDC from one chain to another with Pimlico Fast Bridge, either to a recipient or straight into calls on the destination chain. The user signs one token authorization and holds no gas on either chain. For the supported routes, limits and how a request runs, see the [Fast Bridge overview](/references/fast-bridge). + +This guide bridges native USDC from Base to Arbitrum with an EIP-3009 authorization. For BSC, which only takes Permit2, see [Bridge from a token without EIP-3009](#bridge-from-a-token-without-eip-3009). + +## Prerequisites + +- A Pimlico API key. [Create one](/guides/create-api-key). +- An EOA holding USDC on the origin chain, within the [per-request limit](/references/fast-bridge#supported-chains-and-tokens). + +::::code-group + +```bash [npm] +npm install viem +``` + +```bash [pnpm] +pnpm install viem +``` + +```bash [bun] +bun install viem +``` + +:::: + +## Steps + +::::steps + +### Set up a bridge client + +Create a public client for the origin chain, the user's account, and a helper that calls the Fast Bridge API with your API key. A rejected request returns a `code` and a `message`; the helper throws them. + +```ts +// [!include ~/snippets/payments/fast-bridge.ts:setup] +``` + +:::warning +If this code runs in your users' browsers or apps, don't ship your API key in it. Route requests through your own backend, as described in [How to protect your API keys](/guides/how-to/security/protect-api-keys). +::: + +### Get the bridge's approval address + +Call [`GET /addresses`](/references/fast-bridge/endpoints#get-addresses). Make every authorization out to `approvalAddress`: the bridge rejects any other payee with `AUTHORIZATION_MISMATCH`. + +```ts +// [!include ~/snippets/payments/fast-bridge.ts:addresses] +``` + +### Sign a ReceiveWithAuthorization + +Have the user sign an EIP-3009 `ReceiveWithAuthorization` for the amount to bridge, with `to` set to `approvalAddress` and a random 32-byte nonce. Give it a `validBefore` a few minutes ahead: the origin pull must land before it. + +```ts +// [!include ~/snippets/payments/fast-bridge.ts:sign] +``` + +### Send the request + +Send the authorization to [`POST /execute/sync`](/references/fast-bridge/endpoints#post-executesync) with the route and the `recipient` on the destination chain. If you'd rather not hold the connection open, send it to [`POST /execute`](/references/fast-bridge/endpoints#post-execute) instead, which answers `202` as soon as the request is accepted. + +```ts +// [!include ~/snippets/payments/fast-bridge.ts:execute] +``` + +If the request is rejected, nothing has moved: fix what the [error code](/references/fast-bridge/errors) names, and sign a new authorization if it was used or has expired. + +### Check that the USDC arrived + +If the response is still `pending`, poll [`GET /status/{requestId}`](/references/fast-bridge/endpoints#get-statusrequestid) until it isn't, or until the destination leg is `reverted` or `failed`, which leaves the request `pending`. Show the transfer as delivered only when `status` is `success`; `destination.transactionHash` is the delivery on the destination chain. + +```ts +// [!include ~/snippets/payments/fast-bridge.ts:status] +``` + +:::: + +## Run calls on the destination chain + +To use the bridged USDC in the same request, for example to deposit it into a vault, send `calls` instead of `recipient`. The bridge delivers `amountOut` to `multicaller` from `/addresses`, which then runs your calls in order as `msg.sender`, so approve and spend from `multicaller` and send any output to the user. + +```ts +// [!include ~/snippets/payments/fast-bridge.ts:calls] +``` + +If a call would revert, the request is rejected with `CALLS_SIMULATION_REVERTED` before any funds move. If you already know the gas your calls need, pass it as `gasLimit` to skip the estimate. + +:::warning +Make your calls spend or forward the full `amountOut`. The bridge doesn't return anything left on `multicaller` after your calls run. +::: + +## Bridge from a token without EIP-3009 + +If the origin token only supports Permit2, such as Binance-Peg USDC on BSC, have the user approve the [Permit2 contract](https://github.com/Uniswap/permit2) (`0x000000000022D473030F116dDEE9F6B43aC78BA3`) for the token once, then sign a Permit2 `PermitTransferFrom` with `spender` set to the bridge's `approvalAddress`. Send it as a `permit2` authorization in the same request body. + +```ts +// [!include ~/snippets/payments/fast-bridge.ts:permit2] +``` + +Without the Permit2 approval, the request is rejected with `INSUFFICIENT_ALLOWANCE`. Base, Arbitrum and Robinhood Chain take both authorization types. + +## Related + +- [Fast Bridge overview](/references/fast-bridge) +- [Fast Bridge endpoints](/references/fast-bridge/endpoints) +- [Fast Bridge errors](/references/fast-bridge/errors) +- [How to send USDC without ETH for gas](/guides/how-to/payments/send-usdc-without-eth) +- [How to accept USDC from an EOA](/guides/how-to/payments/accept-usdc-from-eoa) diff --git a/docs/pages/references/fast-bridge/endpoints.mdx b/docs/pages/references/fast-bridge/endpoints.mdx new file mode 100644 index 00000000..6be3ec7f --- /dev/null +++ b/docs/pages/references/fast-bridge/endpoints.mdx @@ -0,0 +1,244 @@ +--- +description: "HTTP reference for Pimlico Fast Bridge: POST /execute, POST /execute/sync, GET /status/{requestId} and GET /addresses, with request bodies, authorizations and the bridge status object." +--- + +# Fast Bridge Endpoints + +Fast Bridge is an HTTP API. Requests and responses are JSON. For what the bridge does and the supported routes, see the [overview](/references/fast-bridge). + +## URL and authentication + +``` +https://api.pimlico.io/v1/bridge +``` + +Pass your Pimlico API key as the `apikey` query parameter, or as an `Authorization: Bearer YOUR_API_KEY` header. + +## Wire conventions + +| Field kind | Format | +|---|---| +| Amounts, `nonce` (Permit2), `value`, `gasLimit` | Decimal string in base units, such as `"25000000"` | +| `validAfter`, `validBefore`, `deadline` | Unix timestamp in seconds, as a JSON number | +| EVM addresses | `0x`-prefixed hex | +| Solana addresses and signatures | base58 strings | +| `nonce` (EIP-3009), `data`, `signature` | `0x`-prefixed hex | +| Timestamps in responses | ISO 8601 strings | + +## POST /execute + +Validates a bridge request and starts it. Responds with `202 Accepted` and the [bridge status object](#the-bridge-status-object) as soon as the request is accepted, before any leg has run. Poll [`GET /status/{requestId}`](#get-statusrequestid) for the outcome. + +### Request body + +The body delivers either to a `recipient` or to `calls`, never both. + +- **originChainId** - `number`: The chain the tokens come from. +- **destinationChainId** - `number`: The chain they're delivered on. Must differ from `originChainId`. +- **originToken** - `string`: The token on the origin chain. Must be the token the authorization is for. +- **destinationToken** - `string`: The token delivered on the destination chain. +- **authorization** - `object`: How the user gives the bridge the tokens. See [Authorization objects](#authorization-objects). +- **recipient** - `string` (optional): Who receives the tokens on the destination chain. On an EVM destination it defaults to `authorization.from`. Required on a Solana destination, as a Solana address. Not allowed with `calls`. +- **calls** - `array` (optional): At least one call to run on an EVM destination chain, in order, from the bridge's `multicaller` address, which holds `amountOut` when they start. Not allowed with `recipient`, or on a Solana destination. + - **to** - `string`: The address to call. + - **data** - `string`: The calldata. + - **value** - `string` (optional): The native value to send with the call. +- **gasLimit** - `string` (optional): Gas for the calls, greater than 0. When set, the destination operation isn't estimated. Only allowed with `calls`. + +### Authorization objects + +#### `eip3009` + +An EIP-3009 `ReceiveWithAuthorization`, signed by `from` over the token's EIP-712 domain. + +- **type** - `"eip3009"` +- **token** - `string`: The origin token. +- **from** - `string`: The token holder who signed. +- **to** - `string`: Must be `approvalAddress` from [`GET /addresses`](#get-addresses). +- **amount** - `string`: The amount to bridge, greater than 0. This is `amountIn`. +- **validAfter** - `number`: The authorization can't be used before this time. +- **validBefore** - `number`: The authorization can't be used from this time on. +- **nonce** - `string`: The authorization's 32-byte nonce. +- **signature** - `string`: The holder's signature. + +#### `permit2` + +A Permit2 `PermitTransferFrom` (SignatureTransfer), signed by `from` over the Permit2 domain. The holder must have approved Permit2 (`0x000000000022D473030F116dDEE9F6B43aC78BA3`) for the token. + +- **type** - `"permit2"` +- **token** - `string`: The origin token. +- **from** - `string`: The token holder who signed. +- **spender** - `string`: Must be `approvalAddress` from [`GET /addresses`](#get-addresses). +- **amount** - `string`: The amount to bridge, greater than 0. This is `amountIn`. +- **nonce** - `string`: The Permit2 nonce, a uint256. +- **deadline** - `number`: The last second the signature is valid. +- **signature** - `string`: The holder's signature. + +#### `solana-transaction` + +Required when `originChainId` is `101` (Solana), and only allowed then. + +- **type** - `"solana-transaction"` +- **transaction** - `string`: A base64 serialized Solana transaction that: + - makes exactly one transfer of `originToken` into the bridge's reserve token account, for the amount to bridge, + - uses a recent blockhash, not a durable nonce, + - has Pimlico's Solana fee payer as its fee payer, with that signature slot empty, + - is signed by every other signer, including the transfer's authority. + +The transfer's authority is the sender, and the sender's signature is the request ID. + +### Example request + +```shell +curl -X POST "https://api.pimlico.io/v1/bridge/execute?apikey=YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "originChainId": 8453, + "destinationChainId": 42161, + "originToken": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "destinationToken": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "recipient": "0x4b2E8e4dFd4E9c3E6Fe7B2dE1f8A3Cc2b6E9f0a1", + "authorization": { + "type": "eip3009", + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "from": "0x4b2E8e4dFd4E9c3E6Fe7B2dE1f8A3Cc2b6E9f0a1", + "to": "0xAPPROVAL_ADDRESS_FROM_ADDRESSES", + "amount": "25000000", + "validAfter": 0, + "validBefore": 1793402193, + "nonce": "0x5f0c...e1a9", + "signature": "0x8d2b...1c" + } + }' +``` + +### Example response + +`202 Accepted` + +```json +{ + "requestId": "0x7a3f...c21e", + "status": "pending", + "originToken": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "destinationToken": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "amountIn": "25000000", + "fee": "0", + "amountOut": "25000000", + "origin": { "chainId": 8453, "status": "pending" }, + "destination": { + "chainId": 42161, + "recipient": "0x4b2E8e4dFd4E9c3E6Fe7B2dE1f8A3Cc2b6E9f0a1", + "status": "pending" + }, + "createdAt": "2026-10-30T12:00:00.000Z" +} +``` + +## POST /execute/sync + +Takes the same body as [`POST /execute`](#post-execute), then waits for the request to finish before it responds. Responds with `200 OK` and the [bridge status object](#the-bridge-status-object). + +### Query parameters + +- **timeout** - `number` (optional): Seconds to wait for a final status, from 1 to 300. Defaults to 30. + +If the timeout passes first, the response has `status: "pending"` and the request keeps running. Poll [`GET /status/{requestId}`](#get-statusrequestid) for the outcome. + +### Example response + +`200 OK` + +```json +{ + "requestId": "0x7a3f...c21e", + "status": "success", + "originToken": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "destinationToken": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "amountIn": "25000000", + "fee": "0", + "amountOut": "25000000", + "origin": { + "chainId": 8453, + "status": "confirmed", + "transactionHash": "0x1d9e...04b7", + "confirmedAt": "2026-10-30T12:00:01.204Z" + }, + "destination": { + "chainId": 42161, + "recipient": "0x4b2E8e4dFd4E9c3E6Fe7B2dE1f8A3Cc2b6E9f0a1", + "status": "confirmed", + "transactionHash": "0x93c4...7f2d", + "confirmedAt": "2026-10-30T12:00:01.712Z" + }, + "createdAt": "2026-10-30T12:00:00.000Z", + "completedAt": "2026-10-30T12:00:01.712Z" +} +``` + +## GET /status/\{requestId\} + +Returns the current [bridge status object](#the-bridge-status-object) of a request. + +### Path parameters + +- **requestId** - `string`: The `requestId` from the response of `POST /execute` or `POST /execute/sync`. A 32-byte hex string for an EVM origin, or a base58 transaction signature for a Solana origin. + +Only the Pimlico account that sent the request can read its status, for 24 hours after it was sent. Otherwise the response is `404` with `REQUEST_NOT_FOUND`. + +### Example request + +```shell +curl "https://api.pimlico.io/v1/bridge/status/0x7a3f...c21e?apikey=YOUR_API_KEY" +``` + +## GET /addresses + +Returns the bridge's addresses. They're the same on every EVM chain. + +### Example request + +```shell +curl "https://api.pimlico.io/v1/bridge/addresses?apikey=YOUR_API_KEY" +``` + +### Example response + +```json +{ + "approvalAddress": "0x...", + "multicaller": "0x..." +} +``` + +### Return fields + +- **approvalAddress** - `string`: Who every EVM authorization must be made out to: `to` in `eip3009`, `spender` in `permit2`. +- **multicaller** - `string`: The `msg.sender` of your `calls` on the destination chain. It holds `amountOut` when your calls start. + +## The bridge status object + +Every endpoint except `GET /addresses` returns this object. + +- **requestId** - `string`: The request's ID, derived from the authorization. +- **status** - `"pending" | "success" | "failed" | "refunded"`: See [how a request runs](/references/fast-bridge#how-a-request-runs). +- **originToken** - `string`: The origin token. +- **destinationToken** - `string`: The destination token. +- **amountIn** - `string`: The amount taken on the origin chain, in the origin token's base units. +- **fee** - `string`: The bridge fee, in the origin token's base units. +- **amountOut** - `string`: The amount delivered on the destination chain, in the destination token's base units: `amountIn` minus `fee`, converted to the destination token's decimals. +- **origin** - `object`: The origin leg. + - **chainId** - `number` + - **status** - `"pending" | "submitted" | "confirmed" | "reverted" | "failed"` + - **transactionHash** - `string`: Set once the leg is `submitted`, `confirmed` or `reverted`. + - **confirmedAt** - `string`: Set once the leg is `confirmed` or `reverted`. + - **error** - `object`: Set when the leg is `reverted` or `failed`. See [leg errors](/references/fast-bridge/errors#leg-errors). +- **destination** - `object`: The destination leg. + - **chainId** - `number` + - **recipient** - `string`: The `recipient`, or the bridge's `multicaller` address when the request has `calls`. + - **status** - `"pending" | "submitted" | "confirmed" | "reverted" | "failed" | "cancelled"`. `cancelled` means the origin leg didn't confirm, so nothing was delivered. + - **transactionHash** - `string`: Set once the leg is `submitted`, `confirmed` or `reverted`. + - **confirmedAt** - `string`: Set once the leg is `confirmed` or `reverted`. + - **error** - `object`: Set when the leg is `reverted` or `failed`. See [leg errors](/references/fast-bridge/errors#leg-errors). +- **createdAt** - `string`: When the request was accepted. +- **completedAt** - `string`: When the request reached a final status. Not set while it's `pending`. diff --git a/docs/pages/references/fast-bridge/errors.mdx b/docs/pages/references/fast-bridge/errors.mdx new file mode 100644 index 00000000..59cc9fee --- /dev/null +++ b/docs/pages/references/fast-bridge/errors.mdx @@ -0,0 +1,84 @@ +--- +description: "Error codes returned by Pimlico Fast Bridge: rejections such as AUTHORIZATION_MISMATCH and AMOUNT_EXCEEDS_LIMIT, and the leg errors in a bridge status, with what to do about each one." +--- + +# Fast Bridge Errors + +Fast Bridge reports two kinds of errors: **rejections**, when a request is refused before anything moves, and **leg errors**, when a leg of an accepted request doesn't go through. + +## Rejections + +A rejected request returns an HTTP error status with a `code` and a `message` that names the specific problem. No tokens have moved, so you can fix the request and send it again. + +```json +{ + "code": "AUTHORIZATION_MISMATCH", + "message": "authorization must be made out to 0x..." +} +``` + +| Code | HTTP status | Meaning | +|---|---|---| +| `INVALID_REQUEST` | 400 | The body, query or path doesn't match the schema, or a value is out of range, such as a zero amount, an EIP-3009 authorization that isn't valid yet, `calls` on a Solana destination, or a `recipient` of the wrong address type. | +| `UNSUPPORTED_ROUTE` | 400 | The chain or token isn't [supported](/references/fast-bridge#supported-chains-and-tokens), or the origin and destination are the same chain. | +| `AUTHORIZATION_MISMATCH` | 400 | The authorization doesn't fit the request: it's for a different token than `originToken`, its type isn't supported for that token, it isn't made out to the bridge's `approvalAddress`, or a Solana transaction doesn't have the required transfer, fee payer or blockhash. | +| `INVALID_SIGNATURE` | 400 | A signature is missing or invalid. | +| `AUTHORIZATION_EXPIRED` | 422 | The authorization's `validBefore` or `deadline` has passed. | +| `AUTHORIZATION_USED` | 422 | The authorization's nonce was already used on chain, or the same authorization was already sent to the bridge. | +| `INSUFFICIENT_BALANCE` | 422 | The holder doesn't have `amount` of the origin token. | +| `INSUFFICIENT_ALLOWANCE` | 422 | For a `permit2` authorization, the holder hasn't approved Permit2 for enough of the token. | +| `CALLS_SIMULATION_REVERTED` | 422 | One of your `calls` reverts when simulated on the destination chain. | +| `AMOUNT_EXCEEDS_LIMIT` | 422 | `amountOut` is above the destination token's [per-request limit](/references/fast-bridge#supported-chains-and-tokens). | +| `REQUEST_NOT_FOUND` | 404 | `GET /status` has no request with this ID for your account, or it's more than 24 hours old. | + +### What to do + +- **`AUTHORIZATION_MISMATCH`**: get `approvalAddress` from [`GET /addresses`](/references/fast-bridge/endpoints#get-addresses) and sign the authorization out to it, for `originToken`, with an authorization type the [origin token supports](/references/fast-bridge#supported-chains-and-tokens). +- **`AUTHORIZATION_EXPIRED`** or **`AUTHORIZATION_USED`**: have the user sign a new authorization with a new nonce and a later expiry. If you sent the same authorization twice, read the first request with [`GET /status`](/references/fast-bridge/endpoints#get-statusrequestid) instead. +- **`INSUFFICIENT_ALLOWANCE`**: have the user approve Permit2 (`0x000000000022D473030F116dDEE9F6B43aC78BA3`) for the token, or use an `eip3009` authorization where the token supports it. +- **`CALLS_SIMULATION_REVERTED`**: check that your calls only spend the `amountOut` that the bridge's `multicaller` holds, and that they approve any contract that pulls tokens from `multicaller`. +- **`AMOUNT_EXCEEDS_LIMIT`**: split the transfer into several requests, each within the limit. + +## Leg errors + +Once a request is accepted, a leg that doesn't go through has an `error` in the [bridge status object](/references/fast-bridge/endpoints#the-bridge-status-object): + +```json +{ + "chainId": 42161, + "recipient": "0x...", + "status": "reverted", + "transactionHash": "0x...", + "confirmedAt": "2026-10-30T12:00:01.712Z", + "error": { + "code": "CALLS_REVERTED", + "message": "...", + "callIndex": 1, + "data": "0x..." + } +} +``` + +### Origin leg + +If the origin leg is `reverted` or `failed`, nothing is delivered: the destination leg is `cancelled` and the request is `failed`. + +| Code | Leg status | Meaning | +|---|---|---| +| `INSUFFICIENT_BALANCE` | `reverted`, `failed` | The holder no longer had the tokens when the pull ran. | +| `INSUFFICIENT_ALLOWANCE` | `reverted`, `failed` | The holder's Permit2 allowance was reduced after acceptance. | +| `AUTHORIZATION_USED` | `reverted`, `failed` | The authorization's nonce was used by another transaction after acceptance. | +| `AUTHORIZATION_EXPIRED` | `failed` | The pull didn't land before the authorization expired. | +| `TRANSACTION_EXPIRED` | `failed` | A Solana origin transaction didn't land before its blockhash expired. | +| `INTERNAL_ERROR` | `failed` | The bridge couldn't send the pull. | + +A `reverted` origin leg can carry the revert `data`. + +### Destination leg + +If the destination leg is `reverted` or `failed` after the origin leg confirmed, the request stays `pending` and the tokens stay in the bridge's reserve. Refunds aren't sent automatically yet. + +| Code | Leg status | Meaning | +|---|---|---| +| `CALLS_REVERTED` | `reverted` | One of your calls reverted on the destination chain. `callIndex` is the index of the first call that reverted, or `-1` if no call reverts any more when simulated, and `data` its revert data when available. | +| `DESTINATION_TIMEOUT` | `failed` | The delivery wasn't included before its 60-second deadline, or it reverted for a reason other than your calls. The `message` names the transaction and the reason. | diff --git a/docs/pages/references/fast-bridge/index.mdx b/docs/pages/references/fast-bridge/index.mdx new file mode 100644 index 00000000..f0c4dea7 --- /dev/null +++ b/docs/pages/references/fast-bridge/index.mdx @@ -0,0 +1,63 @@ +--- +description: "Overview of Pimlico Fast Bridge: move USDC between Base, Arbitrum, BSC, Robinhood Chain and Solana in seconds from one signed authorization, with supported routes, limits and request lifecycle." +--- + +# Fast Bridge + +Fast Bridge moves stablecoins from one chain to another in seconds. Your user signs one authorization for the token on the origin chain, and Pimlico delivers the same amount from its own liquidity on the destination chain, either to a recipient or into calls you choose. Pimlico pays the gas on both chains, so the user needs no native token anywhere. + +Fast Bridge is an HTTP API that uses your Pimlico API key. See the [endpoints](/references/fast-bridge/endpoints) and [errors](/references/fast-bridge/errors) references, or follow [How to bridge USDC between chains with Fast Bridge](/guides/how-to/payments/bridge-usdc-with-fast-bridge). + +## Supported chains and tokens + +Any route between two different chains in this table is supported. Amounts are converted 1:1 between tokens, adjusted for their decimals. + +| Chain | Chain ID | Token | Address | Decimals | Authorizations | Max per request | +|---|---|---|---|---|---|---| +| Base | `8453` | USDC | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | 6 | `eip3009`, `permit2` | 1,000 USDC | +| Arbitrum | `42161` | USDC (native, not USDC.e) | `0xaf88d065e77c8cC2239327C5EDb3A432268e5831` | 6 | `eip3009`, `permit2` | 1,000 USDC | +| BSC | `56` | USDC (Binance-Peg) | `0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d` | 18 | `permit2` | 1,000 USDC | +| Robinhood Chain | `4663` | USDG | `0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168` | 6 | `eip3009`, `permit2` | 1,000 USDG | +| Solana | `101` | USDC | `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v` | 6 | `solana-transaction` | 1,000 USDC | + +The limit applies to the amount delivered on the destination chain. A larger request is rejected with `AMOUNT_EXCEEDS_LIMIT`. + +## Authorizations + +The authorization is how the user gives the bridge the tokens on the origin chain. Which one you send depends on the origin chain: + +- **`eip3009`**: an EIP-3009 `ReceiveWithAuthorization` signed by the token holder, with `to` set to the bridge's `approvalAddress` from [`GET /addresses`](/references/fast-bridge/endpoints#get-addresses). No approval transaction is needed. +- **`permit2`**: a [Permit2](https://github.com/Uniswap/permit2) `PermitTransferFrom` (SignatureTransfer) signature with `spender` set to the bridge's `approvalAddress`. The holder must have approved Permit2 for the token. +- **`solana-transaction`**: for a Solana origin, a signed Solana transaction that makes exactly one USDC transfer into the bridge's reserve token account. It must use a recent blockhash (not a durable nonce) and Pimlico's Solana fee payer as its fee payer. + +The request ID is derived from the authorization (an EVM authorization's signer and nonce, or a Solana transaction's signature), so the same authorization can only be bridged once. Sending it again is rejected with `AUTHORIZATION_USED`. + +## Destinations + +A request delivers to one of: + +- **A recipient**: the bridge transfers `amountOut` to `recipient`. On an EVM destination it defaults to the authorization's `from`. On a Solana destination, `recipient` is required and must be a Solana address; the recipient's token account is created if it doesn't exist. +- **Calls** (EVM destinations only): the bridge transfers `amountOut` to its `multicaller` address from [`GET /addresses`](/references/fast-bridge/endpoints#get-addresses), which then runs your calls in order, as `msg.sender`. Use this to deposit, swap or pay with the bridged tokens in the same request. Anything your calls leave on `multicaller` isn't returned. + +## How a request runs + +1. **Acceptance.** The bridge checks the route, the authorization's signature, expiry and nonce, the holder's balance (and Permit2 allowance), and the per-request limit. For calls, it simulates them on the destination chain. If any check fails, the request is rejected with an [error code](/references/fast-bridge/errors) and nothing moves. +2. **Origin leg.** The bridge pulls the tokens from the user into its reserve on the origin chain. +3. **Destination leg.** While the origin leg is in flight, the bridge already builds, signs and simulates the delivery on the destination chain. As soon as the origin leg confirms, the delivery is sent, so it doesn't wait on a second round of checks. The delivery has 60 seconds from acceptance to land. +4. **Completion.** The request ends as one of these `status` values: + +| Status | Meaning | +|---|---| +| `pending` | The request is running, or the delivery failed after the origin leg confirmed. In that case the destination leg has an `error` and the tokens stay in the bridge's reserve. | +| `success` | Both legs confirmed: `amountOut` was delivered on the destination chain. | +| `failed` | The origin leg didn't confirm, so the delivery was cancelled and nothing was delivered. | +| `refunded` | Reserved for a failed delivery whose `amountIn` was returned on the origin chain. Refunds aren't sent automatically yet, so such a request stays `pending`. | + +The response of every endpoint is the same [bridge status object](/references/fast-bridge/endpoints#the-bridge-status-object), with the state of each leg. + +## Things to know + +- Origin and destination must be different chains. +- Amounts in requests and responses are decimal strings in the token's base units. +- Only the Pimlico account that sent a request can read its status, for 24 hours after it was sent. +- If the delivery doesn't land within its 60-second deadline, the destination leg fails with `DESTINATION_TIMEOUT` and the request stays `pending`. diff --git a/docs/snippets/payments/fast-bridge.ts b/docs/snippets/payments/fast-bridge.ts new file mode 100644 index 00000000..152c0ec8 --- /dev/null +++ b/docs/snippets/payments/fast-bridge.ts @@ -0,0 +1,253 @@ +// [!region setup] +import { + type Address, + createPublicClient, + encodeFunctionData, + erc20Abi, + type Hex, + http, + parseAbi, + parseUnits, + toHex, +} from "viem" +import { privateKeyToAccount } from "viem/accounts" +import { arbitrum, base, bsc } from "viem/chains" + +const BRIDGE_URL = "https://api.pimlico.io/v1/bridge" +const API_KEY = "YOUR_PIMLICO_API_KEY" + +const USDC_BASE: Address = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" +const USDC_ARBITRUM: Address = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" + +const basePublicClient = createPublicClient({ + chain: base, + transport: http("https://mainnet.base.org"), +}) + +// The EOA that holds the USDC on the origin chain +const user = privateKeyToAccount(process.env.USER_PRIVATE_KEY as Hex) + +type BridgeStatus = { + requestId: string + status: "pending" | "success" | "failed" | "refunded" + amountIn: string + fee: string + amountOut: string + origin: { chainId: number; status: string; transactionHash?: string } + destination: { + chainId: number + recipient: string + status: string + transactionHash?: string + } +} + +// Sends a request to the bridge and throws its { code, message } on rejection +const callBridge = async (path: string, init?: RequestInit): Promise => { + const response = await fetch(`${BRIDGE_URL}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${API_KEY}`, + }, + }) + const body = await response.json() + if (!response.ok) { + throw new Error(`${body.code}: ${body.message}`) + } + return body as T +} +// [!endregion setup] + +// [!region addresses] +const { approvalAddress, multicaller } = await callBridge<{ + approvalAddress: Address + multicaller: Address +}>("/addresses") +// [!endregion addresses] + +// [!region sign] +const amount = parseUnits("25", 6) // 25 USDC +const validBefore = Math.floor(Date.now() / 1000) + 5 * 60 // 5 minutes +const nonce = toHex(crypto.getRandomValues(new Uint8Array(32))) + +// The token's EIP-712 domain +const domainAbi = parseAbi([ + "function name() view returns (string)", + "function version() view returns (string)", +]) +const [name, version] = await Promise.all([ + basePublicClient.readContract({ + address: USDC_BASE, + abi: domainAbi, + functionName: "name", + }), + basePublicClient.readContract({ + address: USDC_BASE, + abi: domainAbi, + functionName: "version", + }), +]) + +const signature = await user.signTypedData({ + domain: { name, version, chainId: base.id, verifyingContract: USDC_BASE }, + types: { + ReceiveWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + primaryType: "ReceiveWithAuthorization", + message: { + from: user.address, + to: approvalAddress, + value: amount, + validAfter: 0n, + validBefore: BigInt(validBefore), + nonce, + }, +}) + +const authorization = { + type: "eip3009", + token: USDC_BASE, + from: user.address, + to: approvalAddress, + amount: amount.toString(), + validAfter: 0, + validBefore, + nonce, + signature, +} +// [!endregion sign] + +// [!region execute] +const result = await callBridge("/execute/sync?timeout=30", { + method: "POST", + body: JSON.stringify({ + originChainId: base.id, + destinationChainId: arbitrum.id, + originToken: USDC_BASE, + destinationToken: USDC_ARBITRUM, + authorization, + // Leave it out to deliver to authorization.from + recipient: user.address, + }), +}) +// [!endregion execute] + +// [!region status] +const waitForBridge = async (requestId: string): Promise => { + while (true) { + const status = await callBridge(`/status/${requestId}`) + // A failed delivery leaves the request pending, with the leg's error set + const deliveryFailed = ["reverted", "failed"].includes( + status.destination.status, + ) + if (status.status !== "pending" || deliveryFailed) { + return status + } + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } +} + +const final = + result.status === "pending" ? await waitForBridge(result.requestId) : result + +if (final.status === "success") { + console.log( + `Delivered ${final.amountOut} on chain ${final.destination.chainId}:`, + final.destination.transactionHash, + ) +} else { + console.log(`Bridge ${final.status}`, final.origin, final.destination) +} +// [!endregion status] + +// [!region calls] +// An ERC-4626 vault on Arbitrum that takes USDC +const VAULT: Address = "0x2222222222222222222222222222222222222222" +const vaultAbi = parseAbi([ + "function deposit(uint256 assets, address receiver) returns (uint256 shares)", +]) + +const callsResult = await callBridge("/execute/sync", { + method: "POST", + body: JSON.stringify({ + originChainId: base.id, + destinationChainId: arbitrum.id, + originToken: USDC_BASE, + destinationToken: USDC_ARBITRUM, + authorization, + // Run by `multicaller`, which holds amountOut when the calls start + calls: [ + { + to: USDC_ARBITRUM, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "approve", + args: [VAULT, amount], + }), + }, + { + to: VAULT, + data: encodeFunctionData({ + abi: vaultAbi, + functionName: "deposit", + args: [amount, user.address], + }), + }, + ], + }), +}) +// [!endregion calls] + +// [!region permit2] +const PERMIT2: Address = "0x000000000022D473030F116dDEE9F6B43aC78BA3" +const USDC_BSC: Address = "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d" // 18 decimals + +const permit2Amount = parseUnits("25", 18) +const deadline = Math.floor(Date.now() / 1000) + 5 * 60 +// Permit2 SignatureTransfer nonces are unordered: any unused uint256 works +const permit2Nonce = BigInt(toHex(crypto.getRandomValues(new Uint8Array(32)))) + +const permit2Signature = await user.signTypedData({ + domain: { name: "Permit2", chainId: bsc.id, verifyingContract: PERMIT2 }, + types: { + PermitTransferFrom: [ + { name: "permitted", type: "TokenPermissions" }, + { name: "spender", type: "address" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + TokenPermissions: [ + { name: "token", type: "address" }, + { name: "amount", type: "uint256" }, + ], + }, + primaryType: "PermitTransferFrom", + message: { + permitted: { token: USDC_BSC, amount: permit2Amount }, + spender: approvalAddress, + nonce: permit2Nonce, + deadline: BigInt(deadline), + }, +}) + +const permit2Authorization = { + type: "permit2", + token: USDC_BSC, + from: user.address, + spender: approvalAddress, + amount: permit2Amount.toString(), + nonce: permit2Nonce.toString(), + deadline, + signature: permit2Signature, +} +// [!endregion permit2] + +console.log(multicaller, callsResult, permit2Authorization) diff --git a/sidebars/guides.ts b/sidebars/guides.ts index 18660ee6..be75de59 100644 --- a/sidebars/guides.ts +++ b/sidebars/guides.ts @@ -371,6 +371,10 @@ export const paymentsSidebar = [ text: "Send gasless remittances", link: "/guides/how-to/payments/remittances", }, + { + text: "Bridge USDC between chains", + link: "/guides/how-to/payments/bridge-usdc-with-fast-bridge", + }, { text: "Send payouts at scale", link: "/guides/how-to/payments/payouts", diff --git a/sidebars/references.ts b/sidebars/references.ts index 48a3f450..fd0be807 100644 --- a/sidebars/references.ts +++ b/sidebars/references.ts @@ -335,6 +335,25 @@ export const paymasterSidebar = [ }, ]; +export const fastBridgeSidebar = [ + { + text: "Overview", + link: "/references/fast-bridge", + }, + { + text: "How to bridge USDC between chains", + link: "/guides/how-to/payments/bridge-usdc-with-fast-bridge", + }, + { + text: "Endpoints", + link: "/references/fast-bridge/endpoints", + }, + { + text: "Errors", + link: "/references/fast-bridge/errors", + }, +]; + export const permissionlessSidebar = [ { text: "Overview", diff --git a/vocs.config.ts b/vocs.config.ts index a1aba2bf..f568294d 100644 --- a/vocs.config.ts +++ b/vocs.config.ts @@ -11,6 +11,7 @@ import { import { guidesSidebar } from "./sidebars/guides"; import { bundlerSidebar, + fastBridgeSidebar, paymasterSidebar, // flashFundSidebar, permissionlessSidebar, @@ -94,6 +95,11 @@ export default defineConfig({ link: "/references/paymaster", items: paymasterSidebar, }, + { + text: "Fast Bridge", + link: "/references/fast-bridge", + items: fastBridgeSidebar, + }, // { // text: "FlashFund", // link: "/references/flash-fund", @@ -162,6 +168,7 @@ export default defineConfig({ { text: "Platform", link: "/references/platform" }, { text: "Bundler", link: "/references/bundler" }, { text: "Paymaster", link: "/references/paymaster" }, + { text: "Fast Bridge", link: "/references/fast-bridge" }, // { text: "FlashFund", link: "/references/flash-fund" }, ], match: "/references",