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