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:
actionDatais encoded withtraderprepended, then the first 32 bytes stripped.- All actions share one field list; the action is identified by the
primaryTypename.
Domain
const domain = {
name: 'LeverupOneClickV2',
version: '1',
chainId: 143,
verifyingContract: '0xea1b8E4aB7f14F7dCA68c5B214303B13078FC5ec', // the Diamond
} as constWARNING
verifyingContract is the Diamond. There is no separate agent contract in this version.
Message type
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 constIdentical 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. 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:
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
import { encodeAbiParameters, parseAbiParameter, type Hex, type Address } from 'viem'
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 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:
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
const nonce = BigInt(Date.now()) // milliseconds, uint64
const deadline = Math.floor(Date.now() / 1000) + 300 // seconds, uint48Constraints and the high-frequency caveat are covered in Security Model → Nonce and deadline.
Signing
import { keccak256 } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
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 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 through a browser wallet works too, but prompts the user each time.
Worked example — market open
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.
Worked example — TP/SL orders
// 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. primaryTypedoes not match the action.verifyingContractis 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.