Skip to main content

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.

Alpha Preview — Endpoints, response shape, and supported routes may change without notice.
Full API reference as one Markdown file: bridge-all.md. Copy it, or feed it to your LLM as context for the entire bridge surface in one paste.
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 always: GET /quote → sign the returned deposit → long-poll GET /status/{intentId}/wait until terminal.

Install

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.
// 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
}

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) {
  const params = new URLSearchParams(p as Record<string, string>);
  const res = await fetch(`${API}/api/2/bridge/quote?${params}`, {
    headers: { Authorization: apiKey },
  });
  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, the prediction row is NOT
  // written and the solver will not process the deposit.
  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>);
  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}`, {
    headers: { Authorization: apiKey },
  });
  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 to the solver L1 address via @nktkas/hyperliquid.
async function signHyperliquid(quote: any, exchange: hl.ExchangeClient) {
  const dep = quote.deposit.hl; // { type: "spotSend", to, token, amount }
  const result = await exchange.spotSend({
    destination: dep.to,
    token: dep.token,
    amount: dep.amount,
  });
  if (result.status !== "ok") throw new Error(`HL spotSend 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`, {
      headers: { Authorization: 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);

  // EVM + HL origins: commit the quote via EIP-712 before broadcasting. The
  // solver rejects deposits whose prediction row is unsigned.
  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).
// 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 USDH → Base ETH (HL spotSend → 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

RouteOrigin code pathHas approve step?
Base ETH → SolanaEVM native bridge()No
Solana SOL → ArbitrumSolana native (transfer + memo)No
BSC USDT → Polygon USDCEVM swapAndBridge() via SwapBridgeHelperYes
Base USDC → Arbitrum USDCEVM direct bridgeToken() on MobulaBridgeYes
HL → BaseHyperLiquid spotSendNo
Any Solana SPL → anywherePre-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 until a Postgres NOTIFY fires (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.
  • maxTradeUsd: 10000. Hard per-intent cap. Split larger orders client-side.
  • gasFeeUsd in the quote is a placeholder ("0.10"). The real fill gas is paid by the solver; the user only pays origin-chain gas to broadcast the deposit.

See also