Signing Messages

How to construct and sign EIP-712 typed data for orders, signer registration, withdrawals, transfers, and subaccount creation

This guide explains how to construct and sign EIP-712 typed data messages for the OBSDN API. All cryptographic operations use the EIP-712 standard for structured data signing.

Overview

OperationPrimary TypePurpose
OrderOrderAuthorize order placement
Signer RegistrationRegister + DelegatedSignerRegister signing wallet for the main account
WithdrawalWithdrawAuthorize fund withdrawal
Subaccount CreationCreateSubaccountCreate a subaccount under the main account
Child Account Signer RegistrationRegisterChildAccountSigner + DelegatedSignerRegister a signing wallet for a child (sub + vault) account
TransferTransferMove funds between accounts under the same main account

Getting the Signing Domain

Before signing, fetch the EIP-712 domain configuration:

GET /chain/config

Response (envelope omitted, only signing-relevant fields shown, full response also includes addrs, native_ccy, rpc_urls, blk_explorer):

{
  "data": {
    "nm": "monad-mainnet",
    "chain_id": 143,
    "domain": {
      "nm": "Obsidian",
      "ver": "1",
      "chain_id": "143",
      "verif_contract": "0x90c3747cd4E6bC6FbebB1b3C54D99737590eBE45"
    },
    "testnet": false
  },
  "request_id": "..."
}

Always fetch the domain at runtime via /chain/config, the values above are a snapshot of the current production deployment.

The fields you need are nested under data.domain:

FieldDescription
nmEIP-712 domain name
verEIP-712 domain version
chain_idChain ID, as a string
verif_contractVerifying contract address

Use these values to construct the EIP712Domain:

const { domain: d } = response.data
const domain = {
  name: d.nm,
  version: d.ver,
  chainId: parseInt(d.chain_id),
  verifyingContract: d.verif_contract,
}

Order Signature

Orders require a signature from your registered signer wallet.

EIP-712 Types

Field names and order are part of the EIP-712 type hash, they must match exactly:

const orderTypes = {
  Order: [
    { name: "sender", type: "address" },
    { name: "marketIndex", type: "uint16" },
    { name: "side", type: "uint8" },
    { name: "size", type: "uint128" },
    { name: "price", type: "uint128" },
    { name: "nonce", type: "uint64" },
  ],
}

Message Fields

FieldTypeDescription
senderaddressSender wallet address (lowercase hex)
marketIndexuint16Market index (see table below)
sideuint80 = BUY, 1 = SELL
sizeuint128Order size in 18 decimals
priceuint128Order price in 18 decimals
nonceuint64Unix timestamp in nanoseconds

Market Index Mapping

IndexMarket
1BTC-PERP
2ETH-PERP
3SOL-PERP

Only a few examples are shown above. Query GET /markets for the most up-to-date list of markets and their indices — the idx field on each market is the value to use for marketIndex in the EIP-712 payload.

Value Conversion

Size and price must be converted to 18 decimal integers:

// Convert decimal string to 18 decimal integer string
function toX18(value: string): string {
  const [whole, fraction = ""] = value.split(".")
  const padded = fraction.padEnd(18, "0").slice(0, 18)
  return BigInt(whole + padded).toString()
}

// Examples:
toX18("1.5") // "1500000000000000000"
toX18("50000") // "50000000000000000000000"
toX18("0.001") // "1000000000000000"

Complete Example (TypeScript + viem)

import { createWalletClient, http } from "viem"
import { privateKeyToAccount } from "viem/accounts"

const signerAccount = privateKeyToAccount("0x...")
const client = createWalletClient({
  account: signerAccount,
  transport: http(),
})

// Fetch domain from /chain/config at runtime.
const domain = {
  name: "Obsidian",
  version: "1",
  chainId: 143,
  verifyingContract: "0x90c3747cd4E6bC6FbebB1b3C54D99737590eBE45" as `0x${string}`,
}

const orderTypes = {
  Order: [
    { name: "sender", type: "address" },
    { name: "marketIndex", type: "uint16" },
    { name: "side", type: "uint8" },
    { name: "size", type: "uint128" },
    { name: "price", type: "uint128" },
    { name: "nonce", type: "uint64" },
  ],
} as const

