- 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-v2with that envelope + a second signature that binds thetimestampto the exactpayloadStr.
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:{"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 executesUSDC.transferFrom(user, gainsVault, collateral) mid-trade. On a fresh wallet, execute-v2 will return 400 with the on-chain revert relayed verbatim:
payload.data.to returned by payloads/create-order) to spend the user’s USDC once per wallet:
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:| 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}` |
timestampmust 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 equalfrom.
Shared helpers
1. Open a position (Lighter)
2. Open a position (Gains, EVM tx)
Gains builds an EVM transaction. The server returnstransport: "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.create-order for a Gains route — note that the calldata field is named callData (camelCase), not data:
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 |
3. Close a position
Full close on Lighter:positionId = Gains trade index from /2/wallet/perp/positions:
4. Cancel an unfilled order
5. Edit TP / SL
Clear the SL and set a new TP on a Gains trade (0 removes the leg):
6. Update margin on an open position (Lighter)
Add 50 USDC of margin to the BTC-USD position:7. Deposit USDC (Lighter multi-tx bridge)
Lighter deposits use a multi-step bridge. The envelope’spayload.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.
8. Withdraw (Lighter L1 sig flow)
Lighter withdraw responses embed anL1 MessageToSign. Sign it, swap it in as L1Sig, re-stringify, sign execute-v2 over the new string.
9. Provision an account (first-time Lighter users)
Three-step sequence: deposit ≥ 5 USDC (§7), poll Lighter for the assignedaccountIndex, 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).
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.
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
- Perp execution client (build payload → sign → execute-v2):
lib/mobula/perp-v2-client.ts - Payload type definitions per action:
lib/mobula/perp-v2-types.ts
Wakushi/defi-api
- Positions service (orchestrates REST + WS):
src/services/PerpPositionService.ts - WebSocket positions stream:
src/services/MobulaPerpPositionsWsService.ts - REST positions fetcher:
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.*(injectsignedTxs, or swapMessageToSign→L1Sig) and sign over the re-stringified envelope. For every other action the envelope is forwarded byte-for-byte. Either way, the string inpayloadStrmust equal the string inside the execute-v2 signed message. - Mutating envelope metadata. Never change
action,dex,chainId,transport, ormarketIdinsidepayloadStr— 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 equalfrom. Deposit/withdraw flows that don’t exposefromskip 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
signedTxfield. Lighter deposit multi-tx goes insidepayloadStraspayload.signedTxs[]. Never swap the two. - Margin vs leverage on Lighter.
update-marginmutates collateral on an existing position (usdcAmount+increase). On Gains, per-trade leverage change is carried byupdate-marginvianewLeverage. chainIdsis a routing hint, not a hard filter. If the requested market is not available on any of the chains inchainIds, the router silently picks a chain where the market exists. Example:chainIds: ['evm:42161']forgains-btc-usdmay returnchainId: 'evm:8453'. Always trust the responsechainIdrather than the request hint.quotesemantics differ per DEX. Gains uses syntheticUSD(e.g.gains-btc-usd), Lighter usesUSDC. Sendingquote: 'USDC'to a Gains route returns"No market matching base/quote". PassmarketIdexplicitly when you want to be unambiguous.baseToken+quoteresolution is not authoritative on Lighter either. Samepairsrow that listsPROVE/USDConlighter:304can still produce"No market matching base/quote"oncreate-order. Fix: derivemarketIdfrom thepairsresponse row (lighter-<base>-<quote>lowercased) and pass it explicitly — same advice as for Gains.- Gains
marketIdshape differs per chain. Mainnet usesgains-<base>-usd(e.g.gains-btc-usd); Arbitrum Sepolia carries the collateral suffix (e.g.gains-hype-usd-usdc). Read the canonicalmarketIdstraight from the/pairsresponse row instead of constructing it client-side. - Response envelope shape. Most endpoints return
{ data: { ... } }(nosuccessflag) on 2xx. The execute-v2 success body addssuccess: trueinsidedata. Parse defensively: readbody.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
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.