EIP-712 Signing

GX Exchange uses EIP-712 typed data signatures for all authenticated exchange operations. This page provides a complete guide to constructing and signing requests.

Overview

EIP-712 provides structured, human-readable message signing. When a user signs an exchange action, the wallet can display the action details clearly, enabling informed approval.

EIP-712 Domain

All signatures use the following domain separator:

{
  "name": "GXExchange",
  "version": "1",
  "chainId": 42069,
  "verifyingContract": "0x0000000000000000000000000000000000000000"
}
FieldValueDescription
name"GXExchange"Protocol name
version"1"Domain version
chainId42069GX Chain L1 chain ID
verifyingContract0x000...000Zero address (no specific contract)

Signing Flow

  1. Construct the action payload — the order, cancel, transfer, or other operation.
  2. Generate a nonce — current Unix timestamp in milliseconds.
  3. Derive the connectionId — zero-pad the nonce to 32 bytes.
  4. Build the EIP-712 typed data — domain + types + message.
  5. Sign — produce the signature using eth_signTypedData_v4 or equivalent.
  6. Submit — send the action, nonce, and signature to POST /exchange.

Type Definitions

Agent Type (for API wallet signing)

const agentTypes = {
  Agent: [
    { name: "source", type: "string" },
    { name: "connectionId", type: "bytes32" },
  ]
};

Order Wire Type

The order action is hashed and signed as part of the Agent message. The source field is set to "a" for mainnet and "b" for testnet.

Connection ID

The connectionId is derived from the nonce:

import { ethers } from "ethers";
 
function nonceToConnectionId(nonce: number): string {
  return ethers.zeroPadValue(ethers.toBeHex(nonce), 32);
}
def nonce_to_connection_id(nonce: int) -> bytes:
    return nonce.to_bytes(32, byteorder="big")

Complete Signing Example

TypeScript (ethers.js v6)

import { ethers } from "ethers";
 
const EIP_712_DOMAIN = {
  name: "GXExchange",
  version: "1",
  chainId: 42069,
  verifyingContract: "0x0000000000000000000000000000000000000000",
};
 
const AGENT_TYPES = {
  Agent: [
    { name: "source", type: "string" },
    { name: "connectionId", type: "bytes32" },
  ],
};
 
async function signAction(
  wallet: ethers.Wallet,
  action: any,
  nonce: number,
  isMainnet: boolean = true
): Promise<{ action: any; nonce: number; signature: any }> {
  const connectionId = ethers.zeroPadValue(ethers.toBeHex(nonce), 32);
 
  const message = {
    source: isMainnet ? "a" : "b",
    connectionId,
  };
 
  const signature = await wallet.signTypedData(
    EIP_712_DOMAIN,
    AGENT_TYPES,
    message
  );
 
  const { r, s, v } = ethers.Signature.from(signature);
 
  return {
    action,
    nonce,
    signature: { r, s, v },
  };
}
 
// Usage: Place a limit buy on BTC
async function placeOrder(wallet: ethers.Wallet) {
  const action = {
    type: "order",
    orders: [{
      a: 0,        // BTC index
      b: true,     // buy
      p: "67000.0",
      s: "0.01",
      r: false,
      t: { limit: { tif: "Gtc" } },
    }],
    grouping: "na",
  };
 
  const nonce = Date.now();
  const signed = await signAction(wallet, action, nonce);
 
  const response = await fetch("https://api.gx.exchange/exchange", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(signed),
  });
 
  return response.json();
}

Python (eth_account)

import time
import requests
from eth_account import Account
from eth_account.messages import encode_typed_data
 
EIP_712_DOMAIN = {
    "name": "GXExchange",
    "version": "1",
    "chainId": 42069,
    "verifyingContract": "0x0000000000000000000000000000000000000000",
}
 
AGENT_TYPES = {
    "Agent": [
        {"name": "source", "type": "string"},
        {"name": "connectionId", "type": "bytes32"},
    ]
}
 
def sign_action(account, action: dict, nonce: int, is_mainnet: bool = True) -> dict:
    connection_id = nonce.to_bytes(32, byteorder="big")
 
    message = {
        "source": "a" if is_mainnet else "b",
        "connectionId": connection_id,
    }
 
    # Build EIP-712 structured data
    typed_data = {
        "types": {
            "EIP712Domain": [
                {"name": "name", "type": "string"},
                {"name": "version", "type": "string"},
                {"name": "chainId", "type": "uint256"},
                {"name": "verifyingContract", "type": "address"},
            ],
            **AGENT_TYPES,
        },
        "primaryType": "Agent",
        "domain": EIP_712_DOMAIN,
        "message": message,
    }
 
    encoded = encode_typed_data(full_message=typed_data)
    signed = account.sign_message(encoded)
 
    return {
        "action": action,
        "nonce": nonce,
        "signature": {
            "r": hex(signed.r),
            "s": hex(signed.s),
            "v": signed.v,
        },
    }
 
# Usage
account = Account.from_key("0xYourPrivateKey...")
action = {
    "type": "order",
    "orders": [{
        "a": 0,
        "b": True,
        "p": "67000.0",
        "s": "0.01",
        "r": False,
        "t": {"limit": {"tif": "Gtc"}},
    }],
    "grouping": "na",
}
 
nonce = int(time.time() * 1000)
signed = sign_action(account, action, nonce)
 
resp = requests.post("https://api.gx.exchange/exchange", json=signed)
print(resp.json())

Signing with an Agent Wallet

When using an API wallet (agent), the signing process is identical except:

  1. You sign with the agent’s private key instead of the main wallet key.
  2. You include vaultAddress in the request to specify which main address the agent acts for.
const signed = await signAction(agentWallet, action, nonce);
 
const response = await fetch("https://api.gx.exchange/exchange", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    ...signed,
    vaultAddress: "0xMainWalletAddress...",
  }),
});

Approve Agent Signing

The approveAgent action has its own signing flow since it authorizes a new signer:

const approveAction = {
  type: "approveAgent",
  agentAddress: agentWallet.address,
  agentName: "Trading Bot",
  nonce: Date.now(),
};
 
// This MUST be signed with the main wallet
const signed = await signAction(mainWallet, approveAction, Date.now());

Verification

The server verifies signatures by:

  1. Recovering the signer address from the EIP-712 signature
  2. Checking if the signer matches the expected address (or an authorized agent)
  3. Validating the nonce is within the 60-second window
  4. Ensuring the nonce has not been used before

Common Mistakes

IssueCauseFix
Invalid signatureWrong chain ID in domainUse chainId: 42069
Invalid signatureWrong source stringUse "a" for mainnet, "b" for testnet
Invalid signatureIncorrect connectionId derivationZero-pad nonce to 32 bytes
Nonce too oldClock skewSynchronize via NTP
Agent not authorizedAgent not approvedSubmit approveAgent first with main wallet