Skip to main content
Every perpetual action on Mobula follows the same two-step pattern:
  1. BuildPOST /2/perp/payloads/<action> with action parameters + an auth signature. You get back a canonical envelope { action, dex, chainId, marketId?, transport, payloadStr }.
  2. ExecutePOST /2/perp/execute-v2 with that envelope + a second signature that binds the timestamp to the exact payloadStr.
Use /2/perp/quote only for previewing fills/fees or picking a DEX. It does not return an executable payload — always call the matching payloads/<action> endpoint before executing.

Prerequisites

Before any first execute, make sure each of the following is satisfied — they are the most common reasons an otherwise-valid flow fails on a fresh wallet.

Base URL

Perp endpoints are currently served from the demo gateway:
https://api.mobula.io
If a route returns {"message": "Cannot POST /api/2/perp/...", "error": "Not Found", "statusCode": 404}, double-check the host — the production gateway does not yet expose /2/perp/*. A migration note will be published when the routes are promoted.

Gains: USDC allowance on the trading proxy

Gains’ Diamond proxy executes USDC.transferFrom(user, gainsVault, collateral) mid-trade. On a fresh wallet, execute-v2 will return 400 with the on-chain revert relayed verbatim:
ERC20: transfer amount exceeds allowance
  at FiatTokenV1.sol:270 in FiatTokenV2_2
FiatTokenProxy.transferFrom() at Proxy.sol:66
You must approve the Gains spender (the payload.data.to returned by payloads/create-order) to spend the user’s USDC once per wallet:
import { ethers } from 'ethers';

const USDC = {
  // Native USDC per chain (collateral on Gains)
  'evm:42161': '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // Arbitrum
  'evm:8453':  '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // Base
};

const erc20 = new ethers.Interface([
  'function allowance(address owner, address spender) view returns (uint256)',
  'function approve(address spender, uint256 amount) returns (bool)',
]);

async function ensureGainsAllowance(
  wallet: ethers.Wallet,
  chainId: 'evm:42161' | 'evm:8453',
  spender: string,            // payload.data.to from the create-order envelope
  needed: bigint,             // collateralAmount in 6-decimal USDC units
) {
  const provider = new ethers.JsonRpcProvider(rpcUrlFor(chainId));
  const usdc = new ethers.Contract(USDC[chainId], erc20, wallet.connect(provider));
  const current: bigint = await usdc.allowance(wallet.address, spender);
  if (current >= needed) return;

  const tx = await usdc.approve(spender, ethers.MaxUint256);
  await tx.wait(); // Wait for confirmation before calling execute-v2
}
Lighter never needs an ERC-20 approval — collateral is held in the Lighter account balance, funded via payloads/deposit. The ”≥ 5 USDC deposit” prerequisite for first-time Lighter accounts is documented separately on each Lighter payload page.

Read on-chain state from a real RPC, not the wallet provider

For every Gains tx (create-order, close-position, cancel-order, edit-order, update-margin) you must fill nonce, gas, maxFeePerGas, and maxPriorityFeePerGas yourself before signing. Read these from a chain RPC (e.g. viem createPublicClient({ chain, transport: http() }) or ethers JsonRpcProvider). Some embedded-wallet providers (Privy, Magic, …) expose an EIP-1193 interface that returns cached or synthetic state. In particular, Privy’s first transaction defaults to nonce: 0 regardless of chain state, causing NONCE_TOO_LOW on every retry after the first attempt. Always route the eth_getTransactionCount, eth_estimateGas, and eth_feeHistory reads to the network directly.

Gains gas floor — Diamond proxy under-reports

estimateGas against the Gains Diamond proxy frequently returns ~28 k (a delegate-call short-circuit at a state read), while a real create-order burns ~270 k. Set a floor of 1 500 000 as gasLimit (costs cents on Arbitrum) or take max(estimate * 2, 1_500_000n). The same proxy quirk affects every Gains write action, not just create-order. Likewise, bump fees with headroom: maxFeePerGas = suggested * 3n is a safe default to survive the create-order → execute-v2 roundtrip on Arbitrum/Base.

Signatures at a glance

Two distinct signatures are used across the flow:
StepEndpointSigned message
BuildPOST /2/perp/payloads/<action>`${endpoint}-${timestamp}` (e.g., api/2/perp/payloads/create-order-1735686300000)
ExecutePOST /2/perp/execute-v2`api/2/perp/execute-v2-${timestamp}-${payloadStr}`
Rules for both:
  • timestamp must be within 30s of server time.
  • Each signature is single-use (30s replay window).
  • For actions that carry payload.data.from (create-order, close-position, …), the execute-v2 signer must equal from.

Shared helpers

import { Wallet } from 'ethers';

const BASE = 'https://api.mobula.io';

async function buildPayload(
  wallet: Wallet,
  action: string,
  body: Record<string, unknown>,
) {
  const endpoint = `api/2/perp/payloads/${action}`;
  const timestamp = Date.now();
  const signature = await wallet.signMessage(`${endpoint}-${timestamp}`);
  const res = await fetch(`${BASE}/${endpoint}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ...body, timestamp, signature }),
  });
  if (!res.ok) throw new Error(`build ${action} failed: ${await res.text()}`);
  return (await res.json()).data as {
    action: string;
    dex: 'gains' | 'lighter';
    chainId: string;
    marketId?: string;
    transport: 'offchain-api' | 'evm-tx';
    payloadStr: string;
  };
}

async function executeV2(
  wallet: Wallet,
  envelope: Awaited<ReturnType<typeof buildPayload>>,
  extras: { signedTx?: `0x${string}` } = {},
) {
  const timestamp = Date.now();
  const signature = await wallet.signMessage(
    `api/2/perp/execute-v2-${timestamp}-${envelope.payloadStr}`,
  );
  const res = await fetch(`${BASE}/api/2/perp/execute-v2`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ...envelope, ...extras, timestamp, signature }),
  });
  if (!res.ok) throw new Error(`execute-v2 failed: ${await res.text()}`);
  return (await res.json()).data as {
    success: true;
    executionDetails?: Array<{ txHash: string; type: string; status: string }>;
    processId?: string;
  };
}

1. Open a position (Lighter)

const envelope = await buildPayload(wallet, 'create-order', {
  baseToken: 'BTC',
  quote: 'USDC',
  long: true,
  reduceOnly: false,
  leverage: 10,
  collateralAmount: 100,
  orderType: 'market',
  maxSlippageP: 0.5,
  dexes: ['lighter'],
});

const result = await executeV2(wallet, envelope);
console.log('filled:', result.executionDetails);

2. Open a position (Gains, EVM tx)

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.
quote value differs per DEX. Gains markets use a synthetic quote (USD), not the ERC-20 collateral. Lighter markets use USDC. Sending quote: 'USDC' to a Gains route returns "No market matching base/quote". When in doubt, pass marketId explicitly (e.g. gains-btc-usd) and skip quote — the server derives the rest.
Real envelope shape returned by create-order for a Gains route — note that the calldata field is named callData (camelCase), not data:
{
  "type": "evm",
  "orderType": "market",
  "data": {
    "from": "0xaa...8741",
    "to": "0xff162c694eaa571f685030649814282ea457f169",
    "callData": "0x5bfcc4f8...",
    "value": "0",
    "chainId": 42161
  }
}
Every field the client must fill before signing (defaults are NOT auto-populated by the server — leaving any of these out produces INTRINSIC_GAS_TOO_LOW, NONCE_TOO_LOW, or TRANSACTION_UNDERPRICED on broadcast):
FieldSourceNotes
topayload.data.toGains Diamond proxy
datapayload.data.callDatarename — the JSON field is callData
valuepayload.data.value (often "0")parse to BigInt
chainIdpayload.data.chainIdnumeric (42161, 8453)
noncechain RPC eth_getTransactionCount(addr, "pending")do not read from an embedded-wallet provider — see prerequisites
gasLimitfloor 1_500_000nGains Diamond proxy under-reports estimateGas (~28 k vs ~270 k actual)
maxFeePerGasfeeData.maxFeePerGas * 3nheadroom for the create-order → execute-v2 roundtrip
maxPriorityFeePerGasfeeData.maxPriorityFeePerGasrequired on EIP-1559 chains (Arbitrum/Base)
type2EIP-1559
import { ethers } from 'ethers';

await ensureGainsAllowance(wallet, 'evm:42161', /* spender from envelope */ payload.data.to, 200_000000n);

