Complete walkthroughs of the unified Mobula perp execution flow — build a signed canonical payload with /2/perp/payloads/<action>, then submit it to /2/perp/execute-v2. Covers Lighter and Gains Network.
Every perpetual action on Mobula follows the same two-step pattern:
Build — POST /2/perp/payloads/<action> with action parameters + an auth signature. You get back a canonical envelope { action, dex, chainId, marketId?, transport, payloadStr }.
Execute — POST /2/perp/execute-v2 with that envelope + a second signature that binds the timestamp to the exact payloadStr.
Use /2/perp/quoteonly for previewing fills/fees or picking a DEX. It does not return an executable payload — always call the matching payloads/<action> endpoint before executing.
Gains builds an EVM transaction. The server returns transport: "evm-tx"; parse payloadStr, extract payload.data, sign locally, and feed the raw signed tx back via signedTx.
Lighter deposits use a Relay-style multi-step bridge. The envelope’s payload.steps[] lists EVM transactions the user must sign; the client injects the resulting hex strings back as payload.signedTxs[], re-stringifies, and signs execute-v2 over the new string. No top-level signedTx field is used.
import { ethers } from 'ethers';const envelope = await buildPayload(wallet, 'deposit', { dex: 'lighter', chainId: 'lighter:301', originChainId: 'evm:42161', amountUsdc: '250',});const parsed = JSON.parse(envelope.payloadStr) as { payload: { steps: Array<{ kind: string; items: Array<{ status: string; data: { to: string; data: string; value?: string; chainId: number; gas?: string; maxFeePerGas?: string; maxPriorityFeePerGas?: string; }; }>; }>; signedTxs?: string[]; };};const provider = new ethers.JsonRpcProvider(ARBITRUM_RPC);let nonce = await provider.getTransactionCount(wallet.address);const feeData = await provider.getFeeData();const signedTxs: string[] = [];for (const step of parsed.payload.steps) { if (step.kind !== 'transaction') continue; for (const item of step.items) { if (item.status === 'complete') continue; const t = item.data; const signed = await wallet.signTransaction({ to: t.to, data: t.data, value: t.value ? BigInt(t.value) : 0n, chainId: t.chainId, nonce: nonce++, gasLimit: t.gas ? BigInt(t.gas) : 1_500_000n, maxFeePerGas: t.maxFeePerGas ? BigInt(t.maxFeePerGas) : feeData.maxFeePerGas, maxPriorityFeePerGas: t.maxPriorityFeePerGas ? BigInt(t.maxPriorityFeePerGas) : feeData.maxPriorityFeePerGas, type: 2, }); signedTxs.push(signed); }}parsed.payload.signedTxs = signedTxs;const finalPayloadStr = JSON.stringify(parsed);// sign execute-v2 over the UPDATED stringconst execTs = Date.now();const execSig = await wallet.signMessage(`api/2/perp/execute-v2-${execTs}-${finalPayloadStr}`);const res = await fetch(`${BASE}/api/2/perp/execute-v2`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: envelope.action, dex: envelope.dex, chainId: envelope.chainId, transport: envelope.transport, payloadStr: finalPayloadStr, timestamp: execTs, signature: execSig, }),}).then(r => r.json());const { processId } = res.data;if (!processId) throw new Error('deposit did not return processId');async function pollProcess(id: string) { for (let i = 0; i < 60; i++) { const r = await fetch(`${BASE}/api/2/perp/check-process?processId=${id}`).then(r => r.json()); if (r.status && r.status !== 'pending') return r; await new Promise(res => setTimeout(res, 2000)); } throw new Error('deposit timeout');}console.log(await pollProcess(processId));
A hackathon project built on top of the Mobula perp endpoints — useful as a working end-to-end example covering client-side signing/execution and server-side position reads (REST + WebSocket).Client (Next.js) — Wakushi/defi-client
Signing execute-v2 over the wrong string. For deposit and Lighter withdraw you must mutate payload.* (inject signedTxs or swap MessageToSign → L1Sig) and sign over the re-stringified envelope. For every other action the envelope is forwarded byte-for-byte. Either way, the string in payloadStr must equal the string inside the execute-v2 signed message.
Mutating envelope metadata. Never change action, dex, chainId, transport, or marketId inside payloadStr — execute-v2 cross-checks them against the request fields and rejects mismatches.
Signing with the wrong account. For actions whose envelope carries payload.data.from (Gains EVM txs, Lighter orders), the execute-v2 signer must equal from. Deposit/withdraw flows that don’t expose from skip that check.
Reusing signatures. Build + execute signatures are each single-use within 30s of their timestamp.
Wrong signed-tx channel. Gains single-tx actions go in the top-levelsignedTx field. Lighter deposit multi-tx goes inside payloadStr as payload.signedTxs[]. Never swap the two.
Mixing margin vs leverage on Lighter.update-margin mutates collateral on an existing position (usdcAmount + increase). update-leverage sets leverage/margin-mode for future orders. On Gains, per-trade leverage change is carried by update-margin via newLeverage.