> ## 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, 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");

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

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

export interface QuoteParams {
  originChainId: string;          // "evm:8453" | "evm:56" | "evm:42161" | "evm:137" | "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
  destinationType?: string;        // destination account venue (e.g. HyperLiquid): "spot" (default) | "perps"
  originType?: string;             // origin account venue (e.g. HyperLiquid): "spot" (default) | "perps"
}

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 MUST go in the query string (the server reads it from
  // ?apiKey=, not an Authorization header) — omit it and /quote returns
  // { error: "Missing required parameter: apiKey" }.
  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 evm:* and hl:mainnet origins, json.data.signatureRequired is true and
  // json.data.typedData carries the EIP-712 payload the depositor must sign.
  // Until you commit the signature via confirmQuote, your signed intent is NOT
  // committed and the solver will not process the deposit.
  //
  // json.data also carries `recommendedSlippage` (% — sign with at least this or
  // the fill gets refunded; bridge() enforces it) and a `fees` breakdown.
  // `estimatedAmountOut` / `estimatedAmountOutUsd` are already net of every fee.
  return json.data;
}

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

export async function signAndConfirmQuote(
  p: QuoteParams,
  quote: any,
  signer: PrivateKeyAccount,
  apiKey: string,
) {
  if (!quote.signatureRequired) return quote; // 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 / deposited / filling / retrying — re-fire immediately
  }
}

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

export async function bridge(
  params: QuoteParams,
  signers: Signers,
  apiKey: string,
) {
  let quote = await getQuote(params, apiKey);

  // Bump slippage to the quote's recommendation before committing. The solver
  // refunds any fill that lands below the signed minAmountOut (failure code
  // 'slippage'), so a tolerance under recommendedSlippage is a near-guaranteed
  // refund. recommendedSlippage = the route's implied spread (bridge fee + gas +
  // price impact) plus a drift buffer. Re-quoting regenerates the typedData
  // (EVM/HL) and the memo minAmountOut (Solana) at the safe tolerance.
  const wantSlippage = Number(params.slippage ?? "1");
  if (quote.recommendedSlippage != null && wantSlippage < quote.recommendedSlippage) {
    params = { ...params, slippage: String(quote.recommendedSlippage) };
    quote = await getQuote(params, apiKey);
  }

  // EVM + HL origins: commit the quote via EIP-712 before broadcasting. The
  // solver rejects 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 usdSend → EVM destination)
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)`);
```

## 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                  |
| 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.
* **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).
* **`maxTradeUsd: 400`.** Hard per-intent cap. Split larger orders client-side.
* **Honor `recommendedSlippage` or risk a refund.** Every quote returns a
  `recommendedSlippage` (%) derived from the route's implied spread (bridge fee +
  gas + price impact) plus a drift buffer, floored at 1%. 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 — small amounts on expensive chains are the usual victims. The
  `bridge()` function above re-quotes at the recommendation automatically.
* **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 the sum of all of them. `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.

## See also

* [Bridge Quote](/rest-api-reference/endpoint/bridge-quote)
* [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)