async function signOrder(params: {
  senderAddress: string
  marketIndex: number
  side: "BUY" | "SELL"
  size: string // e.g., "1.5"
  price: string // e.g., "50000"
}) {
  const nonce = BigInt(Date.now()) * 1_000_000n // nanoseconds

  const message = {
    sender: params.senderAddress.toLowerCase(),
    marketIndex: params.marketIndex,
    side: params.side === "BUY" ? 0 : 1,
    size: toX18(params.size),
    price: toX18(params.price),
    nonce: nonce.toString(),
  }

  const signature = await client.signTypedData({
    domain,
    types: orderTypes,
    primaryType: "Order",
    message,
  })

  return { signature, nonce: nonce.toString() }
}

// Usage
const { signature, nonce } = await signOrder({
  senderAddress: "0x1234...",
  marketIndex: 1,
  side: "BUY",
  size: "0.1",
  price: "50000",
})

// Submit order: REST auth uses three headers: x-api-key, x-api-timestamp,
// x-api-signature (HMAC-SHA256 over `${ts}${method}${path}${body}` with the
// API secret). See the Authentication guide for the full recipe.
const body = JSON.stringify({
  mkt_id: "BTC-PERP",
  sd: "ORDER_SIDE_BUY",
  ot: "ORDER_TYPE_LIMIT",
  sz: 0.1,
  px: 50000,
  tif: "TIME_IN_FORCE_GTC",
  nonce,
  sig: signature,
})

await fetch("https://api.obsdn.trade/orders", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-api-key": apiKey,
    "x-api-timestamp": ts,
    "x-api-signature": hmacSign(apiSecret, `${ts}POST/orders${body}`),
  },
  body,
})

Signer Registration Signatures

Registering a signer requires two signatures: one from the sender (main wallet) and one from the signer wallet.

Self-signing: If sender and signer are the same wallet, both signatures use the same private key but different typed data structures.

1. Sender Signature (Register type)

The sender wallet signs to authorize the signer. The sender (main) wallet is bound to sender and the authorized signer to signer:

const registerTypes = {
  Register: [
    { name: "sender", type: "address" },
    { name: "signer", type: "address" },
    { name: "message", type: "string" },
    { name: "nonce", type: "uint64" },
  ],
}

const senderMessage = {
  sender: senderAddress.toLowerCase(), // sender (main) wallet address
  signer: signerAddress.toLowerCase(), // signer wallet address
  message: "Sign to authorize trading bot", // human-readable message
  nonce: (BigInt(Date.now()) * 1_000_000n).toString(),
}

const senderSignature = await senderWallet.signTypedData({
  domain,
  types: registerTypes,
  primaryType: "Register",
  message: senderMessage,
})

2. Signer Signature (DelegatedSigner type)

The signer wallet signs to prove ownership. The primary type is DelegatedSigner:

const delegatedSignerTypes = {
  DelegatedSigner: [{ name: "account", type: "address" }],
}

const signerMessage = {
  account: senderAddress.toLowerCase(), // sender wallet address
}

const signerSignature = await signerWallet.signTypedData({
  domain,
  types: delegatedSignerTypes,
  primaryType: "DelegatedSigner",
  message: signerMessage,
})

Complete Registration Example

import { createWalletClient, http } from "viem"
import { privateKeyToAccount } from "viem/accounts"

// Two separate wallets
const senderAccount = privateKeyToAccount("0x...sender_key")
const signerAccount = privateKeyToAccount("0x...signer_key")

// Fetch from /chain/config at runtime.
const domain = {
  name: "Obsidian",
  version: "1",
  chainId: 143,
  verifyingContract: "0x90c3747cd4E6bC6FbebB1b3C54D99737590eBE45" as `0x${string}`,
}

