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<number, string> = {
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<number, readonly string[]> = {
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<string> {
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<IntentStatus> {
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.
balanceOfandallowance(trader, diamond)prefetched for every enabled fee token.- Fee-token selection includes the trade's own spend,
extraFeeincluded. - If you run a broker integration, 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.
noncetransmitted as a string.actionDatabuilt with thetraderprefix stripped.- EIP-712 uses the shared seven-field struct with the correct
primaryTypeper 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
reasonis decoded into readable text.