const envelope = await buildPayload(wallet, 'create-order', {
  baseToken: 'BTC',
  // either omit `quote` and let the server derive it from the Gains market,
  // or pass the synthetic quote explicitly:
  // quote: 'USD',
  marketId: 'gains-btc-usd',
  long: true,
  reduceOnly: false,
  leverage: 20,
  collateralAmount: 200,
  orderType: 'limit',
  openPrice: 60000,
  tp: 72000,
  sl: 55000,
  dexes: ['gains'],
  // chainIds is a routing hint — see "Common pitfalls" below
});

const parsed = JSON.parse(envelope.payloadStr);
const txReq = parsed.payload.data; // { to, callData, value, chainId, from }

const provider = new ethers.JsonRpcProvider(rpcUrlFor(txReq.chainId));
const [nonce, feeData] = await Promise.all([
  provider.getTransactionCount(wallet.address, 'pending'),
  provider.getFeeData(),
]);

const signedTx = await wallet.signTransaction({
  to: txReq.to,
  data: txReq.callData,                              // note: `callData`, not `data`
  value: txReq.value ? BigInt(txReq.value) : 0n,
  chainId: txReq.chainId,
  nonce,
  gasLimit: 1_500_000n,                              // floor — Diamond proxy under-reports
  maxFeePerGas: (feeData.maxFeePerGas ?? 0n) * 3n,   // headroom across the roundtrip
  maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? 0n,
  type: 2,
});