async function registerSigner() {
  const nonce = BigInt(Date.now()) * 1_000_000n
  const message = "Sign to authorize trading bot"

  // 1. Sender signs to delegate authority
  const senderClient = createWalletClient({
    account: senderAccount,
    transport: http(),
  })

  const senderSignature = await senderClient.signTypedData({
    domain,
    types: {
      Register: [
        { name: "sender", type: "address" },
        { name: "signer", type: "address" },
        { name: "message", type: "string" },
        { name: "nonce", type: "uint64" },
      ],
    },
    primaryType: "Register",
    message: {
      sender: senderAccount.address.toLowerCase(),
      signer: signerAccount.address.toLowerCase(),
      message,
      nonce: nonce.toString(),
    },
  })

  // 2. Signer signs to prove ownership
  const signerClient = createWalletClient({
    account: signerAccount,
    transport: http(),
  })

  const signerSignature = await signerClient.signTypedData({
    domain,
    types: {
      DelegatedSigner: [{ name: "account", type: "address" }],
    },
    primaryType: "DelegatedSigner",
    message: {
      account: senderAccount.address.toLowerCase(),
    },
  })

  // 3. Submit registration
  const response = await fetch("https://api.obsdn.trade/auth/signers", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      sndr_addr: senderAccount.address,
      signer_addr: signerAccount.address,
      sndr_sig: senderSignature,
      signer_sig: signerSignature,
      nonce: nonce.toString(),
      msg: message,
      nm: "My Trading Bot",
    }),
  })

  return response.json()
}

Withdrawal Signature

Withdrawals require a signature from your registered signer wallet.

EIP-712 Types

const withdrawTypes = {
  Withdraw: [
    { name: "sender", type: "address" },
    { name: "token", type: "address" },
    { name: "amount", type: "uint128" },
    { name: "nonce", type: "uint64" },
  ],
}

Message Fields

FieldTypeDescription
senderaddressSender wallet address
tokenaddressToken contract address (e.g., USDC)
amountuint128Amount scaled to 18 decimals
nonceuint64Unix timestamp in nanoseconds

Example

async function signWithdrawal(params: {
  senderAddress: string
  tokenAddress: string
  amount: string // scaled to 18 decimals
}) {
  const nonce = BigInt(Date.now()) * 1_000_000n

  const signature = await signerClient.signTypedData({
    domain,
    types: {
      Withdraw: [
        { name: "sender", type: "address" },
        { name: "token", type: "address" },
        { name: "amount", type: "uint128" },
        { name: "nonce", type: "uint64" },
      ],
    },
    primaryType: "Withdraw",
    message: {
      sender: params.senderAddress.toLowerCase(),
      token: params.tokenAddress.toLowerCase(),
      amount: params.amount,
      nonce: nonce.toString(),
    },
  })

  return { signature, nonce: nonce.toString() }
}

// Submit withdrawal
const { signature, nonce } = await signWithdrawal({
  senderAddress: "0x1234...",
  tokenAddress: "0xUSDC...",
  amount: toX18("100"),
})

const body = JSON.stringify({
  tkn: "0xUSDC...",
  amt: "100",
  nonce: nonce,
  sig: signature,
})

await fetch("https://api.obsdn.trade/transfers/withdraw", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-api-key": apiKey,
    "x-api-timestamp": ts,
    "x-api-signature": hmacSign(apiSecret, `${ts}POST/transfers/withdraw${body}`),
  },
  body,
})

The REST body uses amt as a human-readable decimal string (e.g., "100"), while the signed EIP-712 amount is that value scaled to 18 decimals. Sign the scaled value, send the decimal value.


Subaccount Creation Signatures

Creating a subaccount requires two signatures: one from the main wallet authorizing the new subaccount, and one from the subaccount wallet proving ownership. There is no nonce in this payload, the on-chain account state prevents replay.

EIP-712 Types

const createSubaccountTypes = {
  CreateSubaccount: [
    { name: "main", type: "address" },
    { name: "subaccount", type: "address" },
  ],
}

Message Fields

FieldTypeDescription
mainaddressMain account wallet address
subaccountaddressSubaccount wallet address

Both signatures sign the same typed data (same primary type, same main and subaccount values), they differ only in the signing key.

Example

import { createWalletClient, http } from "viem"
import { privateKeyToAccount } from "viem/accounts"

