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
| Operation | Primary Type | Purpose |
|---|---|---|
| Order | Order | Authorize order placement |
| Signer Registration | Register + DelegatedSigner | Register signing wallet for the main account |
| Withdrawal | Withdraw | Authorize fund withdrawal |
| Subaccount Creation | CreateSubaccount | Create a subaccount under the main account |
| Child Account Signer Registration | RegisterChildAccountSigner + DelegatedSigner | Register a signing wallet for a child (sub + vault) account |
| Transfer | Transfer | Move funds between accounts under the same main account |
Getting the Signing Domain
Before signing, fetch the EIP-712 domain configuration:
GET /chain/configResponse (envelope omitted, only signing-relevant fields shown — full response also includes addrs, native_ccy, rpc_urls, blk_explorer):
{
"data": {
"nm": "base-sepolia",
"chain_id": 84532,
"domain": {
"nm": "Obsidian",
"ver": "1",
"chain_id": "84532",
"verif_contract": "0x988Af38b04a377322aB9A5214F045938348dB155"
},
"testnet": true
},
"request_id": "..."
}Always fetch the domain at runtime — the values above are illustrative (staging on base-sepolia). Production runs on
a different chain ID and verifying contract.
The fields you need are nested under data.domain:
| Field | Description |
|---|---|
nm | EIP-712 domain name |
ver | EIP-712 domain version |
chain_id | Chain ID, as a string |
verif_contract | Verifying 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: "size", type: "uint128" },
{ name: "price", type: "uint128" },
{ name: "nonce", type: "uint64" },
{ name: "productIndex", type: "uint8" },
{ name: "orderSide", type: "uint8" },
],
}Message Fields
| Field | Type | Description |
|---|---|---|
sender | address | Sender wallet address (lowercase hex) |
size | uint128 | Order size in 18 decimals |
price | uint128 | Order price in 18 decimals |
nonce | uint64 | Unix timestamp in nanoseconds |
productIndex | uint8 | Market index (see table below) |
orderSide | uint8 | 0 = BUY, 1 = SELL |
Product Index Mapping
| Index | Market |
|---|---|
| 1 | BTC-PERP |
| 2 | ETH-PERP |
| 3 | SOL-PERP |
GET /markets.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"
import { base } from "viem/chains"
const signerAccount = privateKeyToAccount("0x...")
const client = createWalletClient({
account: signerAccount,
chain: base,
transport: http(),
})
// Fetch domain from /chain/config — values shown are illustrative (staging).
const domain = {
name: "Obsidian",
version: "1",
chainId: 84532,
verifyingContract: "0x988Af38b04a377322aB9A5214F045938348dB155" as `0x${string}`,
}
const orderTypes = {
Order: [
{ name: "sender", type: "address" },
{ name: "size", type: "uint128" },
{ name: "price", type: "uint128" },
{ name: "nonce", type: "uint64" },
{ name: "productIndex", type: "uint8" },
{ name: "orderSide", type: "uint8" },
],
} as const
async function signOrder(params: {
senderAddress: string
size: string // e.g., "1.5"
price: string // e.g., "50000"
productIndex: number
side: "BUY" | "SELL"
}) {
const nonce = BigInt(Date.now()) * 1_000_000n // nanoseconds
const message = {
sender: params.senderAddress.toLowerCase(),
size: toX18(params.size),
price: toX18(params.price),
nonce: nonce.toString(),
productIndex: params.productIndex,
orderSide: params.side === "BUY" ? 0 : 1,
}
const signature = await client.signTypedData({
domain,
types: orderTypes,
primaryType: "Order",
message,
})
return { signature, nonce: nonce.toString() }
}
// Usage
const { signature, nonce } = await signOrder({
senderAddress: "0x1234...",
size: "0.1",
price: "50000",
productIndex: 1,
side: "BUY",
})
// 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 signer address is bound to the field named signer:
const registerTypes = {
Register: [
{ name: "signer", type: "address" },
{ name: "message", type: "string" },
{ name: "nonce", type: "uint64" },
],
}
const senderMessage = {
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 — values shown are illustrative (staging).
const domain = {
name: "Obsidian",
version: "1",
chainId: 84532,
verifyingContract: "0x988Af38b04a377322aB9A5214F045938348dB155" 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: "signer", type: "address" },
{ name: "message", type: "string" },
{ name: "nonce", type: "uint64" },
],
},
primaryType: "Register",
message: {
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
| Field | Type | Description |
|---|---|---|
sender | address | Sender wallet address |
token | address | Token contract address (e.g., USDC) |
amount | uint128 | Amount in token decimals |
nonce | uint64 | Unix timestamp in nanoseconds |
Example
async function signWithdrawal(params: {
senderAddress: string
tokenAddress: string
amount: string // in token 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() }
}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
| Field | Type | Description |
|---|---|---|
main | address | Main account wallet address |
subaccount | address | Subaccount 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 — values shown are illustrative (staging).
const domain = {
name: "Obsidian",
version: "1",
chainId: 84532,
verifyingContract: "0x988Af38b04a377322aB9A5214F045938348dB155" 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)
| Field | Type | Description |
|---|---|---|
main | address | Parent (main) account address |
childAccount | address | Child account (sub or vault) the signer will sign for |
signer | address | Signer wallet being authorized |
message | string | Human-readable message |
nonce | uint64 | Unix 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
| Field | Type | Description |
|---|---|---|
from | address | Source account (main or sub) |
to | address | Destination account (main or sub, same main) |
token | address | Token contract address |
amount | uint128 | Amount in 18 decimals |
nonce | uint64 | Unix 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
| Issue | Solution |
|---|---|
| Wrong domain | Fetch latest from /chain/config |
| Wrong decimals | Size/price use 18 decimals |
| Stale nonce | Use current timestamp in nanoseconds |
| Wrong signer | Sign with registered signer wallet, not sender |
Nonce Requirements
- Must be Unix timestamp in nanoseconds (not milliseconds)
- Must be unique per operation type
// Correct: nanoseconds
const nonce = BigInt(Date.now()) * 1_000_000n
// Wrong: milliseconds
const nonce = Date.now() // Missing * 1_000_000