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 Pulse.

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.