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 — 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 IndexedDBimport { 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 onchainTwo 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
CryptoKeywithextractable: falsemeans a script that gains page access still cannot read out the private key.
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:
const permissions = (1n << 0n) | (1n << 1n) // MARKET_OPEN + MARKET_CLOSE onlySee Actions Reference for the bit layout.
Revocation
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.
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
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).
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:
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.