const result = await executeV2(wallet, envelope, { signedTx });
console.log('broadcast:', result.executionDetails?.[0]?.txHash);

3. Close a position

Full close on Lighter:
const envelope = await buildPayload(wallet, 'close-position', {
  dex: 'lighter',
  chainId: 'lighter:301',
  marketId: 'lighter-btc-usd',
  closePercentage: 100,
});
await executeV2(wallet, envelope);
Partial close (25%) on Gains — positionId = Gains trade index from /2/wallet/perp/positions:
const envelope = await buildPayload(wallet, 'close-position', {
  dex: 'gains',
  chainId: 'evm:42161',
  marketId: 'gains-btc-usd',
  positionId: '12345',
  closePercentage: 25,
});
// gains → transport === 'evm-tx', sign + forward signedTx (see section 2)

4. Cancel an unfilled order

const envelope = await buildPayload(wallet, 'cancel-order', {
  dex: 'lighter',
  chainId: 'lighter:301',
  marketId: 'lighter-btc-usd',
  orderIndex: '42',
});
await executeV2(wallet, envelope);

5. Edit TP / SL

Clear the SL and set a new TP on a Gains trade (0 removes the leg):
const envelope = await buildPayload(wallet, 'edit-order', {
  dex: 'gains',
  chainId: 'evm:42161',
  marketId: 'gains-btc-usd',
  positionId: '12345',
  newTp: 75000,
  newSl: 0,
});

6. Update margin on an open position (Lighter)

Add 50 USDC of margin to the BTC-USD position:
const envelope = await buildPayload(wallet, 'update-margin', {
  dex: 'lighter',
  chainId: 'lighter:301',
  marketId: 'lighter-btc-usd',
  usdcAmount: 50,
  increase: true,
});
await executeV2(wallet, envelope);

7. Deposit USDC (Lighter multi-tx bridge)

Lighter deposits use a 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';

// First-time Lighter deposit must transfer >= 5 USDC — Lighter only assigns an
// accountIndex once the L1 address has bridged at least that much.
const envelope = await buildPayload(wallet, 'deposit', {
  dex: 'lighter',
  chainId: 'lighter:304',
  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[] = [];

// Preserve step iteration order — do NOT sort or group by chain.
// `payload.signedTxs[i]` must line up with the i-th incomplete `item` the
// server emitted, otherwise execute-v2 rejects the bundle.
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 string
const 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));

8. Withdraw (Lighter L1 sig flow)

Lighter withdraw responses embed an L1 MessageToSign. Sign it, swap it in as L1Sig, re-stringify, sign execute-v2 over the new string.
const envelope = await buildPayload(wallet, 'withdraw', {
  dex: 'lighter',
  chainId: 'lighter:301',
  amountUsdc: '100',
});

const parsed = JSON.parse(envelope.payloadStr) as {
  payload: { MessageToSign: string; L1Sig?: string };
};

const l1Sig = await wallet.signMessage(parsed.payload.MessageToSign);
parsed.payload.L1Sig = l1Sig;
delete (parsed.payload as Partial<typeof parsed.payload>).MessageToSign;

