> ## 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: Full TypeScript Implementation

> [Alpha Preview] One pasteable TypeScript file that bridges across EVM, Solana, and HyperLiquid using the Mobula Bridge API directly — with examples for many routes.

<Warning>
  **Alpha Preview** — Endpoints, response shape, and supported routes may change
  without notice.
</Warning>

<Note>
  **Full API reference as one Markdown file:** [bridge-all.md](/rest-api-reference/endpoint/bridge-all.md).
  Copy it, or feed it to your LLM as context for the entire bridge surface in one paste.
</Note>

This page is **one self-contained TypeScript file** that calls the Mobula Bridge
API directly (no SDK). It exposes a single `bridge()` function that handles
every origin chain — EVM, Solana, and HyperLiquid — and the bottom of the page
calls it for several different routes you can mix and match.

The flow is: `GET /quote` (preview) → for `evm:*` and `hl:mainnet` origins, sign
the returned EIP-712 `typedData` and re-call `/quote` to commit the signature →
broadcast the returned `deposit` → long-poll `GET /status/{intentId}/wait` until
terminal. (Solana origins skip the signature — the depositor-signed memo carries
the binding.)

## Install

```bash theme={null}
npm i viem @solana/web3.js @nktkas/hyperliquid
```

## `bridge.ts`

Copy this entire block as one file. It exports `bridge(params, signers, apiKey)`
which works for every supported route.

