Skip to content

Signing and intent codecs

This page defines every EIP-712 signing path. Use integer values exactly as encoded; do not pass UI-formatted decimal strings into a signer. Deterministic full preimages, struct hashes, domain separators, and final digests are in the signing test vectors maintained with the contracts.

The Spot order-intent domain belongs to the delegated EOA. Read wallet-local nonce and trigger state from that EOA, not from the shared implementation. See one-click trading wallets for the complete execution and keeper model.

EIP-712 domains and digest

Both contracts use the standard four-field domain:

EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)
domainSeparator = keccak256(abi.encode(
  keccak256(bytes(domainType)),
  keccak256(bytes(name)),
  keccak256(bytes(version)),
  chainId,
  verifyingContract
))
digest = keccak256(0x1901 || domainSeparator || structHash)
Contract Name Version Verifying contract
AccountCore KuruAccountCore 1 AccountCore proxy address
KuruTradingWallet KuruTradingWallet 1 Delegated trading EOA

Always use the live chain ID. The domain contains no salt or extensions. eip712Domain() returns the authoritative live values and EIP-5267 field mask 0x0f; reject a mismatch before requesting a signature.

For each message below:

typeHash   = keccak256(bytes(exactTypeString))
structHash = keccak256(abi.encode(typeHash, field1, field2, ...))

AccountCore signed-authorizer paths accept EOAs and ERC-1271 contract wallets through its unified signature checker. Wallet-domain actions use ECDSA and must recover directly to the delegated EOA (address(this)). KuruTradingWallet.isValidSignature exposes that same EOA check through ERC-1271; it does not add another signing authority.

A valid signature alone is not authorization. Each wallet action also checks the current AccountCore authorization epoch, live TRADE permission, deadline, nonce, market, payload hashes, and action-specific state.

AccountCore signed messages

Authorize or revoke a trading EOA

AuthorizeAccountSigner(address account,address authorizer,address signer,uint32 permissions,uint64 expiry,uint256 nonce,uint256 deadline)
RevokeAccountSigner(address account,address authorizer,address signer,uint256 nonce,uint256 deadline)

authorizer signs and any relayer may submit. signer is the delegated trading EOA. nonce must equal accountSignerAuthorizationNonces(account) and deadline is inclusive. The authorizer must have live account-administration authority when the relay executes.

Authorization, revocation, and relevant subaccount changes advance the account-scoped epoch. Every wallet action binds that epoch as authNonce, so revocation permanently invalidates payloads created under the older value. A permission expiry of zero means no expiry; time-based expiry does not itself advance the epoch.

CreateSubaccount(address root,address subaccount,uint256 nonce,uint256 deadline)
  • root is msg.sender; it must resolve to a root account when creation executes.
  • subaccount is both the proposed account and the required signer.
  • nonce = accountSignerAuthorizationNonces(subaccount) before execution.
  • deadline is an inclusive Unix timestamp; execution requires block.timestamp <= deadline.
  • Creation registers the root if needed, registers the subaccount, then increments the new subaccount's authorization epoch. A signature cannot be replayed after registration.

Builder-authorized referrals

BuilderReferralAuthorization(address rootAccount,address builder,uint32 makerFeePps,uint32 takerFeePps,uint64 referralExpiry,bytes32 nonce,uint256 deadline)
PerpBuilderReferralAuthorization(address rootAccount,address builder,uint32 makerFeePps,uint32 takerFeePps,uint64 referralExpiry,bytes32 nonce,uint256 deadline)

The builder signs; any caller may submit. Spot and Perp use distinct type hashes and distinct authorizationUsed(builder,nonce) mappings. nonce is an arbitrary bytes32 one-time value, not a counter. The requested fees must exactly match the builder's live product-specific tier. A tier with expiry 0 accepts any referral expiry; a finite tier requires a nonzero referral expiry no later than the tier expiry. Both deadline and nonzero tier/referral expiries are inclusive.

When the account has a live referral, replacement is accepted only when:

newMakerFee <= oldMakerFee
newTakerFee <= oldTakerFee
oldExpiry == 0  => newExpiry == 0
oldExpiry != 0  => newExpiry == 0 or newExpiry >= oldExpiry
and at least one of:
  newMakerFee < oldMakerFee
  newTakerFee < oldTakerFee
  oldExpiry != 0 and (newExpiry == 0 or newExpiry > oldExpiry)

An absent or expired referral skips this improvement predicate.

Intent payload hashes

The signed types contain bytes32 payload hashes. These are Solidity ABI-array hashes, not the EIP-712 element-by-element array hash used for a nested typed-data member:

packedOpsHash         = keccak256(packedOps)                         // raw bytes
ordersHash            = keccak256(abi.encode(orders))
cancelSlotIdxsHash    = keccak256(abi.encode(cancelSlotIdxs))
expectedOrderIdsHash  = keccak256(abi.encode(expectedOrderIds))

NativeOrder ABI tuple = (uint8 side,
                         uint96 quantity,
                         uint32 price,
                         uint8 tif,
                         uint8 executionInstruction,
                         uint32 minSizeAfterBlock)
orders ABI type       = (uint8,uint96,uint32,uint8,uint8,uint32)[]

side                  = BUY 0 | SELL 1
tif                   = GTC 0 | IOC 1 | FOK 2
executionInstruction  = NONE 0 | POST_ONLY 1

