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

# Execute Perp Trades End-to-End

> 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:

1. **Build** — `POST /2/perp/payloads/<action>` with action parameters + an auth signature. You get back a canonical envelope `{ action, dex, chainId, marketId?, transport, payloadStr }`.
2. **Execute** — `POST /2/perp/execute-v2` with that envelope + a second signature that binds the `timestamp` to the exact `payloadStr`.

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

## 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**:

```typescript theme={null}
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
}
```

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

### 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:

| Step    | Endpoint                         | Signed message                                                                            |
| ------- | -------------------------------- | ----------------------------------------------------------------------------------------- |
| Build   | `POST /2/perp/payloads/<action>` | `` `${endpoint}-${timestamp}` `` (e.g., `api/2/perp/payloads/create-order-1735686300000`) |
| Execute | `POST /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

```typescript theme={null}
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)

```typescript theme={null}
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`.

<Note>
  **`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.
</Note>

Real envelope shape returned by `create-order` for a Gains route — note that the calldata field is named `callData` (camelCase), not `data`:

```json theme={null}
{
  "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):

| Field                  | Source                                               | Notes                                                                      |
| ---------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------- |
| `to`                   | `payload.data.to`                                    | Gains Diamond proxy                                                        |
| `data`                 | `payload.data.callData`                              | **rename** — the JSON field is `callData`                                  |
| `value`                | `payload.data.value` (often `"0"`)                   | parse to BigInt                                                            |
| `chainId`              | `payload.data.chainId`                               | numeric (`42161`, `8453`)                                                  |
| `nonce`                | chain RPC `eth_getTransactionCount(addr, "pending")` | do **not** read from an embedded-wallet provider — see prerequisites       |
| `gasLimit`             | floor `1_500_000n`                                   | Gains Diamond proxy under-reports `estimateGas` (\~28 k vs \~270 k actual) |
| `maxFeePerGas`         | `feeData.maxFeePerGas * 3n`                          | headroom for the create-order → execute-v2 roundtrip                       |
| `maxPriorityFeePerGas` | `feeData.maxPriorityFeePerGas`                       | required on EIP-1559 chains (Arbitrum/Base)                                |
| `type`                 | `2`                                                  | EIP-1559                                                                   |

```typescript theme={null}
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:

```typescript theme={null}
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`:

```typescript theme={null}
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

```typescript theme={null}
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):

```typescript theme={null}
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:

```typescript theme={null}
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.

```typescript theme={null}
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.

```typescript theme={null}
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).

```typescript theme={null}
// 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,
  }),
});
```

<Warning>
  Forwarding the envelope byte-for-byte (via the `executeV2` helper) returns `500 "Create-account payload is missing L1 signature"`. The `MessageToSign` → `L1Sig` swap is mandatory whenever the payload carries a `MessageToSign` field.
</Warning>

## 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](/streams/wss-perp-events). For state (current open positions + pending orders), use the [Perp Positions Stream](/streams/wss-perp-positions).

```typescript theme={null}
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](/streams/wss-perp-events) 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`](https://github.com/Wakushi/defi-client)**

* Perp execution client (build payload → sign → execute-v2): [`lib/mobula/perp-v2-client.ts`](https://github.com/Wakushi/defi-client/blob/main/lib/mobula/perp-v2-client.ts)
* Payload type definitions per action: [`lib/mobula/perp-v2-types.ts`](https://github.com/Wakushi/defi-client/blob/main/lib/mobula/perp-v2-types.ts)

**Server (NestJS) — [`Wakushi/defi-api`](https://github.com/Wakushi/defi-api)**

* Positions service (orchestrates REST + WS): [`src/services/PerpPositionService.ts`](https://github.com/Wakushi/defi-api/blob/master/src/services/PerpPositionService.ts)
* WebSocket positions stream: [`src/services/MobulaPerpPositionsWsService.ts`](https://github.com/Wakushi/defi-api/blob/master/src/services/MobulaPerpPositionsWsService.ts)
* REST positions fetcher: [`src/services/PerpPositionsRestService.ts`](https://github.com/Wakushi/defi-api/blob/master/src/services/PerpPositionsRestService.ts)

***

## 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 `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-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)                                   | Cause                                                                               | Fix                                                                                                |
| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `ERC20: transfer amount exceeds allowance` (Gains 400)                     | USDC `approve(spender, …)` never executed for this wallet                           | Run the `ensureGainsAllowance` helper from the Prerequisites section before the first Gains order. |
| `INTRINSIC_GAS_TOO_LOW`                                                    | `gasLimit` left at 0 or below the chain's minimum                                   | Set `gasLimit: 1_500_000n` for Gains, or take `max(estimate * 2, 1_500_000n)`.                     |
| `NONCE_TOO_LOW`                                                            | Stale nonce — typically reading from an embedded-wallet provider that defaults to 0 | Fetch `eth_getTransactionCount(addr, "pending")` from a real chain RPC.                            |
| `TRANSACTION_UNDERPRICED`                                                  | `maxFeePerGas` not high enough to survive base-fee drift between build and execute  | Use `feeData.maxFeePerGas * 3n` (Arbitrum / Base).                                                 |
| `Cannot POST /api/2/perp/...` (404)                                        | Hit the production gateway                                                          | Switch to `https://api.mobula.io` (perp routes are on the demo gateway today).                     |
| `payloadStr metadata does not match request metadata`                      | Mutated `action`/`dex`/`chainId`/`transport`/`marketId` between build and execute   | Forward the envelope metadata fields verbatim from the build response.                             |
| `signature signer does not match payload.from`                             | Execute-v2 signed by a different EOA than `payload.data.from`                       | Sign execute-v2 with the same wallet that the envelope was built for.                              |
| `signature already used`                                                   | Replay of a 30s-window signature                                                    | Re-sign with a fresh `timestamp` — every build and execute call needs its own signature.           |
| `timestamp expired`                                                        | Build or execute timestamp >30s from server clock                                   | Re-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 route                                               | Use `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.             |

***

## Related

<CardGroup cols={2}>
  <Card title="Quote" icon="calculator" href="/rest-api-reference/endpoint/perp-quote">Preview fills and pick a DEX before building the payload.</Card>
  <Card title="Execute" icon="bolt" href="/rest-api-reference/endpoint/perp-execute">Full execute-v2 reference including signature and error table.</Card>
  <Card title="Check Process" icon="hourglass" href="/rest-api-reference/endpoint/perp-check-process">Poll status of async deposits.</Card>
  <Card title="Retrieve Perp Markets" icon="list" href="/cookbooks/retrieve-perp-markets">List all tradable perp markets via the Perp Pairs endpoint.</Card>
  <Card title="Perp Positions Stream" icon="signal-stream" href="/streams/wss-perp-positions">Live state — open positions + pending orders.</Card>
  <Card title="Perp Events Stream" icon="signal-stream" href="/streams/wss-perp-events">Live lifecycle — fills, liquidations, TP/SL, cancels.</Card>
  <Card title="DEX Status" icon="heart-pulse" href="/data/perps/perp-dex-status">Confirm Lighter / Gains upstreams are healthy before routing.</Card>
  <Card title="Perp Fees" icon="coins" href="/perps-fees">Fee breakdown per DEX with worked examples.</Card>
</CardGroup>
