Typed-data signing
Status: this is the signing scheme. Every /exchange action is signed as
structured EIP-712 typed data (eth_signTypedData_v4). There is no alternate
or legacy scheme to choose between — a wallet (MetaMask, Rabby, Ledger,
WalletConnect) renders each action field by name in its signing prompt.
Each action has a real per-action EIP-712 type, so the wallet shows the user the
actual fields they are signing — destination, amount, agentName — rather
than an opaque blob. The server reconstructs the typed struct from action.type
action.params, recomputes the digest, and recovers the signer.
How it works
| Typed data | |
|---|---|
| Wallet prompt | Each field rendered by name |
| Primary type | MetaFluxTransaction:<Action> (one per action) |
| What is hashed | The structured fields (atomic EIP-712 encoding) |
Users see what they sign in a standard wallet — transfers, withdrawals, agent approvals, and account/staking/vault/spot-margin/earn/bridge settings all carry named fields.
Wire shape
{
"signature": "0x…<65-byte hex>…1b",
"nonce": 1735689600001,
"action": {
"type": "send_asset",
"params": { /* the action fields */ }
}
}
| Field | Meaning |
|---|---|
nonce | The single envelope nonce is also the nonce field inside the signed typed struct — they must match. |
action.type | snake_case action tag. |
action.params | The action fields. Must carry the same values (and the same canonical decimal strings) you hashed. |
The server reconstructs the typed struct from action.type + action.params,
recomputes the EIP-712 digest, recovers the signer, and authorizes it (signer is
the account, or an approved agent of it).
sig_scheme is vestigial. Earlier builds carried a sig_scheme selector on
the envelope. It is no longer required and the server ignores it — typed-data
recovery runs unconditionally. Omit it. If you do send it, the only accepted
value is "typed".
EIP-712 domain
One domain per network, cache it:
EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)
name = "MetaFlux"
version = "1"
chainId = <the node's chain id> // 8964 mainnet · 114514 testnet · 31337 devnet
verifyingContract = 0x0000000000000000000000000000000000000000
Every typed message also carries a metafluxChain string as its first field.
It is a human-readable tag of the same chain id, and it is part of the signed
struct:
chainId | metafluxChain |
|---|---|
8964 | "Mainnet" |
114514 | "Testnet" |
31337 | "Devnet" |
| any other | "Devnet" |
The chain id is fixed per network — take it from networks
and use the matching tag. A metafluxChain or chainId that
doesn't match the node recovers a different signer and the request is rejected.
Encoding rules (atomic EIP-712)
Standard EIP-712 hashStruct:
typeHash = keccak256(encodeType)
hashStruct = keccak256( typeHash ‖ encodeData )
digest = keccak256( 0x19 0x01 ‖ domainSeparator ‖ hashStruct )
encodeData is each field, in declared order, encoded to one 32-byte word:
| Field type | Encoding |
|---|---|
address | 20 bytes, right-aligned (12 zero bytes on the left). |
uintN | big-endian, zero-left-padded to 32 bytes. |
bool | uint8 0 / 1, zero-padded to 32 bytes. |
string | keccak256(utf8_bytes). |
bytes | keccak256(raw_bytes). |
T[] (e.g. address[]) | keccak256( concat of each element's 32-byte word ). |
Sign the 32-byte digest with secp256k1 and serialize the signature as
r ‖ s ‖ v (65 bytes). Both legacy v ∈ {27, 28} and v ∈ {0, 1} are accepted.
Decimals are canonical strings — hash then parse
Any amount / quantity field is an EIP-712 string carrying the canonical
decimal text ("1500.5", "750.25"). The server hashes the verbatim string
and then parses it to a number — so the exact characters matter:
"1.0" and "1.00" hash differently even though they are the same number.
Pick one canonical form per amount and send the identical string in
action.params that you put in the typed message you signed. A mismatch
(trailing zero, missing decimal point, scientific notation) recovers a different
signer and is rejected.
This is why typed signing carries decimals as strings rather than scaled integers: the wallet prompt shows a human-readable amount, and the hash-then-parse rule keeps the signed bytes unambiguous.
Action type strings
For each action the primary type is MetaFluxTransaction:<Action> and the
encodeType string is given below (the field order is the message field order).
action.type is the snake_case tag you put on the POST.
These tables are PARTIAL — they list roughly half of the chain's type strings. They cover the actions an integrator assembles by hand. Not listed: the order and cancel bodies (see Orders and cancels), the RFQ and FBA lanes, and the governance, validator and deployer actions.
For a trading or account action with no row here, take the string from a client
SDK, not from a guess. @metaflux-dex/client and
metaflux-client (Rust) carry the order, cancel, TWAP, sub-account and RFQ
strings byte-identical to the chain.
The governance and validator actions are in NEITHER SDK. ApproveUpgrade,
ApproveUpgradeAt, ArmFeatures, ArmFeaturesAt, CValidator,
GovAdjustSpotValue, GovVote, SetMarkMode, SetMetaliquiditySet,
SetPmShockGrid, VoteAppHash and VoteGlobal have no row here and no SDK
type. A validator casts them with the mtf-node gov CLI over its local socket,
which builds the digest itself, so no integrator assembles them by hand.
A type string is hashed whole into the typehash, so one wrong byte, one wrong field order or one missing field makes the action unsignable — the chain recovers a stranger and refuses it. Copy the string; never retype it.
Transfers
action.type | encodeType |
|---|---|
send_asset | MetaFluxTransaction:SendAsset(string metafluxChain,uint32 sourceDex,uint32 destinationDex,uint32 asset,address destination,string amount,bool toPerp,uint64 nonce) |
usd_class_transfer ⚠️ | MetaFluxTransaction:UsdClassTransfer(string metafluxChain,string ntl,bool toPerp,uint64 nonce) |
withdraw | MetaFluxTransaction:Withdraw(string metafluxChain,uint32 asset,string amount,uint32 destinationChainId,bool useCctp,uint64 nonce) |
⚠️ usd_class_transfer is REJECTED on this network. The type string above is
the frozen truth and your library may still carry it, but the action never
succeeds: there is one USDC pool, so there is no second class to move to. It
answers USDC is unified; no class transfer needed, and the nonce is spent
either way. See USDC.
Core → EVM
Two actions move value from the Core ledger to MetaFluxEVM. See which one to use.
action.type | encodeType |
|---|---|
core_evm_transfer | MetaFluxTransaction:CoreEvmTransfer(string metafluxChain,string amount,bool toEvm,address destination,uint32 asset,uint64 nonce) |
core_evm_transfer (V2) | MetaFluxTransaction:CoreEvmTransferV2(string metafluxChain,string amount,bool toEvm,address destination,uint32 asset,uint32 destinationChainId,bytes data,uint64 nonce) |
send_to_evm_with_data ⚠️ | MetaFluxTransaction:SendToEvmWithData(string metafluxChain,uint32 token,string amount,uint32 sourceDex,address destinationRecipient,bool toPerp,uint32 destinationChainId,bytes data,uint64 transferNonce,uint64 nonce) |
Notes on specific fields:
core_evm_transfer: the envelope you send picks the type string. Carry neitherdatanordestination_chain_idand you signCoreEvmTransfer, byte-identically to before those fields existed. Include either key and you signCoreEvmTransferV2. Presence is the selector, not emptiness —"data": []and"destination_chain_id": 0both count as present.send_to_evm_with_data: two different nonces.transferNonceisparams.nonce, carried with the transfer. The trailingnonceis the ordinary envelope nonce. They are separate signed fields, so sending the same value for both is legal but not required.send_to_evm_with_data:datais abytesfield — hashed askeccak256(raw_bytes), so an empty payload hashes the empty byte string. On the POST it is an array of byte integers, not a hex string.send_to_evm_with_datais live. An earlier version of this note said the network refused the action. That stopped being true when the lane was restored and released. The type string above is frozen. The action does refuse five things: asource_dexother than0,to_perp: true, adestination_chain_idthat is neither0nor the local EVM chain id,dataover 4096 bytes, and an amount that truncates to a zero EVM credit. See the action for each rule.
Account, staking & vault
action.type | encodeType |
|---|---|
approve_agent | MetaFluxTransaction:ApproveAgent(string metafluxChain,address agentAddress,string agentName,uint64 expiresAtMs,uint64 nonce) |
set_referrer | MetaFluxTransaction:SetReferrer(string metafluxChain,address referrer,uint64 nonce) |
approve_broker_fee | MetaFluxTransaction:ApproveBuilderFee(string metafluxChain,address builder,uint16 maxFeeBps,uint64 nonce) |
set_display_name | MetaFluxTransaction:SetDisplayName(string metafluxChain,string displayName,uint64 nonce) |
set_position_mode | MetaFluxTransaction:SetPositionMode(string metafluxChain,bool hedge,uint64 nonce) |
user_portfolio_margin | MetaFluxTransaction:UserPortfolioMargin(string metafluxChain,bool enroll,uint64 nonce) |
convert_to_multi_sig_user | MetaFluxTransaction:ConvertToMultiSigUser(string metafluxChain,address[] signers,uint32 threshold,uint64 nonce) |
update_leverage | MetaFluxTransaction:UpdateLeverage(string metafluxChain,uint32 asset,uint32 leverage,bool isIsolated,uint64 nonce) |
claim_rewards | MetaFluxTransaction:ClaimRewards(string metafluxChain,address validator,uint64 nonce) |
link_staking_user | MetaFluxTransaction:LinkStakingUser(string metafluxChain,address target,uint64 nonce) |
create_vault | MetaFluxTransaction:CreateVault(string metafluxChain,string name,uint64 lockPeriodSecs,uint8 kind,uint64 nonce) |
vault_modify | MetaFluxTransaction:VaultModify(string metafluxChain,uint64 vaultId,string newName,bool hasNewLockPeriodSecs,uint64 newLockPeriodSecs,bool hasNewManagementFeeBps,uint16 newManagementFeeBps,bool hasNewPaused,bool newPaused,uint64 nonce) |
spot_margin_close | MetaFluxTransaction:SpotMarginClose(string metafluxChain,uint32 pair,uint64 limitPx,uint64 nonce) |
noop | MetaFluxTransaction:Noop(string metafluxChain,uint64 nonce) |
claim_referral_rewards | MetaFluxTransaction:ClaimReferralRewards(string metafluxChain,uint64 nonce) |
claim_broker_rewards | MetaFluxTransaction:ClaimBuilderRewards(string metafluxChain,uint64 nonce) |
Notes on specific fields:
claim_rewards:validator= the zero address means claim across all delegations.create_vault:kindis0= User,1= Metaliquidity.noop: the chain tag and the envelope nonce are the only signed fields, because the action carries no params. It touches no state; it burns the nonce. Use it to invalidate an in-flight action signed with the same nonce.approve_broker_fee: the row above is not a typographic error. The action type saysbroker; theencodeTypesaysApproveBuilderFee. Sign the string exactly as printed. The type string is hashed into every signature ever made for this action, so one changed byte stops every historical signature from verifying. The older action typeapprove_builder_feeis still accepted and signs the same string. See broker codes.approve_agent:expiresAtMsis a sentinel, and it is always in the digest. For an approval that never expires, OMITexpires_at_msfrom the POST params and signexpiresAtMs = 0. Sign a non-zero value and the approval carries that expiry. A caller that leaves the field out of the struct signs a four-field digest the chain never computes, so the recovered signer is a stranger and the action is refused.vault_modify: the digest binds every field the action applies. Each optional field signs as two words — a presencebool, then the value. Set the flag totrueonly when the wire payload carries that key. Sign the value as0orfalsewhen it does not. An absent key and a key sent as0are DIFFERENT digests, so one signature covers exactly one wire form.newNamesigns as""when the payload sends no name; the chain refuses an empty name, so""can only mean unchanged. This string replaces a four-fieldVaultModify, and a signature made with that older string is refused. Not live yet: the new string ships with the next node release. A live node still bindsnewNamealone, so sign the four-field form until the release lands. See the action.claim_referral_rewardsandclaim_broker_rewards: the chain tag and the envelope nonce are the only signed fields, because neither action carries params. Both drain the WHOLE accrued credit and neither reports the amount, so read the credit first — see fees.claim_broker_rewards: the same frozen-spelling rule asapprove_broker_fee. The action type saysbroker; theencodeTypesaysClaimBuilderRewards. Sign the string exactly as printed. The older action typeclaim_builder_rewardsis still accepted and signs the same string. See broker codes.
Margin
action.type | encodeType |
|---|---|
update_isolated_margin | MetaFluxTransaction:UpdateIsolatedMargin(string metafluxChain,uint32 asset,string delta,uint64 nonce) |
top_up_isolated_only_margin | MetaFluxTransaction:TopUpIsolatedOnlyMargin(string metafluxChain,uint32 asset,string amount,uint64 nonce) |
delta and amount are canonical decimal strings (hash-then-parse).
Staking
action.type | encodeType |
|---|---|
token_delegate | MetaFluxTransaction:TokenDelegate(string metafluxChain,address validator,string amount,bool isUndelegate,uint8 lockMonths,uint64 nonce) |
amount is a canonical decimal string. isUndelegate = true undelegates,
false delegates.
lockMonths is the staking lock tier: 0 (flexible), 1, 6 or 24.
Omitting it from the POST params is not the same as omitting it from the
digest. The POST field defaults to 0, so a bare delegate stays valid on the
wire. The typed struct has no such default: lockMonths is always one of the six
signed fields, including on an undelegate, where the chain ignores the value but
still hashes it. Sign a five-field struct and the chain computes a digest you
never signed, so the recovered signer is a stranger and the action is refused.
Tier 0 earns no revenue share — see
staking for the tier rules.
Vault
action.type | encodeType |
|---|---|
vault_transfer | MetaFluxTransaction:VaultTransfer(string metafluxChain,uint64 vaultId,bool deposit,string amount,uint64 nonce) |
vault_withdraw | MetaFluxTransaction:VaultWithdraw(string metafluxChain,uint64 vaultId,string shares,uint64 nonce) |
vault_transfer.deposit = true deposits, false withdraws; amount is a
canonical decimal string. vault_withdraw.shares is a canonical decimal string.
Metaliquidity
action.type | encodeType |
|---|---|
register_metaliquidity_operator | MetaFluxTransaction:RegisterMetaliquidityOperator(string metafluxChain,uint64 vaultId,address operator,bool allowed,uint64 expiresAtMs,uint64 nonce) |
expiresAtMs is a sentinel. For an operator that never expires, OMIT
expires_at_ms from the POST params and sign expiresAtMs = 0. Sending an
explicit expires_at_ms: 0 is rejected, because absent and explicit zero
flatten to the same digest and the node refuses the ambiguity.
expiresAtMs is always in the digest, even though expires_at_ms is
optional on the wire. Omitting it signs as 0 — encode expiresAtMs = 0.
Sign a non-zero value and the approval carries that expiry. See
register_metaliquidity_operator.
Spot margin
action.type | encodeType |
|---|---|
spot_margin_open | MetaFluxTransaction:SpotMarginOpen(string metafluxChain,uint32 pair,uint64 size,uint64 limitPx,string borrow,uint64 nonce) |
amount and borrow are canonical decimal strings; size and limitPx are
integers.
Earn
action.type | encodeType |
|---|---|
earn_deposit | MetaFluxTransaction:EarnDeposit(string metafluxChain,uint32 asset,string amount,uint64 nonce) |
earn_withdraw | MetaFluxTransaction:EarnWithdraw(string metafluxChain,uint32 asset,string shares,uint64 nonce) |
amount and shares are canonical decimal strings.
There is no typed struct for createEarnPool. It is a validator governance
vote, not a user action, and it is
not on /exchange.
BOLE pool
action.type | encodeType |
|---|---|
borrow_lend | MetaFluxTransaction:BorrowLend(string metafluxChain,uint8 kind,string amount,uint64 nonce) |
kind signs as a uint8, not as the string you POST. The wire carries
"Lend" / "UnLend" / "Borrow" / "Repay"; the digest carries 0 / 1 / 2
/ 3 in that order. Sign the number, post the string. amount is a canonical
decimal string.
"Borrow" is refused unless the sender is an approved liquidator. The other three
kinds are open to any account. See
borrow_lend.
Spot deployment (MIP-1)
The six spot deployer actions.
Each is sender-authorized, so no struct carries an owner — the recovered
signer is the deployer.
action.type | encodeType |
|---|---|
spot_register_token | MetaFluxTransaction:SpotRegisterToken(string metafluxChain,string symbol,uint8 szDecimals,uint8 weiDecimals,string maxDeployFee,uint64 nonce) |
spot_register_pair | MetaFluxTransaction:SpotRegisterPair(string metafluxChain,uint32 base,uint32 quote,string name,string maxDeployFee,uint64 nonce) |
spot_set_pair_params | MetaFluxTransaction:SpotSetPairParams(string metafluxChain,uint32 pair,uint32 takerFeeDbps,uint32 makerFeeDbps,uint64 minNotionalCents,uint64 nonce) |
spot_set_pair_active | MetaFluxTransaction:SpotSetPairActive(string metafluxChain,uint32 pair,bool active,uint64 nonce) |
spot_seed_holders | MetaFluxTransaction:SpotSeedHolders(string metafluxChain,uint32 asset,address[] holders,string[] amounts,uint64 nonce) |
spot_finalize_supply | MetaFluxTransaction:SpotFinalizeSupply(string metafluxChain,uint32 asset,string maxSupply,uint64 nonce) |
Perp deployer actions
PerpSetSubDeployerPerms is LIVE. Measured on the public testnet: the node
accepts the variant and asks for its params, while a made-up action name in
the same request answers unknown variant. That control is what separates the
two answers.
PerpRegisterAsset also CHANGES in a coming release. It gains string name, the
name of the dex the market joins. The type string below is the NEW one, so the
digest moves: a signature built over the old struct, without name, is invalid
after the upgrade, and a signature over the new struct is invalid before it.
The perp deployer actions. Each is sender-authorized: the recovered signer is the deployer, and per-market authority is checked against the market's deployer and the permission bits its delegates hold.
action.type | encodeType |
|---|---|
perp_register_asset | MetaFluxTransaction:PerpRegisterAsset(string metafluxChain,string symbol,uint8 decimals,string name,uint64 nonce) |
perp_set_oracle | RETIRED — MetaFluxTransaction:PerpSetOracle(string metafluxChain,uint32 asset,uint16 oracleSourceMask,uint64 nonce) |
perp_set_leverage | MetaFluxTransaction:PerpSetLeverage(string metafluxChain,uint32 asset,uint8 maxLeverage,uint64 nonce) |
perp_set_fee_tier | MetaFluxTransaction:PerpSetFeeTier(string metafluxChain,uint32 asset,uint32 takerFeeDbps,uint32 makerFeeDbps,uint32 deployerFeeBps,uint64 nonce) |
perp_set_maker_rebate | MetaFluxTransaction:PerpSetMakerRebate(string metafluxChain,uint32 asset,uint16 rebateBps,uint64 nonce) |
perp_set_min_size | MetaFluxTransaction:PerpSetMinSize(string metafluxChain,uint32 asset,uint64 minOrderSize,uint64 nonce) |
perp_activate_market | MetaFluxTransaction:PerpActivateMarket(string metafluxChain,uint32 asset,uint64 nonce) |
perp_deactivate_market | MetaFluxTransaction:PerpDeactivateMarket(string metafluxChain,uint32 asset,uint64 nonce) |
perp_set_sub_deployers | MetaFluxTransaction:PerpSetSubDeployers(string metafluxChain,uint32 asset,address subDeployer,bool add,uint64 nonce) |
perp_set_sub_deployer_perms | MetaFluxTransaction:PerpSetSubDeployerPerms(string metafluxChain,uint32 asset,address subDeployer,uint16 permissions,uint64 nonce) |
Both rows are live.
PerpSetSubDeployerPermsis new. It grants a delegate an exact permission mask instead of every power.permissionsis in the digest, so one signature binds one (market, delegate, mask) triple. The bit table is onperp_set_sub_deployers.PerpSetOracleis retired. The type string is NOT deleted and every committed payload still decodes, but the node refuses the action. Stop signing it. The mask it wrote has no reader.
PerpSetSubDeployers itself does not change. Its type string, its digest and
its meaning are the same before and after: add: true grants every permission
bit, add: false revokes. A client that signs it keeps working, and a delegate
you already granted keeps every power it has.
name sits between decimals and nonce, and it is in the digest. It names
the dex, and symbol must start with name plus :. Both strings are hashed,
so one signature binds one (dex, symbol) pair and cannot be re-aimed at another
dex. name is required on your first registration and write-once after it — the
rejection rules are on
perp_register_asset.
Fee units differ inside one struct. takerFeeDbps and makerFeeDbps are
DECI-bps; deployerFeeBps is bps. A value moved between the two fields is off by
ten.
No struct carries a bid. A perp market is priced by the Dutch clock and paid at registration, so a non-zero bid is refused.
maxDeployFee and maxSupply are canonical decimal strings under the
hash-then-parse rule — hash
the exact characters you send.
spot_seed_holders carries two arrays, and both are in the digest.
holders is address[]; amounts is string[], one canonical decimal string
per holder, in the same order. Encode each array as
keccak256( concat of the elements' 32-byte words ), where a string[]
element's word is keccak256(utf8_bytes) of that string. The two arrays are
parallel: reordering one alone changes the digest and produces a different
distribution.
None of these six carries a bid field. The deploy fee is paid at commit and
bounded by the signed maxDeployFee; there is nothing to escrow and nothing to
refund.
Agent abstraction & bridge
action.type | encodeType |
|---|---|
agent_set_abstraction | MetaFluxTransaction:AgentSetAbstraction(string metafluxChain,address user,uint8 kind,string value,uint64 nonce) |
bridge_withdraw | MetaFluxTransaction:BridgeWithdraw(string metafluxChain,uint8 chain,uint32 asset,uint64 amount,string dstAddr,uint64 nonce) |
Notes on specific fields:
agent_set_abstraction:valueis an EIP-712string— sign the verbatim string (it is not a number; hashed askeccak256(utf8)).bridge_withdraw: the typedchainfield is auint8—1= Base,2= Arbitrum. But the POSTaction.params.chainis the string name ("Base"/"Arbitrum"). So sign theuint8in the typed message and send the string name inparams.bridge_withdraw:amountis auint64integer (not a decimal string);dstAddris the destination-chain address string.
Scale ladder
The scale ladder actions bind the compact
request — you sign the range and the distribution, not the expanded rungs. Each
has an owner-less primary type and a _WITH_OWNER twin; the twin is used only
when the wire carries an owner (an agent / operator acting for another account),
with owner inserted right after metafluxChain, mirroring batch_order.
action.type | encodeType |
|---|---|
scale_order | MetaFluxTransaction:ScaleOrder(string metafluxChain,uint32 market,string side,uint32 n,uint64 pxLow,uint64 pxHigh,uint64 totalSize,string dist,bytes32 weights,string tif,bool reduceOnly,string stpMode,string positionSide,string cloid,uint64 nonce) |
scale_order (with owner) | MetaFluxTransaction:ScaleOrder(string metafluxChain,address owner,uint32 market,string side,uint32 n,uint64 pxLow,uint64 pxHigh,uint64 totalSize,string dist,bytes32 weights,string tif,bool reduceOnly,string stpMode,string positionSide,string cloid,uint64 nonce) |
cancel_scale | MetaFluxTransaction:CancelScale(string metafluxChain,uint32 market,string cloid,uint64 nonce) |
cancel_scale (with owner) | MetaFluxTransaction:CancelScale(string metafluxChain,address owner,uint32 market,string cloid,uint64 nonce) |
Notes on specific fields:
weightsis abytes32the client pre-hashesT[]-style:keccak256(concat(per-weight uint256 words))fordist == "custom", and the zero hash (0x00…00) for every otherdist. This binds the exact weight vector without inflating the message — a 100-rung ladder signs the same size message as a 2-rung one. The wireparams.weightsstill carries the full array (the server rebuilds and re-verifies it); for a non-customdistsend an empty array.side/dist/tif/stpMode/positionSide/cloidare EIP-712strings, signed verbatim in theirsnake_casewire form (positionSideis""when omitted).pxLow/pxHigh/totalSizeareuint64integers on the wire (widened internally).
Chase
A chase order binds one self-repricing
leg: you sign the intent, the node re-prices the resting leg to track the touch.
Like the scale ladder it has an owner-less primary type and a _WITH_OWNER twin
(owner right after metafluxChain).
action.type | encodeType |
|---|---|
chase_order | MetaFluxTransaction:ChaseOrder(string metafluxChain,uint32 market,string side,uint64 size,string cloid,string stpMode,string positionSide,uint32 intervalBlocks,uint64 ttlMs,uint32 maxReprices,uint64 nonce) |
chase_order (with owner) | MetaFluxTransaction:ChaseOrder(string metafluxChain,address owner,uint32 market,string side,uint64 size,string cloid,string stpMode,string positionSide,uint32 intervalBlocks,uint64 ttlMs,uint32 maxReprices,uint64 nonce) |
cancel_chase | MetaFluxTransaction:CancelChase(string metafluxChain,uint32 market,uint64 chaseOid,uint64 nonce) |
cancel_chase (with owner) | MetaFluxTransaction:CancelChase(string metafluxChain,address owner,uint32 market,uint64 chaseOid,uint64 nonce) |
Notes on specific fields:
side/stpMode/positionSide/cloidare EIP-712strings signed verbatim in theirsnake_casewire form; each is""when omitted.cloidis hashed as the verbatim0x-hex STRING, not the raw 16 bytes.size/intervalBlocks/ttlMs/maxRepricesare integer words.cancel_chase.chaseOidis the registry cancel handle from thechase_orderack (statuses[0].chase.chase_oid), not the resting leg oid.
Fields that are not in the typed digest
One action has a params key that the typed type string does not cover, so
the server forces it to its default:
create_vault— theCreateVaulttype has noparent, socreate_vaultis top-level (no parent). Omitparent.
approve_agent is not in this class, whatever an older copy of this page
said. ApproveAgent DOES bind uint64 expiresAtMs. Omitting expires_at_ms
from the POST is right for a never-expiring approval, but the STRUCT still
carries the field and signs it as 0.
Action expiry (expiresAfter)
Every action type optionally carries a top-level expiresAfter (uint64
milliseconds): an expiry time, signed into the digest, after which the action is
no longer valid. It is a defence against late replay — a signature that leaks or
is held back by a relay stops working once its expiry passes. See
POST /exchange → optional action expiry
for the wire behaviour and rejection rules.
The fold is uniform across every action type and follows one rule:
expiresAfter == 0(or absent) — the default. The digest is byte-for-byte identical to the action's normal digest. Nothing about signing changes unless you opt in.expiresAfter != 0. Two changes, both deterministic:- The type string's trailing
…,uint64 nonce)becomes…,uint64 nonce,uint64 expiresAfter). - One extra 32-byte word —
expiresAfteras a big-endianuint64, left-padded — is appended toencodeDataafter thenonceword.
- The type string's trailing
So for withdraw:
// expiresAfter == 0 (or omitted): unchanged
MetaFluxTransaction:Withdraw(string metafluxChain,uint32 asset,string amount,uint32 destinationChainId,bool useCctp,uint64 nonce)
// expiresAfter != 0: folded
MetaFluxTransaction:Withdraw(string metafluxChain,uint32 asset,string amount,uint32 destinationChainId,bool useCctp,uint64 nonce,uint64 expiresAfter)
eth_signTypedData_v4 field placement
When expiresAfter is non-zero, add it as the last field of the action's type
array and set it in the message (as a decimal string, like any uint64):
types['MetaFluxTransaction:Withdraw'].push({ name: 'expiresAfter', type: 'uint64' });
message.expiresAfter = '1735693200000'; // only when non-zero
When it is 0 / absent, do not add the field or the message key — that
reproduces the legacy typed data exactly.
Worked delta — withdraw with and without expiry
A withdraw of "100.5" of asset 0 to chain id 8453 on Testnet
(chainId = 114514), useCctp = false, nonce = 1735689600000. The two digests
below are pinned by the cross-implementation known-answer test — a compliant
eth_signTypedData_v4 assembly reproduces them exactly:
expiresAfter | Signed EIP-712 digest (32 bytes) |
|---|---|
0 / omitted | 0x425495f369661cdff0c274cd16ee5ad91294892a924b9a84033f09183b087c0e |
1735693200000 | 0x9ad23a96bb83b8bdd427fe9023b4855e8689be66da73da745f9af0acb59f5833 |
The first row is identical to the digest you get from the plain (no-expiry)
withdraw — proof that opting out costs nothing. The second row differs only
because the folded type string and the appended expiresAfter word changed the
struct hash.
Worked example — send_asset (a transfer)
A transfer of "750.25" of asset 2 from spot DEX 0 to perp DEX 1, into the
perp wallet, on Testnet (chainId = 114514).
The object you hand to eth_signTypedData_v4:
{
"types": {
"EIP712Domain": [
{ "name": "name", "type": "string" },
{ "name": "version", "type": "string" },
{ "name": "chainId", "type": "uint256" },
{ "name": "verifyingContract", "type": "address" }
],
"MetaFluxTransaction:SendAsset": [
{ "name": "metafluxChain", "type": "string" },
{ "name": "sourceDex", "type": "uint32" },
{ "name": "destinationDex", "type": "uint32" },
{ "name": "asset", "type": "uint32" },
{ "name": "destination", "type": "address" },
{ "name": "amount", "type": "string" },
{ "name": "toPerp", "type": "bool" },
{ "name": "nonce", "type": "uint64" }
]
},
"primaryType": "MetaFluxTransaction:SendAsset",
"domain": {
"name": "MetaFlux",
"version": "1",
"chainId": 114514,
"verifyingContract": "0x0000000000000000000000000000000000000000"
},
"message": {
"metafluxChain": "Testnet",
"sourceDex": 0,
"destinationDex": 1,
"asset": 2,
"destination": "0x3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c",
"amount": "750.25",
"toPerp": true,
"nonce": 28
}
}
// MetaMask / EIP-1193 provider
const signature = await window.ethereum.request({
method: 'eth_signTypedData_v4',
params: [signerAddress, JSON.stringify(typedData)],
});
await fetch(`${BASE_URL}/exchange`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
signature,
nonce: 28, // MUST equal message.nonce
action: {
type: 'send_asset',
params: {
source_dex: 0,
destination_dex: 1,
asset: 2,
destination: '0x3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c',
amount: '750.25', // SAME canonical string you signed
to_perp: true,
},
},
}),
});
Worked example — approve_agent (an account action)
Approve an agent named "trading-bot" on Testnet (chainId = 114514), with
no expiry. expiresAtMs is in the struct and signs as 0; the POST omits
expires_at_ms entirely. Leave the field out of the struct and the digest has
four fields where the chain hashes five, so the signature recovers a stranger.
{
"types": {
"EIP712Domain": [
{ "name": "name", "type": "string" },
{ "name": "version", "type": "string" },
{ "name": "chainId", "type": "uint256" },
{ "name": "verifyingContract", "type": "address" }
],
"MetaFluxTransaction:ApproveAgent": [
{ "name": "metafluxChain", "type": "string" },
{ "name": "agentAddress", "type": "address" },
{ "name": "agentName", "type": "string" },
{ "name": "expiresAtMs", "type": "uint64" },
{ "name": "nonce", "type": "uint64" }
]
},
"primaryType": "MetaFluxTransaction:ApproveAgent",
"domain": {
"name": "MetaFlux",
"version": "1",
"chainId": 114514,
"verifyingContract": "0x0000000000000000000000000000000000000000"
},
"message": {
"metafluxChain": "Testnet",
"agentAddress": "0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
"agentName": "trading-bot",
"expiresAtMs": 0,
"nonce": 1
}
}
const signature = await window.ethereum.request({
method: 'eth_signTypedData_v4',
params: [signerAddress, JSON.stringify(typedData)],
});
await fetch(`${BASE_URL}/exchange`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
signature,
nonce: 1,
action: {
type: 'approve_agent',
params: {
agent: '0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1',
name: 'trading-bot',
// no expires_at_ms on the wire; the struct signed expiresAtMs = 0
},
},
}),
});
See agent wallets for the approval lifecycle (an approval becomes effective one block after commit).
Verifying your encoding
Before submitting, recover the signer locally against your own assembled digest
and confirm it matches the expected address — if it doesn't, the bug is in your
typed-data assembly, not the chain. The atomic encoding above is the full
specification; a cross-implementation known-answer test pins each action's digest
byte-for-byte, so any compliant eth_signTypedData_v4 implementation reproduces
the same result.
Orders and cancels
Orders and cancels (submit_order, batch_order, cancel_order,
batch_cancel, and the scale_order / cancel_scale ladder
actions) are submitted through the same /exchange envelope and signed the same
EIP-712 typed-data way. Their action-body shapes are in the
POST /exchange action catalog.
Order type strings and the trailing fold
A trigger leg may carry a trailing callback,
trigger.trail_px. That field moves
WHERE a position closes, so it is a control field and it is signed. It is
folded into the order type strings the same presence-selected way
expiresAfter is folded into every action: no
trail_px key, no change at all; a trail_px key anywhere, a different type
string and a different digest.
The selector is presence, not value. An explicit trail_px: 0 is a
present trail. It takes the trailing digest and is then rejected on admission
(trailing callback must be > 0). To sign as before, omit the key.
submit_order
// no trail_px key — the frozen type string, unchanged
MetaFluxTransaction:SubmitOrder(string metafluxChain,uint32 market,string side,string kind,uint64 size,uint64 limitPx,string tif,string stpMode,bool reduceOnly,string cloid,uint16 builderFee,address builderUser,string positionSide,uint64 triggerPx,bool triggerIsMarket,string triggerTpsl,uint64 nonce)
// trail_px present — trailPx folded in before nonce
MetaFluxTransaction:SubmitOrder(string metafluxChain,uint32 market,string side,string kind,uint64 size,uint64 limitPx,string tif,string stpMode,bool reduceOnly,string cloid,uint16 builderFee,address builderUser,string positionSide,uint64 triggerPx,bool triggerIsMarket,string triggerTpsl,uint64 trailPx,uint64 nonce)
trailPx is one extra 32-byte word — the callback as a big-endian uint64,
left-padded — inserted after triggerTpsl and before nonce. It is not
appended at the end, so it does not collide with the expiresAfter fold, which
still goes last.
batch_order
A batch does not widen its per-leg encoding. The orders field stays exactly
what it was — a keccak over each leg's fixed-width words, in leg order — so a leg
is the same number of words whether it trails or not. Widening a leg would make
the per-leg encoding variable-length inside a flat, unprefixed concatenation,
which is malleable: two different batches could hash the same.
Instead the callbacks travel in a second field, trailPxs, present only when
at least one leg trails:
// no leg carries trail_px — frozen, unchanged
MetaFluxTransaction:BatchOrder(string metafluxChain,bytes32 orders,string grouping,uint64 nonce)
MetaFluxTransaction:BatchOrder(string metafluxChain,address owner,bytes32 orders,string grouping,uint64 nonce)
// at least one leg carries trail_px — trailPxs folded in after grouping
MetaFluxTransaction:BatchOrder(string metafluxChain,bytes32 orders,string grouping,bytes32 trailPxs,uint64 nonce)
MetaFluxTransaction:BatchOrder(string metafluxChain,address owner,bytes32 orders,string grouping,bytes32 trailPxs,uint64 nonce)
Computing trailPxs. Like orders, it is a plain keccak over a
concatenation you build yourself, not an EIP-712 array encoding. Walk the legs in
the same order orders walks them, and emit two fixed-width words per
leg:
- the presence flag — a 32-byte word,
1if that leg carriestrail_px, else0 - the callback —
trail_pxas a big-endianuint64, left-padded to 32 bytes;0for a leg that does not trail
trailPxs = keccak256(concat(those words)). Every leg contributes both words,
including the ones with no trail — that is what makes which leg trails part
of the digest. Moving the trail from leg 0 to leg 1 changes trailPxs while
orders stays identical, and the signature stops verifying.
The presence word is not redundant with the value word: without it, "no trail" and "a trail of 0" would hash alike, and one signature would cover two wire forms that behave differently.
eth_signTypedData_v4 field placement
// submit_order, only when the order carries trail_px
types['MetaFluxTransaction:SubmitOrder'].splice(16, 0, { name: 'trailPx', type: 'uint64' });
message.trailPx = '100000000000';
// batch_order, only when some leg carries trail_px
// (index 3 without owner, 4 with owner — always just after `grouping`)
types['MetaFluxTransaction:BatchOrder'].splice(3, 0, { name: 'trailPxs', type: 'bytes32' });
message.trailPxs = '0x...';
When no trail is present, do not add the field or the message key. That
reproduces the legacy typed data exactly, which is why an older client that never
heard of trail_px keeps signing valid orders with no change.
Known-answer digests
Pinned on Testnet (chainId = 114514), nonce = 1. A compliant
eth_signTypedData_v4 assembly reproduces them byte-for-byte.
| Vector | expiresAfter | Signed EIP-712 digest |
|---|---|---|
submit_order with trailPx = 50000000 | 0 | 0xf78212e9ab8ad38ad455552cd9343a7a6637a8d331f23528fe7ae84713a20b64 |
| the same order | 1900000000000 | 0x3f4d7fd0d3fb293e604fe6e5c4fc52e7b76830eaa39f8dc5d4d26b34372d5d92 |
batch_order with owner, 2 legs, leg 1 trails | 0 | 0xdf6da2a4e1c3cabd1852bfa1aa05495a839d3787f1a01e2df18c199b53453b88 |
| the same batch with no leg trailing | 0 | 0xef21c04ccb568652ab2d8950dffd1bd289acaafde846199f74a8ba72e0f5dad8 |
The last row is the control, and it is identical to the digest the same batch
produced before trail_px was bound — proof that not sending the field costs
nothing. The two batch rows also share one orders hash,
0x1894b6b95a1e0af9b6c694e7ff0eef0f467701a1215973bb25c42f932f43f300, and differ
only in trailPxs:
0x74a1e15aa3dfcb4bfbf5c65b533597fe064fc7492edd6f5f843427d22feaf26d (trailing)
versus
0x012893657d8eb2efad4de0a91bcd0e39ad9837745dec3ea923737ea803fc8e3d (control).
Which actions can trail
Only submit_order and batch_order carry trail_px. The
scale_order ladder, chase_order, TWAP and RFQ
derive their legs with no trailing callback, and their type strings are
untouched. A multi-sig inner payload is signed over
its own bytes and is likewise unaffected.
See also
POST /exchange— the endpoint and full action catalog- Agent wallets — approval lifecycle
- Networks —
chainIdper network