abi.encode(array) includes the top-level dynamic offset, array length, and 32-byte ABI words. Using packed encoding, JSON serialization, encodePacked, or a generic EIP-712 array helper will produce the wrong hash. packedOps uses the exact 32-byte-per-operation layout in orderbook internals.

Immediate intents

The common header fields, in order, are:

uint40 accountId, address market, uint256 authNonce,
uint64 nonce, uint64 deadline, bytes32 clientOrderId,
address builder, uint32 builderFeePps

Append the payload hashes to the exact type string:

ReplaceBySlotIntent(uint40 accountId,address market,uint256 authNonce,uint64 nonce,uint64 deadline,bytes32 clientOrderId,address builder,uint32 builderFeePps,bytes32 packedOpsHash,bytes32 expectedOrderIdsHash)

BatchIntent(uint40 accountId,address market,uint256 authNonce,uint64 nonce,uint64 deadline,bytes32 clientOrderId,address builder,uint32 builderFeePps,bytes32 ordersHash,bytes32 cancelSlotIdxsHash,bytes32 expectedOrderIdsHash)

authNonce must equal the current authorization epoch for the address returned by AccountCore.userAddressById(accountId). The delegated EOA must have live TRADE permission. Any relayer may submit a valid signed action. market must be nonzero; a zero builder requires builderFeePps == 0, while a nonzero builder is subject to the downstream live approval and fee cap. Immediate nonces are strictly increasing per delegated EOA, must exceed lastSeenOrderNonce(), and cannot exceed block.timestamp * 1000 + maxFutureNonceSkewMillis. A reverting call does not consume the nonce. This one namespace is shared by replace and batch intents across every account and market for the same delegated EOA.

authorizeAccountSigner and revokeAccountSigner increment the account address's authorization epoch, invalidating every older signed intent for that account. A permission expiry emits no event and does not increment the epoch, which is why live permission is checked separately at execution.

expectedOrderIds is parallel to packed operations for replacement and parallel to cancel slots for batch. 0 requires an empty slot; 2^64 - 1 (ANY_ORDER_ID) skips the identity check; every other value must equal the live slot order ID. The replacement payload length must be divisible by 32 and expectedOrderIds.length == packedOps.length / 32; for batch, expectedOrderIds.length == cancelSlotIdxs.length.

Trigger creation and storage payloads

Trigger creation uses the same header plus triggerExpiry, conditionHash, and the corresponding payload hashes:

CreateReplaceTriggerIntent(uint40 accountId,address market,uint256 authNonce,uint64 nonce,uint64 deadline,uint64 triggerExpiry,bytes32 clientOrderId,address builder,uint32 builderFeePps,bytes32 conditionHash,bytes32 packedOpsHash,bytes32 expectedOrderIdsHash)

CreateBatchTriggerIntent(uint40 accountId,address market,uint256 authNonce,uint64 nonce,uint64 deadline,uint64 triggerExpiry,bytes32 clientOrderId,address builder,uint32 builderFeePps,bytes32 conditionHash,bytes32 ordersHash,bytes32 cancelSlotIdxsHash,bytes32 expectedOrderIdsHash)

The returned triggerId is the final EIP-712 digest. getTrigger(triggerId).payload is not opaque:

REPLACE_BY_SLOT_PACKED payload = abi.encode(bytes packedOps, uint64[] expectedOrderIds)
BATCH payload = abi.encode(NativeOrder[] orders, uint8[] cancelSlotIdxs, uint64[] expectedOrderIds)

Decode by the stored TriggerAction: 0=NONE, 1=REPLACE_BY_SLOT_PACKED, 2=BATCH. Status is 0=NONE, 1=ACTIVE, 2=CANCELED, 3=FIRED, 4=EXPIRED. Trigger nonces are unordered but one-time in usedTriggerNonce(nonce) and obey the same future-skew bound. Creation requires an unexpired header and triggerExpiry > block.timestamp. This unordered namespace is shared by both trigger-create forms and cancellation across every account and market for the same delegated EOA; it is separate from the immediate lastSeenOrderNonce namespace.

Creation commits and stores the arrays but does not inspect live slot order IDs or enforce their parallel lengths. Those checks happen only when the trigger fires; a malformed stored payload can therefore remain ACTIVE but unexecutable until canceled or expired.

At fire time, block.timestamp == triggerExpiry is still allowed. After that time the stored status remains ACTIVE until a registered keeper calls executeTrigger; that call sets EXPIRED, deletes the stored signature, emits TriggerExpired, and returns without touching the orderbook. For a live trigger, the original ECDSA signature is rechecked against the delegated EOA together with authorization epoch, live permission, and slot bindings. No deadline is forwarded to the orderbook call. A downstream revert rolls back the tentative FIRED status and signature deletion, so the trigger remains ACTIVE.

The contract never evaluates the trigger condition. conditionHash is the signed commitment; executionReportHash is unsigned data supplied by the registered keeper for audit correlation.

Trigger cancellation

CancelTriggerIntent(uint40 accountId,uint256 authNonce,uint64 nonce,uint64 deadline,bytes32 triggerId)

Cancellation may be relayed by anyone and requires a fresh unordered trigger nonce, a current authorization epoch, live wallet TRADE permission, an inclusive deadline, and an ACTIVE trigger belonging to the same account. Success marks the nonce used and the trigger CANCELED atomically; a revert consumes neither.