Skip to content

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

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:

ActionOffset value in the encodingArray starts at byte
BATCH_MARKET_CLOSE (8)0x600x40
BATCH_CREATE_DECREASE_ORDERS (11)0x600x40
BATCH_UPDATE_DECREASE_ORDERS (12)0x400x20

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.

Signing

ts
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

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.

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.

Trading perpetuals involves risk. Nothing here is financial advice.