```typescript theme={null}
// bridge.ts — Mobula Bridge API client (Alpha Preview)
// Covers EVM, Solana, and HyperLiquid origins in one entry point.

import { createWalletClient, defineChain, http, type Hex, type PrivateKeyAccount } from "viem";
import { arbitrum, base, bsc, polygon, type Chain } from "viem/chains";
import {
  Connection,
  Keypair,
  PublicKey,
  SystemProgram,
  Transaction,
  TransactionInstruction,
  VersionedTransaction,
} from "@solana/web3.js";
import * as hl from "@nktkas/hyperliquid";

const API = "https://api.mobula.io";
const MEMO_PROGRAM = new PublicKey("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr");

// Robinhood Chain (evm:4663) isn't in viem/chains — define it inline. It's a
// native-ETH-only chain (no canonical USDC), so it bridges via native + swap.
const robinhood = defineChain({
  id: 4663,
  name: "Robinhood Chain",
  nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
  rpcUrls: { default: { http: ["https://rpc.mainnet.chain.robinhood.com"] } },
});

const EVM_CHAINS: Record<number, Chain> = {
  8453: base,
  56: bsc,
  42161: arbitrum,
  137: polygon,
  4663: robinhood,
};

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface QuoteParams {
  originChainId: string;          // "evm:8453" | "evm:56" | "evm:42161" | "evm:137" | "evm:4663" | "solana:solana" | "hl:mainnet"
  destinationChainId: string;
  amount: string;                  // human units, NOT wei (e.g. "0.05")
  walletAddress: string;           // destination recipient
  senderAddress?: string;          // origin sender (required for Solana SPL)
  originToken?: string;            // omit for native; EVM addresses are checksummed server-side
  destinationToken?: string;
  slippage?: string;               // percent (default "1", range 0..50), or "auto"
  destinationType?: string;        // destination account venue (e.g. HyperLiquid): "spot" (default) | "perps"
  originType?: string;             // origin account venue (e.g. HyperLiquid): "spot" (default) | "perps"
  feeBps?: string;                 // optional integrator fee in bps (0..500). Requires feeWallet.
  feeWallet?: string;              // integrator payout address — MUST be a DESTINATION-ecosystem address
}

export interface Signers {
  evm?: PrivateKeyAccount;                              // signs EVM origins
  solana?: { signer: Keypair; connection: Connection }; // signs Solana origins
  hl?: hl.ExchangeClient;                                // signs HyperLiquid origins
}

// ---------------------------------------------------------------------------
// 1) Quote
// ---------------------------------------------------------------------------

export async function getQuote(p: QuoteParams, apiKey: string) {
  // The API key goes in the query string (?apiKey=); an Authorization: Bearer
  // header also works on /quote. With neither, /quote returns
  // { error: "Missing required parameter: apiKey (or Authorization: Bearer <token>)" }.
  const params = new URLSearchParams({ ...(p as Record<string, string>), apiKey });
  const res = await fetch(`${API}/api/2/bridge/quote?${params}`);
  const json = await res.json();
  if (json.error) throw new Error(json.error);
  // For hl:mainnet origins, json.data.signatureRequired is true and
  // json.data.typedData carries the EIP-712 payload the depositor must sign.
  // Until you commit that signature, the intent is NOT committed and the solver
  // will not process the transfer. evm:* and solana:solana origins commit on
  // this call — json.data.prediction.persisted is already true.
  //
  // json.data also carries `recommendedSlippage` and a `fees` breakdown.
  // With slippage=auto, `appliedSlippage` is used to build the signed minAmountOut.
  // `estimatedAmountOut` / `estimatedAmountOutUsd` are already net of every fee.
  return json.data;
}

// ---------------------------------------------------------------------------
// 1b) Sign the quote (EIP-712) and commit it server-side. HL only.
// ---------------------------------------------------------------------------

export async function signAndConfirmQuote(
  p: QuoteParams,
  quote: any,
  signer: PrivateKeyAccount,
  apiKey: string,
) {
  if (!quote.signatureRequired) return quote; // EVM/Solana origin — no sig needed.
  if (!quote.typedData) throw new Error("Quote missing typedData");

  const signature = await signer.signTypedData({
    domain: quote.typedData.domain,
    types: quote.typedData.types,
    primaryType: quote.typedData.primaryType,
    message: quote.typedData.message,
  });

  const params = new URLSearchParams({ ...(p as Record<string, string>), apiKey });
  params.set("intentId", quote.intentId);
  params.set("deadline", String(quote.deadline));
  params.set("minAmountOut", quote.typedData.message.minAmountOut);
  params.set("signature", signature);

  const res = await fetch(`${API}/api/2/bridge/quote?${params}`);
  const json = await res.json();
  if (json.error) throw new Error(json.error);
  return json.data; // prediction.persisted === true on success
}

// ---------------------------------------------------------------------------
// 2) Sign and broadcast the deposit (one helper per origin VM)
// ---------------------------------------------------------------------------

// EVM: optional approve step (ERC-20 origins) then the deposit TX.
async function signEvm(quote: any, account: PrivateKeyAccount): Promise<string> {
  const tx = quote.deposit.evm;
  const chain = EVM_CHAINS[tx.chainId];
  if (!chain) throw new Error(`Unsupported EVM chainId ${tx.chainId}`);
  const wallet = createWalletClient({ account, chain, transport: http() });

  const approve = quote.steps?.find((s: any) => s.type === "approve");
  if (approve) {
    // approvalAmount is MAX_UINT256 — one approve per (token, spender) lasts forever.
    // Skip this if the on-chain allowance already covers `amount`.
    await wallet.sendTransaction({
      to: approve.tx.to as Hex,
      data: approve.tx.data as Hex,
      value: BigInt(approve.tx.value),
    });
  }

  return wallet.sendTransaction({
    to: tx.to as Hex,
    data: tx.data as Hex,
    value: BigInt(tx.value),
  });
}

// Solana: native (transfer + memo carrying intent payload) or SPL (pre-built versioned TX).
async function signSolana(
  quote: any,
  signer: Keypair,
  connection: Connection,
): Promise<string> {
  const sol = quote.deposit.solana;

  if (sol.serializedTx) {
    // SPL: full versioned TX (transfer + ATA create if needed + memo) is pre-built server-side.
    const tx = VersionedTransaction.deserialize(Buffer.from(sol.serializedTx, "base64"));
    tx.sign([signer]);
    return connection.sendRawTransaction(tx.serialize());
  }

  // Native SOL: build SystemProgram.transfer + memo instruction ourselves.
  const tx = new Transaction()
    .add(
      SystemProgram.transfer({
        fromPubkey: signer.publicKey,
        toPubkey: new PublicKey(sol.to),
        lamports: BigInt(sol.amount),
      }),
    )
    .add(
      new TransactionInstruction({
        keys: [{ pubkey: signer.publicKey, isSigner: true, isWritable: true }],
        programId: MEMO_PROGRAM,
        data: Buffer.from(sol.memo),
      }),
    );
  tx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash;
  tx.feePayer = signer.publicKey;
  tx.sign(signer);
  return connection.sendRawTransaction(tx.serialize());
}

// HyperLiquid: spotSend (spot wallet) or usdSend (perps/core USDC) to the solver
// L1 address via @nktkas/hyperliquid — the action matches deposit.hl.type, set by
// originType. usdSend is always USDC and carries no token.
async function signHyperliquid(quote: any, exchange: hl.ExchangeClient) {
  const dep = quote.deposit.hl; // { type: "spotSend" | "usdSend", to, token?, amount }
  const result =
    dep.type === "usdSend"
      ? await exchange.usdSend({ destination: dep.to, amount: dep.amount })
      : await exchange.spotSend({ destination: dep.to, token: dep.token, amount: dep.amount });
  if (result.status !== "ok") throw new Error(`HL ${dep.type} rejected: ${JSON.stringify(result)}`);
}

// ---------------------------------------------------------------------------
// 3) Wait for the fill — no client-side sleep, server already blocks.
// ---------------------------------------------------------------------------

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

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

// ---------------------------------------------------------------------------
// Unified entry point — dispatches on the deposit shape from /quote.
// ---------------------------------------------------------------------------

export async function bridge(
  params: QuoteParams,
  signers: Signers,
  apiKey: string,
) {
  // Default to the server recommendation. The returned typed data and deposit
  // transaction contain the resulting absolute minAmountOut.
  if (params.slippage == null) params = { ...params, slippage: "auto" };
  let quote = await getQuote(params, apiKey);

  // HL origins: commit the quote via EIP-712 before sending the transfer. The
  // solver rejects HL deposits whose signed intent has not been committed.
  if (quote.signatureRequired) {
    if (!signers.evm) {
      throw new Error("EVM signer required to sign the quote (EVM and HL origins use EVM keys)");
    }
    quote = await signAndConfirmQuote(params, quote, signers.evm, apiKey);
  }

  if (quote.deposit?.evm) {
    if (!signers.evm) throw new Error("EVM signer required for this route");
    const hash = await signEvm(quote, signers.evm);
    console.log(`deposit ${hash}`);
  } else if (quote.deposit?.solana) {
    if (!signers.solana) throw new Error("Solana signer required for this route");
    const sig = await signSolana(quote, signers.solana.signer, signers.solana.connection);
    console.log(`deposit ${sig}`);
  } else if (quote.deposit?.hl) {
    if (!signers.hl) throw new Error("HyperLiquid client required for this route");
    await signHyperliquid(quote, signers.hl);
  } else {
    throw new Error("Quote returned unknown deposit shape");
  }

  return waitForFill(quote.intentId, apiKey);
}
```

