// 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);
}