const finalPayloadStr = JSON.stringify(parsed);

const execTs = Date.now();
const execSig = await wallet.signMessage(`api/2/perp/execute-v2-${execTs}-${finalPayloadStr}`);

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

9. Provision an account (first-time Lighter users)

Three-step sequence: deposit ≥ 5 USDC (§7), poll Lighter for the assigned accountIndex, then call create-account. The envelope embeds an L1 challenge (MessageToSign + empty L1Sig) that must be sign-and-swapped before execute-v2 — same shape as Lighter withdraw (§8).
// 1. discover accountIndex assigned by Lighter after the deposit settles
async function pollLighterAccountIndex(eoa: string) {
  for (let i = 0; i < 200; i++) {
    const res = await fetch(
      `https://mainnet.zklighter.elliot.ai/api/v1/account?by=l1_address&value=${eoa}`,
      { headers: { Accept: 'application/json' } },
    ).then(r => r.json()).catch(() => null);
    if (res?.code === 200 && res.accounts?.[0]?.account_index != null) {
      return res.accounts[0].account_index as number;
    }
    await new Promise(r => setTimeout(r, 1000));
  }
  throw new Error('Lighter accountIndex not found');
}

const accountIndex = await pollLighterAccountIndex(wallet.address);

// 2. build envelope — chainId MUST be lighter:304, accountIndex is required
const envelope = await buildPayload(wallet, 'create-account', {
  dex: 'lighter',
  chainId: 'lighter:304',
  accountIndex,
  apiKeyIndex: 100, // any unused slot
});

// 3. swap MessageToSign -> L1Sig, then sign execute-v2 over the new string
const parsed = JSON.parse(envelope.payloadStr);
let finalPayloadStr = envelope.payloadStr;

if (parsed.payload?.MessageToSign) {
  parsed.payload.L1Sig = await wallet.signMessage(parsed.payload.MessageToSign);
  delete parsed.payload.MessageToSign;
  finalPayloadStr = JSON.stringify(parsed);
}

const execTs = Date.now();
const execSig = await wallet.signMessage(
  `api/2/perp/execute-v2-${execTs}-${finalPayloadStr}`,
);

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,
  }),
});
Forwarding the envelope byte-for-byte (via the executeV2 helper) returns 500 "Create-account payload is missing L1 signature". The MessageToSignL1Sig swap is mandatory whenever the payload carries a MessageToSign field.

10. Listen for fills, liquidations, TP/SL, cancels

execute-v2 returns the broadcast result, not the final lifecycle of the trade. To learn that an order filled, was liquidated, hit TP/SL, was canceled, or had its margin / leverage / TP / SL updated, subscribe to the Perp Events Stream. For state (current open positions + pending orders), use the Perp Positions Stream.
const events = new WebSocket('wss://stream-perps-prod-eu.mobula.io/');

events.addEventListener('open', () => {
  events.send(JSON.stringify({
    type: 'stream',
    authorization: process.env.MOBULA_API_KEY,
    payload: {
      name: `perp-events-${crypto.randomUUID()}`,
      chainIds: ['evm:42161', 'evm:8453', 'lighter:304'],
      events: ['order'],
      subscriptionTracking: 'true', // string, not boolean
    },
  }));
});

events.addEventListener('message', (msg) => {
  const frame = JSON.parse(msg.data.toString());
  const e = frame.data;
  if (e?.traderAddress !== wallet.address.toLowerCase()) return;
  // e.type ∈ MARKET_BUY | MARKET_SELL | LIQUIDATION_LONG | LIQUIDATION_SHORT |
  //          TAKE_PROFIT | STOP_LOSS | LIMIT_BUY | LIMIT_SELL | TRADE_STORED |
  //          UPDATE_TP | UPDATE_SL | POSITION_SIZE_*_EXECUTED |
  //          LEVERAGE_UPDATE_EXECUTED | MARKET_*_CANCELED
  console.log(e.type, e.tradeId, e.transactionHash);
});
See the Perp Events Stream reference for the full envelope, dedupe contract, and the chain-id mapping caveat (Lighter is lighter:304 on this stream, lighter:301 on positions).

Reference implementation

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 Server (NestJS) — Wakushi/defi-api