const mainAccount = privateKeyToAccount("0x...main_key")
const subAccount = privateKeyToAccount("0x...sub_key")

// Fetch from /chain/config at runtime.
const domain = {
  name: "Obsidian",
  version: "1",
  chainId: 143,
  verifyingContract: "0x90c3747cd4E6bC6FbebB1b3C54D99737590eBE45" as `0x${string}`,
}

const types = {
  CreateSubaccount: [
    { name: "main", type: "address" },
    { name: "subaccount", type: "address" },
  ],
} as const

async function createSubaccount() {
  const message = {
    main: mainAccount.address.toLowerCase(),
    subaccount: subAccount.address.toLowerCase(),
  }

  const mainClient = createWalletClient({ account: mainAccount, transport: http() })
  const mainSig = await mainClient.signTypedData({
    domain,
    types,
    primaryType: "CreateSubaccount",
    message,
  })

  const subClient = createWalletClient({ account: subAccount, transport: http() })
  const subSig = await subClient.signTypedData({
    domain,
    types,
    primaryType: "CreateSubaccount",
    message,
  })

  return await fetch("https://api.obsdn.trade/subaccounts", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      // HMAC headers: see the Authentication guide.
    },
    body: JSON.stringify({
      sub_addr: subAccount.address,
      main_sig: mainSig,
      sub_sig: subSig,
      nm: "Trading Bot Sub",
    }),
  }).then((r) => r.json())
}

The subaccount must be a fresh wallet (no balances, no on-chain activity). A main account is capped at 10 subaccounts.


Child Account Signer Registration Signatures

Registering a signer for a child account (either a subaccount or a vault) requires two signatures: one from the parent (main) account authorizing the signer for a specific child, and one from the signer wallet proving ownership. This mirrors the regular Signer Registration flow but binds the signer to the child account rather than the main account.

1. Parent Signature (RegisterChildAccountSigner type)

The parent (main) account signs to authorize a signer wallet for one of its child accounts:

const registerChildTypes = {
  RegisterChildAccountSigner: [
    { name: "main", type: "address" },
    { name: "childAccount", type: "address" },
    { name: "signer", type: "address" },
    { name: "message", type: "string" },
    { name: "nonce", type: "uint64" },
  ],
}

const parentMessage = {
  main: mainAddress.toLowerCase(), // parent (main) wallet
  childAccount: childAddress.toLowerCase(), // sub or vault address
  signer: signerAddress.toLowerCase(), // signer wallet to authorize
  message: "Sign to authorize trading bot for child",
  nonce: (BigInt(Date.now()) * 1_000_000n).toString(),
}

The EIP-712 field is childAccount (camelCase). The matching REST request field is child_acct (snake_case). Don't accidentally sign child_acct, it would change the typehash and the signature would not verify.

2. Signer Signature (DelegatedSigner type)

The signer wallet signs the same DelegatedSigner payload used in the regular flow, with account set to the child account address:

const delegatedSignerTypes = {
  DelegatedSigner: [{ name: "account", type: "address" }],
}

const signerMessage = {
  account: childAddress.toLowerCase(),
}

Message Fields (parent payload)

FieldTypeDescription
mainaddressParent (main) account address
childAccountaddressChild account (sub or vault) the signer will sign for
signeraddressSigner wallet being authorized
messagestringHuman-readable message
nonceuint64Unix timestamp in nanoseconds

Example