## Multi-route example

This is one driver script that bridges across four different route shapes
using the `bridge()` function above. It exercises every code path (native,
direct-bridge token with approval, swap-and-bridge, Solana, HyperLiquid).

```typescript theme={null}
// usage.ts
import { privateKeyToAccount } from "viem/accounts";
import { Connection, Keypair } from "@solana/web3.js";
import * as hl from "@nktkas/hyperliquid";
import bs58 from "bs58";
import { bridge, type Signers } from "./bridge";

const API_KEY = process.env.MOBULA_API_KEY!;

// Shared signers — pass whichever ones are relevant for the routes you use.
const evmAccount = privateKeyToAccount(process.env.EVM_PK as `0x${string}`);
const solSigner = Keypair.fromSecretKey(bs58.decode(process.env.SOL_SK!));
const solConn = new Connection("https://api.mainnet-beta.solana.com");
const hlClient = new hl.ExchangeClient({
  wallet: evmAccount,                  // HL signs with an EVM key
  transport: new hl.HttpTransport(),
});

const signers: Signers = {
  evm: evmAccount,
  solana: { signer: solSigner, connection: solConn },
  hl: hlClient,
};

const SOL_ADDR = solSigner.publicKey.toBase58();
const EVM_ADDR = evmAccount.address;

// 1) Base ETH → Solana SOL (native → native, no approve)
const r1 = await bridge({
  originChainId: "evm:8453",
  destinationChainId: "solana:solana",
  amount: "0.05",
  walletAddress: SOL_ADDR,
  senderAddress: EVM_ADDR,
}, signers, API_KEY);
console.log("Base→Solana filled:", r1.fillTxHash, `(${r1.latencyMs}ms)`);

// 2) Solana SOL → Arbitrum ETH (Solana native origin, EVM destination)
const r2 = await bridge({
  originChainId: "solana:solana",
  destinationChainId: "evm:42161",
  amount: "0.5",
  walletAddress: EVM_ADDR,
  senderAddress: SOL_ADDR,
}, signers, API_KEY);
console.log("Solana→Arbitrum filled:", r2.fillTxHash, `(${r2.latencyMs}ms)`);

// 3) BSC USDT → Polygon USDC (ERC-20 → ERC-20 — exercises approve + swapAndBridge)
const r3 = await bridge({
  originChainId: "evm:56",
  destinationChainId: "evm:137",
  originToken: "0x55d398326f99059fF775485246999027B3197955",      // USDT on BSC
  destinationToken: "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", // USDC on Polygon
  amount: "10",
  walletAddress: EVM_ADDR,
  senderAddress: EVM_ADDR,
}, signers, API_KEY);
console.log("BSC USDT → Polygon USDC filled:", r3.fillTxHash);

// 4) Base USDC → Arbitrum USDC (direct-bridge token path — approve + bridgeToken, no swap)
const r4 = await bridge({
  originChainId: "evm:8453",
  destinationChainId: "evm:42161",
  originToken: "0x833589fcD6eDb6E08f4c7C32D4f71b54bdA02913",      // USDC on Base
  destinationToken: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // USDC on Arbitrum
  amount: "25",
  walletAddress: EVM_ADDR,
  senderAddress: EVM_ADDR,
}, signers, API_KEY);
console.log("Base USDC → Arbitrum USDC filled:", r4.fillTxHash);

// 5) HyperLiquid USDC → Base ETH (HL spotSend → EVM destination; usdSend when originType=perps)
const r5 = await bridge({
  originChainId: "hl:mainnet",
  destinationChainId: "evm:8453",
  amount: "5",
  walletAddress: EVM_ADDR,
  senderAddress: EVM_ADDR,
}, signers, API_KEY);
console.log("HL → Base filled:", r5.fillTxHash, `(${r5.latencyMs}ms)`);

// 6) Base ETH → Robinhood Chain ETH (native → native on a native-only chain)
const r6 = await bridge({
  originChainId: "evm:8453",
  destinationChainId: "evm:4663",
  amount: "0.02",
  walletAddress: EVM_ADDR,
  senderAddress: EVM_ADDR,
}, signers, API_KEY);
console.log("Base → Robinhood filled:", r6.fillTxHash, `(${r6.latencyMs}ms)`);

// 7) Base USDC → Arbitrum USDC with a 30 bps integrator fee paid to feeWallet
//    (feeWallet is a DESTINATION-ecosystem address — 0x here since dest is EVM)
const r7 = await bridge({
  originChainId: "evm:8453",
  destinationChainId: "evm:42161",
  originToken: "0x833589fcD6eDb6E08f4c7C32D4f71b54bdA02913",      // USDC on Base
  destinationToken: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // USDC on Arbitrum
  amount: "25",
  walletAddress: EVM_ADDR,
  senderAddress: EVM_ADDR,
  feeBps: "30",
  feeWallet: EVM_ADDR,
}, signers, API_KEY);
console.log("Base → Arbitrum (with integrator fee) filled:", r7.fillTxHash);
```

