Signing
Every /exchange action is an EIP-712 signature, so any Ethereum wallet or library can produce it; /info reads are unsigned. There are two flows, and both end in the same envelope and the same keccak256(0x19 ‖ 0x01 ‖ domainSeparator ‖ structHash) digest:
| Flow | Signed by | Used for |
|---|---|---|
| Direct EIP-712 | Account key | Fund movements and authorizations — approveAgent, tokenSend, withdraw3, approveBuilderFee, revokeBuilderFee |
| Agent signing | Agent key | Trading — orders, cancels, margin, outcomeOperate, claimBuilderRewards |
Domain
The standard four-field EIP-712 domain, separate per network so a testnet signature can never replay on mainnet. salt is unused.
EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)| Network | name | version | chainId | domainSeparator |
|---|---|---|---|---|
| Mainnet | TxFlow-Mainnet | "1" | 869 | 0x878a21f7c464d592bad925771b5ab6f52897c4321b30eb6cfdf4ea55c749ad8d |
| Testnet | TxFlow-Testnet | "1" | 2511 | 0x839dd579adc05b1545a37213db5dd3dc6928532ddf73dfa1b53f647421060a8f |
version is the apiVersion as a decimal string, and verifyingContract is always the zero address.
Request envelope
Every action, whichever flow signed it, is submitted in the same envelope:
{
"action": { "type": "<actionType>", "...": "action fields" },
"signature": { "r": "0x…", "s": "0x…", "v": 27 },
"nonce": 1757992743994,
"vaultAddress": null,
"signatureChainId": null
}| Field | Type | Description |
|---|---|---|
action | object | The action body. type selects the operation; see Exchange API. |
signature.r, signature.s | hex string | The 32-byte ECDSA scalars. |
signature.v | integer | 27 or 28 (27 + recoveryId). |
nonce | uint64 | Millisecond timestamp. Whenever the action body carries its own nonce, the envelope must repeat the same value. |
vaultAddress | address · optional | Trade on behalf of a vault. Covered by the signature. |
signatureChainId | uint32 · optional | Overrides the domain chainId for wallets that force their connected chain. |
signatureChainId only changes the EIP-712 domain, never the struct hash, and it applies only to direct EIP-712 actions (approveAgent, approveBuilderFee, revokeBuilderFee, tokenSend, withdraw3). Agent-signed actions ignore it.
Flow 1 — Direct EIP-712
Each account-key action has its own EIP-712 primary type, listed on its reference page, and the action fields are the signed message — so the wallet prompt shows the real values.
- Build the struct hash for the action's type:
structHash = keccak256(abi.encode(typeHash, encodedFields…)). String fields are hashed withkeccak256, addresses are left-padded to 32 bytes, integers are ABI-encoded. signingHash = keccak256(0x19 ‖ 0x01 ‖ domainSeparator ‖ structHash).- Sign
signingHashwith secp256k1 and emit{r, s, v}.
The recovered address is the account itself. The nonce comes from action.nonce.
Example — approveAgent
This is the action that enables trading: the account key authorizes an agent key to sign orders on its behalf.
ApproveAgent(string txflowNetwork,uint32 chainId,uint32 apiVersion,address agentAddress,string agentName,uint64 nonce){
"type": "approveAgent",
"txflowNetwork": "TxFlow-Testnet",
"chainId": 2511,
"apiVersion": 1,
"agentAddress": "0x0202020202020202020202020202020202020202",
"agentName": "my-trading-bot",
"nonce": 2000
}A null agentName is hashed as the empty string. To revoke an agent, submit the same action with agentAddress set to the zero address and the existing agentName.
Flow 2 — Agent signing
The trading actions (and vaultTransfer) are not typed data themselves. The body is compacted into a 32-byte connectionId and wrapped in one fixed EIP-712 type:
Agent(string txflowNetwork,uint32 chainId,uint32 apiVersion,bytes32 connectionId)- MessagePack-encode the action body with the
typefield removed, as a map with named keys. - Append the nonce as 8 bytes big-endian.
- Append the vault flag:
0x00whenvaultAddressis null, otherwise0x01followed by the 20 address bytes. connectionId = keccak256(msgpack ‖ nonceBE8 ‖ vaultFlag).- Hash
Agent{txflowNetwork, chainId, apiVersion, connectionId}as EIP-712 and sign the result.
The recovered address must be an agent already registered through approveAgent — the node rejects any other signer with Agent … is not authorized by any account. The nonce comes from the envelope's nonce.
MessagePack rules
The encoding must be byte-identical to the server's, so the details matter:
- Encode as a map with named keys, not an array. Keys are the wire names, including the abbreviated order fields (
a,b,p,s,r,t,c,m,o). - Keep the node's field order, which is the key order of each action's request example (for an order body:
a,b,p,s,r,t,c,m,expiresAt). The field tables sort required fields first, so do not copy their order, and do not sort keys. - Omit optional fields that are null rather than encoding them as nil — for example, leave
tpslout of a plainmodify2instead of sending"tpsl": null. - Use the most compact integer encoding, native booleans, and native strings. Prices and sizes are strings and are hashed exactly as written, so
"50000.0"and"50000"are different messages. - Addresses inside an action body (such as
vaultTransfer.vaultAddress) are encoded as binary, not as a hex string.
Example — order
{
"type": "order",
"grouping": "na",
"orders": [
{ "a": 1, "b": true, "p": "50000.0", "s": "0.01", "r": false, "t": { "limit": { "tif": "Gtc" } } }
]
}With nonce = 1700000000000 and no vault, the msgpack bytes are 0x82a867726f7570696e67a26e61a66f72646572739186a16101a162c3a170a735303030302e30a173a4302e3031a172c2a17481a56c696d697481a3746966a3477463, giving connectionId = 0x6546d1c3acf430a28a0b2552c66e985f6543646660874734732ae9e9daf4d806.
Builder-fee actions
The builder-code actions intentionally use both signing flows.
The approval actions use direct EIP-712. approveBuilderFee and revokeBuilderFee are signed by the account key under distinct primary types. Their different typehashes prevent a signature for one action from being replayed as the other. Both support signatureChainId, which changes only the EIP-712 domain chain while the business chainId remains in the signed struct.
The reward claim uses tagged Agent signing. claimBuilderRewards has no fields. Its MessagePack preimage keeps the type tag so it cannot collide with other field-less actions:
{ "type": "claimBuilderRewards" }| Action | Preimage | Signer | Replay nonce |
|---|---|---|---|
approveBuilderFee | ApproveBuilderFee EIP-712 struct | Account key | action.nonce |
revokeBuilderFee | RevokeBuilderFee EIP-712 struct | Account key | action.nonce |
claimBuilderRewards | Tagged action | Registered agent | envelope nonce |
The approval signer must be the account itself; an authorized trading agent cannot approve or revoke a builder on the account's behalf. The reward claim follows the Agent flow and therefore requires an agent authorized by the builder account.
Nonces
Nonces are millisecond timestamps, not counters, so concurrent clients can sign in parallel without coordinating. The node applies three checks:
- The nonce must fall within two days before and one day after the block timestamp.
- The nonce must not already be used. Each address keeps its 100 highest nonces; once that set is full, a new nonce must exceed the smallest one still stored.
- Two transactions from the same address in the same block may not share a nonce.
In practice Date.now() per request is correct. A client that sends more than 100 transactions inside the retention window must simply keep its nonces increasing.
Signatures must also be canonical low-S (s <= n/2). Most libraries normalize this automatically; high-S signatures are rejected as non-canonical.
Transaction hash
Once signed, a transaction's id is derived from the digest and the signature together, so two signers submitting the same action and nonce still get distinct transaction hashes:
txHash = keccak256(signingHash[32] ‖ r[32] ‖ s[32] ‖ v[1])The v byte here is the raw recovery id (0 or 1), not the 27/28 used in JSON.
Verifying a signature
To check a signature server-side, or to debug your own client:
- Recompute
signingHashwith the flow that matches the action. - Take
recoveryId = v - 27. - Recover the public key with secp256k1 ECDSA recovery.
- The signer address is the last 20 bytes of the keccak256 hash of the uncompressed public key without its leading byte.
For agent actions, resolve that address through agentOwner to get the account it trades for.
Test vectors
All vectors use the throwaway key 0xabab…abab (address 0xe239cdc5fbe977a8a141b72194d3cf8c41bc5bc6) and testnet domain parameters. If your implementation reproduces both, the two flows are wired correctly.
order — the action and connectionId from the example above, nonce = 1700000000000, no vault:
signingHash | 0xeae1ae63709636fb9567accd33bc5dd2717bfac4d97e9a853f91c7fb391e1cc6 |
r | 0x4064848dd881985c52dacdc00f7046f1010f7f370e34620d36fcf3b2d1ecb3e0 |
s | 0x25c45ad67b9506ac653fc3658f47aa8b6002bfa86a31ae619b334d71aa8b4f6f |
v | 27 |
approveAgent — the action from the example above, nonce = 2000:
structHash | 0xbb5293971660f3252f455b6debbda781841fb23bd45cbbf0199c4de5250573b9 |
signingHash | 0x1df22801b48ae1402ad36b244aa245fd6e842fa5fef2c40819b1e6c661cd2212 |
r | 0x4431d4afb9fcd0dcd619afd55a7044cc26cb0ee1d436a1b06d836da217842ead |
s | 0x54a0755ee3d8c241235a1828a2be732d72e9c152b5de25fc7c169966de4210f7 |
v | 27 |
Next steps
- Try Testnet — a working browser client that approves an agent and signs a real order.
- Exchange API — every action type and which flow signs it.
- TypeScript SDK — typed clients that implement this scheme for you.