async function registerChildAccountSigner(params: {
  mainAccount: ReturnType<typeof privateKeyToAccount>
  signerAccount: ReturnType<typeof privateKeyToAccount>
  childAddress: string
}) {
  const nonce = BigInt(Date.now()) * 1_000_000n
  const message = "Sign to authorize trading bot for child"

  const mainClient = createWalletClient({ account: params.mainAccount, transport: http() })
  const parentSig = await mainClient.signTypedData({
    domain,
    types: {
      RegisterChildAccountSigner: [
        { name: "main", type: "address" },
        { name: "childAccount", type: "address" },
        { name: "signer", type: "address" },
        { name: "message", type: "string" },
        { name: "nonce", type: "uint64" },
      ],
    },
    primaryType: "RegisterChildAccountSigner",
    message: {
      main: params.mainAccount.address.toLowerCase(),
      childAccount: params.childAddress.toLowerCase(),
      signer: params.signerAccount.address.toLowerCase(),
      message,
      nonce: nonce.toString(),
    },
  })

  const signerClient = createWalletClient({ account: params.signerAccount, transport: http() })
  const signerSig = await signerClient.signTypedData({
    domain,
    types: { DelegatedSigner: [{ name: "account", type: "address" }] },
    primaryType: "DelegatedSigner",
    message: { account: params.childAddress.toLowerCase() },
  })

  return await fetch("https://api.obsdn.trade/auth/child-accounts/signers", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      // HMAC headers: see the Authentication guide.
    },
    body: JSON.stringify({
      child_acct: params.childAddress,
      signer: params.signerAccount.address,
      msg: message,
      nonce: nonce.toString(),
      signer_sig: signerSig,
      parent_acct_sig: parentSig,
      nm: "Child Trading Bot",
    }),
  }).then((r) => r.json())
}

Transfer Signature

Internal transfers move funds between accounts under the same main account (main↔sub or sub↔sub). They require a single signature from the main account wallet, even when transferring out of a subaccount.

EIP-712 Types

const transferTypes = {
  Transfer: [
    { name: "from", type: "address" },
    { name: "to", type: "address" },
    { name: "token", type: "address" },
    { name: "amount", type: "uint128" },
    { name: "nonce", type: "uint64" },
  ],
}

Message Fields

FieldTypeDescription
fromaddressSource account (main or sub)
toaddressDestination account (main or sub, same main)
tokenaddressToken contract address
amountuint128Amount in 18 decimals
nonceuint64Unix timestamp in nanoseconds

The REST request body uses tkn and amt, but the signed EIP-712 message uses token and amount. The request amt is a human-readable decimal string (e.g., "10.5"), while the signed amount is that value scaled to 18 decimals as a uint128 integer string (e.g., "10500000000000000000"). Sign the scaled value, send the decimal value.

Example

async function signTransfer(params: {
  fromAddress: string
  toAddress: string
  tokenAddress: string
  amount: string // human-readable, e.g., "10.5"
}) {
  const nonce = BigInt(Date.now()) * 1_000_000n

  // signerClient is the main-account wallet client.
  const signature = await signerClient.signTypedData({
    domain,
    types: {
      Transfer: [
        { name: "from", type: "address" },
        { name: "to", type: "address" },
        { name: "token", type: "address" },
        { name: "amount", type: "uint128" },
        { name: "nonce", type: "uint64" },
      ],
    },
    primaryType: "Transfer",
    message: {
      from: params.fromAddress.toLowerCase(),
      to: params.toAddress.toLowerCase(),
      token: params.tokenAddress.toLowerCase(),
      amount: toX18(params.amount),
      nonce: nonce.toString(),
    },
  })

  return await fetch("https://api.obsdn.trade/transfers/send-funds", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      // HMAC headers: see the Authentication guide.
    },
    body: JSON.stringify({
      from: params.fromAddress,
      to: params.toAddress,
      tkn: params.tokenAddress,
      amt: params.amount,
      nonce: nonce.toString(),
      sig: signature,
    }),
  }).then((r) => r.json())
}

Common Issues

Invalid Signature

IssueSolution
Wrong domainFetch latest from /chain/config
Wrong decimalsSize/price use 18 decimals
Stale nonceUse current timestamp in nanoseconds
Wrong signerSign with registered signer wallet, not sender

Nonce Requirements

  • Must be Unix timestamp in nanoseconds (not milliseconds)
  • Must be unique per operation type
  • Nonces must be within 5 minutes of the server's current time. Nonces outside this window are rejected with nonce too far from current timestamp.
// Correct: nanoseconds
const nonce = BigInt(Date.now()) * 1_000_000n

// Wrong: milliseconds
const nonce = Date.now() // Missing * 1_000_000

Next Steps

On this page