Skip to main content

Agent wallets

An agent wallet is a key that signs trading actions for a master account. This page describes how to approve, use and rotate one.

tip

Stable.

An agent wallet, also called an "API wallet", is a key that signs trading actions for a master account. It never has withdrawal authority. Market makers use this setup: the master key stays in cold storage, and a hot key runs the bots.

The agent wallet is the same primitive as the API wallets of the dominant on-chain perp DEX. It is a drop-in match at the protocol level.

Benefits​

  • Cold-storage master. Approve once from the cold key. After that, the high-value key does not sign again.
  • Per-bot scope. Use a different agent for each strategy or each machine. If one agent is compromised, revoke it and leave the others.
  • Expiry. Approve with an expiry timestamp. The key stops working on its own, even if you do not revoke it.
  • Audit. A specific agent signs each action, so the chain log shows which key did what.

The lifecycle​

The master signs approve_agent once. After that block commits, the agent key signs order actions. An approval can have an expiry, so a hot key retires itself even if you never revoke it.

The authorization check​

warning

An agent is not a general proxy for the master. An agent can sign an action only if that action has an owner field. Order, cancel, amend and margin actions have one. Fund movement and account control do not. See what an agent cannot do.

A request to POST /exchange has three parts:

action = the state-mutating action
nonce = per-account replay nonce
signature = secp256k1 ECDSA over the EIP-712 typed-data digest

There is no top-level sender field. The chain reads the account from the action body, then runs one of two checks.

recovered_addr = ecrecover(eip712_typed_digest(action), signature)

if action has an `owner` field: # e.g. submit_order, batch_order
if recovered_addr == owner: admit # master signed
elif recovered_addr is an approved,
unexpired agent of owner: admit # agent signed for master
else: 401

else: # e.g. bridge_withdraw, approve_agent
account = recovered_addr # the signer IS the account

This has three results:

  1. There are no bearer tokens and no API keys. The signature is the authentication. Possession of the agent's private key proves the authority. Nothing in the URL or the headers grants access.
  2. The agent claim is inside the action. You name the master at owner, or at params.owner on a batch. A claimed owner proves nothing until the recovered signer matches that account or its set of approved agents.
  3. An owner-less action that an agent signs does not fail. It acts on the agent's own account. See what an agent cannot do.

Since block 45,811,287, the owner is also inside the signed digest of the order, cancel, TWAP, schedule-cancel, leverage, margin and position-mode actions, so an agent signature verifies under the one account it was made for.

EIP-712 envelope​

The signed payload for any action is:

struct_hash = keccak256( eip712_encode(action) ) # per-action typed struct
signed_hash = keccak256( 0x1901 ‖ domain_separator ‖ struct_hash )
signature = secp256k1_sign( signed_hash, agent_private_key )
warning

struct_hash is EIP-712 typed-data encoding. It is not a serialization of the JSON that you post. Each action has its own frozen type string, and the digest binds only the fields that the type string names. A field that the type string omits is unsigned. Use the digest builder of your SDK. Do not hash the request body.

The domain separator is:

domain_separator = keccak256(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") ‖
keccak256("MetaFlux") ‖
keccak256("1") ‖
chain_id_as_uint256_be ‖
address(0).padded_to_32
)

This composition follows the standard EIP-712 envelope. EVM clients that already support EIP-712 (MetaMask, Rabby, Ledger, WalletConnect) can use this domain without changes.

The action is signed as EIP-712 structured typed data. Each action variant has one primary type (MetaFluxTransaction:<Action>), so wallets show each field by name. Typed-data signing gives the type string for each action. Signature recovery and EVM compatibility are the same when the master signs and when an approved agent signs.

What the chain stores​

For each master account, the chain stores a set of approved agents:

approval = {
agent : address (20 bytes),
approved_at_ms : u64 (block time at approval),
expires_at_ms : u64 or null (null = no expiry),
name : optional label for bookkeeping
}

All time fields are block time from consensus, not wall-clock time. Thus every validator agrees on the status of an agent at the same block height.

tip

A vault operator is an agent of the vault, not of the leader. A Metaliquidity vault leader registers a strategy key with register_metaliquidity_operator. The chain writes that key into the approved-agent set of the vault address. This is the same set that this page describes. An operator thus signs an order whose owner is the vault address, and the authorization check resolves it with no special case.

As a result, do not require owner == signer in a client. That equality is not the chain's rule. If a client enforces it, the vault path becomes unreachable. To check the vault's agents before you sign, read {"type":"account_state","address":"<vault>","detail":"overview"}.

Approving an agent​

The master sends an approve_agent action to POST /exchange. Sign it with the master key. The action has no owner, so the signer becomes the approving account.

{
"signature": "0x<master_signature>",
"nonce": 1735689600001,
"action": {
"type": "approve_agent",
"params": {
"agent": "0x<agent_addr>",
"expires_at_ms": 1735689600000,
"name": "trading-bot-1"
}
}
}

expires_at_ms:

  • null: no expiry. The agent stays valid until you retire it.
  • A positive integer: the unix time in ms after which the chain rejects requests that the agent signs.

name is only a label for your own records. The userState and subAccounts info queries show it.

Trading from the agent​

After the approval block commits, sign the action with the agent's key. Name the master at owner inside the action body. There is no sender field. Your SDK builds the EIP-712 digest and sends the signed bundle. The chain recovers the agent's address and sees that it is different from owner. It then checks the approval set of the master and admits the action.