Common pitfalls

  • Signing execute-v2 over the wrong string. For deposit, Lighter withdraw, and Lighter create-account you must mutate payload.* (inject signedTxs, or swap MessageToSignL1Sig) 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-level signedTx field. Lighter deposit multi-tx goes inside payloadStr as payload.signedTxs[]. Never swap the two.
  • Margin vs leverage on Lighter. update-margin mutates collateral on an existing position (usdcAmount + increase). On Gains, per-trade leverage change is carried by update-margin via newLeverage.
  • chainIds is a routing hint, not a hard filter. If the requested market is not available on any of the chains in chainIds, the router silently picks a chain where the market exists. Example: chainIds: ['evm:42161'] for gains-btc-usd may return chainId: 'evm:8453'. Always trust the response chainId rather than the request hint.
  • quote semantics differ per DEX. Gains uses synthetic USD (e.g. gains-btc-usd), Lighter uses USDC. Sending quote: 'USDC' to a Gains route returns "No market matching base/quote". Pass marketId explicitly when you want to be unambiguous.
  • baseToken + quote resolution is not authoritative on Lighter either. Same pairs row that lists PROVE/USDC on lighter:304 can still produce "No market matching base/quote" on create-order. Fix: derive marketId from the pairs response row (lighter-<base>-<quote> lowercased) and pass it explicitly — same advice as for Gains.
  • Gains marketId shape differs per chain. Mainnet uses gains-<base>-usd (e.g. gains-btc-usd); Arbitrum Sepolia carries the collateral suffix (e.g. gains-hype-usd-usdc). Read the canonical marketId straight from the /pairs response row instead of constructing it client-side.
  • Response envelope shape. Most endpoints return { data: { ... } } (no success flag) on 2xx. The execute-v2 success body adds success: true inside data. Parse defensively: read body.data, then check for the action-specific fields you actually need.
  • Reading state via the wallet provider. See the prerequisites — embedded-wallet providers (Privy etc.) can return stale nonce / feeData. Always read via a chain RPC.

Common errors

Errors you will hit while wiring the flow, with the typical cause and the fix:
Error (verbatim from execute-v2 / RPC)CauseFix
ERC20: transfer amount exceeds allowance (Gains 400)USDC approve(spender, …) never executed for this walletRun the ensureGainsAllowance helper from the Prerequisites section before the first Gains order.
INTRINSIC_GAS_TOO_LOWgasLimit left at 0 or below the chain’s minimumSet gasLimit: 1_500_000n for Gains, or take max(estimate * 2, 1_500_000n).
NONCE_TOO_LOWStale nonce — typically reading from an embedded-wallet provider that defaults to 0Fetch eth_getTransactionCount(addr, "pending") from a real chain RPC.
TRANSACTION_UNDERPRICEDmaxFeePerGas not high enough to survive base-fee drift between build and executeUse feeData.maxFeePerGas * 3n (Arbitrum / Base).
Cannot POST /api/2/perp/... (404)Hit the production gatewaySwitch to https://api.mobula.io (perp routes are on the demo gateway today).
payloadStr metadata does not match request metadataMutated action/dex/chainId/transport/marketId between build and executeForward the envelope metadata fields verbatim from the build response.
signature signer does not match payload.fromExecute-v2 signed by a different EOA than payload.data.fromSign execute-v2 with the same wallet that the envelope was built for.
signature already usedReplay of a 30s-window signatureRe-sign with a fresh timestamp — every build and execute call needs its own signature.
timestamp expiredBuild or execute timestamp >30s from server clockRe-sign immediately before the request — do not pre-compute.
could not build create-order payload + "No market matching base/quote"quote: 'USDC' sent to a Gains routeUse quote: 'USD' on Gains, or omit quote and pass marketId: 'gains-<base>-usd'.
Failed to broadcast signed transaction on <chainId>RPC rejected signedTx (often a fee/nonce/gas issue)Inspect the errors[] list in the response body — it carries the upstream RPC reason.

Quote

Preview fills and pick a DEX before building the payload.

Execute

Full execute-v2 reference including signature and error table.

Check Process

Poll status of async deposits.

Retrieve Perp Markets

List all tradable perp markets via the Perp Pairs endpoint.

Perp Positions Stream

Live state — open positions + pending orders.

Perp Events Stream

Live lifecycle — fills, liquidations, TP/SL, cancels.

DEX Status

Confirm Lighter / Gains upstreams are healthy before routing.

Perp Fees

Fee breakdown per DEX with worked examples.