# LeverUp Developer Docs
> Integrate with the LeverUp perpetuals protocol on Monad — onchain contracts, gasless one-click trading, and REST API.
---
Source: https://developer-docs.leverup.xyz/introduction/what-is-leverup.md
# What is LeverUp
LeverUp is a decentralized perpetuals protocol on Monad. Traders open leveraged long or short
positions against a protocol liquidity pool, using crypto, forex, equity, index, and commodity
markets — all settled onchain, all non-custodial.
For an integrator, LeverUp exposes three surfaces:
| Surface | What it is | Gas | Key custody |
| :--- | :--- | :--- | :--- |
| **Onchain** | A single Diamond (EIP-2535) proxy contract you call directly | You pay | You hold the trading key |
| **Gasless (1CT)** | Sign trade intents offchain, protocol relays and executes them | Protocol pays | Signing key can only trade, never transfer |
| **REST API** | HTTP endpoints for prices, pairs, positions, history, analytics | n/a | Read-only |
## How a trade works
LeverUp is oracle-settled, not order-book matched. A trade goes through two phases:
1. **Request.** You submit the trade. The protocol locks your collateral and requests a price.
2. **Fill.** A keeper delivers the oracle price and the position opens at that price, adjusted for
the pair's slippage model.
This means an open is not instant within a single transaction — your transaction creates a *pending*
trade, and a follow-up keeper transaction turns it into an open position. The same applies to
closes. Integrations that need certainty should watch [events](/onchain/events) or poll
[positions](/onchain/reading-data), not assume the receipt of their own transaction is the final state.
## Positions are merged per market
LeverUp does not create one position object per trade. A position is a **slot**, keyed
deterministically by:
```
keccak256(abi.encode(user, pairBase, isLong, lvToken, "position.v1"))
```
Open BTC long twice with the same collateral and you end up with **one** position at a blended entry
price, not two. This is why partial closes and TP/SL orders exist as first-class operations — see
[Managing a Position](/onchain/manage-position) and [TP/SL Orders](/onchain/tpsl-orders).
You can compute this hash offchain, or read it from
[`getPositionHash`](/onchain/reading-data#getpositionhash).
## What you need
- A Monad mainnet RPC endpoint.
- An EVM account with `MON` for gas (not required if you only use [1CT](/gasless/overview)) and a
supported collateral token.
- For price-sensitive calls, access to the LeverUp oracle endpoint —
see [Oracle & Prices](/api/oracle).
Next: [Quickstart](/introduction/quickstart).
---
Source: https://developer-docs.leverup.xyz/introduction/quickstart.md
# Quickstart
This page opens a real position on Monad mainnet from a standalone script, then reads it back and
closes it. Budget about 10 minutes.
If you would rather trade without holding gas or signing every transaction, skip to
[Gasless Trading](/gasless/overview) — but read this page first, because the trade parameters are
identical.
::: danger Real funds
Everything here targets mainnet. Start with a few dollars of collateral.
:::
## 1. Prerequisites
- Node 18+ and `viem` (`npm i viem`).
- An account holding `MON` for gas and `USDC` for collateral.
- A Monad mainnet RPC URL.
## 2. Shared setup
Create `config.ts`. Every other snippet on this site assumes these exports.
```ts
import { defineChain, createPublicClient, createWalletClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
export const monad = defineChain({
id: 143,
name: 'Monad',
nativeCurrency: { name: 'MON', symbol: 'MON', decimals: 18 },
rpcUrls: { default: { http: [process.env.RPC_URL!] } },
blockExplorers: { default: { name: 'MonadVision', url: 'https://monadvision.com' } },
contracts: { multicall3: { address: '0xcA11bde05977b3631167028862bE2a173976CA11' } },
})
export const DIAMOND = '0xea1b8E4aB7f14F7dCA68c5B214303B13078FC5ec' as const
export const USDC = '0x754704Bc059F8C67012fEd69BC8A327a5aafb603' as const
export const LVUSD = '0xFD44B35139Ae53FFF7d8F2A9869c503D987f00d1' as const
export const API = 'https://service.leverup.xyz'
export const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
export const publicClient = createPublicClient({ chain: monad, transport: http() })
export const walletClient = createWalletClient({ account, chain: monad, transport: http() })
```
## 3. Find the pair
Pair addresses are not stable across listings. Look them up:
```ts
import { API } from './config'
const res = await fetch(`${API}/v1/pairs?symbol=BTC&size=1`)
const { content } = await res.json()
const pairBase = content[0].base as `0x${string}`
console.log(content[0].pairName, pairBase, content[0].status)
```
## 4. Fetch oracle data
Opening a position requires a fresh oracle payload and the fee to attach. Fetch it immediately
before sending the transaction.
```ts
async function fetchOracleUpdate(pairBase: `0x${string}`, collateral: `0x${string}`) {
const res = await fetch(`${API}/v1/oracle/price/updates/by-position`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pairBase,
collateral,
blockChain: 'MONAD',
options: {
includeEncodingData: true,
includeFee: true,
includePrice: true,
includePublishTime: false,
},
}),
})
if (!res.ok) throw new Error(`oracle: HTTP ${res.status}`)
const data = await res.json()
return {
updateData: {
pythPriceUpdateData: data.pythPriceUpdateData ?? [],
pythProPriceUpdateData: data.pythProPriceUpdateData ?? [],
},
// Attach the sum as msg.value; `verifition_fee` is spelled that way in the API.
value: BigInt(data.updateFee ?? '0') + BigInt(data.verifition_fee ?? '0'),
extraFee: 0n,
}
}
```
## 5. Approve collateral
One-time, per collateral token. Approve the Diamond, not a facet address.
```ts
import { parseUnits, erc20Abi } from 'viem'
import { publicClient, walletClient, DIAMOND, USDC } from './config'
const hash = await walletClient.writeContract({
address: USDC,
abi: erc20Abi,
functionName: 'approve',
args: [DIAMOND, parseUnits('1000', 6)],
})
await publicClient.waitForTransactionReceipt({ hash })
```
## 6. Open a position
$100 notional BTC long, backed by 10 USDC of margin — roughly 10x.
```ts
import { parseUnits } from 'viem'
import { publicClient, walletClient, DIAMOND, USDC, LVUSD } from './config'
const OPEN_ABI = [{
type: 'function',
name: 'openMarketTradeV2',
stateMutability: 'payable',
inputs: [
{
name: 'data', type: 'tuple', components: [
{ name: 'pairBase', type: 'address' },
{ name: 'isLong', type: 'bool' },
{ name: 'tokenIn', type: 'address' },
{ name: 'lvToken', type: 'address' },
{ name: 'amountIn', type: 'uint96' },
{ name: 'qty', type: 'uint128' },
{ name: 'price', type: 'uint128' },
{ name: 'stopLoss', type: 'uint128' },
{ name: 'takeProfit', type: 'uint128' },
{ name: 'broker', type: 'uint24' },
],
},
{
name: 'updateData', type: 'tuple', components: [
{ name: 'pythPriceUpdateData', type: 'bytes[]' },
{ name: 'pythProPriceUpdateData', type: 'bytes[]' },
],
},
{ name: 'extraFee', type: 'uint96' },
],
outputs: [{ name: 'tradeHash', type: 'bytes32' }],
}] as const
const oracle = await fetchOracleUpdate(pairBase, USDC)
// Read the current price from the same oracle response and allow 1% of slippage.
const markPrice = 100_000 // replace with the live price
const qty = parseUnits((100 / markPrice).toFixed(10), 10) // $100 notional
const worstPrice = parseUnits(String(markPrice * 1.01), 18) // long -> max price
const hash = await walletClient.writeContract({
address: DIAMOND,
abi: OPEN_ABI,
functionName: 'openMarketTradeV2',
args: [
{
pairBase,
isLong: true,
tokenIn: USDC,
lvToken: LVUSD,
amountIn: parseUnits('10', 6),
qty,
price: worstPrice,
stopLoss: 0n,
takeProfit: 0n,
broker: 0,
},
oracle.updateData,
oracle.extraFee,
],
value: oracle.value,
})
await publicClient.waitForTransactionReceipt({ hash })
```
::: warning This does not mean you have a position yet
`openMarketTradeV2` creates a *pending* trade. A keeper fills it in a follow-up transaction.
Poll until the position appears.
:::
## 7. Read the position back
```ts
import { formatUnits } from 'viem'
const READ_ABI = [{
type: 'function',
name: 'getPositionsV4',
stateMutability: 'view',
inputs: [{ name: 'user', type: 'address' }, { name: 'pairBase', type: 'address' }],
outputs: [{
type: 'tuple[]', name: '', components: [
{ name: 'positionHash', type: 'bytes32' },
{ name: 'pair', type: 'string' },
{ name: 'pairBase', type: 'address' },
{ name: 'tokenIn', type: 'address' },
{ name: 'marginToken', type: 'address' },
{ name: 'isLong', type: 'bool' },
{ name: 'margin', type: 'uint96' },
{ name: 'qty', type: 'uint128' },
{ name: 'entryPrice', type: 'uint128' },
{ name: 'stopLoss', type: 'uint128' },
{ name: 'takeProfit', type: 'uint128' },
{ name: 'openFee', type: 'uint96' },
{ name: 'executionFee', type: 'uint96' },
{ name: 'fundingFee', type: 'int256' },
{ name: 'timestamp', type: 'uint32' },
{ name: 'holdingFee', type: 'uint96' },
{ name: 'earliestCloseTime', type: 'uint256' },
{ name: 'accruedFundingFee', type: 'int256' },
{ name: 'accruedHoldingFee', type: 'uint256' },
],
}],
}] as const
for (let i = 0; i < 30; i++) {
const positions = await publicClient.readContract({
address: DIAMOND,
abi: READ_ABI,
functionName: 'getPositionsV4',
args: [account.address, pairBase],
})
if (positions.length > 0) {
const p = positions[0]
console.log('hash ', p.positionHash)
console.log('side ', p.isLong ? 'LONG' : 'SHORT')
console.log('qty ', formatUnits(p.qty, 10))
console.log('entry ', formatUnits(p.entryPrice, 18))
console.log('margin ', formatUnits(p.margin, 18), 'LVUSD')
break
}
await new Promise((r) => setTimeout(r, 1000))
}
```
## 8. Close it
```ts
const CLOSE_ABI = [{
type: 'function',
name: 'closeTrade',
stateMutability: 'nonpayable',
inputs: [{ name: 'positionHash', type: 'bytes32' }],
outputs: [],
}] as const
const hash = await walletClient.writeContract({
address: DIAMOND,
abi: CLOSE_ABI,
functionName: 'closeTrade',
args: [positionHash],
})
await publicClient.waitForTransactionReceipt({ hash })
```
Like opening, this requests a close. The position disappears once the keeper settles it.
## Where to go next
| Goal | Page |
| :--- | :--- |
| Understand the units you just used | [Precision & Units](/introduction/precision) |
| Limit orders, TP/SL, margin management | [Onchain Integration](/onchain/overview) |
| Trade without gas or wallet popups | [Gasless Trading](/gasless/overview) |
| Query data over HTTP instead | [REST API](/api/overview) |
| Something reverted | [Error Reference](/reference/errors) |
---
Source: https://developer-docs.leverup.xyz/introduction/networks.md
# Networks & Contracts
All documentation on this site targets **Monad mainnet**.
## Network
| Field | Value |
| :--- | :--- |
| Network | Monad Mainnet |
| Chain ID | `143` |
| Native currency | `MON` (18 decimals) |
| Block explorer | `https://monadvision.com` |
### RPC
LeverUp does not operate a public RPC. Use any Monad mainnet RPC endpoint — the public endpoint
published by the Monad Foundation works for development, and a dedicated provider is strongly
recommended for production, since opening a position involves several reads plus a write and
public endpoints are rate limited.
`viem` does not ship a built-in Monad mainnet chain object, so define one:
```ts
import { defineChain } from 'viem'
export const monad = defineChain({
id: 143,
name: 'Monad',
nativeCurrency: { name: 'MON', symbol: 'MON', decimals: 18 },
rpcUrls: {
default: { http: ['https://rpc.monad.xyz'] }, // replace with your provider
},
blockExplorers: {
default: { name: 'MonadVision', url: 'https://monadvision.com' },
},
contracts: {
multicall3: { address: '0xcA11bde05977b3631167028862bE2a173976CA11' },
},
})
```
Every code sample on this site imports `monad` from this snippet.
## Contracts
LeverUp uses the Diamond pattern (EIP-2535). **One address serves every trading facet** — trading,
limit orders, TP/SL orders, one-click trading, pair configuration, and all read functions. There is
no separate address to look up per trading feature. Standalone helper contracts are listed below
when an integration calls them directly.
| Contract | Address |
| :--- | :--- |
| **LeverUp Diamond** | `0xea1b8E4aB7f14F7dCA68c5B214303B13078FC5ec` |
| [LVMONMinter](/onchain/minting-lvmon) | `0x6FbEa6986F38aA85D09a8e9d8E5c71499ef70909` |
| Multicall3 | `0xcA11bde05977b3631167028862bE2a173976CA11` |
::: tip
The Diamond address is also the EIP-712 `verifyingContract` for [gasless trading](/gasless/signing#domain).
There is no separate agent contract.
:::
## Tokens
| Token | Address | Decimals |
| :--- | :--- | :--- |
| USDC | `0x754704Bc059F8C67012fEd69BC8A327a5aafb603` | 6 |
| WMON | `0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A` | 18 |
| LVUSD | `0xFD44B35139Ae53FFF7d8F2A9869c503D987f00d1` | 18 |
| LVMON | `0x91b81bfbe3A747230F0529Aa28d8b2Bc898E6D56` | 18 |
Native `MON` is represented as the zero address `0x0000000000000000000000000000000000000000` where a
token address is expected.
`LVUSD` and `LVMON` are the protocol's settlement tokens. You do not need to hold them to trade — see
[Collateral and lvToken](/introduction/concepts#collateral-and-lvtoken) for when each is required.
## Service endpoints
| Service | Base URL |
| :--- | :--- |
| REST API | `https://service.leverup.xyz` |
| One-Click Trading | `https://oneclick-01-keeper.leverup.xyz` |
## Trading pairs
Pair addresses change as markets are listed. Do not hardcode them — fetch the live list from
[`GET /v1/pairs`](/api/pairs#list-pairs).
---
Source: https://developer-docs.leverup.xyz/introduction/concepts.md
# Core Concepts
## The Diamond
Every contract interaction goes through a single address — the LeverUp Diamond
(`0xea1b8E4aB7f14F7dCA68c5B214303B13078FC5ec`). Opening trades, limit orders, TP/SL orders,
one-click trading, pair configuration and all read functions are facets behind that one proxy.
Practically, this means: point every `readContract` / `writeContract` at the Diamond address, and
supply only the ABI fragment for the function you are calling.
## Pairs
A market is identified by its **`pairBase`** — an address that acts as the pair's identifier. It is
not a token you can hold or transfer; it is the key you pass to every trading and read function.
Pair addresses change as markets are listed and delisted. Fetch them from
[`GET /v1/pairs`](/api/pairs#list-pairs) rather than hardcoding.
Pairs carry a `status`:
| Status | Meaning |
| :--- | :--- |
| `AVAILABLE` | Open and close both allowed |
| `REDUCE_ONLY` | Only closes and reductions allowed |
| `CLOSE` | Market disabled |
Non-crypto markets (equities, indices, forex, commodities) also have trading hours. `GET /v1/pairs`
returns `nextOpen` / `nextClose`; attempting to trade a closed market reverts with `MarketClosed`.
## Collateral and lvToken
Every position references two tokens:
- **`tokenIn`** — what you deposit.
- **`lvToken`** — the protocol's settlement token for that position. Margin, fees and PnL are
denominated in it.
The pairing is fixed:
| `tokenIn` | Required `lvToken` |
| :--- | :--- |
| USDC | LVUSD |
| LVUSD | LVUSD |
| Native MON (`0x0` address) | LVMON |
| WMON | LVMON |
| LVMON | LVMON |
In short: **USD-denominated collateral settles in LVUSD, MON-denominated collateral settles in
LVMON.** Passing a mismatched pair reverts.
You do not need to hold LV tokens to trade. Depositing USDC mints the LVUSD side internally. If you
already hold LVUSD or LVMON you can pass it directly as `tokenIn`, which skips a conversion step.
::: warning
[One-click trading](/gasless/overview) only accepts ERC-20 collateral. To trade MON via 1CT, wrap it
to WMON first.
:::
## Positions are merged slots
A position is keyed by `(user, pairBase, isLong, lvToken)`, not by trade:
```
positionHash = keccak256(abi.encode(user, pairBase, isLong, lvToken, "position.v1"))
```
Opening the same market and direction twice increases the existing position and blends the entry
price — you never get two BTC-long positions with the same collateral. Consequences:
- To reduce exposure without exiting, use [partial close](/onchain/closing#partial-close).
- A position can carry [multiple TP legs and one SL](/onchain/tpsl-orders).
- `positionHash` is stable and predictable, so you can compute it before the position exists.
::: info Legacy positions
Positions created before the merged-position upgrade use a random hash instead of the deterministic
key. They are full-close only and do not support TP/SL orders — they keep using the embedded
`updateTradeTp` / `updateTradeSl` fields. Read functions return both kinds; a position whose hash
does not match `getPositionHash(...)` for its own parameters is a legacy position.
:::
## Oracle updates
LeverUp settles against Pyth. Price-sensitive contract calls take an `OracleUpdateData` struct and a
native-token fee alongside it:
```solidity
struct OracleUpdateData {
bytes[] pythPriceUpdateData;
bytes[] pythProPriceUpdateData;
}
```
You do **not** fetch this from Pyth directly. Request it from the LeverUp backend, which selects the
right feeds for the pair and collateral and returns the fee to attach:
- Endpoint: [`POST /v1/oracle/price/updates/by-position`](/api/oracle#price-updates-by-position)
- Attach `updateFee + verifition_fee` as the transaction `value`.
Payloads are short-lived. Fetch immediately before sending the transaction, not at the start of a
long-running flow.
Calls that need oracle data: opening market and limit orders, removing margin, and closes. Calls
that only change stored values — updating a take-profit, cancelling an order — do not.
::: tip 1CT skips this entirely
When trading through [1CT](/gasless/overview), the relayer fetches oracle data for you. Intents never
carry oracle payloads.
:::
## Fees
| Fee | When | Paid in |
| :--- | :--- | :--- |
| Open fee | On open | `lvToken` |
| Close fee | On close | `lvToken` |
| Holding fee | Accrues while open | `lvToken` |
| Funding fee | Accrues while open, signed | `lvToken` |
| Oracle update fee | Per price-sensitive transaction | Native `MON` |
| Execution fee (1CT only) | Per intent | Configurable ERC-20, see [Execution Fee](/gasless/fees) |
| `extraFee` (optional) | Per open, if you set it | `tokenIn` — see [Brokers & Referrals](/introduction/brokers#extrafee) |
Fee rates are per pair and readable onchain via `getPairFeeConfig`. Some markets have a minimum
holding period (`minHoldingSeconds` in `GET /v1/pairs`) before a position can be closed; a position
also exposes `earliestCloseTime`.
Part of the open and close fee can be credited to a referral channel via the `broker` field that
appears on every fee-charging call. This does not change what the trader pays — see
[Brokers & Referrals](/introduction/brokers).
## Slippage
Fills are not at the raw oracle price. Each pair has a slippage model that adjusts the fill based on
open interest and trade size. This is why every open takes a `price` field — it is the **worst
acceptable price**, not a limit price:
- Long: the maximum price you will accept.
- Short: the minimum price you will accept.
If the adjusted fill falls outside it, the trade is refunded rather than filled. See
[Reading Data](/onchain/reading-data#pair-slippage-configuration) for reading the model's parameters.
For limit orders, `price` is the actual limit price.
## Keepers
Two things happen asynchronously, executed by protocol keepers:
1. **Fills.** A pending open or close is settled once the keeper delivers the oracle price.
2. **Triggers.** Limit orders, TP/SL orders and liquidations fire when the price condition is met.
Your transaction succeeding means the *request* was accepted, not that the position exists. Confirm
via [events](/onchain/events) or by polling [`getPositionsV4`](/onchain/reading-data#getpositionsv4).
Next: [Precision & Units](/introduction/precision), then pick an integration path —
[Onchain](/onchain/overview) or [Gasless](/gasless/overview).
---
Source: https://developer-docs.leverup.xyz/introduction/precision.md
# Precision & Units
Most integration bugs are unit bugs. This page is the single reference — every other page links back
here.
## Quick table
| Field | Unit | Example |
| :--- | :--- | :--- |
| `qty` | base asset amount, **1e10** | 0.001 BTC → `10000000` |
| `price`, `stopLoss`, `takeProfit`, `triggerPrice`, `entryPrice` | USD, **1e18** | $100,000 → `100000000000000000000000` |
| `amountIn` | `tokenIn` decimals | 10 USDC → `10000000` |
| `extraFee` | `tokenIn` decimals | see [Brokers & Referrals](/introduction/brokers#extrafee) |
| `margin`, `openFee`, `closeFee`, `holdingFee`, `fundingFee`, `pnl` | `lvToken` decimals | LVUSD and LVMON are both 18 |
| `antiDdosFee` | fee token decimals | see [Execution Fee](/gasless/fees) |
| `nonce` | milliseconds since epoch, `uint64` | `1785312000123` |
| `deadline` | seconds since epoch, `uint48` | `1785312300` |
| Percentage fields ending in `P` | **1e4** unless noted | `50` → 0.5% |
| `holdingFeeRate` | **1e12** | |
| `fundingFeeRate`, `longAccFundingFeePerShare` | **1e18**, signed | |
| `shareP`, `minCloseFeeP`, `lvTokenDiscountP` | **1e5** | |
## Unrealized PnL REST totals
The [current unrealized PnL endpoint](/api/history#current-unrealized-pnl) returns
`unrealizedPnlUsd`, `unrealizedPnlAfterAccruedFeesUsd`, `fundingFeeUsd` and `holdingFeeUsd` as
**decimal USD strings**, without an implied integer scale. `"-12.5"` means **-$12.50**. Do not
pass these values to `BigInt` or divide them by `1e18`; preserve the strings or use decimal arithmetic.
This differs from raw onchain amounts and the scaled integer strings in `portfolio_data`.
The endpoint's `blockNumber` is an integer string; `blockTimestamp` and optional `priceAsOf` are
Unix seconds.
## `qty` is the trap
`qty` is **not** notional USD and **not** wei. It is the amount of the *base* asset, scaled by 1e10 —
the same scale for every pair, regardless of the asset's own decimals.
To convert from a USD position size:
```ts
import { parseUnits } from 'viem'
// $100 of BTC at $100,000
const notionalUsd = 100
const price = 100_000
const qty = parseUnits((notionalUsd / price).toFixed(10), 10)
// 0.001 BTC -> 10_000_000n
```
Going the other way, when reading a position:
```ts
import { formatUnits } from 'viem'
const baseAmount = formatUnits(position.qty, 10) // "0.001"
const notionalUsd =
(Number(position.qty) / 1e10) * (Number(position.entryPrice) / 1e18)
```
::: warning
`.toFixed(10)` before `parseUnits` is not optional. `100 / 100000` in JavaScript is
`0.001`, but many other pairs produce values with more than 10 decimal places, and `parseUnits`
throws on excess precision.
:::
## Prices are always 1e18
Every price field in the protocol — the acceptable price on an open, stop-loss, take-profit, trigger
prices on TP/SL orders, and the entry/close prices you read back — is USD at 18 decimals. This holds
regardless of the collateral token's decimals.
```ts
const price = parseUnits('100000', 18) // $100,000
```
## Amounts follow the token
`amountIn` is denominated in `tokenIn`'s own decimals. USDC is 6; WMON, LVUSD and LVMON are 18. Read
`decimals()` rather than assuming.
Values *inside* a position (`margin`, fees, PnL) are denominated in the **`lvToken`**, not `tokenIn`.
A position funded with 10 USDC (`amountIn` at 6 decimals) reports its margin in LVUSD at 18
decimals — the two are not the same scale. See
[Collateral and lvToken](/introduction/concepts#collateral-and-lvtoken).
## Percentages
Fields whose name ends in `P` are integer percentages with an implied scale. The common case is 1e4,
where `10000` = 100%:
```ts
const slippagePercent = Number(config.slippageLongP) / 100 // 50 -> 0.5%
```
The exceptions are listed in the table above — `shareP`, `minCloseFeeP` and `lvTokenDiscountP` use
1e5, and `holdingFeeRate` uses 1e12.
## Signed values
`fundingFee`, `accruedFundingFee`, `pnl` and `fundingFeeRate` are signed. A positive funding fee
means the trader **receives**; negative means the trader **pays**. Decode them as `int256`/`int96`,
not `uint`.
## JSON transport
REST API responses and 1CT request bodies carry large integers as **strings**, not JSON numbers.
`nonce` in particular exceeds `Number.MAX_SAFE_INTEGER` and will silently lose precision if you let
`JSON.stringify` write it as a number.
```ts
body: JSON.stringify({
nonce: nonce.toString(), // correct
antiDdosFee: antiDdosFee.toString(),
})
```
---
Source: https://developer-docs.leverup.xyz/introduction/brokers.md
# Brokers & Referrals
A **broker** is a registered referral channel identified by a `uint24` id. If you are routing trades
on behalf of users — a frontend, a bot, a copy-trading service — a broker id lets you earn from the
flow you bring.
Two independent mechanisms hang off that id:
| | Fee share | `extraFee` |
| :--- | :--- | :--- |
| Where the money comes from | A slice of the protocol fee the trade already pays | An **extra** charge on top |
| Effect on the trader | None — the trader pays the same either way | Increases their cost |
| Amount | Fixed rate configured per broker | You choose it, per trade |
| Payout | Accrues onchain, withdraw when you want | Transferred at fill |
| Configured | By LeverUp, when your id is registered | The **amount** is not — you pass it per call. The **receiver** still comes from your registered record |
You can use either, both, or neither.
## Getting an id
Broker registration is permissioned — you cannot self-register. Contact LeverUp to be assigned an id
and to set the receiver address and commission rate. Once registered you can read your record back
onchain at any time (see [Reading your broker record](#reading-your-broker-record)).
## Fee share
Pass your id in the `broker` field and a slice of that trade's fee is credited to your broker
account:
```
commission = tradeFee × commissionP / 1e4
```
`commissionP` is set per broker by LeverUp — confirm your rate when your id is assigned. The split is
bounded so that commission plus the protocol's own shares never exceeds the fee itself, which is why
**this costs the trader nothing**. Passing a broker id does not change the fee they pay; it only
changes where part of it goes.
Commission accrues per token in protocol storage rather than being transferred on each trade.
::: warning `broker: 0` is not "no broker"
An id of `0`, or an id that has never been registered, falls back to the protocol's **default
broker** — it does not skip the referral. If you are not running a broker integration this is
harmless and `0` is the right value to pass. But if you *are*, passing `0` by accident silently
credits your flow to someone else.
This fallback covers the fee share only. [`extraFee`](#extrafee) has no fallback and behaves
differently on an unregistered id.
:::
### Open and close are credited separately
The broker on a position's opening trade does **not** carry over to its close. The close fee is
credited to whichever broker id is passed on the closing call — and to the id stored on a
[TP/SL order](/onchain/tpsl-orders) when a keeper executes one.
So a complete broker integration passes its id in **four** places:
1. The open (`OpenDataInput.broker`)
2. The close (`closeTrade(hash, broker)` / `batchCloseTrade(hashes, broker)` / partial close)
3. Each TP/SL order it creates (`DecreaseOrderInput.broker`)
4. Each TP/SL order it updates (`DecreaseOrderUpdateInput.broker`)
Miss one and that portion of the flow is credited to the default broker.
## `extraFee`
`extraFee` is a surcharge you add on top of the trade, transferred to your broker's receiver address
when the position opens.
The **amount** is not configured anywhere — the protocol stores no rate and enforces no cap, and it
is simply a number your client passes on each open. The **receiver** is not yours to choose: it is
the address on your registered broker record. **`extraFee` only reaches you if you hold a registered
id**, and unlike the fee share it does not fall back to the default broker when the id is unknown.
```solidity
// on the open — the surcharge is pulled and parked against the order
tokenIn.transferFrom(trader, extraFee); // charged in addition to amountIn
pendingExtraFees[tradeHash] = extraFee;
// at fill — read straight off the broker record, with no _getBrokerOrDefault
address receiver = brokers[broker].receiver;
lvToken.transfer(receiver, convertedExtraFee); // reverts when receiver is 0x0
```
| | |
| :--- | :--- |
| Denominated in | `tokenIn` when you pass it; converted to `lvToken` at payout |
| Available on | Market open and limit open only |
| Refunded | Yes — when the open is refunded through the normal path, a failed check, `extraFee` comes back with the collateral. A reverted payout is not that path; see below |
| Event | `ExtraFeeCharged(brokerId, token, amount, user, tradeHash)` |
### What each `broker` value does to `extraFee`
| `broker` | Outcome |
| :--- | :--- |
| Your registered id | The surcharge reaches your receiver. |
| `0`, or any other **registered** id | The open succeeds and the surcharge goes to that broker's receiver. The trader is charged and you receive nothing. |
| An **unregistered** id | `receiver` is the zero address and the transfer reverts. The position never opens. |
::: danger It fails late, and quietly
Nothing on the open path validates the broker id. The surcharge is pulled from the trader in the
submitting transaction and only paid out later, in the keeper's price callback — and that callback
is invoked inside `try … catch` in `LibPriceFacade`, so a revert there is swallowed.
With an unregistered id and `extraFee > 0`, what you observe is not an error. It is an open that
silently never fills, after the trader's `extraFee` has already left their wallet. Verify your id
with [`getBrokerById`](#reading-your-broker-record) before you send a non-zero `extraFee`.
:::
::: warning Allowance must cover it
The trader's approval to the Diamond has to cover `amountIn + extraFee` — and on the
[gasless path](/gasless/overview), `amountIn + extraFee + antiDdosFee` when the execution fee is
charged in the same token. A short allowance fails the transfer.
:::
Because `extraFee` is a real cost to the trader, disclose it in your interface. It is not part of
the protocol's quoted fees and will not appear in any fee preview you read from the contract.
## Where `broker` appears
Both integration paths carry it, on every operation that charges a fee.
| Operation | Onchain | Gasless action | `broker` | `extraFee` |
| :--- | :--- | :--- | :---: | :---: |
| Market open | `openMarketTradeV2` | `MARKET_OPEN` (0) | yes | yes |
| Limit open | `openLimitOrderV2` | `LIMIT_OPEN` (2) | yes | yes |
| Close | `closeTrade` | `MARKET_CLOSE` (1) | yes | — |
| Batch close | `batchCloseTrade` | `BATCH_MARKET_CLOSE` (8) | yes | — |
| Partial close | `closeTrade(hash, qty, broker)` | `PARTIAL_CLOSE` (9) | yes | — |
| Create TP/SL orders | `batchCreateDecreaseOrders` | `BATCH_CREATE_DECREASE_ORDERS` (11) | per order | — |
| Update TP/SL orders | `batchUpdateDecreaseOrders` | `BATCH_UPDATE_DECREASE_ORDERS` (12) | per order | — |
| Cancel order, change TP/SL, add/remove margin | | actions 3–7, 10, 13 | — | — |
The last row has no `broker` field because those operations charge no fee — there is nothing to
share.
## Reading your broker record
```solidity
function getBrokerById(uint24 id) external view returns (BrokerInfo memory);
function brokers(uint start, uint8 length) external view returns (BrokerInfo[] memory);
```
```solidity
struct BrokerInfo {
string name;
string url;
address receiver; // where commission and extraFee are paid
uint24 id;
uint16 commissionP; // 1e4 — your share of the trade fee
uint16 daoShareP; // 1e4
uint16 LpPoolP; // 1e4
CommissionInfo[] commissions;
}
struct CommissionInfo {
address token;
uint total; // lifetime
uint pending; // withdrawable now
}
```
```ts
import { publicClient, DIAMOND } from './config'
const BROKER_ABI = [{
type: 'function',
name: 'getBrokerById',
stateMutability: 'view',
inputs: [{ name: 'id', type: 'uint24' }],
outputs: [{
type: 'tuple', name: '', components: [
{ name: 'name', type: 'string' },
{ name: 'url', type: 'string' },
{ name: 'receiver', type: 'address' },
{ name: 'id', type: 'uint24' },
{ name: 'commissionP', type: 'uint16' },
{ name: 'daoShareP', type: 'uint16' },
{ name: 'LpPoolP', type: 'uint16' },
{ name: 'commissions', type: 'tuple[]', components: [
{ name: 'token', type: 'address' },
{ name: 'total', type: 'uint256' },
{ name: 'pending', type: 'uint256' },
]},
],
}],
}] as const
const broker = await publicClient.readContract({
address: DIAMOND,
abi: BROKER_ABI,
functionName: 'getBrokerById',
args: [myBrokerId],
})
console.log('rate ', Number(broker.commissionP) / 100, '%')
console.log('receiver ', broker.receiver)
for (const c of broker.commissions) {
console.log(` ${c.token} pending ${c.pending} lifetime ${c.total}`)
}
```
`commissions` amounts use each token's own decimals — see
[Precision & Units](/introduction/precision).
## Withdrawing commission
```solidity
function withdrawCommission(uint24 id) external;
```
Sweeps every token with a non-zero `pending` balance for that broker in one call.
```ts
const WITHDRAW_ABI = [{
type: 'function',
name: 'withdrawCommission',
stateMutability: 'nonpayable',
inputs: [{ name: 'id', type: 'uint24' }],
outputs: [],
}] as const
const hash = await walletClient.writeContract({
address: DIAMOND,
abi: WITHDRAW_ABI,
functionName: 'withdrawCommission',
args: [myBrokerId],
})
await publicClient.waitForTransactionReceipt({ hash })
```
The call is permissionless — anyone can trigger it — but funds always go to the broker's registered
`receiver`, so there is nothing to protect. You can have a keeper call it on a schedule without
holding any privileged key. Emits `WithdrawBrokerCommission(id, token, operator, amount)`.
To change the receiver address, contact LeverUp — it is an admin-controlled field.
---
Source: https://developer-docs.leverup.xyz/onchain/overview.md
# Onchain Integration
Call the LeverUp Diamond directly from your own account. You control the key, you pay gas, and
nothing sits between your code and the contracts.
If you want the protocol to relay and pay for your transactions instead, see
[Gasless Trading](/gasless/overview). The trade parameters are the same either way — this section is
worth reading first regardless of which path you take.
## Trading calls use one address
| | |
| :--- | :--- |
| Diamond | `0xea1b8E4aB7f14F7dCA68c5B214303B13078FC5ec` |
| Chain ID | `143` (Monad mainnet) |
Point every trading call at that address and supply only the ABI fragment for the function you
need. There is no separate address for limit orders, reads, or one-click trading. Standalone helper
contracts are called out explicitly; for example, minting LVMON uses the separate `LVMONMinter`.
For a single machine-readable ABI covering direct trading writes, current reads, lifecycle events,
and custom errors, see [Trading ABI](/onchain/trading-abi).
## What you can do
| Operation | Function | Oracle data | Page |
| :--- | :--- | :---: | :--- |
| Mint LVMON from MON or WMON | `LVMONMinter.mint` | no | [Minting LVMON](/onchain/minting-lvmon) |
| Open at market | `openMarketTradeV2` | yes | [Market Orders](/onchain/market-orders) |
| Open a limit order | `openLimitOrderV2` | yes | [Limit Orders](/onchain/limit-orders) |
| Cancel a limit order | `cancelLimitOrder`, `batchCancelLimitOrders` | no | [Limit Orders](/onchain/limit-orders#cancelling) |
| Change a limit order's TP/SL | `updateOrderTpAndSl` | no | [Limit Orders](/onchain/limit-orders#updating-tpsl) |
| Close fully | `closeTrade`, `batchCloseTrade` | no | [Closing](/onchain/closing) |
| Close partially | `closeTrade(hash, closeQty, broker)` | no | [Closing](/onchain/closing#partial-close) |
| Add margin | `addMargin` | no | [Managing a Position](/onchain/manage-position#add-margin) |
| Remove margin | `removeMargin` | yes | [Managing a Position](/onchain/manage-position#remove-margin) |
| Change a position's TP/SL | `updateTradeTpAndSl` | no | [Managing a Position](/onchain/manage-position#take-profit-and-stop-loss) |
| Create TP/SL orders | `batchCreateDecreaseOrders` | no | [TP/SL Orders](/onchain/tpsl-orders) |
| Update / cancel TP/SL orders | `batchUpdateDecreaseOrders`, `cancelDecreaseOrder` | no | [TP/SL Orders](/onchain/tpsl-orders) |
| Read positions, orders, markets | `getPositionsV4`, `getLimitOrders`, … | n/a | [Reading Data](/onchain/reading-data) |
"Oracle data: yes" means the call takes an `OracleUpdateData` argument and requires a native-token
fee as `msg.value`. See [Oracle updates](/introduction/concepts#oracle-updates).
## Two-phase execution
Opens and closes are requests, not immediate state changes:
```
your tx keeper tx
│ │
├─ openMarketTradeV2 ──────────►│
│ collateral locked │ oracle price delivered
│ MarketPendingTrade emitted ├─► position opens
│ │ OpenPosition / PositionIncreased emitted
```
A successful receipt on your own transaction confirms the request was accepted. Confirm the outcome
by watching [events](/onchain/events) or polling
[`getPositionsV4`](/onchain/reading-data#getpositionsv4).
Requests can also be **refunded** — if the filled price falls outside your acceptable price, the
market is closed, or a limit fails. A refund emits `PendingTradeRefund` with a reason code; see the
[Error Reference](/reference/errors#refund-reasons).
## Before your first write
1. **Approve the Diamond** for the collateral token you intend to use. Approve the Diamond address
itself.
2. **Look up the pair** with [`GET /v1/pairs`](/api/pairs#list-pairs) — do not hardcode `pairBase`.
3. **Pair `tokenIn` with the right `lvToken`** —
see [Collateral and lvToken](/introduction/concepts#collateral-and-lvtoken).
4. **Get the units right** — `qty` at 1e10, prices at 1e18.
See [Precision & Units](/introduction/precision).
5. **If you route trades for others**, decide what to pass in `broker` before you start — it appears
on opens, closes and TP/SL orders, and `0` is not a neutral value. See
[Brokers & Referrals](/introduction/brokers).
## Shared snippets
All examples on the following pages assume the `config.ts` from the
[Quickstart](/introduction/quickstart#2-shared-setup), plus this oracle helper:
```ts
import { API } from './config'
export async function fetchOracleUpdate(
pairBase: `0x${string}`,
collateral: `0x${string}`,
) {
const res = await fetch(`${API}/v1/oracle/price/updates/by-position`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pairBase,
collateral,
blockChain: 'MONAD',
options: { includeEncodingData: true, includeFee: true, includePrice: true },
}),
})
if (!res.ok) throw new Error(`oracle: HTTP ${res.status}`)
const data = await res.json()
return {
updateData: {
pythPriceUpdateData: data.pythPriceUpdateData ?? [],
pythProPriceUpdateData: data.pythProPriceUpdateData ?? [],
},
value: BigInt(data.updateFee ?? '0') + BigInt(data.verifition_fee ?? '0'),
}
}
```
::: tip Native MON collateral
When `tokenIn` is native MON (`0x0`), request oracle data using the **WMON** address as
`collateral`, and set the transaction `value` to `amountIn + oracleValue` instead of just
`oracleValue`.
:::
---
Source: https://developer-docs.leverup.xyz/onchain/trading-abi.md
# Trading ABI
Use this ABI for direct onchain trading against the LeverUp Diamond on Monad mainnet. It is a raw
JSON ABI array, so it can be passed directly to viem, ethers, web3.js, or another EVM client.
| | |
| :--- | :--- |
| Contract | LeverUp Diamond |
| Address | `0xea1b8E4aB7f14F7dCA68c5B214303B13078FC5ec` |
| Chain ID | `143` (Monad mainnet) |
| Format | JSON ABI array |
## Download
Download leverup-trading-abi.json
Stable URL:
```text
https://developer-docs.leverup.xyz/abi/leverup-trading-abi.json
```
The file contains the user-facing direct-trading functions, current read functions, lifecycle
events, and relevant custom errors. The same ABI is used with the Diamond address for every entry —
facet names only describe how the contract is organised internally.
The bundle intentionally excludes:
- admin functions and keeper-only settlement functions;
- legacy `V2` and `V3` read methods when a `V4` method is available;
- `_forOneclick` functions, which are protocol relay entrypoints rather than public integration
methods;
- ERC-20 approval and the standalone `LVMONMinter` ABI.
See [Authorizing an Agent](/gasless/authorization) for 1CT authorization calls and
[Minting LVMON](/onchain/minting-lvmon) for the separate minter contract.
::: tip Pin the ABI in production
The download URL always represents the current documented deployment. Vendor the JSON into your
application and review the [changelog](/reference/changelog) before updating it; this keeps a docs
release from changing your runtime unexpectedly.
:::
## Load the ABI
```ts
import type { Abi } from 'viem'
const ABI_URL = 'https://developer-docs.leverup.xyz/abi/leverup-trading-abi.json'
const response = await fetch(ABI_URL)
if (!response.ok) throw new Error(`ABI download failed: HTTP ${response.status}`)
export const tradingAbi = await response.json() as Abi
```
Or download and vendor it during your build:
```bash
curl -L https://developer-docs.leverup.xyz/abi/leverup-trading-abi.json \
--output leverup-trading-abi.json
```
```ts
import tradingAbi from './leverup-trading-abi.json'
import { getContract } from 'viem'
import { publicClient, walletClient, account, DIAMOND } from './config'
const trading = getContract({
address: DIAMOND,
abi: tradingAbi,
client: { public: publicClient, wallet: walletClient },
})
const positions = await trading.read.getPositionsV4([account.address, pairBase])
```
## Write functions
### Open orders
| Function | Payable | Description |
| :--- | :---: | :--- |
| `openMarketTradeV2(OpenDataInput, OracleUpdateData, uint96)` | yes | Request a market open. Returns the pending trade hash. |
| `openLimitOrderV2(OpenDataInput, OracleUpdateData, uint96)` | yes | Place a limit order. Returns the order hash. |
Both calls require an oracle update and may require native-token `msg.value`. The last argument is
the optional broker `extraFee`, not the oracle fee. See [Market Orders](/onchain/market-orders) and
[Limit Orders](/onchain/limit-orders) for executable examples.
### Change or cancel a limit order
| Function | Description |
| :--- | :--- |
| `updateOrderTp(bytes32,uint128)` | Change the order's take-profit price. |
| `updateOrderSl(bytes32,uint128)` | Change or clear the order's stop-loss price. |
| `updateOrderTpAndSl(bytes32,uint128,uint128)` | Change TP and SL atomically. |
| `cancelLimitOrder(bytes32)` | Cancel one unfilled limit order. |
| `batchCancelLimitOrders(bytes32[])` | Cancel several unfilled limit orders. |
These calls are not payable. Prices use 1e18 precision; see
[Precision & Units](/introduction/precision).
### Close a position
| Function signature | Description |
| :--- | :--- |
| `closeTrade(bytes32)` | Fully close using the position's existing broker. |
| `closeTrade(bytes32,uint24)` | Fully close and specify the close-fee broker. |
| `closeTrade(bytes32,uint128,uint24)` | Partially close `closeQty` and specify the broker. |
| `batchCloseTrade(bytes32[])` | Fully close several positions using their existing brokers. |
| `batchCloseTrade(bytes32[],uint24)` | Fully close several positions with one broker ID. |
The ABI includes all overloads. With ethers, use the full signature when the overload is ambiguous,
for example `contract['closeTrade(bytes32,uint128,uint24)'](...)`. See
[Closing & Partial Close](/onchain/closing) for lifecycle details.
### Manage a position
| Function | Payable | Description |
| :--- | :---: | :--- |
| `addMargin(bytes32,address,uint96)` | yes | Add collateral to an open position. |
| `removeMargin(bytes32,uint96,OracleUpdateData)` | yes | Remove margin after an oracle-backed risk check. |
| `updateTradeTp(bytes32,uint128)` | no | Change the embedded full-position take profit. |
| `updateTradeSl(bytes32,uint128)` | no | Change or clear the embedded full-position stop loss. |
| `updateTradeTpAndSl(bytes32,uint128,uint128)` | no | Change embedded TP and SL atomically. |
`addMargin` is payable only when adding native MON; `removeMargin` uses `msg.value` for the oracle
update fee. See [Managing a Position](/onchain/manage-position).
### TP/SL decrease orders
| Function | Description |
| :--- | :--- |
| `createDecreaseOrder(bytes32,DecreaseOrderInput)` | Create one standalone TP or SL order. |
| `batchCreateDecreaseOrders(bytes32,DecreaseOrderInput[])` | Create multiple orders atomically. |
| `batchUpdateDecreaseOrders(DecreaseOrderUpdateInput[])` | Update multiple active orders. |
| `cancelDecreaseOrder(bytes32)` | Cancel one order. |
| `cancelAllDecreaseOrders(bytes32)` | Cancel every order attached to a position. |
These calls are not payable. `closeQty` uses 1e10 precision. See
[TP/SL Orders](/onchain/tpsl-orders) for order constraints and tuple definitions.
## Read functions
Reads use the same Diamond address and do not require a wallet.
| Area | Functions | Description |
| :--- | :--- | :--- |
| Positions | `getPositionsV4`, `getPositionByHashV4`, `getPositionByKeyV4` | Return current position snapshots. |
| Position identity | `getPositionHash`, `getPositionTrader` | Compute or resolve a merged position hash. |
| Pending opens | `getPendingTrade` | Return a pending market-open request. |
| Limit orders | `getLimitOrders`, `getLimitOrderByHash` | Return active limit orders. |
| TP/SL orders | `getDecreaseOrders`, `getTraderDecreaseOrders`, `getDecreaseOrder` | Return active standalone decrease orders. |
| Market state | `getMarketInfoV2`, `getMarketInfosV2` | Return open interest and funding state. |
| Pair configuration | `getPairByBaseV4`, `getPairConfig`, `getPairFeeConfig`, `getPairHoldingFeeRate`, `getPairSlippageConfig` | Return trading limits, fees, holding rates, and slippage settings. |
| Locked assets | `traderAssets` | Return protocol-locked totals by token and purpose. |
Return tuple layouts and examples are documented in [Reading Data](/onchain/reading-data).
## Shared ABI types
The JSON file includes the complete tuple components. These are the main types you will encounter:
| Type | Used by | Notes |
| :--- | :--- | :--- |
| `OpenDataInput` | Market and limit opens | Pair, direction, collateral, quantity, price bounds, TP/SL, and broker. |
| `OracleUpdateData` | Opens and margin removal | Pyth and Pyth Pro update byte arrays. |
| `PositionV4` | Position reads | Current merged-position state and accrued fee fields. |
| `LimitOrderView` | Limit-order reads | Active order state plus its `orderHash`. |
| `DecreaseOrderInput` | TP/SL order creation | Kind, trigger price, close quantity, and broker. |
| `DecreaseOrderUpdateInput` | TP/SL order updates | Order hash plus replacement values. |
| `OpenTradeEventV2` | Settlement events | Full position snapshot after an open, increase, or decrease. |
| `CloseInfo` | Settlement events | Fill price, funding fee, close fee, PnL, and holding fee. |
Solidity enums appear in JSON ABI as their integer types. In particular,
`DecreaseOrderKind` is `uint8`: `0` is TP and `1` is SL.
## Events and errors
The ABI contains request and settlement events, including `MarketPendingTrade`, `OpenPosition`,
`PositionIncreased`, `PendingTradeRefund`, `OpenLimitOrder`, `CloseTradeRequested`, `ClosePosition`,
`PositionDecreased`, and the decrease-order events. Use settlement events—not only the transaction
receipt—to determine whether a two-phase open or close actually completed. See [Events](/onchain/events).
Relevant custom error entries are included so clients can decode revert data with the same ABI.
Some protocol failures still use Solidity's standard `Error(string)` or `Panic(uint256)` formats;
handle those as a fallback. See [Error Reference](/reference/errors).
---
Source: https://developer-docs.leverup.xyz/onchain/minting-lvmon.md
# Minting LVMON
`LVMONMinter` converts native MON or WMON into LVMON. The current mainnet configuration mints
LVMON **1:1** with the input amount; all three assets use 18 decimals.
This is a standalone helper contract, not a Diamond facet. Calls on this page go to the
`LVMONMinter` address.
| Contract | Address |
| :--- | :--- |
| LVMONMinter | `0x6FbEa6986F38aA85D09a8e9d8E5c71499ef70909` |
| WMON | `0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A` |
| LVMON | `0x91b81bfbe3A747230F0529Aa28d8b2Bc898E6D56` |
## Access requirement
Only whitelisted callers can mint. Check the exact address that will call `mint` before asking the
user to approve or send funds:
```solidity
function whitelist(address account) external view returns (bool);
```
The check uses `msg.sender`. If a smart account or another contract submits the call, that contract
must be whitelisted — whitelisting the user's EOA is not enough. Only the `LVMONMinter` owner can
change this list; there is no public self-registration transaction.
## ABI and setup
The examples use the clients and `account` from the
[Quickstart config](/introduction/quickstart#2-shared-setup).
```ts
import { parseAbi } from 'viem'
export const LVMON_MINTER = '0x6FbEa6986F38aA85D09a8e9d8E5c71499ef70909' as const
export const WMON = '0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A' as const
export const LVMON_MINTER_ABI = parseAbi([
'function whitelist(address account) view returns (bool)',
'function mint(uint256 amountIn) payable returns (uint256 lvmonOut)',
'function issuer() view returns (address)',
'function wmon() view returns (address)',
'function lvmon() view returns (address)',
'event Minted(address indexed user, uint256 monIn, uint256 lvmonOut)',
])
```
Check access once at the start of the flow:
```ts
import { account, publicClient } from './config'
import { LVMON_MINTER, LVMON_MINTER_ABI } from './lvmon-minter'
const allowed = await publicClient.readContract({
address: LVMON_MINTER,
abi: LVMON_MINTER_ABI,
functionName: 'whitelist',
args: [account.address],
})
if (!allowed) throw new Error('This caller is not authorized to mint LVMON')
```
## Mint with native MON
Pass the same amount to the `amountIn` argument and transaction `value`. The contract wraps the MON
to WMON, mints LVMON, and sends the LVMON back to `msg.sender` in one transaction.
```ts
import { formatEther, parseEther, parseEventLogs } from 'viem'
import { account, publicClient, walletClient } from './config'
import { LVMON_MINTER, LVMON_MINTER_ABI } from './lvmon-minter'
const amountIn = parseEther('1')
const { request, result: expectedOut } = await publicClient.simulateContract({
account,
address: LVMON_MINTER,
abi: LVMON_MINTER_ABI,
functionName: 'mint',
args: [amountIn],
value: amountIn,
})
console.log('Expected LVMON:', formatEther(expectedOut))
const hash = await walletClient.writeContract(request)
const receipt = await publicClient.waitForTransactionReceipt({ hash })
const [minted] = parseEventLogs({
abi: LVMON_MINTER_ABI,
eventName: 'Minted',
logs: receipt.logs,
})
if (!minted) throw new Error('Minted event not found')
console.log('Minted LVMON:', formatEther(minted.args.lvmonOut))
```
`mint` has no `minLvmonOut` parameter. Simulate immediately before submitting and use the returned
value or `Minted.lvmonOut` as the authoritative output instead of assuming the conversion rate in
accounting code.
## Mint with WMON
For WMON, first approve the `LVMONMinter`, then call `mint` without transaction `value`.
```ts
import { erc20Abi, parseEther } from 'viem'
import { account, publicClient, walletClient } from './config'
import { LVMON_MINTER, LVMON_MINTER_ABI, WMON } from './lvmon-minter'
const amountIn = parseEther('1')
const approvalHash = await walletClient.writeContract({
account,
address: WMON,
abi: erc20Abi,
functionName: 'approve',
args: [LVMON_MINTER, amountIn],
})
await publicClient.waitForTransactionReceipt({ hash: approvalHash })
const { request } = await publicClient.simulateContract({
account,
address: LVMON_MINTER,
abi: LVMON_MINTER_ABI,
functionName: 'mint',
args: [amountIn],
// No value: sending any native MON selects the native-MON path.
})
const hash = await walletClient.writeContract(request)
await publicClient.waitForTransactionReceipt({ hash })
```
Approve only the amount you intend to mint. A successful call consumes that WMON allowance and
delivers the resulting LVMON to the caller.
## Function reference
```solidity
function mint(uint256 amountIn) external payable returns (uint256 lvmonOut);
```
| Input mode | `amountIn` | `msg.value` | Additional requirement |
| :--- | ---: | ---: | :--- |
| Native MON | MON amount, 18 decimals | Must equal `amountIn` | Caller needs enough MON for the input and gas |
| WMON | WMON amount, 18 decimals | `0` | Approve the minter to spend at least `amountIn` WMON |
The mode is selected solely by whether `msg.value` is greater than zero. Do not send native MON on
a WMON mint call.
Read-only configuration:
| Function | Returns |
| :--- | :--- |
| `whitelist(account)` | Whether `account` may call `mint` |
| `issuer()` | The LVMON issuer used by the minter |
| `wmon()` | The configured WMON token |
| `lvmon()` | The LVMON token returned to the caller |
| `owner()` | Address allowed to update the caller whitelist |
`setWhitelist(address account, bool allowed)` also exists, but is owner-only. Integrations should
use `whitelist(account)` to check access rather than attempting the administrative call.
## Events
```solidity
event Minted(address indexed user, uint256 monIn, uint256 lvmonOut);
event WhitelistUpdated(address indexed account, bool allowed);
```
`Minted.monIn` is the input amount for either mode: native MON or WMON. `Minted.user` is the caller
and recipient of the LVMON.
## Common reverts
| Revert | Cause |
| :--- | :--- |
| `lvmon-minter: caller not whitelisted` | `msg.sender` is not on the minter's caller whitelist |
| `lvmon-minter: amount mismatch` | Native `msg.value` does not equal `amountIn` |
| `lvmon-minter: amount=0` | WMON mode was called with a zero amount |
| Token transfer failure | WMON balance or allowance is below `amountIn` |
| `mint not enabled` | Protocol-level LVMON minting is paused |
| `token reserve disabled` | The WMON reserve is disabled at the issuer |
All wrapping, reserve deposit, minting, and transfer steps are atomic. If any step reverts, the
caller keeps the original MON or WMON. A WMON approval submitted as an earlier transaction remains
in place until it is used or replaced.
---
Source: https://developer-docs.leverup.xyz/onchain/market-orders.md
# Market Orders
Open a position at the current oracle price.
## `openMarketTradeV2`
```solidity
function openMarketTradeV2(
OpenDataInput memory data,
OracleUpdateData calldata updateData,
uint96 extraFee
) external payable returns (bytes32 tradeHash);
```
Returns the **pending trade hash**, not the position hash. The position hash is deterministic and
separate — see [Positions are merged slots](/introduction/concepts#positions-are-merged-slots).
### `OpenDataInput`
```solidity
struct OpenDataInput {
address pairBase; // market identifier
bool isLong;
address tokenIn; // collateral you deposit
address lvToken; // settlement token, see below
uint96 amountIn; // tokenIn decimals
uint128 qty; // base asset amount, 1e10
uint128 price; // worst acceptable price, 1e18
uint128 stopLoss; // 1e18, 0 to disable
uint128 takeProfit; // 1e18, 0 to disable
uint24 broker; // referral id, 0 for the default broker
}
```
| Field | Notes |
| :--- | :--- |
| `pairBase` | From [`GET /v1/pairs`](/api/pairs#list-pairs). Do not hardcode. |
| `tokenIn` / `lvToken` | Must be a valid pair — [see the table](/introduction/concepts#collateral-and-lvtoken). |
| `amountIn` | Margin **plus** the open fee. Under-funding reverts. |
| `qty` | Base asset, 1e10. `$100 of BTC at $100k` → `10000000`. |
| `price` | Slippage bound, **not** a limit price. Long: maximum acceptable. Short: minimum acceptable. |
| `stopLoss` / `takeProfit` | Optional, set at open. Can be changed later, or replaced by [TP/SL orders](/onchain/tpsl-orders). |
| `broker` | Referral channel for this trade's fee. `0` credits the default broker, not "no broker" — see [Brokers & Referrals](/introduction/brokers). |
### `updateData` and `extraFee`
`updateData` is the oracle payload from
[`POST /v1/oracle/price/updates/by-position`](/api/oracle#price-updates-by-position). Attach
`updateFee + verifition_fee` as the transaction `value`.
`extraFee` is an optional surcharge you add on top of the trade, denominated in `tokenIn` and paid to
your broker's receiver when the position fills. Pass `0` unless you operate a broker integration.
When non-zero:
- It is transferred from the trader **in addition to** `amountIn`, so the allowance must cover
`amountIn + extraFee`.
- It goes to the receiver of whatever `broker` id you passed — pairing `extraFee > 0` with
`broker: 0` pays the default broker, not you.
- It is returned along with the collateral if the open is refunded.
See [Brokers & Referrals](/introduction/brokers#extrafee).
## Example
```ts
import { parseUnits } from 'viem'
import { publicClient, walletClient, account, DIAMOND, USDC, LVUSD } from './config'
import { fetchOracleUpdate } from './oracle'
const OPEN_MARKET_ABI = [{
type: 'function',
name: 'openMarketTradeV2',
stateMutability: 'payable',
inputs: [
{
name: 'data', type: 'tuple', components: [
{ name: 'pairBase', type: 'address' },
{ name: 'isLong', type: 'bool' },
{ name: 'tokenIn', type: 'address' },
{ name: 'lvToken', type: 'address' },
{ name: 'amountIn', type: 'uint96' },
{ name: 'qty', type: 'uint128' },
{ name: 'price', type: 'uint128' },
{ name: 'stopLoss', type: 'uint128' },
{ name: 'takeProfit', type: 'uint128' },
{ name: 'broker', type: 'uint24' },
],
},
{
name: 'updateData', type: 'tuple', components: [
{ name: 'pythPriceUpdateData', type: 'bytes[]' },
{ name: 'pythProPriceUpdateData', type: 'bytes[]' },
],
},
{ name: 'extraFee', type: 'uint96' },
],
outputs: [{ name: 'tradeHash', type: 'bytes32' }],
}] as const
export async function openLong(
pairBase: `0x${string}`,
notionalUsd: number,
marginUsdc: string,
markPrice: number,
slippagePct = 1,
) {
const oracle = await fetchOracleUpdate(pairBase, USDC)
const qty = parseUnits((notionalUsd / markPrice).toFixed(10), 10)
const worstPrice = parseUnits(
(markPrice * (1 + slippagePct / 100)).toFixed(18),
18,
)
const hash = await walletClient.writeContract({
address: DIAMOND,
abi: OPEN_MARKET_ABI,
functionName: 'openMarketTradeV2',
args: [
{
pairBase,
isLong: true,
tokenIn: USDC,
lvToken: LVUSD,
amountIn: parseUnits(marginUsdc, 6),
qty,
price: worstPrice,
stopLoss: 0n,
takeProfit: 0n,
broker: 0,
},
oracle.updateData,
0n,
],
value: oracle.value,
})
return publicClient.waitForTransactionReceipt({ hash })
}
```
For a short, set `isLong: false` and invert the slippage bound:
```ts
const worstPrice = parseUnits((markPrice * (1 - slippagePct / 100)).toFixed(18), 18)
```
## Using native MON as collateral
```ts
const oracle = await fetchOracleUpdate(pairBase, WMON) // quote against WMON
const amountIn = parseUnits('1', 18)
await walletClient.writeContract({
address: DIAMOND,
abi: OPEN_MARKET_ABI,
functionName: 'openMarketTradeV2',
args: [
{ /* ... */ tokenIn: '0x0000000000000000000000000000000000000000', lvToken: LVMON, amountIn, /* ... */ },
oracle.updateData,
0n,
],
value: amountIn + oracle.value, // collateral travels as msg.value
})
```
No approval is needed for native MON.
## After sending
The transaction emits `MarketPendingTrade` and locks your collateral. A keeper then fills it:
- **Filled** → `OpenPosition` (new slot) or `PositionIncreased` (existing slot).
- **Refunded** → `PendingTradeRefund` with a reason code.
Poll [`getPositionsV4`](/onchain/reading-data#getpositionsv4) or subscribe to
[events](/onchain/events).
## Common reverts
| Error | Cause |
| :--- | :--- |
| `MarketClosed` / `PairClosed` | Outside trading hours, or the pair is disabled |
| `UnsupportedMarginToken` | `tokenIn` / `lvToken` mismatch |
| `InvalidAmount` | `amountIn` below the minimum, or below the open fee |
| `InsufficientLiquidityPool` | Pool cannot back the position size |
| `BelowDegenModeMinLeverage` | Resulting leverage below the pair's minimum |
Full list: [Error Reference](/reference/errors).
---
Source: https://developer-docs.leverup.xyz/onchain/limit-orders.md
# Limit Orders
Place an order that opens a position when the market reaches your price. Collateral is locked when
the order is placed, and the order is executed by a keeper when the trigger condition is met.
## `openLimitOrderV2`
```solidity
function openLimitOrderV2(
OpenDataInput memory data,
OracleUpdateData calldata updateData,
uint96 extraFee
) external payable returns (bytes32 orderHash);
```
Same [`OpenDataInput`](/onchain/market-orders#opendatainput) struct as a market order, with one
difference:
| Field | Market order | Limit order |
| :--- | :--- | :--- |
| `price` | Worst acceptable fill price | **The limit price** |
Everything else — `qty` at 1e10, `tokenIn`/`lvToken` pairing, `amountIn` covering margin plus fee —
behaves identically. `broker` and `extraFee` work exactly as they do for market orders; see
[Brokers & Referrals](/introduction/brokers).
Oracle data is required at placement time even though the order does not fill immediately; it is used
to validate the order against the current market.
## Example
```ts
import { parseUnits } from 'viem'
import { publicClient, walletClient, DIAMOND, USDC, LVUSD } from './config'
import { fetchOracleUpdate } from './oracle'
const OPEN_LIMIT_ABI = [{
type: 'function',
name: 'openLimitOrderV2',
stateMutability: 'payable',
inputs: [
{
name: 'data', type: 'tuple', components: [
{ name: 'pairBase', type: 'address' },
{ name: 'isLong', type: 'bool' },
{ name: 'tokenIn', type: 'address' },
{ name: 'lvToken', type: 'address' },
{ name: 'amountIn', type: 'uint96' },
{ name: 'qty', type: 'uint128' },
{ name: 'price', type: 'uint128' },
{ name: 'stopLoss', type: 'uint128' },
{ name: 'takeProfit', type: 'uint128' },
{ name: 'broker', type: 'uint24' },
],
},
{
name: 'updateData', type: 'tuple', components: [
{ name: 'pythPriceUpdateData', type: 'bytes[]' },
{ name: 'pythProPriceUpdateData', type: 'bytes[]' },
],
},
{ name: 'extraFee', type: 'uint96' },
],
outputs: [{ name: 'orderHash', type: 'bytes32' }],
}] as const
// Buy $100 of BTC if it drops to $95,000, with a stop at $90,000.
const limitPrice = 95_000
const oracle = await fetchOracleUpdate(pairBase, USDC)
const hash = await walletClient.writeContract({
address: DIAMOND,
abi: OPEN_LIMIT_ABI,
functionName: 'openLimitOrderV2',
args: [
{
pairBase,
isLong: true,
tokenIn: USDC,
lvToken: LVUSD,
amountIn: parseUnits('10', 6),
qty: parseUnits((100 / limitPrice).toFixed(10), 10),
price: parseUnits(String(limitPrice), 18),
stopLoss: parseUnits('90000', 18),
takeProfit: 0n,
broker: 0,
},
oracle.updateData,
0n,
],
value: oracle.value,
})
await publicClient.waitForTransactionReceipt({ hash })
```
The `orderHash` is emitted in the `OpenLimitOrder` event and returned by
[`getLimitOrders`](/onchain/reading-data#getlimitorders).
## Updating TP/SL
Adjust the take-profit and stop-loss that will be applied when the order fills. No oracle data
needed.
```solidity
function updateOrderTp(bytes32 orderHash, uint128 takeProfit) external;
function updateOrderSl(bytes32 orderHash, uint128 stopLoss) external;
function updateOrderTpAndSl(bytes32 orderHash, uint128 takeProfit, uint128 stopLoss) external;
```
```ts
const UPDATE_ORDER_ABI = [{
type: 'function',
name: 'updateOrderTpAndSl',
stateMutability: 'nonpayable',
inputs: [
{ name: 'orderHash', type: 'bytes32' },
{ name: 'takeProfit', type: 'uint128' },
{ name: 'stopLoss', type: 'uint128' },
],
outputs: [],
}] as const
await walletClient.writeContract({
address: DIAMOND,
abi: UPDATE_ORDER_ABI,
functionName: 'updateOrderTpAndSl',
args: [orderHash, parseUnits('120000', 18), parseUnits('90000', 18)],
})
```
Pass `0` to clear a stop-loss.
## Cancelling
```solidity
function cancelLimitOrder(bytes32 orderHash) external;
function batchCancelLimitOrders(bytes32[] calldata orderHashes) external;
```
Cancelling returns the locked collateral. No oracle data needed.
```ts
const CANCEL_ABI = [
{
type: 'function',
name: 'cancelLimitOrder',
stateMutability: 'nonpayable',
inputs: [{ name: 'orderHash', type: 'bytes32' }],
outputs: [],
},
{
type: 'function',
name: 'batchCancelLimitOrders',
stateMutability: 'nonpayable',
inputs: [{ name: 'orderHashs', type: 'bytes32[]' }],
outputs: [],
},
] as const
await walletClient.writeContract({
address: DIAMOND,
abi: CANCEL_ABI,
functionName: 'batchCancelLimitOrders',
args: [[orderHashA, orderHashB]],
})
```
## Order lifecycle
| Event | Meaning |
| :--- | :--- |
| `OpenLimitOrder` | Order placed, collateral locked |
| `ExecuteLimitOrderSuccessful` | Trigger hit, position opened |
| `ExecuteLimitOrderRejected` | Trigger hit but the open failed validation; order stays or is refunded |
| `LimitOrderRefund` | Collateral returned |
| `CancelLimitOrder` | Cancelled by the trader |
| `UpdateOrderTp` / `UpdateOrderSl` | TP/SL changed |
See [Events](/onchain/events#limit-orders) for signatures, and
[Reading Data](/onchain/reading-data#getlimitorders) for querying live orders.
---
Source: https://developer-docs.leverup.xyz/onchain/closing.md
# Closing & Partial Close
Closing does not require oracle data — the keeper supplies the settlement price. All close functions
are on the Diamond.
## Full close
```solidity
function closeTrade(bytes32 positionHash) external;
function closeTrade(bytes32 positionHash, uint24 broker) external;
```
The two-argument form credits the close fee to a broker id. Use `0` or the single-argument form if
you do not operate a broker integration.
::: warning The close broker is independent of the open broker
The broker recorded when the position was opened does **not** carry over. Whatever id you pass here
receives the close-fee share — and the single-argument form credits the default broker. A broker
integration must pass its id on the close as well as the open. See
[Brokers & Referrals](/introduction/brokers#open-and-close-are-credited-separately).
:::
```ts
import { publicClient, walletClient, DIAMOND } from './config'
const CLOSE_ABI = [{
type: 'function',
name: 'closeTrade',
stateMutability: 'nonpayable',
inputs: [{ name: 'positionHash', type: 'bytes32' }],
outputs: [],
}] as const
const hash = await walletClient.writeContract({
address: DIAMOND,
abi: CLOSE_ABI,
functionName: 'closeTrade',
args: [positionHash],
})
await publicClient.waitForTransactionReceipt({ hash })
```
::: warning
Solidity overloads share a name. If you put every `closeTrade` variant into one ABI array, `viem`
cannot tell them apart — pass only the fragment you intend to call, or disambiguate explicitly.
:::
A full close also cancels every [TP/SL order](/onchain/tpsl-orders) attached to that position, each
emitting `DecreaseOrderCancelled`.
## Partial close
```solidity
function closeTrade(bytes32 positionHash, uint128 closeQty, uint24 broker) external;
```
Closes `closeQty` of the position and leaves the rest open. The remainder keeps its entry price, and
accrued funding and holding fees are split proportionally.
```ts
const PARTIAL_CLOSE_ABI = [{
type: 'function',
name: 'closeTrade',
stateMutability: 'nonpayable',
inputs: [
{ name: 'positionHash', type: 'bytes32' },
{ name: 'closeQty', type: 'uint128' },
{ name: 'broker', type: 'uint24' },
],
outputs: [],
}] as const
// Close half of the position.
await walletClient.writeContract({
address: DIAMOND,
abi: PARTIAL_CLOSE_ABI,
functionName: 'closeTrade',
args: [positionHash, position.qty / 2n, 0],
})
```
| `closeQty` | Result |
| :--- | :--- |
| `0` | Full close |
| `>= position.qty` | Full close |
| Between | Partial close, remainder stays open |
`closeQty` uses the same 1e10 scale as `qty` — see [Precision & Units](/introduction/precision).
::: info Merged positions only
Partial close requires a position with a deterministic key. Legacy positions created before the
merged-position upgrade are full-close only, and a partial request against one reverts. See
[Positions are merged slots](/introduction/concepts#positions-are-merged-slots).
:::
## Batch close
```solidity
function batchCloseTrade(bytes32[] calldata positionHashes) external;
function batchCloseTrade(bytes32[] calldata positionHashes, uint24 broker) external;
```
```ts
const BATCH_CLOSE_ABI = [{
type: 'function',
name: 'batchCloseTrade',
stateMutability: 'nonpayable',
inputs: [{ name: 'positionHashes', type: 'bytes32[]' }],
outputs: [],
}] as const
await walletClient.writeContract({
address: DIAMOND,
abi: BATCH_CLOSE_ABI,
functionName: 'batchCloseTrade',
args: [positions.map((p) => p.positionHash)],
})
```
## Finding the position hash
Three ways, in order of convenience:
```ts
// 1. Compute it — no RPC call needed.
import { keccak256, encodeAbiParameters } from 'viem'
const positionHash = keccak256(
encodeAbiParameters(
[{ type: 'address' }, { type: 'address' }, { type: 'bool' }, { type: 'address' }, { type: 'string' }],
[user, pairBase, isLong, lvToken, 'position.v1'],
),
)
// 2. Read it from the contract — getPositionHash(user, pairBase, isLong, lvToken)
// 3. Read open positions — getPositionsV4(user, pairBase)
```
See [Reading Data](/onchain/reading-data#getpositionhash).
## After sending
Closing is a request. The keeper settles it and emits:
| Event | Meaning |
| :--- | :--- |
| `CloseTradeRequested` | Your transaction was accepted |
| `ClosePosition` | Position fully closed and settled |
| `PositionDecreased` | Partial close settled, remainder still open |
| `ExecuteCloseRejected` | Close could not be settled |
`ClosePosition` and `PositionDecreased` carry a `CloseInfo` with the realized numbers:
```solidity
struct CloseInfo {
uint128 closePrice; // 1e18
int96 fundingFee; // lvToken decimals, signed
uint96 closeFee; // lvToken decimals
int96 pnl; // lvToken decimals, signed
uint96 holdingFee; // lvToken decimals
}
```
## Common reverts
| Error | Cause |
| :--- | :--- |
| `NonexistentTrade` | Position already closed, or wrong hash |
| `PositionNotCloseable` | Minimum holding period not elapsed — check `earliestCloseTime` |
| `MarketClosed` | Market outside trading hours |
| `CoolingOffPeriod` | Action temporarily unavailable |
Full list: [Error Reference](/reference/errors).
---
Source: https://developer-docs.leverup.xyz/onchain/manage-position.md
# Managing a Position
Adjust an open position without closing it: change leverage by moving margin, or move the
take-profit and stop-loss.
## Add margin
Deposit more collateral into an open position, lowering its leverage and moving the liquidation
price away.
```solidity
function addMargin(bytes32 tradeHash, address tokenIn, uint96 amount) external payable;
```
No oracle data required.
```ts
import { parseUnits } from 'viem'
import { publicClient, walletClient, DIAMOND, USDC } from './config'
const ADD_MARGIN_ABI = [{
type: 'function',
name: 'addMargin',
stateMutability: 'payable',
inputs: [
{ name: 'tradeHash', type: 'bytes32' },
{ name: 'tokenIn', type: 'address' },
{ name: 'amount', type: 'uint96' },
],
outputs: [],
}] as const
const hash = await walletClient.writeContract({
address: DIAMOND,
abi: ADD_MARGIN_ABI,
functionName: 'addMargin',
args: [positionHash, USDC, parseUnits('5', 6)],
})
await publicClient.waitForTransactionReceipt({ hash })
```
`tokenIn` must be compatible with the position's `lvToken` — you cannot add MON to a position
settling in LVUSD. For native MON, pass the zero address and send the amount as `value`:
```ts
await walletClient.writeContract({
address: DIAMOND,
abi: ADD_MARGIN_ABI,
functionName: 'addMargin',
args: [positionHash, '0x0000000000000000000000000000000000000000', amount],
value: amount,
})
```
## Remove margin
Withdraw collateral from an open position, raising its leverage.
```solidity
function removeMargin(
bytes32 tradeHash,
uint96 lvAmount,
OracleUpdateData calldata updateData
) external payable;
```
Unlike `addMargin`, this **requires oracle data** — the protocol must value the position before
letting collateral out. `msg.value` must cover the oracle update fee.
Note that `lvAmount` is denominated in the position's **`lvToken`** (18 decimals for both LVUSD and
LVMON), not in `tokenIn`. A position funded with USDC still reports and withdraws margin at 18
decimals.
```ts
import { parseUnits } from 'viem'
import { fetchOracleUpdate } from './oracle'
const REMOVE_MARGIN_ABI = [{
type: 'function',
name: 'removeMargin',
stateMutability: 'payable',
inputs: [
{ name: 'tradeHash', type: 'bytes32' },
{ name: 'lvAmount', type: 'uint96' },
{
name: 'updateData', type: 'tuple', components: [
{ name: 'pythPriceUpdateData', type: 'bytes[]' },
{ name: 'pythProPriceUpdateData', type: 'bytes[]' },
],
},
],
outputs: [],
}] as const
const oracle = await fetchOracleUpdate(pairBase, USDC)
await walletClient.writeContract({
address: DIAMOND,
abi: REMOVE_MARGIN_ABI,
functionName: 'removeMargin',
args: [positionHash, parseUnits('2', 18), oracle.updateData], // 2 LVUSD
value: oracle.value,
})
```
The protocol rejects a removal that would leave the position in a bad state:
| Error | Meaning |
| :--- | :--- |
| `ExceedMaxLeverageAfterMarginRemoval` | Resulting leverage above the pair's maximum |
| `LiquidatableAfterMarginRemoval` | Position would be immediately liquidatable |
| `ExceedMaxTakeProfitAfterMarginRemoval` | Existing take-profit would exceed its allowed bound at the new leverage |
| `InsufficientMarginAmount` | `lvAmount` exceeds free margin |
Both operations emit `UpdateMargin(user, tradeHash, beforeMargin, margin)`.
## Take-profit and stop-loss
```solidity
function updateTradeTp(bytes32 tradeHash, uint128 takeProfit) external;
function updateTradeSl(bytes32 tradeHash, uint128 stopLoss) external;
function updateTradeTpAndSl(bytes32 tradeHash, uint128 takeProfit, uint128 stopLoss) external;
```
Prices are 1e18. Passing `0` for `stopLoss` removes it. No oracle data required — these only write
stored trigger values.
```ts
const UPDATE_TPSL_ABI = [{
type: 'function',
name: 'updateTradeTpAndSl',
stateMutability: 'nonpayable',
inputs: [
{ name: 'tradeHash', type: 'bytes32' },
{ name: 'takeProfit', type: 'uint128' },
{ name: 'stopLoss', type: 'uint128' },
],
outputs: [],
}] as const
await walletClient.writeContract({
address: DIAMOND,
abi: UPDATE_TPSL_ABI,
functionName: 'updateTradeTpAndSl',
args: [positionHash, parseUnits('120000', 18), parseUnits('90000', 18)],
})
```
Take-profit is bounded by the position's leverage — an unreachably high target is clamped or
rejected. Stop-loss must be on the loss side of the entry price and is validated on write
(`InvalidStopLoss`).
Emits `UpdateTradeTp` and `UpdateTradeSl` with before/after values.
### When to use TP/SL orders instead
These functions set a **single** take-profit and a **single** stop-loss on the position itself. If
you need partial take-profits — scaling out at several price levels — use
[TP/SL orders](/onchain/tpsl-orders), which support multiple legs per position.
The two mechanisms coexist. Legacy positions support only the embedded fields.
---
Source: https://developer-docs.leverup.xyz/onchain/tpsl-orders.md
# TP/SL Orders
Standalone take-profit and stop-loss orders attached to an open position. Unlike the
[embedded TP/SL fields](/onchain/manage-position#take-profit-and-stop-loss), these support **partial
quantities and multiple legs** — scale out of a position at several price levels.
Internally these are called *decrease orders*.
::: info Merged positions only
TP/SL orders require a position with a deterministic key. Legacy positions keep using
`updateTradeTp` / `updateTradeSl`. See
[Positions are merged slots](/introduction/concepts#positions-are-merged-slots).
:::
## Rules
| | Take-profit (`kind = 0`) | Stop-loss (`kind = 1`) |
| :--- | :--- | :--- |
| Orders per position | Multiple legs | Multiple legs |
| Partial `closeQty` | Yes | Yes |
| Trigger side | Profit side, bounded by leverage | Loss side, validated on write |
Both kinds go through the same quantity rule — `0 < closeQty <= position.qty` — and there is no
per-kind limit on how many you create. Do not assume a position carries at most one stop-loss, or
that a stop-loss closes the full size: if you are reading orders back, handle several partial legs
of either kind.
::: info The stop-loss from the open parameters is a different object
The `stopLoss` you pass when opening is a protocol-managed order: it is always sized to the full
position and is resized automatically as the position grows. It is not reachable through
`createDecreaseOrder` / `batchCreateDecreaseOrders`, and it can coexist with stop-loss orders you
create yourself — so a position may legitimately hold more than one.
:::
Additional behaviour:
- Orders do **not** reserve quantity against each other. Each only requires
`0 < closeQty <= position.qty` at creation; execution clamps to whatever quantity remains. Active
legs can therefore add up to more than the position size — the surplus simply never fills.
- There is a per-position cap on active orders — exceeding it reverts with
`TooManyActiveDecreaseOrders`.
- Fully closing the position cancels all of its orders.
## Data types
```solidity
enum DecreaseOrderKind { TP, SL }
struct DecreaseOrderInput {
DecreaseOrderKind kind;
uint128 triggerPrice; // 1e18
uint128 closeQty; // 1e10
uint24 broker; // close-fee referral, applied when the keeper executes
}
struct DecreaseOrderUpdateInput {
bytes32 orderHash;
uint128 triggerPrice;
uint128 closeQty;
uint24 broker;
}
```
`broker` is carried **on the order**, not taken from the position, and is applied when the keeper
executes it. A broker integration must set it on every order it creates and on every update — see
[Brokers & Referrals](/introduction/brokers#open-and-close-are-credited-separately).
## Creating orders
```solidity
function createDecreaseOrder(bytes32 positionHash, DecreaseOrderInput calldata order)
external returns (bytes32 orderHash);
function batchCreateDecreaseOrders(bytes32 positionHash, DecreaseOrderInput[] calldata orders)
external returns (bytes32[] memory orderHashes);
```
No oracle data required.
```ts
import { parseUnits } from 'viem'
import { publicClient, walletClient, DIAMOND } from './config'
const CREATE_ORDERS_ABI = [{
type: 'function',
name: 'batchCreateDecreaseOrders',
stateMutability: 'nonpayable',
inputs: [
{ name: 'positionHash', type: 'bytes32' },
{
name: 'orders', type: 'tuple[]', components: [
{ name: 'kind', type: 'uint8' },
{ name: 'triggerPrice', type: 'uint128' },
{ name: 'closeQty', type: 'uint128' },
{ name: 'broker', type: 'uint24' },
],
},
],
outputs: [{ name: 'orderHashes', type: 'bytes32[]' }],
}] as const
// Scale out: half at $110k, the rest at $130k, full stop at $90k.
const hash = await walletClient.writeContract({
address: DIAMOND,
abi: CREATE_ORDERS_ABI,
functionName: 'batchCreateDecreaseOrders',
args: [
positionHash,
[
{ kind: 0, triggerPrice: parseUnits('110000', 18), closeQty: position.qty / 2n, broker: 0 },
{ kind: 0, triggerPrice: parseUnits('130000', 18), closeQty: position.qty / 2n, broker: 0 },
{ kind: 1, triggerPrice: parseUnits('90000', 18), closeQty: position.qty, broker: 0 },
],
],
})
await publicClient.waitForTransactionReceipt({ hash })
```
Order hashes are returned by the call and emitted in `DecreaseOrderCreated`.
## Updating orders
```solidity
function batchUpdateDecreaseOrders(DecreaseOrderUpdateInput[] calldata updates) external;
```
Updates are addressed by `orderHash`, so a single call can modify orders across different positions.
```ts
const UPDATE_ORDERS_ABI = [{
type: 'function',
name: 'batchUpdateDecreaseOrders',
stateMutability: 'nonpayable',
inputs: [{
name: 'updates', type: 'tuple[]', components: [
{ name: 'orderHash', type: 'bytes32' },
{ name: 'triggerPrice', type: 'uint128' },
{ name: 'closeQty', type: 'uint128' },
{ name: 'broker', type: 'uint24' },
],
}],
outputs: [],
}] as const
await walletClient.writeContract({
address: DIAMOND,
abi: UPDATE_ORDERS_ABI,
functionName: 'batchUpdateDecreaseOrders',
args: [[{
orderHash,
triggerPrice: parseUnits('115000', 18),
closeQty: position.qty / 4n,
broker: 0,
}]],
})
```
## Cancelling
```solidity
function cancelDecreaseOrder(bytes32 orderHash) external;
function cancelAllDecreaseOrders(bytes32 positionHash) external;
```
```ts
const CANCEL_ORDERS_ABI = [{
type: 'function',
name: 'cancelAllDecreaseOrders',
stateMutability: 'nonpayable',
inputs: [{ name: 'positionHash', type: 'bytes32' }],
outputs: [],
}] as const
await walletClient.writeContract({
address: DIAMOND,
abi: CANCEL_ORDERS_ABI,
functionName: 'cancelAllDecreaseOrders',
args: [positionHash],
})
```
`cancelAllDecreaseOrders` removes every decrease order on the position — take-profit legs and the
stop-loss alike — emitting one `DecreaseOrderCancelled` per order, and leaves the position itself
untouched. On a position that exists but has no orders it succeeds and does nothing. It reverts with
[`NonexistentTrade`](/reference/errors) if the position no longer exists (already fully closed, or
the wrong hash), and with [`UnauthorizedOperation`](/reference/errors) if the caller is not the
position owner — so cancelling after a full close is an error, not a no-op.
Both are available gaslessly as
[`CANCEL_DECREASE_ORDER` (10) and `CANCEL_ALL_DECREASE_ORDERS` (13)](/gasless/actions#action-list).
## Reading orders
```solidity
function getDecreaseOrders(bytes32 positionHash) external view returns (DecreaseOrderInfo[] memory);
function getTraderDecreaseOrders(address user) external view returns (DecreaseOrderInfo[] memory);
function getDecreaseOrder(bytes32 orderHash) external view returns (DecreaseOrderInfo memory);
```
```solidity
struct DecreaseOrderInfo {
bytes32 orderHash;
bytes32 positionHash;
address user;
address pairBase;
bool isLong;
DecreaseOrderKind kind; // 0 = TP, 1 = SL
uint128 triggerPrice; // 1e18
uint128 closeQty; // 1e10
uint24 broker;
uint32 createdAt;
bool active;
}
```
`getTraderDecreaseOrders` returns every live order across all of a trader's positions — the usual
starting point for a dashboard. A missing or removed order reads back as the zero struct
(`user == address(0)`).
```ts
const READ_ORDERS_ABI = [{
type: 'function',
name: 'getTraderDecreaseOrders',
stateMutability: 'view',
inputs: [{ name: 'user', type: 'address' }],
outputs: [{
type: 'tuple[]', name: '', components: [
{ name: 'orderHash', type: 'bytes32' },
{ name: 'positionHash', type: 'bytes32' },
{ name: 'user', type: 'address' },
{ name: 'pairBase', type: 'address' },
{ name: 'isLong', type: 'bool' },
{ name: 'kind', type: 'uint8' },
{ name: 'triggerPrice', type: 'uint128' },
{ name: 'closeQty', type: 'uint128' },
{ name: 'broker', type: 'uint24' },
{ name: 'createdAt', type: 'uint32' },
{ name: 'active', type: 'bool' },
],
}],
}] as const
const orders = await publicClient.readContract({
address: DIAMOND,
abi: READ_ORDERS_ABI,
functionName: 'getTraderDecreaseOrders',
args: [account.address],
})
```
## Events
| Event | Emitted when |
| :--- | :--- |
| `DecreaseOrderCreated(user, positionHash, orderHash, kind, triggerPrice, closeQty, broker)` | Order created |
| `DecreaseOrderUpdated(orderHash, triggerPrice, closeQty, broker)` | Order modified |
| `DecreaseOrderCancelled(user, positionHash, orderHash)` | Cancelled by the trader, swept by a full close or liquidation, or cleaned up as stale |
| `ExecuteDecreaseOrderSuccessful` | Trigger hit and the reduction settled |
See [Events](/onchain/events#tpsl-orders).
---
Source: https://developer-docs.leverup.xyz/onchain/reading-data.md
# Reading Data
All read functions are `view` — free, and callable from any RPC. They live on the same Diamond
address as the write functions.
If you would rather query over HTTP, most of this is also available through the
[REST API](/api/overview), which additionally covers closed positions and history.
## Positions
### `getPositionsV4`
```solidity
function getPositionsV4(address user, address pairBase) external view returns (PositionV4[] memory);
```
Open positions for one user in one market. There are `V2` and `V3` variants with fewer fields; **use
`V4`** — the earlier ones exist only for backwards compatibility.
```solidity
struct PositionV4 {
bytes32 positionHash;
string pair; // "BTC/USD"
address pairBase;
address tokenIn; // collateral deposited
address marginToken; // lvToken — the unit for every amount below
bool isLong;
uint96 margin; // lvToken decimals
uint128 qty; // 1e10
uint128 entryPrice; // 1e18
uint128 stopLoss; // 1e18, 0 if unset
uint128 takeProfit; // 1e18, 0 if unset
uint96 openFee; // lvToken decimals
uint96 executionFee; // lvToken decimals
int256 fundingFee; // signed, total = accrued + since-snapshot
uint32 timestamp;
uint96 holdingFee; // total = accrued + since-snapshot
uint256 earliestCloseTime;
int256 accruedFundingFee; // booked-but-unsettled portion
uint256 accruedHoldingFee; // booked-but-unsettled portion
}
```
`fundingFee` is signed: positive means the trader receives, negative means the trader pays.
`accruedFundingFee` and `accruedHoldingFee` are the already-booked subsets of the totals — you
generally want `fundingFee` and `holdingFee`, not the accrued fields.
```ts
import { formatUnits } from 'viem'
import { publicClient, DIAMOND } from './config'
const POSITION_TUPLE = {
type: 'tuple[]', name: '', components: [
{ name: 'positionHash', type: 'bytes32' },
{ name: 'pair', type: 'string' },
{ name: 'pairBase', type: 'address' },
{ name: 'tokenIn', type: 'address' },
{ name: 'marginToken', type: 'address' },
{ name: 'isLong', type: 'bool' },
{ name: 'margin', type: 'uint96' },
{ name: 'qty', type: 'uint128' },
{ name: 'entryPrice', type: 'uint128' },
{ name: 'stopLoss', type: 'uint128' },
{ name: 'takeProfit', type: 'uint128' },
{ name: 'openFee', type: 'uint96' },
{ name: 'executionFee', type: 'uint96' },
{ name: 'fundingFee', type: 'int256' },
{ name: 'timestamp', type: 'uint32' },
{ name: 'holdingFee', type: 'uint96' },
{ name: 'earliestCloseTime', type: 'uint256' },
{ name: 'accruedFundingFee', type: 'int256' },
{ name: 'accruedHoldingFee', type: 'uint256' },
],
} as const
const READER_ABI = [{
type: 'function',
name: 'getPositionsV4',
stateMutability: 'view',
inputs: [{ name: 'user', type: 'address' }, { name: 'pairBase', type: 'address' }],
outputs: [POSITION_TUPLE],
}] as const
const positions = await publicClient.readContract({
address: DIAMOND,
abi: READER_ABI,
functionName: 'getPositionsV4',
args: [user, pairBase],
})
for (const p of positions) {
console.log(
p.pair,
p.isLong ? 'LONG' : 'SHORT',
formatUnits(p.qty, 10),
'@', formatUnits(p.entryPrice, 18),
'| margin', formatUnits(p.margin, 18), // lvToken decimals
)
}
```
::: tip Scanning all markets
`getPositionsV4` takes one `pairBase` at a time. To sweep a user's whole book, fetch the pair list
from [`GET /v1/pairs`](/api/pairs#list-pairs) and batch the calls through Multicall3 — or use
[`GET /v1/user/{address}/open-positions`](/api/positions#open-positions), which returns everything in
one request.
:::
### `getPositionByHashV4` / `getPositionByKeyV4`
```solidity
function getPositionByHashV4(bytes32 tradeHash) external view returns (PositionV4 memory);
function getPositionByKeyV4(address user, address pairBase, bool isLong, address lvToken)
external view returns (PositionV4 memory);
```
`getPositionByKeyV4` avoids computing the hash yourself.
### `getPositionHash`
```solidity
function getPositionHash(address user, address pairBase, bool isLong, address lvToken)
external pure returns (bytes32);
```
Pure, and reproducible offchain:
```ts
import { keccak256, encodeAbiParameters } from 'viem'
const positionHash = keccak256(
encodeAbiParameters(
[{ type: 'address' }, { type: 'address' }, { type: 'bool' }, { type: 'address' }, { type: 'string' }],
[user, pairBase, isLong, lvToken, 'position.v1'],
),
)
```
### `getPositionTrader`
```solidity
function getPositionTrader(bytes32 positionHash) external view returns (address);
```
### `getPendingTrade`
```solidity
function getPendingTrade(bytes32 tradeHash) external view returns (PendingTrade memory);
```
Look up a trade that has been requested but not yet filled, using the hash returned by
`openMarketTradeV2`.
## Limit orders
### `getLimitOrders`
```solidity
function getLimitOrders(address user, address pairBase) external view returns (LimitOrderView[] memory);
function getLimitOrderByHash(bytes32 orderHash) external view returns (LimitOrderView memory);
```
```solidity
struct LimitOrderView {
bytes32 orderHash;
string pair;
address pairBase;
bool isLong;
address tokenIn;
address lvToken;
uint96 amountIn; // tokenIn decimals
uint128 qty; // 1e10
uint128 limitPrice; // 1e18
uint128 stopLoss; // 1e18
uint128 takeProfit; // 1e18
uint24 broker;
uint32 timestamp;
}
```
```ts
const LIMIT_ORDERS_ABI = [{
type: 'function',
name: 'getLimitOrders',
stateMutability: 'view',
inputs: [{ name: 'user', type: 'address' }, { name: 'pairBase', type: 'address' }],
outputs: [{
type: 'tuple[]', name: '', components: [
{ name: 'orderHash', type: 'bytes32' },
{ name: 'pair', type: 'string' },
{ name: 'pairBase', type: 'address' },
{ name: 'isLong', type: 'bool' },
{ name: 'tokenIn', type: 'address' },
{ name: 'lvToken', type: 'address' },
{ name: 'amountIn', type: 'uint96' },
{ name: 'qty', type: 'uint128' },
{ name: 'limitPrice', type: 'uint128' },
{ name: 'stopLoss', type: 'uint128' },
{ name: 'takeProfit', type: 'uint128' },
{ name: 'broker', type: 'uint24' },
{ name: 'timestamp', type: 'uint32' },
],
}],
}] as const
```
## TP/SL orders
See [TP/SL Orders → Reading orders](/onchain/tpsl-orders#reading-orders) for
`getDecreaseOrders`, `getTraderDecreaseOrders` and `getDecreaseOrder`.
## Brokers
`getBrokerById` and `brokers` return a broker's commission rate, receiver and accrued balances. See
[Brokers & Referrals → Reading your broker record](/introduction/brokers#reading-your-broker-record).
## Market data
### `getMarketInfoV2`
```solidity
function getMarketInfoV2(address pairBase) external view returns (MarketInfoV2 memory);
function getMarketInfosV2(address[] calldata pairBases) external view returns (MarketInfoV2[] memory);
```
```solidity
struct MarketInfoV2 {
address pairBase;
uint256 longQty; // 1e10
uint256 shortQty; // 1e10
uint128 lpLongAvgPrice; // 1e18
uint128 lpShortAvgPrice; // 1e18
int256 fundingFeeRate; // 1e18, signed
int256 lastLongAccFundingFeePerShare; // 1e18
}
```
Open interest per side and the current funding rate. Use the plural form to fetch several markets in
one call.
### Pair configuration
```solidity
function getPairByBaseV4(address base) external view returns (PairView memory);
function getPairConfig(address base) external view returns (PairMaxOiAndFundingFeeConfig memory);
function getPairFeeConfig(address base) external view returns (FeeConfig memory);
function getPairHoldingFeeRate(address base, bool isLong) external view returns (uint40);
```
`getPairByBaseV4` returns everything: status, OI caps, funding parameters, the leverage tiers, the
fee config, holding fee rates, and the minimum holding period.
Leverage is tiered by notional size:
```solidity
struct LeverageMargin {
uint256 notionalUsd; // upper bound of this tier
uint16 maxLeverage;
uint16 initialLostP; // 1e4
uint16 liqLostP; // 1e4
}
```
Fees:
```solidity
struct FeeConfig {
uint16 openFeeP; // 1e4
uint16 closeFeeP; // 1e4
uint24 shareP; // 1e5
uint24 minCloseFeeP; // 1e5
uint24 lvTokenDiscountP; // 1e5
}
```
### Pair slippage configuration
```solidity
function getPairSlippageConfig(address base) external view returns (SlippageConfigView memory);
```
```solidity
enum SlippageType { FIXED, ONE_PERCENT_DEPTH, NET_POSITION, THRESHOLD }
struct SlippageConfigView {
uint256 onePercentDepthAboveUsd;
uint256 onePercentDepthBelowUsd;
uint16 slippageLongP; // 1e4
uint16 slippageShortP; // 1e4
uint256 longThresholdUsd;
uint256 shortThresholdUsd;
SlippageType slippageType;
}
```
Use this to estimate the fill price before sending an open, and to size the `price` bound.
```ts
import { formatUnits } from 'viem'
const SLIPPAGE_ABI = [{
type: 'function',
name: 'getPairSlippageConfig',
stateMutability: 'view',
inputs: [{ name: 'base', type: 'address' }],
outputs: [{
type: 'tuple', name: '', components: [
{ name: 'onePercentDepthAboveUsd', type: 'uint256' },
{ name: 'onePercentDepthBelowUsd', type: 'uint256' },
{ name: 'slippageLongP', type: 'uint16' },
{ name: 'slippageShortP', type: 'uint16' },
{ name: 'longThresholdUsd', type: 'uint256' },
{ name: 'shortThresholdUsd', type: 'uint256' },
{ name: 'slippageType', type: 'uint8' },
],
}],
}] as const
const cfg = await publicClient.readContract({
address: DIAMOND,
abi: SLIPPAGE_ABI,
functionName: 'getPairSlippageConfig',
args: [pairBase],
})
console.log('model ', ['FIXED', 'ONE_PERCENT_DEPTH', 'NET_POSITION', 'THRESHOLD'][cfg.slippageType])
console.log('long slippage ', Number(cfg.slippageLongP) / 100, '%')
console.log('1% depth above', formatUnits(cfg.onePercentDepthAboveUsd, 18))
```
## Account assets
```solidity
function traderAssets(address[] memory tokens) external view returns (TraderAsset[] memory);
```
```solidity
enum AssetPurpose { LIMIT, PENDING, POSITION, PREDICTION_PENDING, PREDICTION }
struct TraderAsset {
AssetPurpose purpose;
address token;
uint256 value;
}
```
Total value locked by the protocol per token, broken down by what it is locked for.
---
Source: https://developer-docs.leverup.xyz/onchain/events.md
# Events
Every event is emitted by the Diamond (`0xea1b8E4aB7f14F7dCA68c5B214303B13078FC5ec`), so a single
address filter captures the whole protocol.
Because opens and closes are [two-phase](/onchain/overview#two-phase-execution), the events fall into
two groups: *request* events from the trader's own transaction, and *settlement* events from the
keeper's transaction. An integration that needs to know the real outcome must watch the settlement
events.
## Opening
| Event | Phase | Meaning |
| :--- | :--- | :--- |
| `MarketPendingTrade` | request | Market open accepted, collateral locked |
| `OpenPosition` | settlement | A new position slot was created |
| `PositionIncreased` | settlement | An existing slot grew, entry price blended |
| `PendingTradeRefund` | settlement | Open failed; collateral returned |
| `SetOpenTradeEarliestCloseTime` | settlement | Minimum holding period recorded |
| `ExtraFeeCharged` | settlement | [`extraFee`](/introduction/brokers#extrafee) paid to the broker's receiver |
```solidity
event MarketPendingTrade(address indexed user, bytes32 indexed tradeHash, OpenDataInput trade);
event OpenPosition(
address indexed user,
bytes32 indexed positionHash,
bytes32 indexed sourceHash,
PositionOpenUpdate update
);
event PositionIncreased(
address indexed user,
bytes32 indexed positionHash,
bytes32 indexed sourceHash,
PositionOpenUpdate update
);
event PendingTradeRefund(address indexed user, bytes32 indexed tradeHash, Refund refund);
event ExtraFeeCharged(
uint24 indexed brokerId,
address token, // lvToken — extraFee is converted before payout
uint96 amount, // lvToken decimals
address user,
bytes32 tradeHash
);
```
`sourceHash` links back to the request: the pending trade hash for a market order, or the order hash
for a limit order.
```solidity
enum PositionOpenSource { MARKET, LIMIT }
struct PositionOpenUpdate {
PositionOpenSource source;
bytes32 sourceHash;
uint128 addedQty; // 1e10
uint128 addedEntryPrice; // 1e18, the fill price of this leg
OpenTradeEventV2 position; // the position after the update
}
```
::: warning
There is no `OpenMarketTrade` event. If you are migrating from an older integration, replace it with
`OpenPosition` / `PositionIncreased`.
:::
## Closing
| Event | Phase | Meaning |
| :--- | :--- | :--- |
| `CloseTradeRequested` | request | Close accepted |
| `ClosePosition` | settlement | Position fully closed, slot removed |
| `PositionDecreased` | settlement | Partial close settled, remainder open |
| `ExecuteDecreaseOrderSuccessful` | settlement | A TP/SL order fired |
| `ExecuteCloseRejected` | settlement | Close could not be settled |
| `CloseTradeReceived` | settlement | Payout transferred to the trader |
```solidity
event CloseTradeRequested(
address indexed user,
bytes32 indexed positionHash,
bytes32 indexed closeHash,
bytes32 requestId,
uint128 closeQty
);
event ClosePosition(
address indexed user,
bytes32 indexed positionHash,
bytes32 indexed closeHash,
uint128 closeQty,
CloseInfo closeInfo,
OpenTradeEventV2 ot
);
event PositionDecreased(
address indexed user,
bytes32 indexed positionHash,
bytes32 indexed closeHash,
uint128 closeQty,
CloseInfo closeInfo,
OpenTradeEventV2 ot
);
event ExecuteCloseRejected(
address indexed user,
bytes32 indexed tradeHash,
ExecutionType executionType,
uint128 execPrice,
uint128 marketPrice
);
```
```solidity
enum ExecutionType { TP, SL, LIQ, ADL }
struct CloseInfo {
uint128 closePrice; // 1e18
int96 fundingFee; // lvToken decimals, signed
uint96 closeFee; // lvToken decimals
int96 pnl; // lvToken decimals, signed
uint96 holdingFee; // lvToken decimals
}
```
`ClosePosition` and `PositionDecreased` cover every kind of exit — manual close, take-profit,
stop-loss, liquidation and ADL. To distinguish them, look at whether the exit was accompanied by
`ExecuteDecreaseOrderSuccessful` (TP/SL order) or read `ExecutionType` from the paired execution
event.
::: info Legacy close events
`CloseTradeSuccessful`, `CloseTradeSuccessfulV2`, `ExecuteCloseSuccessful` and
`ExecuteCloseSuccessfulV2` are retained for positions created before the merged-position upgrade.
New integrations should index `ClosePosition` and `PositionDecreased`.
:::
## Limit orders
```solidity
event OpenLimitOrder(address indexed user, bytes32 indexed orderHash, OpenDataInput data);
event ExecuteLimitOrderSuccessful(address indexed user, bytes32 indexed orderHash);
event ExecuteLimitOrderRejected(address indexed user, bytes32 indexed orderHash, Refund refund);
event LimitOrderRefund(address indexed user, bytes32 indexed orderHash, Refund refund);
event CancelLimitOrder(address indexed user, bytes32 indexed orderHash);
event UpdateOrderTp(address indexed user, bytes32 indexed orderHash, uint256 oldTp, uint256 tp);
event UpdateOrderSl(address indexed user, bytes32 indexed orderHash, uint256 oldSl, uint256 sl);
```
A successful execution emits `ExecuteLimitOrderSuccessful` **and** an `OpenPosition` /
`PositionIncreased` whose `sourceHash` is the order hash.
## Position management
```solidity
event UpdateTradeTp(address indexed user, bytes32 indexed tradeHash, uint256 oldTp, uint256 tp);
event UpdateTradeSl(address indexed user, bytes32 indexed tradeHash, uint256 oldSl, uint256 sl);
event UpdateMargin(address indexed user, bytes32 indexed tradeHash, uint256 beforeMargin, uint256 margin);
```
## TP/SL orders
```solidity
event DecreaseOrderCreated(
address indexed user,
bytes32 indexed positionHash,
bytes32 indexed orderHash,
DecreaseOrderKind kind, // 0 = TP, 1 = SL
uint128 triggerPrice,
uint128 closeQty,
uint24 broker
);
event DecreaseOrderUpdated(bytes32 indexed orderHash, uint128 triggerPrice, uint128 closeQty, uint24 broker);
event DecreaseOrderCancelled(address indexed user, bytes32 indexed positionHash, bytes32 indexed orderHash);
event ExecuteDecreaseOrderSuccessful(
address indexed user,
bytes32 indexed orderHash,
bytes32 indexed positionHash,
DecreaseOrderKind kind,
uint128 closeQty,
CloseInfo closeInfo,
OpenTradeEventV2 ot
);
```
`DecreaseOrderCancelled` fires on explicit cancellation, when a full close or liquidation sweeps the
position's remaining orders, and when a stale order is cleaned up at execution time.
## `OpenTradeEventV2`
The position snapshot carried by settlement events.
```solidity
struct OpenTradeEventV2 {
address user;
uint32 userOpenTradeIndex;
uint40 holdingFeeRate; // 1e12
uint128 entryPrice; // 1e18
uint128 qty; // 1e10
address pairBase;
address tokenPay;
address lvToken;
uint96 lvMargin; // lvToken decimals
uint128 stopLoss; // 1e18
uint128 takeProfit; // 1e18
uint24 broker;
bool isLong;
uint32 timestamp;
uint96 lvOpenFee; // lvToken decimals
uint96 lvExecutionFee; // lvToken decimals
int256 longAccFundingFeePerShare; // 1e18
uint256 openBlock;
int128 accruedFundingFee;
uint128 accruedHoldingFee;
}
```
## Watching events
```ts
import { parseAbiItem } from 'viem'
import { publicClient, DIAMOND } from './config'
const unwatch = publicClient.watchEvent({
address: DIAMOND,
event: parseAbiItem(
'event ClosePosition(address indexed user, bytes32 indexed positionHash, bytes32 indexed closeHash, uint128 closeQty, (uint128,int96,uint96,int96,uint96) closeInfo, (address,uint32,uint40,uint128,uint128,address,address,address,uint96,uint128,uint128,uint24,bool,uint32,uint96,uint96,int256,uint256,int128,uint128) ot)',
),
args: { user: traderAddress },
onLogs: (logs) => {
for (const log of logs) {
const { closeQty, closeInfo } = log.args
console.log('closed', closeQty, 'pnl', closeInfo.pnl)
}
},
})
```
Filtering by `user` is cheap — it is an indexed topic on nearly every event. `positionHash` is
indexed too, so you can follow one position's whole life with a two-topic filter.
For historical data, the [REST API](/api/history) is generally easier than replaying logs: it
already joins events into position and trade records.
## Refund reasons
`PendingTradeRefund`, `ExecuteLimitOrderRejected` and `LimitOrderRefund` carry a `Refund` enum. See
[Error Reference → Refund reasons](/reference/errors#refund-reasons).
---
Source: https://developer-docs.leverup.xyz/onchain/fairlaunch.md
# Fair Launch
Fair Launch is LeverUp's fixed-target fundraising and token-launch mechanism on Monad. A builder
publishes a **plan**: a target amount of LVMON, a time window, the token's identity, and an
incentive wallet. Users contribute LVMON — or native MON, which is minted into LVMON 1:1 — until
the target is reached. A successful plan is launched through [Nad.Fun](https://nad.fun) by a
keeper: the token is created, the raise buys out the bonding curve and seeds the DEX position, and
contributors claim their share. A plan that misses its target never launches, and every
contribution is refundable.
In code the system is called the **Launchpad** (`NadFunFundraisingLaunchpad`); Fair Launch is the
product name. This page uses the two interchangeably.
::: warning Deployment status
Fair Launch is currently live on **Monad testnet** in early access. The addresses on this page are
testnet addresses; the mainnet deployment has not been published yet. Everything else on this site
targets mainnet — see [Networks & Contracts](/introduction/networks).
:::
## Lifecycle
A plan moves through three onchain statuses:
| Status | Value | Meaning |
| :--- | :--- | :--- |
| `Active` | `0` | Raise is open (or has ended below target — check `refundable`) |
| `ReadyToLaunch` | `1` | Target reached; waiting for the keeper |
| `Launched` | `2` | Token created; claiming is open |
The full flow:
1. **Create.** The builder calls `createPlan`. The raise starts immediately and runs until
`endTime`.
2. **Whitelist phase (optional).** For the first `whitelistDuration` seconds only addresses in the
plan's merkle tree can contribute, each up to its own `maxWhitelistLVMON` cap.
3. **Public phase.** Anyone can contribute. There is no per-address cap; the only limit is what is
left of the target.
4. **Target reached.** The plan flips to `ReadyToLaunch` and stops accepting contributions. A
contribution that would overshoot is trimmed — the contract accepts only what fits and the rest
never leaves your wallet.
5. **Launch.** After `endTime`, an authorized keeper calls `launch`, which creates the token on
Nad.Fun and deploys the raised LVMON (details below).
6. **Claim.** Contributors claim their pro-rata share of the public-sale allocation.
**Failure path:** if `endTime` passes with the target unmet, the plan never launches. `refundable`
becomes `true` and each contributor can call `refund` for their full contribution in LVMON. A live
raise cannot be refunded early, and a launched plan cannot be refunded at all.
::: info Filling early does not launch early
A plan that hits its target mid-window sits in `ReadyToLaunch` until `endTime` passes — the keeper
only launches after the window closes. Both gates matter: `status == ReadyToLaunch` **and** `block.timestamp >= endTime`.
:::
## Plan parameters
Everything a builder sets at creation, and the rules the LeverUp app applies on top:
| Parameter | Meaning | Rule / default |
| :--- | :--- | :--- |
| `targetLVMON` | Fixed raise target (18 decimals) | Minimum **230,000 LVMON**; the app's slider ranges up to 15,000,000 |
| `startTime` / `endTime` | Raise window | Starts immediately; default window **24 hours** |
| `whitelistDuration` | Whitelist phase length, in seconds from start | `0` = no whitelist; app default **30 minutes**; the raise must extend at least **5 minutes** beyond it |
| `whitelistRoot` | Merkle root over `(contributor, maxWhitelistLVMON)` leaves | Full tree is published as a public JSON manifest, so anyone can rebuild proofs |
| `incentiveRecipient` | Wallet that receives the builder-incentive allocation at launch | Visible onchain before you contribute |
| `launchMetadataURI` | Extended metadata: description, roadmap, whitelist manifest | — |
| `createParams` | Nad.Fun token identity: name, symbol, metadata URI, fee vaults, DEX type | Token image is rejected if Nad.Fun flags it NSFW; fee-stream vaults are set to **10% platform / 90% creator**; `dexType 0` (V2) |
The minimum target is not arbitrary: 230,000 LVMON is just above the create fee plus a full
bonding-curve buyout (10 + 229,596 — see [What launch does](#what-launch-does)), so every
successful Fair Launch graduates its token to a live DEX pool in the launch transaction. There are
no half-filled bonding curves.
## Contributing
There are four entry points, all feeding the same per-address total (`contributionOf`):
| Function | Path |
| :--- | :--- |
| `contributeLVMON` | Contribute LVMON directly |
| `contributeNative` | Send MON with the call; it is minted into LVMON **1:1** and credited |
| `contributeLVMONWhitelist` / `contributeNativeWhitelist` | Same two paths during the whitelist phase — additionally take the address's cap and merkle proof |
Contributing twice adds up. There is no oversubscription: once `raisedLVMON` reaches the target,
the raise is closed, and a contribution that would cross the line is accepted only partially.
`previewContribution(planId, amount)` shows exactly how a contribution would split
(`acceptedLVMON` / `unusedLVMON`) and estimates the resulting token allocation — surface it before
any contribute button.
## What launch does
`launch` is keeper-gated. The LeverUp app runs the keeper: it scans for launchable plans every 60
seconds (up to 5 per round), mines a CREATE2 salt through Nad.Fun's API so the token address is
deterministic, and submits with a 1,200-second transaction deadline.
Inside the launch transaction, the raised LVMON is spent in three steps:
1. **Create fee** — up to **10 LVMON** (`maxCreateFee()`) paid to Nad.Fun for token creation.
2. **Curve buyout** — up to **229,596 LVMON** is the initial buy on the token's bonding curve.
This is exactly the amount that fills the curve, so the token *graduates* immediately: Nad.Fun
moves it to its DEX with an opening pool of roughly **191.09M tokens / ~224k MON**.
3. **DEX buy** — everything left is swapped in that fresh pool (standard Nad.Fun swap fees apply
on this leg, 160 bps total in the current V2 model).
All tokens acquired in steps 2–3 land in the launchpad contract, which then splits them between
contributors and the builder (next section). The incentive allocation is transferred to
`incentiveRecipient` in the same transaction.
## Token allocation
Every Fair Launch token has a fixed total supply of **1,000,000,000** (18 decimals). At launch the
contract computes:
- **`launchMarketCapLVMON`** — total supply × the pool's post-launch spot price.
- **Public sale** — `totalSupply × targetLVMON / launchMarketCapLVMON`, capped at what the
launchpad holds. This is the contributors' pool: collectively worth exactly the raise at the
launch price, so **contributors enter at the launch market cap, not below it**.
- **Builder incentive** — everything the launchpad holds beyond the public sale, sent to
`incentiveRecipient`. Economically this is the spread the raise captured by buying through the
bonding curve below the final price.
`previewPlanAllocation(targetLVMON)` returns all of this for any hypothetical target before a plan
exists. Values from the deployed testnet contract:
| Target (LVMON) | Launch market cap (LVMON) | Public sale | Builder incentive | Stays in DEX pool |
| ---: | ---: | ---: | ---: | ---: |
| 230,000 | 1.16M | 198.71M (19.87%) | 610.53M (61.05%) | 190.76M (19.08%) |
| 500,000 | 5.53M | 90.44M (9.04%) | 822.22M (82.22%) | 87.34M (8.73%) |
| 1,000,000 | 22.22M | 45.01M (4.50%) | 911.40M (91.14%) | 43.59M (4.36%) |
| 5,000,000 | 557.41M | 8.97M (0.90%) | 982.33M (98.23%) | 8.70M (0.87%) |
| 15,000,000 | 5.02B | 2.99M (0.30%) | 994.11M (99.41%) | 2.90M (0.29%) |
Two dynamics to surface to users:
- **The launch market cap grows super-linearly with the target.** Past the curve buyout, every
extra LVMON buys tokens out of the same AMM pool, pushing the spot price — a 65× larger target
produces a ~4,300× larger launch market cap.
- **Larger targets shift allocation from contributors to the builder.** At the minimum target
contributors receive ~20% of supply; at 15M LVMON they receive 0.3% and the incentive wallet
holds ~99%. Integrators should show `previewPlanAllocation` output next to any contribute
button.
::: tip
The builder incentive is not a fixed percentage — it is a residual. Evaluate a plan by its target,
the resulting split above, and the `incentiveRecipient` wallet, all of which are onchain before
you contribute.
:::
## Claiming and refunds
After launch, `claim` transfers a contributor's share of the public-sale pool, pro-rata to
contribution: `publicSale × contribution ÷ raised`. It can be called once or repeatedly —
`previewClaim(planId, user)` reports the contributed, total claimable, already-claimed and
remaining amounts, and `claim` always sends whatever is still owed.
`refund` only works on a plan whose window ended below target. It returns the full contribution in
LVMON and is tracked per address in `refundedOf`.
## Core numbers
| Setting | Value | Where it lives |
| :--- | :--- | :--- |
| Minimum raise target | 230,000 LVMON | Creation flow (economic floor: fee + curve buyout) |
| Create fee | ≤ 10 LVMON | Contract, `maxCreateFee()` |
| Bonding-curve buyout (max initial buy) | 229,596 LVMON | Contract launch logic |
| Token total supply | 1,000,000,000 (18 decimals) | Nad.Fun |
| Post-graduation pool | ~191.09M tokens / ~224k MON | Nad.Fun V2 curve |
| MON → LVMON conversion | 1:1 | LVMON issuer |
| Default raise window | 24 h | App default |
| Default whitelist window | 30 min | App default |
| Minimum public phase after whitelist | 5 min | App validation |
| Target slider range | 230,000 – 15,000,000 LVMON | App form |
| Post-launch fee-stream split | 10% platform / 90% creator vault | `createParams.vaults` |
| Nad.Fun DEX type | `0` (V2) | `createParams.dexType` |
| Keeper launch scan | Every 60 s, ≤ 5 plans per round | LeverUp keeper |
| Launch transaction deadline | 1,200 s | LeverUp keeper |
## Contract reference
Testnet addresses:
| Contract | Address |
| :--- | :--- |
| Launchpad | `0xC466F271bE0DC8D53B3f42Af0A8375bd8F2b2c3E` |
| LaunchpadUIHelper | `0x90F5b77861b9b250dC0D23C863CBbfbfe0adaA95` |
The calls an integrator actually needs:
| Call | Returns |
| :--- | :--- |
| `getPlan(planId)` | Full raise state: target / raised / remaining, `launchMarketCapLVMON`, time fields, `status`, and the derived `canLaunch` / `refundable` / `whitelistActive` |
| `planCount()` | Total number of plans |
| `contributionOf` / `claimedOf` / `refundedOf` | Per-address accounting for a plan |
| `isWhitelistPhase(planId)` | Whether the whitelist window is currently active |
| `previewContribution(planId, amount)` | Accepted / unused split and estimated token allocation |
| `previewClaim(planId, user)` | Contributed, claimable, claimed and remaining amounts |
| `previewPlanAllocation(targetLVMON)` | Market cap and the three allocations for any target |
| `whitelistLeaf(contributor, maxWhitelistLVMON)` | Exact leaf encoding for merkle-proof construction |
| UIHelper `getLaunchCards(launchpad, offset, limit, viewer)` | Batched list rows, including `viewer`'s own contribution per plan |
Events:
| Event | Emitted when |
| :--- | :--- |
| `PlanCreated(planId, creator, targetLVMON, startTime, endTime)` | A plan is created |
| `WhitelistConfigured(planId, whitelistRoot, whitelistEndTime)` | A plan is created with a whitelist |
LVMON itself is covered in [Core Concepts](/introduction/concepts#collateral-and-lvtoken); token
amounts here use the standard 18-decimal convention described in
[Precision & Units](/introduction/precision).
---
Source: https://developer-docs.leverup.xyz/gasless/overview.md
# Gasless Trading (1CT)
One-Click Trading lets a user authorize a signing key once, onchain, and then trade without wallet
popups and without paying gas. Every subsequent order is an offchain EIP-712 signature that the
protocol relays and executes.
| | Direct onchain | 1CT |
| :--- | :--- | :--- |
| Wallet popup per trade | Yes | **None** |
| Gas per trade | Trader pays | **Protocol pays** |
| Time to submit | Wallet confirmation + broadcast | **Sub-second** |
| Oracle payload | You fetch and attach it | **Relayer handles it** |
| Main wallet key | Signs every transaction | **Never used after setup** |
## How it works
```
main wallet 1CT signing key LeverUp relayer Diamond
│ │ │ │
│ ① authorizeAgent(...) ────┼─────────────────────────────┼──────────────────────►│
│ one transaction, once │ │ │
│ │ │ │
│ ② click "Long" ───────────►│ EIP-712 sign (silent) │ │
│ │ POST /v2/trading/submit-intent │
│ ├────────────────────────────►│ │
│ │ │ batch + oracle prices │
│ │ ├──────────────────────►│
│ │ │ verify + execute
│◄────────────── status ─────┴─────────────────────────────┴───────────────────────│
```
The signing key can only express *trading intent*. It cannot transfer tokens, cannot withdraw, and
cannot act outside the permission bits the user granted. See [Security Model](/gasless/security).
## Integration modes
| Mode | Who signs | Authorization | Typical use |
| :--- | :--- | :--- | :--- |
| **Browser key** | A key generated and stored in the user's browser | `authorizeAgent(localKey, …)` | Frontends, wallets |
| **Hosted agent** | A key your backend holds | `authorizeAgent(botAddress, …)`, optionally restricted per action | Trading bots, copy trading, managed strategies |
| **Self-signing** | The trader's own key | **None needed** — `signer == trader` is accepted directly | Backend scripts, CLIs, integrators holding their own key |
Self-signing is the fastest way to try 1CT: no authorization transaction, and you still get gas
covered and oracle handling done for you.
## What you can do
Fourteen operations, covering everything the onchain path supports:
market open · limit open · market close · batch close · partial close · cancel limit order ·
update limit order TP/SL · add margin · remove margin · update position TP/SL ·
create TP/SL orders · update TP/SL orders · cancel TP/SL order ·
cancel all TP/SL orders on a position
Full list with parameters: [Actions Reference](/gasless/actions).
## Endpoints
Base URL: `https://oneclick-01-keeper.leverup.xyz`
| Method | Path | Purpose |
| :--- | :--- | :--- |
| `GET` | `/v2/trading/anti-ddos-config` | Per-action execution fee configuration |
| `POST` | `/v2/trading/submit-intent?blockchain=MONAD` | Submit a signed intent, returns `intentHash` |
| `GET` | `/v2/trading/{intent_hash}/status` | Poll execution status |
## Integration steps
| Step | What | How often |
| :--- | :--- | :--- |
| 1 | Prepare config — Diamond address, chain id, service URL | once |
| 2 | Prepare a signing key | once per user |
| 3 | [Authorize the agent onchain](/gasless/authorization) | once per user (skip when self-signing) |
| 4 | [Cache the execution fee config](/gasless/fees) | on start, refresh every 60s |
| 5 | [Build `actionData`](/gasless/signing#building-actiondata) | per trade |
| 6 | [Sign the intent](/gasless/signing) | per trade |
| 7 | [Submit and poll](/gasless/submit) | per trade |
A working implementation of steps 4–7 is on the
[Reference Client](/gasless/reference-client) page.
## Prerequisites
- **ERC-20 collateral only.** Native MON is not supported through 1CT — wrap it to WMON first.
- **Approve the Diamond** for both your collateral token and the
[execution fee token](/gasless/fees), before the first trade. If they are the same token, the
allowance must cover `amountIn + extraFee + antiDdosFee`.
- **Referrals work here too.** `broker` and `extraFee` are carried on every fee-charging action —
see [Brokers & Referrals](/introduction/brokers).
- Trade parameters (`qty` at 1e10, prices at 1e18, `tokenIn`/`lvToken` pairing) are identical to the
onchain path. Read [Precision & Units](/introduction/precision) and
[Core Concepts](/introduction/concepts) first.
## What is different from onchain
1. **You never send oracle data.** The relayer fetches it. Intents carry no oracle payload.
2. **Execution is batched.** Your intent is bundled with others and executed in one transaction.
3. **Failures are reported, not reverted.** A rejected intent is *skipped* with a reason string
rather than reverting a transaction you sent. See [Submit & Track](/gasless/submit).
4. **Closes, margin changes and TP/SL creation require the position to be indexed.** The relayer
looks the position up to resolve its oracle feeds, so doing one of these immediately after
opening can fail with `404 Position not found`. Opens are unaffected, and so is cancelling or
updating orders — including `CANCEL_ALL_DECREASE_ORDERS`, which takes a `positionHash` but needs
no lookup. See [Actions Reference](/gasless/actions#actions-that-require-an-indexed-position).
Next: [Security Model](/gasless/security).
---
Source: https://developer-docs.leverup.xyz/gasless/security.md
# Security Model
The question every 1CT integration has to answer for its users is: *what can this key actually do?*
## What the signing key cannot do
The 1CT key is not a wallet delegate. It is a trading-intent signer:
- **It cannot move funds.** There is no transfer, withdrawal, or approval action. The full set of
operations it can authorize is the [14 trading actions](/gasless/actions) — all of which act on the
trader's own positions inside the protocol.
- **It cannot exceed its granted permissions.** Authorization carries a per-action bitmask.
- **It cannot outlive revocation.** Revocation is an onchain write and takes effect immediately.
- **It never touches the main wallet key.** The main wallet signs one authorization transaction and
is not used again.
## Defence layers
| Mechanism | What it prevents |
| :--- | :--- |
| **Permission bitmask** | A key granted "open and close" cannot remove margin. Enforced onchain, per action. |
| **Time-window nonce** | Nonces are millisecond timestamps, must strictly increase, and are only valid from 2 days in the past to 5 minutes in the future. Old signatures cannot be replayed. |
| **Per-intent deadline** | Every intent expires. The reference client defaults to 5 minutes. |
| **Execution fee** | A small per-action fee charged onchain makes spamming the endpoint uneconomical. |
| **Relayer separation** | The relayer holds a restricted role that only lets it *forward* signed intents. It cannot forge a signature or alter one. |
| **Per-intent isolation** | One failing intent in a batch does not affect the others. |
| **Pre-flight simulation** | Each intent is simulated before it goes onchain; intents that would certainly fail never consume gas. |
## Key handling
How the key is stored is the integrator's responsibility, and it is the part most worth getting
right.
### Browser keys
The pattern used by the LeverUp frontend:
```
① User signs a fixed message with their main wallet
② privateKey = keccak256(signature) ← deterministic derivation
③ Encrypt the private key with AES-GCM
- the CryptoKey is generated with extractable: false, so scripts cannot export it
- 12-byte random IV
④ Store { owner, address, cryptoKey, encryptedPk, iv } in IndexedDB
```
```ts
import { keccak256 } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
const signature = await signMessageAsync({ message: ONE_CLICK_WALLET_SIGN_MESSAGE })
const privateKey = keccak256(signature)
const agent = privateKeyToAccount(privateKey)
// agent.address is what you authorize onchain
```
Two properties fall out of this:
- **Deterministic recovery.** A user who switches device or clears storage re-signs the same message
with the same wallet and recovers the same 1CT address — the onchain authorization stays valid.
- **Non-exportable at rest.** Generating the wrapping `CryptoKey` with `extractable: false` means a
script that gains page access still cannot read out the private key.
::: warning Write an honest signing prompt
The message the user signs is the only thing standing between them and a phishing site that asks for
the same signature. State the domain, state the connected address, and state explicitly that the
signature does not authorize any transfer or trade by itself. Keep those guarantees if you adapt
this flow.
:::
### Hosted agents
If your backend holds the agent key, standard key-management applies — HSM or KMS, no key material
in source control or logs, rotation via `authorizeAgent` with a new address.
Grant the narrowest permission set that works. A copy-trading service that only mirrors entries and
exits should not be able to remove margin:
```ts
const permissions = (1n << 0n) | (1n << 1n) // MARKET_OPEN + MARKET_CLOSE only
```
See [Actions Reference](/gasless/actions#permission-bits) for the bit layout.
## Revocation
```solidity
function revokeAgent(address agent) external;
function revokeAgentByName(bytes32 name) external;
function revokeAllAgents() external;
```
Effective as soon as the transaction confirms — no expiry to wait out.
::: warning Order matters when resetting
Revoke onchain and **wait for the receipt** before deleting the local key. Doing it the other way
around leaves an authorization live for a key the user can no longer control.
:::
## Nonce and deadline
```ts
const nonce = BigInt(Date.now()) // milliseconds, uint64
const deadline = Math.floor(Date.now() / 1000) + 300 // seconds, uint48
```
| Constraint | Enforced by |
| :--- | :--- |
| `nonce > lastNonce[trader, signer]` | Contract |
| `nonce` not older than 2 days | Contract and backend |
| `nonce` not more than 5 minutes in the future | Contract and backend |
| `deadline` in the future | Contract and backend |
Nonce sequences are tracked per `(trader, signer)` pair, so several agents for the same trader do not
collide. Read the current value with `getLastNonce(trader, signer)`.
::: warning High-frequency clients
Two intents signed in the same millisecond share a nonce and the second is skipped. Maintain a
monotonic counter instead of calling `Date.now()` twice:
```ts
let last = 0n
const nextNonce = () => (last = BigInt(Math.max(Date.now(), Number(last) + 1)))
```
:::
Also note `nonce` is a `uint64` millisecond timestamp — larger than `Number.MAX_SAFE_INTEGER`. Send
it as a **string** in JSON.
---
Source: https://developer-docs.leverup.xyz/gasless/authorization.md
# Authorizing an Agent
A one-time onchain transaction from the trader's main wallet that grants a signing key permission to
submit trade intents on their behalf.
Skip this page entirely if you are [self-signing](/gasless/overview#integration-modes) —
when `signer == trader`, the contract accepts intents without any authorization.
All functions are on the Diamond (`0xea1b8E4aB7f14F7dCA68c5B214303B13078FC5ec`).
## Which route do you need
| You are building | How the agent gets authorized |
| :--- | :--- |
| A frontend or wallet | Your app calls `authorizeAgent` during onboarding — [below](#authorize). Users should never leave your product to set this up. |
| A bot trading **its own** wallet | Nothing to do. [Self-sign](/gasless/overview#integration-modes) instead. |
| A bot trading **someone else's** wallet | That trader authorizes your bot's address once. They can do it from the [Agent Wallet Manager](#authorize-from-the-app) without you shipping any UI. |
## Authorize from the app
The LeverUp app has a UI for this, at
[app.leverup.xyz/agent-wallets](https://app.leverup.xyz/agent-wallets). Use it to onboard a
counterparty who has no interface of their own, or to inspect and revoke authorizations while
debugging your integration.
1. Open the page and connect the **trader's main wallet**.
2. **+ Add Agent**.
3. Paste the **agent address** — the public address of the signing key you generated. The private
key stays with you; the app never asks for it.
4. Set a **name** (unique per trader) and **permissions** — `ALL`, or tick individual actions.
5. **Authorize**, and confirm the one transaction.
The agent then appears as `ACTIVE`, with **Update** for name and permissions and **Revoke** to
remove it. Every button maps to a contract call documented on this page, so an authorization made
here and one made from your own code are indistinguishable onchain.
## Authorize
```solidity
function authorizeAgent(address agent, bytes32 name, uint256 permissions) external;
```
| Parameter | Notes |
| :--- | :--- |
| `agent` | The signing key's address. Must not be zero or the trader's own address. |
| `name` | A `bytes32` label, unique per trader. Must not be zero. |
| `permissions` | Bitmask — bit *n* grants action *n*. `type(uint256).max` grants everything. |
```ts
import { stringToHex, maxUint256 } from 'viem'
import { publicClient, walletClient, DIAMOND } from './config'
const AUTHORIZE_ABI = [{
type: 'function',
name: 'authorizeAgent',
stateMutability: 'nonpayable',
inputs: [
{ name: 'agent', type: 'address' },
{ name: 'name', type: 'bytes32' },
{ name: 'permissions', type: 'uint256' },
],
outputs: [],
}] as const
const hash = await walletClient.writeContract({
address: DIAMOND,
abi: AUTHORIZE_ABI,
functionName: 'authorizeAgent',
args: [
agentAddress,
stringToHex('LeverUp 1CT', { size: 32 }),
maxUint256,
],
})
await publicClient.waitForTransactionReceipt({ hash })
```
Calling `authorizeAgent` again for an address that is already authorized **updates** it — changing
the name and/or permissions — rather than creating a duplicate.
A trader can have several agents. `name` must be unique within a trader's set; reusing one reverts
with `OneClickV2: name taken`.
## Choosing permissions
`permissions` is a `uint256` where **bit number = action value**:
```ts
import { maxUint256 } from 'viem'
const openOnly = 1n << 0n // MARKET_OPEN
const tradeOnly = (1n << 0n) | (1n << 1n) // MARKET_OPEN + MARKET_CLOSE
const everything = maxUint256 // wildcard
```
The full bit map is in [Actions Reference](/gasless/actions#permission-bits).
::: warning `maxUint256` is a wildcard, not a bit pattern
The contract special-cases `type(uint256).max` and skips per-bit checking entirely. Any other value
is checked bit by bit. This means "everything except one action" must be written as an explicit
mask, and that mask does **not** behave as a wildcard:
```ts
const noWithdraw = maxUint256 & ~(1n << 6n) // every action except REMOVE_MARGIN
```
It also means an explicit mask is a snapshot. Action numbers are **appended** as the protocol gains
operations, and an agent authorized before an action existed does not hold its bit — intents for
that action come back `skipReason = INVALID` until the trader calls
[`updateAgentPermissions`](#update-and-revoke). A wildcard agent picks up new actions with no
action required.
:::
## Query
```solidity
function isAgentAuthorized(address trader, address agent) external view returns (bool);
function hasPermission(address trader, address agent, uint8 action) external view returns (bool);
function getAgentAuth(address trader, address agent) external view returns (AgentAuth memory);
function getAgentByName(address trader, bytes32 name) external view returns (address);
function getAgentCount(address trader) external view returns (uint256);
function getAgents(address trader, uint256 offset, uint256 limit) external view returns (AgentAuth[] memory);
function getLastNonce(address trader, address signer) external view returns (uint64);
```
```solidity
struct AgentAuth {
address agent;
bytes32 name;
uint256 permissions;
uint32 authorizedAt;
}
```
To check whether 1CT is enabled for a user, read `getAgentAuth(user, agentAddress)` and treat a
non-zero `agent` field as authorized:
```ts
const AGENT_AUTH_ABI = [{
type: 'function',
name: 'getAgentAuth',
stateMutability: 'view',
inputs: [{ name: 'trader', type: 'address' }, { name: 'agent', type: 'address' }],
outputs: [{
type: 'tuple', name: '', components: [
{ name: 'agent', type: 'address' },
{ name: 'name', type: 'bytes32' },
{ name: 'permissions', type: 'uint256' },
{ name: 'authorizedAt', type: 'uint32' },
],
}],
}] as const
const auth = await publicClient.readContract({
address: DIAMOND,
abi: AGENT_AUTH_ABI,
functionName: 'getAgentAuth',
args: [user, agentAddress],
})
const isEnabled = auth.agent !== '0x0000000000000000000000000000000000000000'
```
`getAgents` is paginated — use `getAgentCount` to size the query.
## Update and revoke
```solidity
function updateAgentPermissions(address agent, uint256 permissions) external;
function updateAgentName(address agent, bytes32 newName) external;
function revokeAgent(address agent) external;
function revokeAgentByName(bytes32 name) external;
function revokeAllAgents() external;
```
```ts
const REVOKE_ABI = [{
type: 'function',
name: 'revokeAgent',
stateMutability: 'nonpayable',
inputs: [{ name: 'agent', type: 'address' }],
outputs: [],
}] as const
const hash = await walletClient.writeContract({
address: DIAMOND,
abi: REVOKE_ABI,
functionName: 'revokeAgent',
args: [agentAddress],
})
await publicClient.waitForTransactionReceipt({ hash })
```
::: warning
When resetting a user's 1CT setup, revoke onchain and wait for the receipt **before** deleting the
local key material. The reverse order strands a live authorization on a key nobody holds.
:::
## Token approvals
Authorization grants permission to *trade*, not to move tokens. The trader must still approve the
Diamond for:
1. The **collateral token** they intend to trade with (USDC, WMON, LVUSD, LVMON).
2. The **execution fee token** — see [Execution Fee](/gasless/fees).
Without those approvals, intents are accepted by the endpoint and then skipped onchain with
`skipReason = "FEE"` or a collateral transfer failure.
Next: [Actions Reference](/gasless/actions).
---
Source: https://developer-docs.leverup.xyz/gasless/actions.md
# Actions Reference
Every 1CT intent names one **action**. The action determines three things: the EIP-712 type name you
sign under, the ABI layout of `actionData`, and the permission bit required.
## Action list
| # | Action | Description | `actionData` ABI types |
| :--- | :--- | :--- | :--- |
| 0 | `MARKET_OPEN` | Open at market | `address,bool,address,address,uint96,uint128,uint128,uint128,uint128,uint24,uint96` |
| 1 | `MARKET_CLOSE` | Close fully | `bytes32,uint24` |
| 2 | `LIMIT_OPEN` | Place a limit order | same as `MARKET_OPEN` |
| 3 | `LIMIT_CANCEL` | Cancel a limit order | `bytes32` |
| 4 | `LIMIT_UPDATE_TP_SL` | Change a limit order's TP/SL | `bytes32,uint128,uint128` |
| 5 | `ADD_MARGIN` | Add margin | `bytes32,address,uint96` |
| 6 | `REMOVE_MARGIN` | Remove margin | `bytes32,uint96` |
| 7 | `UPDATE_TP_SL` | Change a position's TP/SL | `bytes32,uint128,uint128` |
| 8 | `BATCH_MARKET_CLOSE` | Close several positions | `bytes32[],uint24` |
| 9 | `PARTIAL_CLOSE` | Close part of a position | `bytes32,uint128,uint24` |
| 10 | `CANCEL_DECREASE_ORDER` | Cancel a TP/SL order | `bytes32` |
| 11 | `BATCH_CREATE_DECREASE_ORDERS` | Create TP/SL orders | `bytes32,(uint8,uint128,uint128,uint24)[]` |
| 12 | `BATCH_UPDATE_DECREASE_ORDERS` | Update TP/SL orders | `(bytes32,uint128,uint128,uint24)[]` |
| 13 | `CANCEL_ALL_DECREASE_ORDERS` | Cancel every TP/SL order on a position | `bytes32` |
The listed ABI types are the *parameters* — the encoding also prepends `trader`, then strips it. See
[Building actionData](/gasless/signing#building-actiondata); getting this wrong is the single most
common 1CT integration bug.
## Parameters
### Opening — actions 0 and 2
| Position | Field | Type | Notes |
| :--- | :--- | :--- | :--- |
| 0 | `pairBase` | `address` | Market identifier |
| 1 | `isLong` | `bool` | |
| 2 | `tokenIn` | `address` | ERC-20 only, no native MON |
| 3 | `lvToken` | `address` | Must match `tokenIn` — [see the table](/introduction/concepts#collateral-and-lvtoken) |
| 4 | `amountIn` | `uint96` | `tokenIn` decimals, margin plus open fee |
| 5 | `qty` | `uint128` | Base asset, **1e10** |
| 6 | `price` | `uint128` | **1e18** — worst acceptable price (market) or limit price (limit) |
| 7 | `stopLoss` | `uint128` | 1e18, 0 to disable |
| 8 | `takeProfit` | `uint128` | 1e18, 0 to disable |
| 9 | `broker` | `uint24` | Referral id. `0` credits the default broker — see [Brokers & Referrals](/introduction/brokers) |
| 10 | `extraFee` | `uint96` | Optional surcharge in `tokenIn`, 0 if unused |
::: warning
`extraFee` is part of `actionData` for 1CT opens, where the onchain path takes it as a separate
argument. There are **11** values, not 10.
:::
Both work exactly as they do onchain: the fee share costs the trader nothing, `extraFee` is charged
on top and paid to your broker's receiver. On the gasless path the trader's allowance to the Diamond
must cover `amountIn + extraFee + antiDdosFee` when the [execution fee](/gasless/fees) is charged in
the same token.
### Closing — actions 1, 8, 9
| Action | Values |
| :--- | :--- |
| `MARKET_CLOSE` | `[positionHash, broker]` |
| `BATCH_MARKET_CLOSE` | `[[hashA, hashB, …], broker]` |
| `PARTIAL_CLOSE` | `[positionHash, closeQty, broker]` — `closeQty` at 1e10 |
### Margin — actions 5, 6
| Action | Values |
| :--- | :--- |
| `ADD_MARGIN` | `[positionHash, tokenIn, amount]` — `amount` in `tokenIn` decimals |
| `REMOVE_MARGIN` | `[positionHash, lvAmount]` — `lvAmount` in **`lvToken`** decimals |
### TP/SL — actions 4, 7, 10, 11, 12, 13
| Action | Values |
| :--- | :--- |
| `LIMIT_UPDATE_TP_SL` | `[orderHash, takeProfit, stopLoss]` |
| `UPDATE_TP_SL` | `[positionHash, takeProfit, stopLoss]` |
| `CANCEL_DECREASE_ORDER` | `[orderHash]` — one order |
| `CANCEL_ALL_DECREASE_ORDERS` | `[positionHash]` — every order on the position |
| `BATCH_CREATE_DECREASE_ORDERS` | `[positionHash, DecreaseOrderInput[]]` |
| `BATCH_UPDATE_DECREASE_ORDERS` | `[DecreaseOrderUpdateInput[]]` |
```
DecreaseOrderInput = (kind, triggerPrice, closeQty, broker) // kind: 0 = TP, 1 = SL
DecreaseOrderUpdateInput = (orderHash, triggerPrice, closeQty, broker)
```
::: warning Actions 10 and 13 take different hashes
Both encode as a single `bytes32`, so a swapped argument produces a perfectly valid signature and
a well-formed `actionData` — it fails onchain, not at submission. `CANCEL_DECREASE_ORDER` (10)
takes an **`orderHash`**; `CANCEL_ALL_DECREASE_ORDERS` (13) takes a **`positionHash`**.
The two failures do not even look alike: action 13 given an `orderHash` reverts `NonexistentTrade`,
while action 10 given a `positionHash` reverts `Error("TradingPortalFacet: not order owner")` — a
string revert, so a handler that only decodes custom errors will report it as `Unknown error`.
:::
`CANCEL_ALL_DECREASE_ORDERS` clears every decrease order attached to the position — take-profit legs
and the stop-loss alike — and emits one `DecreaseOrderCancelled` per order removed. The position
itself is untouched. On a position that exists but has no orders it succeeds and does nothing; on a
position that no longer exists it fails with [`NonexistentTrade`](/reference/errors), so cancelling
after a full close is an error, not a no-op.
Behaviour matches the onchain path — both kinds may be partial, and there may be several of each up
to the per-position cap. See [TP/SL Orders](/onchain/tpsl-orders).
## Referrals
Every fee-charging action carries a `broker` id, and the two opens additionally accept `extraFee`:
| Action | `broker` | `extraFee` |
| :--- | :---: | :---: |
| `MARKET_OPEN` (0), `LIMIT_OPEN` (2) | yes | yes |
| `MARKET_CLOSE` (1), `BATCH_MARKET_CLOSE` (8), `PARTIAL_CLOSE` (9) | yes | — |
| `BATCH_CREATE_DECREASE_ORDERS` (11), `BATCH_UPDATE_DECREASE_ORDERS` (12) | per order | — |
| Actions 3–7, 10, 13 | — | — |
Actions 3–7, 10 and 13 charge no fee, so there is nothing to share. Note that the close broker is
independent of the open broker — a broker integration has to pass its id on opens, closes, and every
TP/SL order. See [Brokers & Referrals](/introduction/brokers).
## EIP-712 type names
The `primaryType` is what distinguishes one action from another in a signature. The field list is
identical for all of them.
| Action | `primaryType` |
| :--- | :--- |
| 0 | `OneClickMarketOpen` |
| 1 | `OneClickMarketClose` |
| 2 | `OneClickLimitOpen` |
| 3 | `OneClickLimitCancel` |
| 4 | `OneClickLimitUpdateTpSl` |
| 5 | `OneClickAddMargin` |
| 6 | `OneClickRemoveMargin` |
| 7 | `OneClickUpdateTpSl` |
| 8 | `OneClickBatchMarketClose` |
| 9 | `OneClickPartialClose` |
| 10 | `OneClickCancelDecreaseOrder` |
| 11 | `OneClickBatchCreateDecreaseOrders` |
| 12 | `OneClickBatchUpdateDecreaseOrders` |
| 13 | `OneClickCancelAllDecreaseOrders` |
## Permission bits
Bit number equals action value.
| bit | Action | bit | Action |
| :--- | :--- | :--- | :--- |
| 0 | `MARKET_OPEN` | 7 | `UPDATE_TP_SL` |
| 1 | `MARKET_CLOSE` | 8 | `BATCH_MARKET_CLOSE` |
| 2 | `LIMIT_OPEN` | 9 | `PARTIAL_CLOSE` |
| 3 | `LIMIT_CANCEL` | 10 | `CANCEL_DECREASE_ORDER` |
| 4 | `LIMIT_UPDATE_TP_SL` | 11 | `BATCH_CREATE_DECREASE_ORDERS` |
| 5 | `ADD_MARGIN` | 12 | `BATCH_UPDATE_DECREASE_ORDERS` |
| 6 | `REMOVE_MARGIN` | 13 | `CANCEL_ALL_DECREASE_ORDERS` |
```ts
const canTrade = (1n << 0n) | (1n << 1n) | (1n << 9n) // open, close, partial close
```
::: warning Action numbers are appended over time
An agent authorized with an explicit bitmask holds exactly the bits it was granted. When a new
action is added, existing agents do **not** gain it — they keep returning `skipReason = INVALID`
for that action until the trader calls
[`updateAgentPermissions`](/gasless/authorization#update-and-revoke). Agents authorized with the
`type(uint256).max` wildcard are unaffected and pick up new actions automatically.
:::
## Actions that require an indexed position
Some actions need the position to exist in the relayer's database so it can resolve which oracle
feeds to fetch. These are:
`MARKET_CLOSE` · `BATCH_MARKET_CLOSE` · `PARTIAL_CLOSE` · `ADD_MARGIN` · `REMOVE_MARGIN` ·
`BATCH_CREATE_DECREASE_ORDERS`
Submitting one of these for a position that was opened moments ago can return
`404 Position not found` — the indexer has not caught up yet. Retry with backoff, or wait for the
position to appear in [`getPositionsV4`](/onchain/reading-data#getpositionsv4) first. This is the
only submission failure that is not a `400`.
**Being addressed by `positionHash` is not the test** — needing the relayer to *look the position
up* is. The opens (0 and 2) consume oracle data too, but they carry `pairBase` and `lvToken` in
`actionData`, so their feeds resolve without any lookup and they can never return this error.
The following actions only read or remove stored order state, so they need no feeds at all and are
never subject to this, even though some of them take a `positionHash`:
`LIMIT_CANCEL` · `LIMIT_UPDATE_TP_SL` · `UPDATE_TP_SL` · `CANCEL_DECREASE_ORDER` ·
`BATCH_UPDATE_DECREASE_ORDERS` · `CANCEL_ALL_DECREASE_ORDERS`
In particular `CANCEL_ALL_DECREASE_ORDERS` (13) is `positionHash`-addressed but exempt: you can
clear a freshly-opened position's orders without waiting for the indexer.
Next: [Signing Intents](/gasless/signing).
---
Source: https://developer-docs.leverup.xyz/gasless/signing.md
# Signing Intents
An intent is seven fields, signed with EIP-712. The trade's business parameters are not in the
signed struct directly — they are ABI-encoded into `actionData` and included as a hash.
Two rules account for almost every signature failure:
1. `actionData` is encoded **with `trader` prepended, then the first 32 bytes stripped**.
2. All actions share **one** field list; the action is identified by the `primaryType` name.
## Domain
```ts
const domain = {
name: 'LeverupOneClickV2',
version: '1',
chainId: 143,
verifyingContract: '0xea1b8E4aB7f14F7dCA68c5B214303B13078FC5ec', // the Diamond
} as const
```
::: warning
`verifyingContract` is the **Diamond**. There is no separate agent contract in this version.
:::
## Message type
```ts
const COMMON_EIP712_FIELDS = [
{ name: 'trader', type: 'address' },
{ name: 'action', type: 'uint8' },
{ name: 'nonce', type: 'uint64' },
{ name: 'deadline', type: 'uint48' },
{ name: 'feeToken', type: 'address' },
{ name: 'antiDdosFee', type: 'uint96' },
{ name: 'actionDataHash', type: 'bytes32' },
] as const
```
Identical for all fourteen actions. The type *string* is:
```
OneClickXxx(address trader,uint8 action,uint64 nonce,uint48 deadline,address feeToken,uint96 antiDdosFee,bytes32 actionDataHash)
```
where `OneClickXxx` is the action's [type name](/gasless/actions#eip-712-type-names). Two intents
with identical fields but different `primaryType` produce different signatures — that is what binds a
signature to an action.
## Building `actionData`
```
actionData = abi.encode(trader, ...actionParams).slice(32 bytes)
```
Encode `trader` as the first parameter, then drop the first 32-byte word.
**Why.** The contract reconstructs the calldata by concatenating the function selector, the encoded
`trader`, and your `actionData`:
```solidity
bytes memory cd = bytes.concat(selector, abi.encode(intent.trader), intent.actionData);
address(this).call(cd);
```
For actions containing dynamic types — arrays and structs — the ABI offset pointers must be computed
against a layout that *includes* the `trader` word. Encoding the parameters alone produces offsets
that are one word too small, and the contract decodes garbage.
For purely static actions (0–7, 9, 10, 13) the result is byte-identical either way. Use the formula
uniformly and you will never hit the edge case.
### Implementation
```ts
import { encodeAbiParameters, parseAbiParameter, type Hex, type Address } from 'viem'
const ACTION_DATA_ABI_TYPES: Record = {
0: ['address','bool','address','address','uint96','uint128','uint128','uint128','uint128','uint24','uint96'],
1: ['bytes32','uint24'],
2: ['address','bool','address','address','uint96','uint128','uint128','uint128','uint128','uint24','uint96'],
3: ['bytes32'],
4: ['bytes32','uint128','uint128'],
5: ['bytes32','address','uint96'],
6: ['bytes32','uint96'],
7: ['bytes32','uint128','uint128'],
8: ['bytes32[]','uint24'],
9: ['bytes32','uint128','uint24'],
10: ['bytes32'],
11: ['bytes32', '(uint8,uint128,uint128,uint24)[]'],
12: ['(bytes32,uint128,uint128,uint24)[]'],
13: ['bytes32'],
}
const strip = (hex: Hex): Hex => `0x${hex.slice(2 + 64)}` as Hex
export function buildActionData(action: number, trader: Address, values: unknown[]): Hex {
// Tuple arrays need parseAbiParameter — viem does not accept them as plain type strings.
if (action === 11) {
return strip(encodeAbiParameters(
[{ type: 'address' }, { type: 'bytes32' }, parseAbiParameter('(uint8,uint128,uint128,uint24)[]')],
[trader, values[0] as Hex, values[1] as any],
))
}
if (action === 12) {
return strip(encodeAbiParameters(
[{ type: 'address' }, parseAbiParameter('(bytes32,uint128,uint128,uint24)[]')],
[trader, values[0] as any],
))
}
const types = ACTION_DATA_ABI_TYPES[action]
if (!types) throw new Error(`Unknown action: ${action}`)
return strip(encodeAbiParameters(
['address', ...types].map((t) => ({ type: t })),
[trader, ...values] as any,
))
}
```
With ethers v6:
```ts
import { AbiCoder } from 'ethers'
const coder = AbiCoder.defaultAbiCoder()
const full = coder.encode(['address', ...abiTypes], [trader, ...values])
const actionData = '0x' + full.slice(2 + 64)
```
### Self-check
If you are debugging a dynamic action, compare against these known-good offsets:
| Action | Offset value in the encoding | Array starts at byte |
| :--- | :--- | :--- |
| `BATCH_MARKET_CLOSE` (8) | `0x60` | `0x40` |
| `BATCH_CREATE_DECREASE_ORDERS` (11) | `0x60` | `0x40` |
| `BATCH_UPDATE_DECREASE_ORDERS` (12) | `0x40` | `0x20` |
An offset one word smaller than the table means the `trader` prefix was omitted.
## Nonce and deadline
```ts
const nonce = BigInt(Date.now()) // milliseconds, uint64
const deadline = Math.floor(Date.now() / 1000) + 300 // seconds, uint48
```
Constraints and the high-frequency caveat are covered in
[Security Model → Nonce and deadline](/gasless/security#nonce-and-deadline).
## Signing
```ts
import { keccak256 } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
const ACTION_TYPE_NAMES: Record = {
0: 'OneClickMarketOpen', 1: 'OneClickMarketClose',
2: 'OneClickLimitOpen', 3: 'OneClickLimitCancel',
4: 'OneClickLimitUpdateTpSl', 5: 'OneClickAddMargin',
6: 'OneClickRemoveMargin', 7: 'OneClickUpdateTpSl',
8: 'OneClickBatchMarketClose', 9: 'OneClickPartialClose',
10: 'OneClickCancelDecreaseOrder',
11: 'OneClickBatchCreateDecreaseOrders',
12: 'OneClickBatchUpdateDecreaseOrders',
13: 'OneClickCancelAllDecreaseOrders',
}
const signer = privateKeyToAccount(signingKey)
const typeName = ACTION_TYPE_NAMES[action]
const actionData = buildActionData(action, trader, actionValues)
const actionDataHash = keccak256(actionData)
const signature = await signer.signTypedData({
domain,
types: { [typeName]: COMMON_EIP712_FIELDS },
primaryType: typeName,
message: {
trader,
action,
nonce,
deadline,
feeToken,
antiDdosFee,
actionDataHash,
},
})
```
`signTypedData` on a local account is silent — no popup. That is the whole point of the browser-key
and hosted-agent modes. [Self-signing](/gasless/overview#integration-modes) through a browser wallet
works too, but prompts the user each time.
## Worked example — market open
```ts
import { parseUnits, keccak256 } from 'viem'
const actionValues = [
pairBase, // pairBase
true, // isLong
USDC, // tokenIn
LVUSD, // lvToken
parseUnits('10', 6), // amountIn
parseUnits((100 / 100_000).toFixed(10), 10), // qty — $100 of BTC at $100k
parseUnits('101000', 18), // price — worst acceptable
0n, // stopLoss
0n, // takeProfit
0, // broker — 0 credits the default broker
0n, // extraFee — optional surcharge, see /introduction/brokers
]
const actionData = buildActionData(0, trader, actionValues)
const actionDataHash = keccak256(actionData)
```
Then sign as above and [submit](/gasless/submit).
## Worked example — TP/SL orders
```ts
// TP at $110k for half the position, SL at $90k for all of it.
const actionValues = [
positionHash,
[
[0, parseUnits('110000', 18), position.qty / 2n, 0], // kind 0 = TP
[1, parseUnits('90000', 18), position.qty, 0], // kind 1 = SL
],
]
const actionData = buildActionData(11, trader, actionValues)
```
Note the tuple array is passed as arrays of positional values, not objects.
## Debugging checklist
Persistent `skipReason = INVALID` with a signature that looks correct usually means one of:
- The EIP-712 struct expands the business fields instead of using `actionDataHash`.
- `primaryType` does not match the action.
- `verifyingContract` is not the Diamond address.
- The agent is not authorized, or lacks the permission bit for this action.
Batch actions (8, 11, 12) that decode as garbage onchain mean the `trader` prefix was left out of
`actionData`.
Next: [Execution Fee](/gasless/fees).
---
Source: https://developer-docs.leverup.xyz/gasless/fees.md
# Execution Fee
Because the protocol pays gas for 1CT trades, each intent carries a small onchain fee in an ERC-20
token. It is priced in USD, configured per action, and charged when the intent executes.
The fee is part of the signed message (`feeToken` and `antiDdosFee`), so you must resolve it
*before* signing — and the values you sign have to match the server's current configuration.
## How it works
- Each action has a USD fee amount and a set of accepted fee tokens, each with a priority.
- The server converts USD to token amounts and republishes the configuration **every 60 seconds**.
- The last two generations of configuration remain valid, so a signature stays acceptable for about
**120 seconds** after you read the config.
- On execution the contract transfers `antiDdosFee` of `feeToken` from the trader. A failed transfer
skips the intent with `skipReason = "FEE"` — it is not executed.
- When `antiDdosFee` is `0`, no transfer is attempted onchain — but the relayer still checks
`feeToken` at submission. A disabled action must be signed with `feeToken = 0x0000…0000`; pairing
a zero fee with a real token address is rejected with `400 anti-ddos not enabled`.
## Fetching the configuration
```http
GET /v2/trading/anti-ddos-config
```
```json
[
{ "action": 0, "actionName": "MARKET_OPEN", "feeToken": "0x7547…", "antiDdosFee": "500000", "enabled": true, "priority": 0 },
{ "action": 0, "actionName": "MARKET_OPEN", "feeToken": "0x3bd3…", "antiDdosFee": "12345678901234567", "enabled": true, "priority": 1 },
{ "action": 1, "actionName": "MARKET_CLOSE", "feeToken": "0x0000000000000000000000000000000000000000", "antiDdosFee": "0", "enabled": false, "priority": 2147483647 }
]
```
| Field | Meaning |
| :--- | :--- |
| `action` | Action number |
| `feeToken` | ERC-20 address the fee is charged in |
| `antiDdosFee` | Amount in that token's decimals, as a string |
| `enabled` | `false` means this action is free |
| `priority` | Lower is preferred |
**One action appears on several rows** — one per accepted fee token. When an action is disabled, sign
with `feeToken = 0x0000…0000` and `antiDdosFee = 0`.
Refresh every 60 seconds and cache the result. Do not fetch it inline while signing.
```ts
let feeConfig: AntiDdosConfig[] = []
export async function refreshFeeConfig(baseUrl: string) {
const res = await fetch(`${baseUrl}/v2/trading/anti-ddos-config`)
if (!res.ok) throw new Error(`anti-ddos config: HTTP ${res.status}`)
feeConfig = await res.json()
}
export function startFeeConfigRefresh(baseUrl: string) {
void refreshFeeConfig(baseUrl).catch(console.warn)
return setInterval(() => void refreshFeeConfig(baseUrl).catch(console.warn), 60_000)
}
```
## Selecting a fee token
Walk the enabled options for the action in priority order and pick the first one the trader can
actually pay.
```
for config of options.sortBy(priority):
required = config.antiDdosFee
+ (amount this trade spends of the same token, if any)
if balance(feeToken) >= required and allowance(feeToken, diamond) >= required:
use it
```
```ts
export function selectFeeToken(
action: number,
getAccountState: (token: Address) => { balance: bigint; allowance: bigint } | undefined,
additionalSpends: readonly { token: Address; amount: bigint }[] = [],
): { feeToken: Address; antiDdosFee: bigint } {
const options = feeConfig
.filter((c) => c.action === action && c.enabled)
.sort((a, b) => a.priority - b.priority)
if (options.length === 0) return { feeToken: ZERO_ADDRESS, antiDdosFee: 0n }
for (const opt of options) {
const token = opt.feeToken as Address
const state = getAccountState(token)
if (!state) continue
const extra = additionalSpends
.filter((s) => s.token.toLowerCase() === token.toLowerCase())
.reduce((sum, s) => sum + s.amount, 0n)
const required = BigInt(opt.antiDdosFee) + extra
if (state.balance >= required && state.allowance >= required) {
return { feeToken: token, antiDdosFee: BigInt(opt.antiDdosFee) }
}
}
throw new Error('No fee token with sufficient balance and allowance')
}
```
::: danger Add the trade's own spend
If the trade itself spends the same token as the fee, that amount must be added to `required`.
Skipping this produces intents that pass selection and then fail onchain with
`skipReason = "FEE"`, because the collateral transfer consumed the balance the fee needed.
What counts as the trade's own spend:
| Action | Spends |
| :--- | :--- |
| `MARKET_OPEN`, `LIMIT_OPEN` | `amountIn` **plus [`extraFee`](/introduction/brokers#extrafee)** if you set one |
| `ADD_MARGIN` | `amount` |
```ts
const { feeToken, antiDdosFee } = selectFeeToken(
OneClickAction.MARKET_OPEN,
getCachedErc20State,
[{ token: tokenIn, amount: amountIn + extraFee }], // ← this line
)
```
`extraFee` is the one most integrations miss, because it is optional and usually zero during
development. A broker integration that turns it on later will start seeing `FEE` skips unless this
is accounted for.
:::
## Cache balances and allowances
`selectFeeToken` should read from a cache, never issue RPC calls inline — a round trip in the signing
path defeats the purpose of 1CT.
Prefetch `balanceOf(trader)` and `allowance(trader, diamond)` for every enabled fee token, and
refresh:
- on a timer,
- when the window regains focus,
- whenever the fee configuration changes.
Distinguish the failure modes so you can show something useful:
| State | Meaning | UI |
| :--- | :--- | :--- |
| `config-not-loaded` | Fee config not fetched yet | Wait |
| `account-state-not-ready` | Balance/allowance cache not warm | Wait |
| `needs-approval` | Balance is sufficient, allowance is not | Prompt for approval |
| `insufficient-balance` | Neither is sufficient | Prompt to fund |
## Approvals
The trader must approve the **Diamond** for each fee token they might pay in. Without it, intents are
accepted and then skipped onchain.
Approving the collateral token is separate — see
[Authorizing an Agent → Token approvals](/gasless/authorization#token-approvals). When the fee token
and the collateral token are the same, the allowance has to cover
`amountIn + extraFee + antiDdosFee` in total.
## Failure modes
| Symptom | Cause |
| :--- | :--- |
| `400 antiDdosFee mismatch` | Signed with a config older than ~120 seconds |
| `400 feeToken not supported` | Fee token not in the configuration for that action |
| `400 anti-ddos not enabled` | Disabled action signed with a non-zero fee, **or** with a `feeToken` other than `0x0000…0000` |
| `skipReason = FEE` | No approval to the Diamond, or the balance was consumed by the trade itself |
Next: [Submit & Track](/gasless/submit).
---
Source: https://developer-docs.leverup.xyz/gasless/submit.md
# Submit & Track
## Submit an intent
```http
POST /v2/trading/submit-intent?blockchain=MONAD
Content-Type: application/json
```
```json
{
"trader": "0x…",
"action": 0,
"nonce": "1785312000123",
"deadline": 1785312300,
"feeToken": "0x…",
"antiDdosFee": "500000",
"actionData": "0x…",
"signature": "0x…"
}
```
Returns `200` with the intent hash as a JSON string, e.g. `"0xabc123…"`.
::: warning
`nonce` and `antiDdosFee` must be **strings**. `nonce` is a millisecond timestamp that exceeds
`Number.MAX_SAFE_INTEGER`; sending it as a JSON number silently corrupts it and the signature check
fails.
:::
```ts
const res = await fetch(`${baseUrl}/v2/trading/submit-intent?blockchain=MONAD`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
trader,
action,
nonce: nonce.toString(),
deadline,
feeToken,
antiDdosFee: antiDdosFee.toString(),
actionData,
signature,
}),
})
if (!res.ok) throw new Error(`submit-intent ${res.status}: ${await res.text()}`)
const intentHash = (await res.text()).replace(/^"|"$/g, '')
```
### The intent hash
Computed identically by the backend and the contract:
```solidity
intentHash = keccak256(abi.encode(trader, action, nonce, deadline, actionData));
```
It excludes `feeToken`, `antiDdosFee` and `signature`, and is **not** the EIP-712 digest. Use the
value returned by the response rather than deriving it yourself.
## Validation
The endpoint checks the following before queueing. Any failure returns `400`, except the position
lookup, which returns `404`.
| Check | Error text contains |
| :--- | :--- |
| `deadline` in the future | `deadline already passed` |
| `nonce` not older than 2 days | `nonce too old` |
| `nonce` not more than 5 minutes ahead | `nonce too far in future` |
| `action` in `[0, 13]` | `invalid action` |
| `trader` / `feeToken` are valid addresses | `invalid … address` |
| `actionData` / `signature` non-empty | `must not be empty` |
| `(action, feeToken, antiDdosFee)` matches current or previous config | `antiDdosFee mismatch`, `feeToken not supported`, `anti-ddos not enabled` |
| `actionData` decodes under the action's ABI | decode error |
| For actions whose oracle feeds are resolved from a stored position, the position is known | `Position not found` (`404`) |
::: info The signature is not checked here
Signature validity and agent permissions are enforced **onchain**, not at submission. An intent with
a bad signature or an unauthorized agent is accepted with `200` and later skipped with
`skipReason = INVALID`. Make sure the agent is authorized and holds the right permission bit before
you rely on a `200`.
:::
::: warning Position not found
Actions whose oracle feeds the relayer resolves by looking the position up require that position to
be indexed — `MARKET_CLOSE`, `BATCH_MARKET_CLOSE`, `PARTIAL_CLOSE`, `ADD_MARGIN`, `REMOVE_MARGIN`,
`BATCH_CREATE_DECREASE_ORDERS`. Closing right after opening can hit this. Retry with backoff, or
confirm the position exists via [`getPositionsV4`](/onchain/reading-data#getpositionsv4) first.
Opens are unaffected — they carry the market in `actionData`. Order-only actions are also exempt,
including `CANCEL_ALL_DECREASE_ORDERS`, which takes a `positionHash` but needs no lookup. See
[Actions Reference](/gasless/actions#actions-that-require-an-indexed-position).
This is the only submission failure that is not a `400`.
:::
## Poll status
```http
GET /v2/trading/{intent_hash}/status
```
```json
{
"submitted": true,
"executed": true,
"success": true,
"skipped": false,
"skipReason": null,
"txnHash": "0x…",
"reason": "0x"
}
```
State machine:
```
POST accepted
→ submitted = false queued, waiting to be relayed
→ submitted = true, executed = false broadcast, waiting for the block
→ executed = true, success = true|false settled onchain
or skipped = true, skipReason = INVALID|NONCE|FEE|UNKNOWN
```
An intent can be skipped either because pre-flight simulation rejected it — in which case it never
goes onchain and costs nothing — or because onchain execution rejected it. The response fields look
the same in both cases.
## Polling strategy
A two-phase poll gives the best user experience:
```
Phase 1 — wait for `submitted` (300ms interval, 20s timeout)
- a terminal state during this window is returned immediately
- on submitted = true, report optimistic success and let the user continue
Phase 2 — keep polling for the terminal state in the background
(300ms interval, 5 minute timeout, matching the intent deadline)
- surface an error if it ends in failure
```
```ts
export async function pollIntentStatus(
baseUrl: string,
intentHash: string,
{ interval = 300, timeout = 300_000 } = {},
): Promise {
const until = Date.now() + timeout
while (Date.now() < until) {
const res = await fetch(`${baseUrl}/v2/trading/${intentHash}/status`)
if (res.ok) {
const status: IntentStatus = await res.json()
if (status.executed || status.skipped) return status
}
await new Promise((r) => setTimeout(r, interval))
}
throw new Error('Timed out waiting for intent execution')
}
```
## Interpreting the result
| State | Meaning | What to do |
| :--- | :--- | :--- |
| `submitted = false` | Queued | Keep waiting |
| `submitted = true, executed = false` | Broadcast | Keep waiting |
| `executed = true, success = true` | Done | Refresh positions and orders |
| `executed = true, success = false` | Executed but reverted; `reason` holds the revert data | Decode and show; retry with a **new nonce** |
| `skipped, skipReason = INVALID` | Bad signature, unauthorized agent, missing permission bit, or expired deadline | Check authorization, deadline, EIP-712 structure and `primaryType` |
| `skipped, skipReason = NONCE` | Nonce not increasing, or outside the window | Re-sign with a larger nonce |
| `skipped, skipReason = FEE` | Fee could not be collected | Check the fee token balance and Diamond allowance |
| `skipped, skipReason = UNKNOWN` | Unclassified | Report it |
## Decoding `reason`
`reason` is the raw revert data from the chain. Decode in this order:
1. A `skipReason` enum value → map directly to a message.
2. Standard `Error(string)` / `Panic(uint256)`.
3. Protocol custom errors — see [Error Reference](/reference/errors#contract-errors).
4. Fall back to a generic message.
```ts
import { decodeErrorResult } from 'viem'
const SOLIDITY_ERROR_ABI = [
{ type: 'error', name: 'Error', inputs: [{ name: 'message', type: 'string' }] },
{ type: 'error', name: 'Panic', inputs: [{ name: 'code', type: 'uint256' }] },
] as const
function decodeReason(reason: string): string {
if (!reason || reason === '0x') return ''
const skip = SKIP_REASON_MESSAGES[reason.toUpperCase()]
if (skip) return skip
try {
const decoded = decodeErrorResult({ abi: SOLIDITY_ERROR_ABI, data: reason as `0x${string}` })
if (decoded.errorName === 'Error') return String(decoded.args?.[0] ?? '')
} catch {
// fall through to protocol custom errors
}
return decodeCustomError(reason) ?? 'The transaction could not be executed.'
}
```
## Retrying
A skipped or reverted intent is **not** retried for you. To retry:
1. Sign again with a **new nonce** — never resubmit the same payload.
2. Refresh the [fee configuration](/gasless/fees) if more than ~120 seconds have passed.
3. Refresh the market price and slippage bound if the action is an open.
## Common integration errors
| Symptom | Likely cause |
| :--- | :--- |
| Persistent `INVALID` with a plausible signature | EIP-712 struct expands business fields instead of using `actionDataHash`; wrong `primaryType`; `verifyingContract` is not the Diamond |
| Static actions work, batch actions (8/11/12) decode as garbage | `actionData` missing the `trader` prefix — see [Building actionData](/gasless/signing#building-actiondata) |
| `antiDdosFee mismatch` | Fee config cache older than ~120 seconds, or a disabled fee token |
| `FEE` skips despite sufficient balance | Missing Diamond approval, or the trade's own spend was not added to the required amount |
| Occasional `NONCE` under load | Two intents signed in the same millisecond |
| Signature fails after a working prototype | `nonce` sent as a JSON number instead of a string |
Next: [Reference Client](/gasless/reference-client).
---
Source: https://developer-docs.leverup.xyz/gasless/reference-client.md
# Reference Client
A complete, dependency-light TypeScript client covering fee selection, `actionData` encoding, EIP-712
signing, submission and polling. Copy it into your project and adapt — there is no npm package to
install.
Requires `viem`.
## `leverup-1ct.ts`
```ts
import {
encodeAbiParameters,
parseAbiParameter,
keccak256,
type Hex,
type Address,
} from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
// ───────────────────────── constants ─────────────────────────
export const OneClickAction = {
MARKET_OPEN: 0,
MARKET_CLOSE: 1,
LIMIT_OPEN: 2,
LIMIT_CANCEL: 3,
LIMIT_UPDATE_TP_SL: 4,
ADD_MARGIN: 5,
REMOVE_MARGIN: 6,
UPDATE_TP_SL: 7,
BATCH_MARKET_CLOSE: 8,
PARTIAL_CLOSE: 9,
CANCEL_DECREASE_ORDER: 10,
BATCH_CREATE_DECREASE_ORDERS: 11,
BATCH_UPDATE_DECREASE_ORDERS: 12,
CANCEL_ALL_DECREASE_ORDERS: 13,
} as const
const ACTION_TYPE_NAMES: Record = {
0: 'OneClickMarketOpen',
1: 'OneClickMarketClose',
2: 'OneClickLimitOpen',
3: 'OneClickLimitCancel',
4: 'OneClickLimitUpdateTpSl',
5: 'OneClickAddMargin',
6: 'OneClickRemoveMargin',
7: 'OneClickUpdateTpSl',
8: 'OneClickBatchMarketClose',
9: 'OneClickPartialClose',
10: 'OneClickCancelDecreaseOrder',
11: 'OneClickBatchCreateDecreaseOrders',
12: 'OneClickBatchUpdateDecreaseOrders',
13: 'OneClickCancelAllDecreaseOrders',
}
const ACTION_DATA_ABI_TYPES: Record = {
0: ['address','bool','address','address','uint96','uint128','uint128','uint128','uint128','uint24','uint96'],
1: ['bytes32','uint24'],
2: ['address','bool','address','address','uint96','uint128','uint128','uint128','uint128','uint24','uint96'],
3: ['bytes32'],
4: ['bytes32','uint128','uint128'],
5: ['bytes32','address','uint96'],
6: ['bytes32','uint96'],
7: ['bytes32','uint128','uint128'],
8: ['bytes32[]','uint24'],
9: ['bytes32','uint128','uint24'],
10: ['bytes32'],
11: ['bytes32','(uint8,uint128,uint128,uint24)[]'],
12: ['(bytes32,uint128,uint128,uint24)[]'],
13: ['bytes32'],
}
const COMMON_EIP712_FIELDS = [
{ name: 'trader', type: 'address' },
{ name: 'action', type: 'uint8' },
{ name: 'nonce', type: 'uint64' },
{ name: 'deadline', type: 'uint48' },
{ name: 'feeToken', type: 'address' },
{ name: 'antiDdosFee', type: 'uint96' },
{ name: 'actionDataHash', type: 'bytes32' },
] as const
const ZERO = '0x0000000000000000000000000000000000000000' as Address
// ───────────────────────── actionData ─────────────────────────
const strip = (hex: Hex): Hex => `0x${hex.slice(2 + 64)}` as Hex
/** actionData = abi.encode(trader, ...params) with the leading trader word removed. */
export function buildActionData(action: number, trader: Address, values: unknown[]): Hex {
if (action === OneClickAction.BATCH_CREATE_DECREASE_ORDERS) {
return strip(encodeAbiParameters(
[{ type: 'address' }, { type: 'bytes32' }, parseAbiParameter('(uint8,uint128,uint128,uint24)[]')],
[trader, values[0] as Hex, values[1] as any],
))
}
if (action === OneClickAction.BATCH_UPDATE_DECREASE_ORDERS) {
return strip(encodeAbiParameters(
[{ type: 'address' }, parseAbiParameter('(bytes32,uint128,uint128,uint24)[]')],
[trader, values[0] as any],
))
}
const types = ACTION_DATA_ABI_TYPES[action]
if (!types) throw new Error(`Unknown action: ${action}`)
return strip(encodeAbiParameters(
['address', ...types].map((t) => ({ type: t })),
[trader, ...values] as any,
))
}
// ───────────────────────── fee config ─────────────────────────
export type FeeConfig = {
action: number
actionName: string
feeToken: string
antiDdosFee: string
enabled: boolean
priority: number
}
let feeConfigCache: FeeConfig[] = []
export async function refreshFeeConfig(baseUrl: string) {
const res = await fetch(`${baseUrl}/v2/trading/anti-ddos-config`)
if (!res.ok) throw new Error(`anti-ddos config: HTTP ${res.status}`)
feeConfigCache = await res.json()
}
/** Call once at startup. Keep the handle so you can clearInterval on teardown. */
export function startFeeConfigRefresh(baseUrl: string) {
void refreshFeeConfig(baseUrl).catch(console.warn)
return setInterval(() => void refreshFeeConfig(baseUrl).catch(console.warn), 60_000)
}
/**
* Lowest-priority fee token the trader can actually pay.
* `getAccountState` must read from a cache — do not issue RPC calls here.
* `additionalSpends` must include what the trade itself spends, or the fee
* transfer can fail onchain even though selection succeeded.
*/
export function selectFeeToken(
action: number,
getAccountState: (token: Address) => { balance: bigint; allowance: bigint } | undefined,
additionalSpends: readonly { token: Address; amount: bigint }[] = [],
): { feeToken: Address; antiDdosFee: bigint } {
const options = feeConfigCache
.filter((c) => c.action === action && c.enabled)
.sort((a, b) => a.priority - b.priority)
if (options.length === 0) return { feeToken: ZERO, antiDdosFee: 0n }
for (const opt of options) {
const token = opt.feeToken as Address
const state = getAccountState(token)
if (!state) continue
const extra = additionalSpends
.filter((s) => s.token.toLowerCase() === token.toLowerCase())
.reduce((sum, s) => sum + s.amount, 0n)
const required = BigInt(opt.antiDdosFee) + extra
if (state.balance >= required && state.allowance >= required) {
return { feeToken: token, antiDdosFee: BigInt(opt.antiDdosFee) }
}
}
throw new Error('No fee token with sufficient balance and allowance')
}
// ───────────────────────── nonce ─────────────────────────
let lastNonce = 0n
/** Strictly monotonic, so two intents in the same millisecond do not collide. */
export function nextNonce(): bigint {
const now = BigInt(Date.now())
lastNonce = now > lastNonce ? now : lastNonce + 1n
return lastNonce
}
// ───────────────────────── sign + submit ─────────────────────────
export interface SubmitOpts {
baseUrl: string
chainId: number
diamond: Address
trader: Address
signerPrivateKey: Hex
action: number
actionValues: unknown[]
feeToken: Address
antiDdosFee: bigint
deadlineSeconds?: number
}
export async function signAndSubmitIntent(o: SubmitOpts): Promise {
const typeName = ACTION_TYPE_NAMES[o.action]
if (!typeName) throw new Error(`Unknown action: ${o.action}`)
const signer = privateKeyToAccount(o.signerPrivateKey)
const nonce = nextNonce()
const deadline = Math.floor(Date.now() / 1000) + (o.deadlineSeconds ?? 300)
const actionData = buildActionData(o.action, o.trader, o.actionValues)
const actionDataHash = keccak256(actionData)
const signature = await signer.signTypedData({
domain: {
name: 'LeverupOneClickV2',
version: '1',
chainId: o.chainId,
verifyingContract: o.diamond,
},
types: { [typeName]: COMMON_EIP712_FIELDS },
primaryType: typeName,
message: {
trader: o.trader,
action: o.action,
nonce,
deadline,
feeToken: o.feeToken,
antiDdosFee: o.antiDdosFee,
actionDataHash,
},
} as any)
const res = await fetch(`${o.baseUrl}/v2/trading/submit-intent?blockchain=MONAD`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
trader: o.trader,
action: o.action,
nonce: nonce.toString(), // must be a string
deadline,
feeToken: o.feeToken,
antiDdosFee: o.antiDdosFee.toString(),
actionData,
signature,
}),
})
if (!res.ok) throw new Error(`submit-intent ${res.status}: ${await res.text()}`)
return (await res.text()).replace(/^"|"$/g, '')
}
// ───────────────────────── polling ─────────────────────────
export interface IntentStatus {
submitted: boolean
executed: boolean
success: boolean | null
skipped: boolean
skipReason: string | null
txnHash: string
reason: string
}
export async function pollIntentStatus(
baseUrl: string,
intentHash: string,
{ interval = 300, timeout = 300_000 } = {},
): Promise {
const until = Date.now() + timeout
while (Date.now() < until) {
const res = await fetch(`${baseUrl}/v2/trading/${intentHash}/status`)
if (res.ok) {
const status: IntentStatus = await res.json()
if (status.executed || status.skipped) return status
}
await new Promise((r) => setTimeout(r, interval))
}
throw new Error('Timed out waiting for intent execution')
}
```
## Usage
```ts
import { parseUnits } from 'viem'
import {
OneClickAction,
startFeeConfigRefresh,
selectFeeToken,
signAndSubmitIntent,
pollIntentStatus,
} from './leverup-1ct'
const BASE_URL = 'https://oneclick-01-keeper.leverup.xyz'
const DIAMOND = '0xea1b8E4aB7f14F7dCA68c5B214303B13078FC5ec' as const
const CHAIN_ID = 143
startFeeConfigRefresh(BASE_URL)
```
### Market open
```ts
const amountIn = parseUnits('10', 6)
const BROKER_ID = 0 // your assigned id, see /introduction/brokers
const extraFee = 0n // optional surcharge in tokenIn
const actionValues = [
pairBase,
true, // isLong
USDC, // tokenIn
LVUSD, // lvToken
amountIn,
parseUnits((100 / markPrice).toFixed(10), 10), // qty — $100 notional
parseUnits(String(markPrice * 1.01), 18), // worst acceptable price
0n, // stopLoss
0n, // takeProfit
BROKER_ID, // 0 credits the default broker
extraFee, // optional surcharge, 0n if unused
]
const { feeToken, antiDdosFee } = selectFeeToken(
OneClickAction.MARKET_OPEN,
getCachedErc20State,
// The trade's own spend — include extraFee, or the fee transfer can fail onchain.
[{ token: USDC, amount: amountIn + extraFee }],
)
const intentHash = await signAndSubmitIntent({
baseUrl: BASE_URL,
chainId: CHAIN_ID,
diamond: DIAMOND,
trader: userAddress,
signerPrivateKey: oneClickPrivateKey,
action: OneClickAction.MARKET_OPEN,
actionValues,
feeToken,
antiDdosFee,
})
const status = await pollIntentStatus(BASE_URL, intentHash)
console.log(status.success ? 'filled' : `failed: ${status.skipReason ?? status.reason}`)
```
### Close
```ts
const { feeToken, antiDdosFee } = selectFeeToken(OneClickAction.MARKET_CLOSE, getCachedErc20State)
await signAndSubmitIntent({
baseUrl: BASE_URL, chainId: CHAIN_ID, diamond: DIAMOND,
trader: userAddress,
signerPrivateKey: oneClickPrivateKey,
action: OneClickAction.MARKET_CLOSE,
actionValues: [positionHash, BROKER_ID], // [positionHash, broker]
feeToken, antiDdosFee,
})
```
### Partial close
```ts
await signAndSubmitIntent({
/* … */
action: OneClickAction.PARTIAL_CLOSE,
actionValues: [positionHash, position.qty / 2n, 0],
})
```
### TP/SL orders
```ts
await signAndSubmitIntent({
/* … */
action: OneClickAction.BATCH_CREATE_DECREASE_ORDERS,
actionValues: [
positionHash,
[
[0, parseUnits('110000', 18), position.qty / 2n, 0], // TP, half
[1, parseUnits('90000', 18), position.qty, 0], // SL, all
],
],
})
```
## Before going live
- Fee config fetched at startup and refreshed every 60 seconds.
- `balanceOf` and `allowance(trader, diamond)` prefetched for every enabled fee token.
- Fee-token selection includes the trade's own spend, `extraFee` included.
- If you run a [broker integration](/introduction/brokers), your id is passed on opens, closes, and
every TP/SL order — not just the open.
- Trader has approved the Diamond for both collateral and fee tokens.
- `nonce` transmitted as a string.
- `actionData` built with the `trader` prefix stripped.
- EIP-712 uses the shared seven-field struct with the correct `primaryType` per action.
- The authorization signing prompt states the domain, the connected address, and that the signature
authorizes no transfer.
- Resetting 1CT revokes onchain and waits for the receipt before clearing local key material.
- Polling has a timeout, and `reason` is decoded into readable text.
---
Source: https://developer-docs.leverup.xyz/api/overview.md
# REST API
Read-only HTTP access to prices, markets, positions, trade history, portfolio analytics and
leaderboards. No authentication required.
For placing trades, see [Onchain Integration](/onchain/overview) or
[Gasless Trading](/gasless/overview).
## Base URL
```
https://service.leverup.xyz
```
One-Click Trading is a separate service — see [Gasless Trading](/gasless/overview#endpoints).
## Conventions
**Chain parameter.** Most endpoints take an optional chain selector defaulting to `MONAD`. The
parameter name is inconsistent across endpoints — some use `block_chain`, others `blockchain`, and
request bodies use `blockChain`. Each endpoint below documents the form it accepts. You can omit it
in all cases.
**Numbers are strings.** Amounts, prices and quantities are returned as decimal strings to preserve
precision. The current unrealized PnL endpoint returns decimal USD strings with no integer scale;
other fields generally follow the onchain conventions — see
[Precision & Units](/introduction/precision).
**Addresses.** Accepted in either case; returned lowercase.
**Timestamps.** Either Unix seconds (integer) or ISO-8601 with offset, depending on the field.
## Pagination
Paginated endpoints take `page` (0-based) and `size`, and return:
```json
{
"content": [],
"pageNumber": 0,
"pageSize": 20,
"totalPages": 5,
"totalElements": 92
}
```
Position and order endpoints cap `size` at 100.
## Endpoint index
### Oracle & prices
| Method | Path | |
| :--- | :--- | :--- |
| `POST` | `/v1/oracle/price/updates/by-position` | [Oracle payload for a transaction](/api/oracle#price-updates-by-position) |
| `POST` | `/v1/oracle/price/pairs/latest` | [Latest prices](/api/oracle#latest-pair-prices) |
| `GET` | `/v1/oracle/price/pair-24h-ago` | [Price 24h ago](/api/oracle#historical-price) |
| `GET` | `/v1/oracle/price/pair-hours-ago` | [Price N hours ago](/api/oracle#historical-price) |
### Pairs & volume
| Method | Path | |
| :--- | :--- | :--- |
| `GET` | `/v1/pairs` | [List markets](/api/pairs#list-pairs) |
| `GET` | `/v1/pairs/{pairBase}/volume` | [Volume over a window](/api/pairs#pair-volume) |
| `GET` | `/v1/pairs/{pairBase}/cumulative-volume` | [All-time volume](/api/pairs#cumulative-volume) |
| `GET` | `/v1/trading-volumes/users` | [Per-user daily volume](/api/pairs#user-volumes) |
| `GET` | `/v1/trading-volumes/sum-by-date` | [Protocol volume for a range](/api/pairs#protocol-volume) |
| `GET` | `/v1/trading-volumes/sum-all` | [All-time protocol volume](/api/pairs#protocol-volume) |
### Positions
| Method | Path | |
| :--- | :--- | :--- |
| `GET` | `/v1/user/{address}/open-positions` | [Open positions](/api/positions#open-positions) |
| `GET` | `/v1/user/{address}/positions` | [All positions incl. closed](/api/positions#all-positions) |
### History & portfolio
| Method | Path | |
| :--- | :--- | :--- |
| `GET` | `/v1/user/{address}/trade/history` | [Trade history](/api/history#trade-history) |
| `GET` | `/v1/portfolio/{address}/trade_overview` | [PnL summary](/api/history#trade-overview) |
| `GET` | `/v1/portfolio/{address}/pnl_chart_data` | [PnL time series](/api/history#pnl-chart) |
| `GET` | `/v1/portfolio/{address}/unrealized-pnl` | [Current unrealized PnL](/api/history#current-unrealized-pnl) |
| `GET` | `/v1/portfolio/{address}/portfolio_data` | [Portfolio breakdown](/api/history#portfolio-data) |
### Leaderboard & stats
| Method | Path | |
| :--- | :--- | :--- |
| `GET` | `/v1/leaderboard` | [Global leaderboard](/api/leaderboard#leaderboard) |
| `GET` | `/v1/leaderboard/team/{code}` | [Team leaderboard](/api/leaderboard#team-leaderboard) |
| `GET` | `/v1/leaderboard/my-rank` | [One trader's rank](/api/leaderboard#my-rank) |
| `GET` | `/v1/leaderboard/{address}/trading-stats` | [Stats for a time range](/api/leaderboard#trading-stats) |
| `GET` | `/v1/slvmon/apy` | [sLVMON APY](/api/leaderboard#slvmon) |
| `GET` | `/v1/slvmon/{address}/portfolio` | [sLVMON position](/api/leaderboard#slvmon) |
### Competitions & teams
| Method | Path | |
| :--- | :--- | :--- |
| `GET` | `/v1/trading-competition` | [List competitions](/api/competitions#list-competitions) |
| `GET` | `/v1/trading-competition/{id}/leaderboard` | [Competition leaderboard](/api/competitions#competition-leaderboard) |
| `POST` | `/v1/trading-competition/join` | [Join with an invite code](/api/competitions#join) |
| `GET` | `/v1/genesis-airdrop/…` | [Genesis airdrop teams](/api/competitions#genesis-airdrop) |
## Errors
Standard HTTP status codes. `400` for invalid parameters, `404` for unknown resources, `5xx` for
server errors. Error bodies are Spring-style JSON:
```json
{
"timestamp": "2026-07-27T10:00:00.000+00:00",
"status": 400,
"error": "Bad Request",
"message": "pair_base should not be empty",
"path": "/v1/oracle/price/pair-24h-ago"
}
```
## Caching and rate limits
Several endpoints are cached server-side — pair listings for about a minute, volume for five
minutes, portfolio aggregates for 30 seconds. Polling faster than that returns identical data.
[Current unrealized PnL](/api/history#current-unrealized-pnl) is an exception: its successful responses
use `Cache-Control: no-store`. Respect this header rather than caching the valuation.
There is no published per-key rate limit, but the service is shared. Cache only where permitted,
poll no more than once per second per endpoint, and prefer the [onchain read functions](/onchain/reading-data) for
tight loops.
---
Source: https://developer-docs.leverup.xyz/api/oracle.md
# Oracle & Prices
Base URL: `https://service.leverup.xyz`
## Price updates by position
The oracle payload required by price-sensitive contract calls. Do not fetch this from Pyth directly —
this endpoint selects the correct feeds for the pair and collateral and returns the fee to attach.
```http
POST /v1/oracle/price/updates/by-position
Content-Type: application/json
```
### Request
```json
{
"pairBase": "0xcf5a6076cfa32686c0df13abada2b40dec133f1d",
"collateral": "0x754704Bc059F8C67012fEd69BC8A327a5aafb603",
"blockChain": "MONAD",
"options": {
"includeEncodingData": true,
"includeFee": true,
"includePrice": true,
"includePublishTime": false,
"includeEncodingDataByOracleId": false,
"includePriceSafety": false,
"allowPartial": false
}
}
```
| Field | Required | Notes |
| :--- | :--- | :--- |
| `pairBase` | yes | Market identifier |
| `collateral` | yes | The collateral token. For native MON, pass the **WMON** address. |
| `blockChain` | no | Defaults to `MONAD` |
| `options.includeEncodingData` | | Return the update payloads — required for transactions |
| `options.includeFee` | | Return `updateFee` and `verifition_fee` — required for transactions |
| `options.includePrice` | | Also return current prices |
| `options.includePublishTime` | | Also return publish timestamps |
### Response
```json
{
"pythPriceUpdateData": ["0x..."],
"pythProPriceUpdateData": ["0x..."],
"updateFee": "123456789",
"verifition_fee": "0",
"pythCorePrice": { "0xe62d...": "100123450000000000000000" },
"pythCorePublishTime": { "0xe62d...": 1785312000 },
"failures": []
}
```
| Field | Notes |
| :--- | :--- |
| `pythPriceUpdateData` | Pass as `updateData.pythPriceUpdateData` |
| `pythProPriceUpdateData` | Pass as `updateData.pythProPriceUpdateData` |
| `updateFee` | Wei of native MON |
| `verifition_fee` | Additional verification fee, wei. **The spelling is intentional** — the field is named that way in the API. |
| `pythCorePrice` | Keyed by oracle id, 1e18 |
| `failures` | Non-empty when a feed could not be resolved |
Attach `updateFee + verifition_fee` as the transaction `value`:
```ts
const value = BigInt(data.updateFee ?? '0') + BigInt(data.verifition_fee ?? '0')
```
::: warning Fetch late
Payloads are short-lived. Request immediately before sending the transaction, not at the start of a
long-running flow.
:::
::: tip Not needed for 1CT
[Gasless trading](/gasless/overview) fetches oracle data server-side. Intents never carry payloads.
:::
## Latest pair prices
Current prices for several markets at once.
```http
POST /v1/oracle/price/pairs/latest
Content-Type: application/json
```
```json
{
"pairBases": [
"0xcf5a6076cfa32686c0df13abada2b40dec133f1d",
"0xb5a30b0fdc5ea94a52fdc42e3e9760cb8449fb37"
],
"blockChain": "MONAD"
}
```
### Response
```json
{
"prices": {
"0xcf5a6076cfa32686c0df13abada2b40dec133f1d": "100123450000000000000000",
"0xb5a30b0fdc5ea94a52fdc42e3e9760cb8449fb37": "3456780000000000000000"
},
"failures": []
}
```
Prices are 1e18 strings, keyed by lowercase `pairBase`. Pairs that could not be resolved appear in
`failures` with a reason rather than failing the whole request:
```json
{ "failures": [{ "pairBase": "0xdead…", "reason": "latest oracle price not found" }] }
```
`pairBases` must not be empty. Duplicates are collapsed.
```ts
const res = await fetch(`${API}/v1/oracle/price/pairs/latest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pairBases: [btcBase, ethBase] }),
})
const { prices } = await res.json()
const btcUsd = Number(BigInt(prices[btcBase.toLowerCase()])) / 1e18
```
## Historical price
Price at a point in the past — used for 24h change calculations.
```http
GET /v1/oracle/price/pair-24h-ago?pair_base=0x…
GET /v1/oracle/price/pair-hours-ago?pair_base=0x…&hours_ago=6
```
| Parameter | Required | Notes |
| :--- | :--- | :--- |
| `pair_base` | yes | |
| `hours_ago` | no | 1–24, defaults to 24. Values outside the range return `400`. |
| `block_chain` | no | Defaults to `MONAD` |
### Response
```json
{
"pairBase": "0xcf5a6076cfa32686c0df13abada2b40dec133f1d",
"hoursAgo": 24,
"blockChain": "MONAD",
"oracleId": { "kind": "PYTH_CORE", "id": "0xe62d…", "oracle": null },
"pricePoint": {
"price": "98765430000000000000000",
"publishTime": 1785225600,
"capturedAt": 1785225605
}
}
```
`price` is 1e18. `publishTime` is the oracle's timestamp; `capturedAt` is when LeverUp recorded it.
Returns `404` if no snapshot exists for that window.
```ts
async function priceChange24h(pairBase: string) {
const [nowRes, thenRes] = await Promise.all([
fetch(`${API}/v1/oracle/price/pairs/latest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pairBases: [pairBase] }),
}),
fetch(`${API}/v1/oracle/price/pair-24h-ago?pair_base=${pairBase}`),
])
const now = Number(BigInt((await nowRes.json()).prices[pairBase.toLowerCase()])) / 1e18
const then = Number(BigInt((await thenRes.json()).pricePoint.price)) / 1e18
return ((now - then) / then) * 100
}
```
## Oracle kinds
Feeds are identified by an `OracleIdInput`:
```json
{ "kind": "PYTH_CORE", "id": "0x…", "oracle": null }
```
`kind` is one of `NONE`, `PYTH_CORE`, `PYTH_PRO`, `DEX_ORACLE`. You rarely need to construct these —
the position-based endpoint resolves them for you.
---
Source: https://developer-docs.leverup.xyz/api/pairs.md
# Pairs & Volume
Base URL: `https://service.leverup.xyz`
## List pairs
The authoritative list of tradable markets, including `pairBase` addresses. **Use this instead of
hardcoding addresses** — markets are listed and delisted over time.
```http
GET /v1/pairs
```
| Parameter | Default | Notes |
| :--- | :--- | :--- |
| `block_chain` | `MONAD` | |
| `is_ready_to_display` | `true` | Set `false` to include markets not yet surfaced in the UI |
| `symbol` | — | Filter by symbol, e.g. `BTC` |
| `tags` | — | Filter by tag, repeatable |
| `volume_time_range` | `ONE_DAY` | Window for the `volume` fields: `ONE_DAY`, `SEVEN_DAY`, `THIRTY_DAY`, `ALL` |
| `page` | `0` | |
| `size` | `20` | |
### Response
```json
{
"content": [
{
"base": "0xcf5a6076cfa32686c0df13abada2b40dec133f1d",
"pairName": "BTC/USD",
"symbol": "BTC",
"pairType": "CRYPTO",
"icon": "https://…",
"isDegenPair": false,
"tags": ["majors"],
"listAt": "2025-11-01T00:00:00Z",
"nextOpen": null,
"nextClose": null,
"schedule": null,
"priceDisplayDecimals": 2,
"status": "AVAILABLE",
"minHoldingSeconds": 0,
"holdingTimeThresholdSizeUSD": "0",
"pythPriceFeedId": "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43",
"pythProPriceFeedId": null,
"pythSymbol": "Crypto.BTC/USD",
"volume": "1234567.89",
"volumeUSD": "1234567.89"
}
],
"pageNumber": 0,
"pageSize": 20,
"totalPages": 2,
"totalElements": 24
}
```
| Field | Notes |
| :--- | :--- |
| `base` | The `pairBase` you pass to every contract call |
| `pairType` | `CRYPTO`, `STOCKS`, `FOREX`, `INDICES`, `COMMODITIES` |
| `status` | `AVAILABLE`, `REDUCE_ONLY`, `CLOSE` |
| `nextOpen` / `nextClose` | Non-null for markets with trading hours |
| `minHoldingSeconds` | Minimum time before a position can be closed |
| `priceDisplayDecimals` | Display hint only — prices are still 1e18 |
| `pythPriceFeedId` | The underlying feed. Not needed for the standard flow; use the [oracle endpoint](/api/oracle#price-updates-by-position). |
| `volume` / `volumeUSD` | Over `volume_time_range` |
::: warning Check `status` before trading
`REDUCE_ONLY` allows closes but not opens. `CLOSE` disables the market. Non-crypto markets are also
closed outside their trading hours — opening then reverts with `MarketClosed`.
:::
```ts
async function getPairs() {
const res = await fetch(`${API}/v1/pairs?size=100`)
const { content } = await res.json()
return new Map(content.map((p: any) => [p.symbol, p]))
}
const pairs = await getPairs()
const btc = pairs.get('BTC')
if (btc.status !== 'AVAILABLE') throw new Error(`BTC is ${btc.status}`)
```
## Pair volume
```http
GET /v1/pairs/{pairBase}/volume
```
| Parameter | Default | Notes |
| :--- | :--- | :--- |
| `range` | `ONE_DAY` | `ONE_DAY`, `SEVEN_DAY`, `THIRTY_DAY`, `ALL` |
| `block_chain` | `MONAD` | |
```json
{
"pairBase": "0xcf5a…",
"volume": "1234567.89",
"volumeUSD": "1234567.89",
"period": "ONE_DAY"
}
```
Cached for five minutes.
## Cumulative volume
```http
GET /v1/pairs/{pairBase}/cumulative-volume
```
Same response shape with `period: "ALL"`. Equivalent to `/volume?range=ALL`.
## User volumes
Daily trading volume per user over a date range.
```http
GET /v1/trading-volumes/users?startTime=2026-07-01T00:00:00Z&endTime=2026-07-27T23:59:59Z
```
| Parameter | Required | Notes |
| :--- | :--- | :--- |
| `startTime` | yes | ISO-8601 with offset |
| `endTime` | yes | ISO-8601 with offset |
| `chains` | no | Comma-separated, defaults to `MONAD` |
```json
[
{
"userAddress": "0x…",
"tradeDate": "2026-07-26",
"dailyVolume": "125430.55",
"openCount": 12,
"closeCount": 11,
"openVolume": "64000.00",
"closeVolume": "61430.55"
}
]
```
Returns one row per user per day. Not paginated — keep ranges bounded.
## Protocol volume
```http
GET /v1/trading-volumes/sum-by-date?startTime=…&endTime=…
GET /v1/trading-volumes/sum-all
```
```json
{ "vol": "98765432.10", "usdVol": "98765432.10" }
```
`sum-all` takes only the optional `chains` parameter.
---
Source: https://developer-docs.leverup.xyz/api/positions.md
# Positions & Orders
Base URL: `https://service.leverup.xyz`
These endpoints return indexed data across **all markets** in one request. They also cover closed
positions, which the contract does not retain.
The trade-off is indexing lag: a position appears here a moment after it appears onchain. For
current onchain positions, use [`getPositionsV4`](/onchain/reading-data#getpositionsv4).
For the current USD total across all POOL positions, including accrued fees, use
[Current unrealized PnL](/api/history#current-unrealized-pnl).
## Open positions
```http
GET /v1/user/{user_address}/open-positions
```
| Parameter | Default | Notes |
| :--- | :--- | :--- |
| `block_chain` | `MONAD` | |
| `page` | `0` | |
| `size` | `20` | Capped at 100 |
### Response
```json
{
"content": [
{
"positionHash": "0x…",
"pair": "BTC/USD",
"pairBase": "0xcf5a…",
"trader": "0x…",
"tokenIn": "0x7547…",
"marginToken": "0xfd44…",
"isLong": true,
"margin": "9930000000000000000",
"qty": "10000000",
"entryPrice": "100123450000000000000000",
"stopLoss": "0",
"takeProfit": "0",
"openFee": "70000000000000000",
"executionFee": "0",
"longAccFundingFeePerShare": "0",
"holdingFeeRate": "0",
"timestamp": 1785312000,
"status": "OPEN",
"closeInfo": null
}
],
"pageNumber": 0,
"pageSize": 20,
"totalPages": 1,
"totalElements": 1
}
```
| Field | Unit |
| :--- | :--- |
| `qty` | 1e10 |
| `entryPrice`, `stopLoss`, `takeProfit` | 1e18 |
| `margin`, `openFee`, `executionFee` | `marginToken` decimals — 18 for LVUSD and LVMON |
| `holdingFeeRate` | 1e12 |
| `longAccFundingFeePerShare` | 1e18, signed |
| `timestamp` | Unix seconds |
`marginToken` is the `lvToken`. Every amount above is denominated in it, not in `tokenIn`.
::: info Accrued fees are not here
This endpoint returns the position as indexed at open time. Live accrued funding and holding fees
come from [`getPositionsV4`](/onchain/reading-data#getpositionsv4) onchain.
:::
## All positions
Open, closing, closed and cancelled.
```http
GET /v1/user/{user_address}/positions
```
| Parameter | Default | Notes |
| :--- | :--- | :--- |
| `block_chain` | `MONAD` | |
| `page` | `0` | |
| `size` | `20` | Capped at 100 |
| `timestamp_after` | — | Only positions opened after this Unix second |
| `close_time_after` | — | Only positions closed after this Unix second |
Same shape as above, plus a populated `closeInfo` for closed positions:
```json
{
"positionHash": "0x…",
"status": "CLOSED",
"closeInfo": {
"closePrice": "101500000000000000000000",
"fundingFee": "-1200000000000000",
"closeFee": "70000000000000000",
"pnl": "1370000000000000000",
"holdingFee": "500000000000000",
"closeTime": 1785315600
}
}
```
| `status` | Meaning |
| :--- | :--- |
| `PENDING` | Requested, not yet filled |
| `OPEN` | Live |
| `CLOSING` | Close requested, not yet settled |
| `CLOSED` | Settled |
| `CANCELLED` | Never opened — refunded |
`closeInfo` is present for `CLOSED`, and for `CLOSING` once the PnL is known. `fundingFee` and `pnl`
are signed. All amounts use `marginToken` decimals.
```ts
async function realizedPnl(address: string, sinceUnix: number) {
const res = await fetch(
`${API}/v1/user/${address}/positions?close_time_after=${sinceUnix}&size=100`,
)
const { content } = await res.json()
return content
.filter((p: any) => p.status === 'CLOSED')
.reduce((sum: bigint, p: any) => sum + BigInt(p.closeInfo.pnl), 0n)
}
```
## Limit orders
There is no public per-user limit-order endpoint. Read them onchain with
[`getLimitOrders`](/onchain/reading-data#getlimitorders), and TP/SL orders with
[`getTraderDecreaseOrders`](/onchain/tpsl-orders#reading-orders).
Order lifecycle events do appear in [trade history](/api/history#trade-history), so a completed order
is visible there after the fact.
---
Source: https://developer-docs.leverup.xyz/api/history.md
# Trade History & Portfolio
Base URL: `https://service.leverup.xyz`
## Trade history
Every protocol event affecting a trader, decoded and joined against the position it belongs to. This
is the easiest way to build an activity feed without replaying onchain logs.
```http
GET /v1/user/{user_address}/trade/history
```
| Parameter | Default | Notes |
| :--- | :--- | :--- |
| `page` | `0` | |
| `size` | `10` | |
| `block_chain` | `MONAD` | |
### Response
```json
{
"content": [
{
"id": "…",
"transactionHash": "0x…",
"blockNumber": 1234567,
"transactionIndex": 3,
"logIndex": 7,
"hash": "0x…",
"positionHash": "0x…",
"pairBase": "0xcf5a…",
"qty": "10000000",
"entryPrice": "100123450000000000000000",
"closePrice": null,
"pnl": null,
"tradeType": "MARKET",
"operationType": "OPEN_POSITION",
"blockTime": "2026-07-27T10:00:00Z",
"isLong": true,
"tokenIn": "0x7547…",
"lvToken": "0xfd44…",
"amountIn": "10000000",
"margin": "9930000000000000000",
"tokenInPrice": "1000000000000000000",
"position": { },
"detail": { }
}
],
"pageNumber": 0,
"pageSize": 10,
"totalPages": 12,
"totalElements": 116
}
```
`position` embeds the same object returned by [Positions](/api/positions), so a single history page
gives you both the event and the position state.
Units follow the onchain conventions: `qty` at 1e10, `entryPrice` / `closePrice` at 1e18, `amountIn`
in `tokenIn` decimals, and `margin` / `pnl` in `lvToken` decimals. See
[Precision & Units](/introduction/precision).
### `operationType`
Each value corresponds to the onchain event of the same name — see [Events](/onchain/events).
| Group | Values |
| :--- | :--- |
| Opening | `MARKET_PENDING_TRADE`, `OPEN_POSITION`, `POSITION_INCREASED`, `PENDING_TRADE_REFUND` |
| Closing | `CLOSE_POSITION`, `POSITION_DECREASED`, `EXECUTE_CLOSE_SUCCESSFUL`, `EXECUTE_CLOSE_REJECTED` |
| Limit orders | `OPEN_LIMIT_ORDER`, `EXECUTE_LIMIT_ORDER_SUCCESSFUL`, `EXECUTE_LIMIT_ORDER_REJECTED`, `CANCEL_LIMIT_ORDER`, `LIMIT_ORDER_REFUND`, `UPDATE_ORDER_TP`, `UPDATE_ORDER_SL` |
| TP/SL orders | `DECREASE_ORDER_CREATED`, `DECREASE_ORDER_UPDATED`, `DECREASE_ORDER_CANCELLED`, `EXECUTE_DECREASE_ORDER_SUCCESSFUL` |
| Position management | `UPDATE_MARGIN`, `UPDATE_TRADE_TP`, `UPDATE_TRADE_SL` |
Older records may carry `OPEN_MARKET_TRADE` and `CLOSE_TRADE_SUCCESSFUL` from the pre-merge event
model. Treat them as equivalent to `OPEN_POSITION` and `CLOSE_POSITION` when rendering history.
```ts
async function activityFeed(address: string) {
const res = await fetch(`${API}/v1/user/${address}/trade/history?size=50`)
const { content } = await res.json()
return content.map((e: any) => ({
at: e.blockTime,
what: e.operationType,
pair: e.position?.pair ?? e.pairBase,
tx: e.transactionHash,
}))
}
```
## Current unrealized PnL
Returns the current USD valuation of all of a trader's **POOL positions** on Monad, including
legacy and merged positions. This is an account total, with no pagination or time-range filter.
Cross-Exchange positions are excluded.
```http
GET /v1/portfolio/{user_address}/unrealized-pnl?block_chain=MONAD
```
| Parameter | Location | Required | Notes |
| :--- | :--- | :--- | :--- |
| `user_address` | Path | yes | A valid EVM wallet address; returned lowercase |
| `block_chain` | Query | no | Defaults to `MONAD`; use this spelling, not `blockchain` |
No authentication or wallet signature is required. Mainnet example (replace the address with the
wallet you want to query):
```bash
curl --fail-with-body \
'https://service.leverup.xyz/v1/portfolio/0x0000000000000000000000000000000000000001/unrealized-pnl?block_chain=MONAD'
```
### Response fields
All four monetary fields are **decimal USD strings**: `"12.5"` means **$12.50**, not a scaled
integer. Do not divide them by `1e18`. Keep strings or use decimal arithmetic when calculating with
these values; see [Precision & Units](/introduction/precision#unrealized-pnl-rest-totals).
| Field | Type | Meaning |
| :--- | :--- | :--- |
| `userAddress` | string | Lowercase wallet address |
| `blockChain` | string | `MONAD` |
| `positionCount` | integer | Number of onchain positions included |
| `unrealizedPnlUsd` | string | Mark-to-market PnL, with each position's loss capped at its margin |
| `unrealizedPnlAfterAccruedFeesUsd` | string | PnL including accrued funding and holding fees, capped independently per position |
| `fundingFeeUsd` | string | Total funding: positive is income, negative is an expense |
| `holdingFeeUsd` | string | Total accrued holding expense, reported as a nonnegative amount |
| `blockNumber` | string | Block used to read positions and their accrued fees |
| `blockTimestamp` | integer | Timestamp of that block, in Unix seconds |
| `priceAsOf` | integer, null or absent | Earliest publication time among the quotes used, in Unix seconds; null or omitted for an empty account |
Illustrative response:
```json
{
"userAddress": "0x0000000000000000000000000000000000000001",
"blockChain": "MONAD",
"positionCount": 1,
"unrealizedPnlUsd": "10",
"unrealizedPnlAfterAccruedFeesUsd": "12",
"fundingFeeUsd": "3",
"holdingFeeUsd": "1",
"blockNumber": "108450856",
"blockTimestamp": 1790507755,
"priceAsOf": 1790507755
}
```
With no positions, `positionCount` is `0` and all monetary fields are `"0"`. The response still
includes the current position-snapshot block; `priceAsOf` is null or omitted. A failed query is not
an empty account.
### Valuation and fees
For each position, the service calculates signed PnL from its quantity, entry price and current
mark price, then values the result using its settlement token (`marginToken`). The loss floor is
that position's margin. In settlement-token units, before conversion to USD:
```text
gross = max(rawPnl, -margin)
net = max(rawPnl + fundingFee - holdingFee, -margin)
```
The two floors are applied independently before summing the USD totals. Consequently,
`unrealizedPnlAfterAccruedFeesUsd` cannot always be reconstructed by adding the aggregate funding
and holding amounts to the already-capped `unrealizedPnlUsd`. Integer rounding can also leave tiny
differences.
Accrued fees are already included; do not add them again. Paid opening fees are not deducted again,
and the net field excludes estimated closing fees, execution fees and slippage. It is a current
valuation, not a guaranteed amount receivable on closing. Realized PnL from
[Portfolio data](#portfolio-data) and [Trading stats](/api/leaderboard#trading-stats) is separate.
### Freshness, caching and errors
Positions and accrued fees are read from one onchain block. Quotes are fetched separately, so
`blockTimestamp` and `priceAsOf` need not be equal. The endpoint does not wait for the historical
statistics publication window.
Successful responses carry `Cache-Control: no-store`. The current freshness limit for both the
position snapshot and quotes is 30 seconds, with up to 5 seconds of future clock skew; valuation has
a 10-second processing timeout. Client/network latency is additional. A recently retrieved DEX
quote can still have an old publication time and be rejected, including when a market has stopped
publishing fresh prices.
| Status | Meaning | Client handling |
| :--- | :--- | :--- |
| `200` | Complete valuation, including an empty account | Display the returned decimal USD amounts |
| `400` | Invalid address or invalid chain parameter | Correct the request |
| `503` | Complete current valuation unavailable: RPC failure/timeout, missing token information, missing/nonpositive/stale quotes, or an explicitly unsafe quote | Show an unavailable state and retry with backoff; do not substitute zero or a partial total |
One unavailable position or required quote causes the entire valuation to fail. Optional price
safety signals are honored when supplied; a successful response does not mean every provider
supplied an independent safety verdict. Clients should branch on the HTTP status rather than
matching error-message text.
## Trade overview
Headline PnL numbers for a trader.
```http
GET /v1/portfolio/{user_address}/trade_overview?timeframe=DAYS_30&is_after_fee=true
```
| Parameter | Required | Notes |
| :--- | :--- | :--- |
| `timeframe` | yes | `ALL_TIME`, `DAYS_7`, `DAYS_30`, `DAYS_60` |
| `is_after_fee` | yes | Whether PnL is net of fees |
| `blockchain` | no | Defaults to `MONAD` |
```json
{
"realizedPnlUsd": "1234.56",
"biggestWinUsd": "890.12",
"winRate": 0.62,
"updatedAt": "2026-07-27T10:00:00Z"
}
```
`winRate` is a fraction, not a percentage. Cached for 30 seconds.
## PnL chart
Time series for a PnL chart.
```http
GET /v1/portfolio/{user_address}/pnl_chart_data?timeframe=DAYS_30&is_after_fee=true&timezone=UTC
```
| Parameter | Required | Notes |
| :--- | :--- | :--- |
| `timeframe` | yes | `ALL_TIME`, `DAYS_7`, `DAYS_30`, `DAYS_60` |
| `is_after_fee` | yes | |
| `timezone` | yes | Determines day boundaries, e.g. `UTC`, `Asia/Shanghai` |
| `blockchain` | no | |
```json
{
"data": [
{ "t": 1785225600, "pnl": "120.50" },
{ "t": 1785312000, "pnl": "-45.20" }
],
"updatedAt": "2026-07-27T10:00:00Z",
"resolution": "DAY_1"
}
```
`t` is a Unix second bucket start. `resolution` is `DAY_1`, `DAY_7` or `DAY_30`, chosen by the server
based on the timeframe. Cached for 30 seconds.
## Portfolio data
Aggregate breakdown across markets.
```http
GET /v1/portfolio/{user_address}/portfolio_data
```
```json
{
"trader": "0x…",
"realizedPNL": "1500000000000000000000",
"realizedPNLAfterFees": "1234560000000000000000",
"tradingVolumeUSD": "250000000000000000000000",
"gain": 31,
"loss": 19,
"metrics": {
"positionLiveCount": 2,
"orderLiveCount": 1
},
"tradingPairs": [
{
"pairBase": "0xcf5a…",
"pairName": "BTC/USD",
"pairSymbol": "BTC",
"pairType": "CRYPTO",
"tradingVolumeQty": "125000000000",
"tradingVolumeUSD": "180000000000000000000000"
}
]
}
```
`realizedPNL`, `realizedPNLAfterFees` and `tradingVolumeUSD` are USD scaled by **1e18**;
`tradingVolumeQty` is base-asset quantity scaled by **1e10**. These units differ from the already
decimal USD amounts in [Current unrealized PnL](#current-unrealized-pnl). See
[Precision & Units](/introduction/precision).
`gain` and `loss` are counts of profitable and unprofitable closed positions.
---
Source: https://developer-docs.leverup.xyz/api/leaderboard.md
# Leaderboard & Stats
Base URL: `https://service.leverup.xyz`
## Leaderboard
```http
GET /v1/leaderboard
```
| Parameter | Default | Notes |
| :--- | :--- | :--- |
| `timeframe` | all time | `ONE_DAY`, `SEVEN_DAY`, `THIRTY_DAY`, `ALL` |
| `sort_field` | `PNL` | `PNL`, `WIN_RATE`, `BIGGEST_WIN`, `TOTAL_VOL_USD` |
| `sort_direction` | `DESC` | `ASC` or `DESC` |
| `page` | `0` | |
| `size` | `50` | |
| `search` | — | Filter by address |
| `block_chain` | `MONAD` | |
### Response
```json
{
"content": [
{
"rank": 1,
"userAddress": "0x…",
"pnl": 125430.55,
"totalVolUsd": 8900000.00,
"winCount": 210,
"loseCount": 96,
"biggestWin": 24500.00,
"winRate": 0.686,
"totalCount": 1523,
"highLeverageVolUsd": 4500000.00,
"lowLeverageVolUsd": 4400000.00
}
],
"pageNumber": 0,
"pageSize": 50,
"totalPages": 31,
"totalElements": 1523
}
```
| Field | Notes |
| :--- | :--- |
| `pnl`, `totalVolUsd`, `biggestWin` | USD, decimal |
| `winRate` | Fraction, not percentage |
| `totalCount` | Total ranked traders, not this trader's trade count |
| `highLeverageVolUsd` / `lowLeverageVolUsd` | Volume split by leverage band |
## Team leaderboard
Same shape, scoped to one team.
```http
GET /v1/leaderboard/team/{team_code}
```
Accepts the same parameters as `/v1/leaderboard`.
## My rank
One trader's standing without paging through the board.
```http
GET /v1/leaderboard/my-rank?user_address=0x…
```
| Parameter | Required | Notes |
| :--- | :--- | :--- |
| `user_address` | yes | |
| `timeframe` | no | |
| `sort_field` | no | `PNL` by default — rank depends on it |
| `sort_direction` | no | |
| `block_chain` | no | |
Returns a single `LeaderboardDataResponse`. A trader outside the ranked set gets their stats back
with `rank: 0`; a trader with no history gets an all-zero record.
```ts
const res = await fetch(`${API}/v1/leaderboard/my-rank?user_address=${address}`)
const me = await res.json()
console.log(me.rank === 0 ? 'unranked' : `#${me.rank}`, 'pnl', me.pnl)
```
## Trading stats
Aggregate stats for an arbitrary time window — useful for campaign scoring.
```http
GET /v1/leaderboard/{user_address}/trading-stats?start_time=1785225600&end_time=1785312000
```
| Parameter | Required | Notes |
| :--- | :--- | :--- |
| `start_time` | yes | Unix seconds |
| `end_time` | yes | Unix seconds |
| `block_chain` | no | |
Returns a `LeaderboardDataResponse` with `rank: 0` — it is a stats query, not a ranking. A trader
with no activity in the window returns an all-zero record rather than a 404.
## sLVMON
Staked LVMON.
```http
GET /v1/slvmon/apy?days=7
```
| Parameter | Default |
| :--- | :--- |
| `days` | `7` |
| `blockChain` | `MONAD` |
Returns a bare decimal, e.g. `12.34`.
```http
GET /v1/slvmon/{user_address}/portfolio
```
```json
{
"shares": "1000000000000000000",
"principal": "1000000000000000000",
"cumulativeRealizedPnl": "45000000000000000"
}
```
All values are 18-decimal strings.
---
Source: https://developer-docs.leverup.xyz/api/competitions.md
# Competitions & Teams
Base URL: `https://service.leverup.xyz`
Endpoints for trading competitions and the Genesis Airdrop team program. Useful if you are building a
community dashboard or surfacing LeverUp campaigns in your own product.
## List competitions
```http
GET /v1/trading-competition
```
| Parameter | Default | Notes |
| :--- | :--- | :--- |
| `status` | all | `UPCOMING`, `ONGOING`, `ENDED` |
| `isReadyToDisplay` | `true` | |
| `userAddress` | — | When supplied, populates `hasJoined` |
### Response
```json
[
{
"id": 7,
"title": "Summer Trading Cup",
"description": "…",
"banner": "https://…",
"ruleLink": "https://…",
"ruleId": "…",
"leaderboardType": "PNL_BASED",
"collateralToken": null,
"rewardAmount": "50000",
"rewardTokenAddress": "0x…",
"rewardType": "TOKEN",
"rewardDescription": "…",
"startTime": 1785225600,
"endTime": 1787817600,
"currentParticipants": 1240,
"tradingVolume": 18500000.0,
"isReadyToDisplay": true,
"status": "ONGOING",
"hasJoined": false,
"requiresInviteCode": false
}
]
```
Returns a plain array, not a paginated envelope. `startTime` and `endTime` are Unix seconds.
`COLLATERAL_PNL_VOLUME_BASED` competitions set `collateralToken` to the deposited ERC-20 address whose trades count. Their leaderboard exposes both PnL and Total Volume as sortable columns. Other collateral deposits by the same participant are excluded.
## Competition leaderboard
```http
GET /v1/trading-competition/{id}/leaderboard
```
| Parameter | Default | Notes |
| :--- | :--- | :--- |
| `page` | `0` | |
| `limit` | `10` | Note: `limit`, not `size` |
| `orderBy` | — | A key from `headers` |
| `orderDirection` | `desc` | |
| `userAddress` | — | Returns this user's global row and rank when present |
### Response
```json
{
"headers": [
{ "key": "1", "label": "PnL" },
{ "key": "2", "label": "Total Volume" }
],
"data": {
"content": [
{ "rank": 1, "trader": "0x111…", "stats": { "1": 12500.0, "2": 890000.0 } }
],
"pageNumber": 0,
"pageSize": 10,
"totalPages": 124,
"totalElements": 500
},
"currentUser": {
"rank": 487,
"trader": "0xabc…",
"stats": { "1": -20.0, "2": 420.0 }
}
}
```
Columns are dynamic. `headers` describes what each competition ranks on, and `stats` is keyed by
those same keys — render generically rather than assuming a fixed set. Clients displaying configured
reward ranks should paginate to the greater of `data.totalElements` and the applicable reward schedule
length, using vacant rows for rewarded ranks without participants. `currentUser` is supplemental and
does not increase the page size or page count.
```ts
const res = await fetch(`${API}/v1/trading-competition/${id}/leaderboard?limit=25`)
const { headers, data } = await res.json()
for (const row of data.content) {
console.log(row.trader, headers.map((h) => `${h.label}: ${row.stats[h.key] ?? '-'}`).join(' | '))
}
```
## Join
```http
POST /v1/trading-competition/join
Content-Type: application/json
```
For a public competition (`requiresInviteCode: false`):
```json
{ "trader": "0x…", "competitionId": 7 }
```
For a private competition, include its invite code:
```json
{ "trader": "0x…", "competitionId": 7, "inviteCode": "ABC123" }
```
Returns `200` with an empty body on success. `trader` is required. A null or
blank database invite code makes the competition public. Existing clients may
omit `competitionId` and send only `trader` plus `inviteCode`.
::: info No signature required
This endpoint takes an address as a parameter and does not verify wallet ownership. It registers
participation only — it cannot move funds or place trades. Do not treat it as an authentication
mechanism.
:::
## Genesis Airdrop
Team-based points program.
### Team info for a user
```http
GET /v1/genesis-airdrop/{user_address}/team-info
```
```json
{
"leaderTeam": {
"teamName": "Alpha",
"code": "ALPHA1",
"totalGeneralPoints": "125000",
"totalDegenPoints": "42000",
"teamMemberCount": 38,
"latestEpochTrading": { "epoch": 6, "volumeUSD": "980000" }
},
"memberTeam": null
}
```
`leaderTeam` is the team the address leads; `memberTeam` is the one it belongs to. Either can be
`null`.
### Team by invite code
```http
GET /v1/genesis-airdrop/team-info-by-code/{code}
```
Returns a single `TeamInfo`, or `null` if the code is unknown.
### Team members
```http
GET /v1/genesis-airdrop/{user_address}/team-members?page=0&size=10
```
The path address is the **team leader**. Paginated.
```json
{
"content": [
{
"member": "0x…",
"joinDate": "2026-06-01T00:00:00Z",
"cumulativeVolumeUSD": "125000",
"cumulativeGeneralPointsToLeader": "3400",
"cumulativeDegenPointsToLeader": "1200"
}
]
}
```
### Boost history
```http
GET /v1/genesis-airdrop/{user_address}/team-boost-history?page=0&size=10
```
```json
{
"content": [
{ "epoch": 6, "generalTeamBoostPoints": "3400", "degenTeamBoostPoints": "1200" }
]
}
```
### Team leaderboard
```http
GET /v1/genesis-airdrop/team-leaderboard?epoch=0&page=0&size=10
```
`epoch` defaults to `0`, meaning the current epoch.
```json
{
"content": [
{
"teamName": "Alpha",
"currentEpochVolume": "980000",
"currentGeneralPoints": "125000",
"currentDegenPoints": "42000"
}
]
}
```
---
Source: https://developer-docs.leverup.xyz/reference/errors.md
# Error Reference
Where failures surface depends on the path you are on:
| Path | Failure appears as |
| :--- | :--- |
| Onchain | A revert on your transaction, or a `PendingTradeRefund` / rejection event during settlement |
| Gasless (1CT) | A `400` at submission — or `404` for `Position not found` — or `skipped` / `success = false` in the [intent status](/gasless/submit#poll-status) |
| REST API | An HTTP status code |
## Contract errors
Custom errors raised by the protocol. On the onchain path these revert your transaction; on the 1CT
path they arrive as encoded revert data in the status `reason` field.
### Market and pair state
| Error | Meaning |
| :--- | :--- |
| `MarketClosed` | The market is outside its trading hours |
| `PairClosed` | Trading is disabled for this pair |
| `UnsupportedChain` | Operation not supported on this network |
### Collateral and amounts
| Error | Meaning |
| :--- | :--- |
| `UnsupportedToken` | Token not accepted by the protocol |
| `UnsupportedMarginToken` | Token cannot be used as margin — usually a `tokenIn` / `lvToken` mismatch |
| `InvalidAmount` | Amount is zero, below the minimum, or does not cover the fee |
| `InsufficientMarginAmount` | Position does not hold enough margin for the operation |
| `InsufficientLiquidityPool` | Pool cannot back the requested size |
### Leverage and risk
| Error | Meaning |
| :--- | :--- |
| `BelowDegenModeMinLeverage` | Resulting leverage below the pair's minimum |
| `ExceedMaxLeverageAfterMarginRemoval` | Removal would push leverage past the maximum |
| `LiquidatableAfterMarginRemoval` | Removal would make the position immediately liquidatable |
| `ExceedMaxTakeProfitAfterMarginRemoval` | Existing take-profit would exceed its bound at the new leverage |
### Positions and orders
| Error | Meaning |
| :--- | :--- |
| `NonexistentTrade` | Position does not exist — already closed, or the wrong hash |
| `PositionNotCloseable` | Cannot be closed yet — check `earliestCloseTime` |
| `InvalidStopLoss` | Stop-loss price invalid for this position |
| `TooManyActiveDecreaseOrders` | Per-position TP/SL order cap reached |
| `CoolingOffPeriod` | Temporarily unavailable |
### Oracle
| Error | Meaning |
| :--- | :--- |
| `InvalidPrice` | Oracle returned an unusable price |
| `InvalidUpdateFee` | `msg.value` does not cover the oracle update fee |
### 1CT authorization
| Error | Meaning |
| :--- | :--- |
| `UnauthorizedOperation` | The intent targets a position the `trader` does not own |
::: warning Authorization failures are not custom errors
A bad signature, an unauthorized agent, a missing permission bit, and an expired `deadline` do
**not** revert with a custom error. The intent is skipped with `skipReason = INVALID` and the
transaction succeeds — see
[Interpreting the result](/gasless/submit#interpreting-the-result). Do not write a handler that
matches on a revert selector for these; match on `skipReason`.
:::
Some failures surface as `Error(string)` instead of a custom error, most notably
`OneClickV2: name taken` when [authorizing an agent](/gasless/authorization#authorize) with a name
already in use.
## Refund reasons
Carried by `PendingTradeRefund`, `ExecuteLimitOrderRejected` and `LimitOrderRefund` as an enum. A
refund means the request was rejected at settlement and collateral was returned — nothing reverted.
| # | Reason | Meaning |
| :--- | :--- | :--- |
| 0 | `NO` | No refund |
| 1 | `SWITCH` | Feature disabled |
| 2 | `PAIR_STATUS` | Pair not tradable |
| 3 | `AMOUNT_IN` | Collateral amount invalid |
| 4 | `USER_PRICE` | Fill fell outside the acceptable price — the most common one |
| 5 | `MIN_NOTIONAL_USD` | Position size below the minimum |
| 6 | `MAX_NOTIONAL_USD` | Position size above the maximum |
| 7 | `MAX_LEVERAGE` | Leverage above the tier's maximum |
| 8 | `TP` | Take-profit invalid |
| 9 | `SL` | Stop-loss invalid |
| 10 | `PAIR_OI` | Open interest cap for the pair reached |
| 11 | `OPEN_LOST` | Position would open already in loss beyond the allowed bound |
| 12 | `SYSTEM` | Internal rejection |
| 13 | `FEED_DELAY` | Oracle price too stale |
| 14 | `PRICE_PROTECTION` | Price protection triggered |
| 15 | `RESERVE_TOKEN_NOT_ACTIVE` | Collateral token disabled |
| 16 | `LV_TOKEN_NOT_ACTIVE` | Settlement token disabled |
| 17 | `MIN_LEVERAGE` | Leverage below the minimum |
`USER_PRICE` usually means the slippage bound was too tight or the price moved between quoting and
settlement. Widen the bound, or re-quote closer to submission.
## 1CT skip reasons
| `skipReason` | Meaning | Fix |
| :--- | :--- | :--- |
| `INVALID` | Signature invalid, agent unauthorized, permission bit missing, or deadline passed | Check authorization, deadline, EIP-712 structure and `primaryType` |
| `NONCE` | Nonce not strictly increasing, or outside the time window | Re-sign with a larger nonce |
| `FEE` | Execution fee could not be collected | Check fee-token balance and Diamond allowance |
| `UNKNOWN` | Unclassified | Report it |
See [Submit & Track](/gasless/submit#interpreting-the-result).
## 1CT submission errors
All return `400` except `Position not found`, which returns `404`. If you branch on the status code
to decide whether to retry, note that the one retryable failure is the `404`.
| Message contains | Cause |
| :--- | :--- |
| `deadline already passed` | `deadline` is in the past |
| `nonce too old` | Nonce more than 2 days old |
| `nonce too far in future` | Nonce more than 5 minutes ahead |
| `invalid action` | Action outside `[0, 13]` |
| `invalid … address` | Malformed `trader` or `feeToken` |
| `must not be empty` | Empty `actionData` or `signature` |
| `antiDdosFee mismatch` | Fee config cache older than ~120 seconds |
| `feeToken not supported` | Token not configured for this action |
| `anti-ddos not enabled` | Free action signed with a non-zero fee, **or** with a `feeToken` other than `0x0000…0000` — a disabled action needs both to be zero |
| `Position not found` (`404`) | Position not yet indexed — [see the caveat](/gasless/actions#actions-that-require-an-indexed-position) |
## Solidity panics
Occasionally surfaced through the 1CT `reason` field.
| Code | Meaning |
| :--- | :--- |
| `0x01` | Assertion failed |
| `0x11` | Arithmetic overflow or underflow |
| `0x12` | Division by zero |
| `0x21` | Invalid enum value |
| `0x22` | Invalid storage encoding |
| `0x31` | Pop from an empty array |
| `0x32` | Array index out of bounds |
| `0x41` | Out of memory |
| `0x51` | Call to an uninitialized function |
## Decoding revert data
```ts
import { decodeErrorResult, type Abi } from 'viem'
const STANDARD_ERRORS = [
{ type: 'error', name: 'Error', inputs: [{ name: 'message', type: 'string' }] },
{ type: 'error', name: 'Panic', inputs: [{ name: 'code', type: 'uint256' }] },
] as const satisfies Abi
const PROTOCOL_ERRORS = [
{ type: 'error', name: 'MarketClosed', inputs: [] },
{ type: 'error', name: 'NonexistentTrade', inputs: [] },
{ type: 'error', name: 'InsufficientLiquidityPool', inputs: [] },
// …add the errors your integration surfaces
] as const satisfies Abi
export function decodeRevert(data: `0x${string}`): string {
for (const abi of [STANDARD_ERRORS, PROTOCOL_ERRORS]) {
try {
const decoded = decodeErrorResult({ abi, data })
if (decoded.errorName === 'Error') return String(decoded.args?.[0])
if (decoded.errorName === 'Panic') return `Panic 0x${(decoded.args?.[0] as bigint).toString(16)}`
return decoded.errorName
} catch {
// try the next ABI
}
}
return 'Unknown error'
}
```
## HTTP status codes
| Code | Meaning |
| :--- | :--- |
| `200` | Success |
| `400` | Invalid parameters |
| `404` | Resource not found |
| `500` | Server error — retry with backoff |
---
Source: https://developer-docs.leverup.xyz/reference/changelog.md
# Changelog
Notable changes to the integration surface.
## 2026-09-27 — Current unrealized PnL API
Added [`GET /v1/portfolio/{user_address}/unrealized-pnl`](/api/history#current-unrealized-pnl)
for a trader's current POOL position valuation on Monad. Returns gross and accrued-fee-adjusted PnL,
funding and holding fees as decimal USD strings, together with block and quote timestamps.
Successful responses use `no-store`; incomplete or stale valuations return `503`.
## 2026-08 — LVMONMinter documented
The mainnet [`LVMONMinter`](/onchain/minting-lvmon) converts native MON or WMON into LVMON. Calls
are permissioned by caller address and go to the standalone minter contract, not the Diamond.
## 2026-07 — `CANCEL_ALL_DECREASE_ORDERS` (1CT action 13)
The onchain `cancelAllDecreaseOrders(positionHash)` now has a gasless counterpart:
[`CANCEL_ALL_DECREASE_ORDERS`](/gasless/actions), action **13**, signed under
`OneClickCancelAllDecreaseOrders` with `actionData` of a single `bytes32` positionHash. It clears
every take-profit leg and the stop-loss on a position in one intent, and charges no fee.
Two things to check if you already have a 1CT integration:
- **Agents authorized with an explicit permission bitmask do not have bit 13.** They will keep
getting `skipReason = INVALID` for this action until the trader calls `updateAgentPermissions`.
Agents authorized with the `type(uint256).max` wildcard pick it up automatically.
- **Action validation widened to `[0, 13]`.** If you mirror the relayer's range check client-side,
update it.
## 2026-07 — Documentation rewrite
The documentation was restructured around three integration paths — [onchain](/onchain/overview),
[gasless](/gasless/overview), and [REST API](/api/overview) — and rewritten against the current
contracts and services.
If you built against the previous version of these docs, the following changed.
### One-Click Trading is now V2
The previous documentation described the V1 flow. V2 is a different protocol; a V1-shaped request
will not produce a valid signature.
| | Previous docs (V1) | Current (V2) |
| :--- | :--- | :--- |
| Authorization | Separate `OneClickAgent` contract | Built into the Diamond — `verifyingContract` is the **Diamond address** |
| Operations | Open and close only | [14 actions](/gasless/actions) |
| EIP-712 struct | Business fields expanded per action | [Seven shared fields](/gasless/signing#message-type) plus `actionDataHash` |
| Replay protection | `salt` | Millisecond `nonce`, strictly increasing, time-windowed |
| Permissions | All or nothing | [Per-action bitmask](/gasless/actions#permission-bits) |
| Endpoint | `POST /v1/trading/send-open-position-v2` | `POST /v2/trading/submit-intent` |
| Execution fee | None | [Per-action ERC-20 fee](/gasless/fees) |
Migrating means rewriting the signing layer, not adjusting it. Start at
[Signing Intents](/gasless/signing).
### Trading events renamed
`OpenMarketTrade` no longer exists. Market and limit opens now emit:
| Old | New |
| :--- | :--- |
| `OpenMarketTrade` | `OpenPosition` (new slot) or `PositionIncreased` (existing slot) |
| `CloseTradeSuccessful` | `ClosePosition`, or `PositionDecreased` for a partial close |
The old close events are retained for positions created before the merged-position upgrade. See
[Events](/onchain/events).
### `getPositionsV2` superseded by `getPositionsV4`
`getPositionsV2` still exists but its `Position` struct gained an `earliestCloseTime` field — an ABI
copied from the older documentation decodes incorrectly. Use
[`getPositionsV4`](/onchain/reading-data#getpositionsv4), which also exposes `accruedFundingFee` and
`accruedHoldingFee`.
### Positions are merged per market
A position is now a slot keyed by `(user, pairBase, isLong, lvToken)` rather than one object per
trade. Opening the same market twice blends into one position. See
[Core Concepts](/introduction/concepts#positions-are-merged-slots).
### Newly documented operations
These existed but were not covered previously:
- [Partial close](/onchain/closing#partial-close)
- [Add and remove margin](/onchain/manage-position)
- [Update a position's TP/SL](/onchain/manage-position#take-profit-and-stop-loss)
- [TP/SL orders](/onchain/tpsl-orders) — multiple partial take-profit legs per position
- [Market info and pair configuration reads](/onchain/reading-data#market-data)
### Code samples
Samples previously imported a `monad` chain object from `viem/chains`, which does not exist. Define
the chain with `defineChain` — see [Networks & Contracts](/introduction/networks#rpc).
### Testnet removed
The documentation covers Monad mainnet only.
### Trading pairs are no longer listed inline
Pair addresses moved out of the docs and into [`GET /v1/pairs`](/api/pairs#list-pairs). Hardcoded
addresses go stale as markets are listed and delisted.