## How each route maps to a code path

| Route                     | Origin code path                           | Has `approve` step? |
| ------------------------- | ------------------------------------------ | ------------------- |
| Base ETH → Solana         | EVM native `bridge()`                      | No                  |
| Solana SOL → Arbitrum     | Solana native (transfer + memo)            | No                  |
| BSC USDT → Polygon USDC   | EVM `swapAndBridge()` via SwapBridgeHelper | Yes                 |
| Base USDC → Arbitrum USDC | EVM direct `bridgeToken()` on MobulaBridge | Yes                 |
| HL → Base                 | HyperLiquid `spotSend`                     | No                  |
| Base ETH → Robinhood      | EVM native `bridge()` (native-only chain)  | No                  |
| Any Solana SPL → anywhere | Pre-built versioned TX (`serializedTx`)    | No                  |

The `bridge()` function above auto-dispatches by inspecting `quote.deposit` and
`quote.steps` — you don't pick the path manually.

## Notes worth knowing

* **No client-side sleep when polling `/status/{id}/wait`.** The server already
  blocks server-side and resolves the long-poll the instant the intent goes
  terminal (default 30 s window, capped 60 s). Re-firing the request immediately
  keeps one connection always waiting on the next state change.
* **Want the intermediate states?** `/wait` resolves once, on the terminal row.
  Subscribe to `bridge-status` on `wss://api.mobula.io` instead when your UI
  needs to move through `filling` as it happens, or to follow several bridges on
  one connection — see
  [Bridge Status](/rest-api-reference/endpoint/bridge-status#live-updates-over-websocket).
* **Approvals are `MAX_UINT256`.** One approve per (token, spender) is enough
  forever — read the on-chain `allowance(owner, spender)` and skip the approve
  step if it's already non-zero (or above your amount).
* **Stale `/wait` responses.** If you start a new bridge while a previous
  `/wait` is in flight, the previous response can land *after* your new
  `intentId` is active. Track the active intent on your side and discard any
  `/wait` result that doesn't match it.
* **Hyperliquid fills complete before their tx hash exists.** An HL-destination
  fill is final the moment the funds leave HL, but HL assigns the canonical L1
  hash a moment later — so a `filled` response can carry `fillTxHash: null` with
  `fillTxHashPending: true`. **Show "complete" as soon as `status` is `filled`;
  don't block on the hash.** Fetch the hash afterward with a second long-poll,
  `GET /status/{id}/wait?waitForFillTxHash=true`, and patch your explorer link
  when it lands. See [Bridge Status → best practice](/rest-api-reference/endpoint/bridge-status#best-practice-don-t-block-completion-on-the-fill-hash-hyperliquid).
* **No fixed per-intent cap.** The old `maxTradeUsd: 400` limit was removed —
  trade size is now bounded by the solver's available inventory on the
  destination route. An intent the solver can't source is refunded, so always
  handle the `refunded` terminal state rather than assuming a fixed ceiling.
* **Optional integrator fee (`feeBps` + `feeWallet`).** Pass both to skim a cut
  for yourself: `feeBps` is your fee in basis points (0–500, i.e. up to 5%) and
  `feeWallet` is where it's paid. The fee is paid in **USDC on the destination
  chain at fill time**, so `feeWallet` must be a valid *destination-ecosystem*
  address (0x for EVM/HL destinations, base58 for Solana) — a mismatch is
  rejected. It's already folded into the signed `minAmountOut`, so the recipient
  still receives exactly `estimatedAmountOut`; the fee comes out of the bridged
  amount, not on top. When set, the quote's `fees` object echoes back
  `integratorFeeBps` and `integratorFeeUsd`.
* **Honor `recommendedSlippage` or risk a refund.** Every quote returns a
  `recommendedSlippage` (%) — the measured price impact of the quote's swap
  leg(s) plus any fee-on-transfer tax plus a 1% drift buffer, with a 2% floor on
  native-token destinations (bridge fee and gas are already deducted from the
  quoted output, not part of slippage). The solver refunds any fill that lands
  below the signed `minAmountOut` (failure code `slippage`), so signing with a
  tolerance under `recommendedSlippage` is a near-guaranteed refund. The
  `bridge()` function above passes `slippage=auto`, so the server applies the
  recommendation and builds that floor into the quote before it is signed.
* **Fees are real and already deducted from `estimatedAmountOut`.** The quote's
  `fees` object breaks them down: `bridgeFeeUsd` (protocol fee, `bridgeFeeBps`,
  currently `5`), `destFillGasUsd` (what the solver pays to fill on the
  destination chain), and `destActivationCostUsd` (present only when the
  destination needs a one-off account-creation cost — e.g. Solana ATA rent for a
  first-time recipient). `gasFeeUsd` equals `destFillGasUsd`, and `totalFeeUsd`
  is `bridgeFeeUsd + gasFeeUsd` (+ the integrator cut when you set one) —
  activation cost is deducted from the output but not folded into `totalFeeUsd`.
  `estimatedAmountOut` / `estimatedAmountOutUsd` are net of every fee — the
  recipient receives exactly that. The user additionally pays only origin-chain
  gas to broadcast the deposit; the solver pays the destination fill gas itself.
* **EVM origins can skip origin gas entirely (`gasless=true`).** Quote with
  `gasless=true` and Mobula broadcasts the deposit for the user and pays its
  gas — an EVM wallet holding zero ETH/BNB/POL can approve and bridge with one
  signature. The user signs the quote's `steps` as an EIP-712 batch (EIP-7702)
  instead of sending them, and you post that batch to
  [`POST /execute`](/rest-api-reference/endpoint/bridge-execute) in place of the
  `sendTransaction` calls in the file above; everything after (status polling,
  refunds) is unchanged. The origin gas is not free — it appears as
  `fees.originSponsorGasUsd` and is deducted from the output. Cross-chain,
  ERC-20-origin routes only.
* **Solana origins skip it too, with no extra call.** `gasless=true` on a
  `solana:solana` origin returns `deposit.solana.serializedTx` **already signed
  by Mobula's fee payer** (`coSigned: true`, alongside `feePayer` and the
  `blockhash` that signature covers). Deserialize, have the user's wallet add
  its signature, submit to any RPC — the code path in the file above is
  unchanged except that you must NOT replace `recentBlockhash`, which would void
  the co-signature. Priced the same way (`fees.originSponsorGasUsd`), and the
  transaction lives as long as its blockhash (\~60s), so re-quote rather than sit
  on it. SPL origins only.

## See also

* [Bridge Quote](/rest-api-reference/endpoint/bridge-quote)
* [Bridge Execute (gasless EVM deposits)](/rest-api-reference/endpoint/bridge-execute)
* [Bridge Status (+ wait)](/rest-api-reference/endpoint/bridge-status)
* [Bridge Routes](/rest-api-reference/endpoint/bridge-routes)
* [Full reference as one Markdown file](/rest-api-reference/endpoint/bridge-all.md)