{
"signature": "0x<agent_signature>",
"nonce": 1735689600002,
"action": {
"type": "submit_order",
"order": {
"owner": "0x<master_addr>",
"market": 0,
"side": "bid",
"kind": "limit",
"size": 10000,
"limit_px": 5000000000000,
"tif": "gtc",
"stp_mode": "cancel_oldest",
"reduce_only": false
}
}
}

On batch_order, the routing owner is at params.owner, on the batch level. The node ignores the per-leg owner.

Propagation delay​

After approve_agent commits at block height H, requests in block H+1 and later see the new approval.

Thus, after you send approve_agent, wait one consensus tick before you start agent-signed traffic. An SDK retry policy with linear backoff handles the boundary.

A shorter expiry, which retires an agent, has the same one-block delay.

Rotation and expiry​

An agent stops working in one of two ways:

  • Expiry. You set it at approval time, and it executes itself. When now > expires_at_ms, requests fail. You do not need to send anything else.
  • Re-approval with a shorter expiry. A new approve_agent for the same agent address overwrites the previous record. An expires_at_ms in the past retires the key.

For routine rotation, use expiry. The SDKs handle the renewal schedule.

Replay protection​

The chain enforces per-user nonces:

  • Each action has a nonce.
  • The chain rejects a nonce that the same user already used, even when the signature is valid.

Thus the same agent can send concurrent actions safely when each one has a unique nonce. SDKs usually use unix ms with jitter.

For a request that an agent signs, the nonce space belongs to the master (the resolved account), not to the agent. Two different agents of the same master share the nonce space.

Production checklist​

Practices for a fleet of agent keys in production:

ItemWhy
Master in cold storage (hardware wallet / HSM)The master signs approve_agent and withdrawals (bridge_withdraw). These are rare events
One agent per host / containerA compromised host exposes only the authority of its agent. You revoke it and leave the others
expires_at_ms set to ≤ 30 days from approvalSets a renewal schedule. A missed renewal revokes the key automatically
Agent name encodes the host + start timeGives a clear audit trail: mm-host-3 / 2026-Q2
Rotation script: pre-stage new agent before old expiresSend approve_agent for the new key 24h before the old expiry, move the traffic, and let the old key expire
Compromise drill: revoke + rotate runbook tested quarterlyWhen a key leaks, the steps must run without thought
Poll /info account_state with detail: "overview" after every approval / rotationConfirms that the chain state matches what you expect. No real-time event reports a change to an agent approval
Use a different agent for cancel-only vs full tradingA cancel-only key is safer in a semi-trusted environment

Rotation pattern​

day -1 submit approve_agent { agent: new_key, expires_at_ms: NOW + 30d }
wait 1 block (consensus tick); confirm via /info agents
day 0 flip traffic in your bot: stop using old_key, start using new_key
day 0 submit approve_agent { agent: old_key, expires_at_ms: NOW + 1h }
to bound the old key's remaining authority window
day +1h old_key expires automatically

The pre-stage step avoids a window in which both keys are in use in parallel. Such a window is also safe, because concurrent agents share the nonce space of the master.

What an agent cannot do​

An agent can sign an action only if that action has an owner field. That is the full rule. It gives agents the trading surface and nothing else.

An agent can sign these actions, and only these:

GroupActions
Perp orderssubmit_order, batch_order, scale_order, chase_order, twap_order
Cancelscancel_order, batch_cancel, cancel_by_cloid, cancel_all_orders, cancel_scale, cancel_chase, twap_cancel, schedule_cancel
Amendsmodify, batch_modify
Spotspot_order, spot_cancel
Marginupdate_leverage, update_isolated_margin, top_up_isolated_only_margin, set_position_mode
Specialist venuesrfq_request, rfq_quote, rfq_accept, fba_submit

The master key signs every other action. These include every withdrawal and transfer (bridge_withdraw, core_evm_transfer, send_asset, usd_class_transfer), every vault action, Earn and spot margin, staking, sub-accounts, multi-sig conversion, approve_broker_fee, set_referrer, set_referrer_by_code, register_referral_code, set_display_name, portfolio-margin enrolment, and approve_agent itself. There is no agent-of-agent recursion.

danger

A master-only action that an agent signs does not return 401. It acts on the agent's own account, with no warning.

Those actions have no owner field, so the chain treats the recovered signer as the account. An agent-signed bridge_withdraw tries to withdraw the agent's balance. That balance is normally zero, so the action fails for the wrong reason or moves the wrong funds. An agent-signed approve_agent approves a sub-agent of the agent, and grants nothing over the master.

Never send a master-only action through an agent key. Never treat its success as proof that the master acted.

Failure cases​

SymptomCauseFix
401 signer is neither the owner nor an approved agent on every requestThe approval has not committed yetWait one block after approve_agent
The same 401 after a period of correct operationThe agent expiredApprove again with a new expiry, or rotate to a new agent
The same 401 from the first requestThe signing chainId does not match the node, so recovery returns a different addressSign against the chain id for your network. See networks
A withdrawal or transfer "succeeds" but the master's balance does not changeThe agent key signed a master-only action, so it acted on the agent's accountSign every master-only action with the master key. See what an agent cannot do
400 action carries no ownerThe action needs an owner and none was sentSet owner (or params.owner on a batch)

See also​