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": "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:
| 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: "marketIndex", type: "uint16" },
{ name: "side", type: "uint8" },
{ name: "size", type: "uint128" },
{ name: "price", type: "uint128" },
{ name: "nonce", type: "uint64" },
],
}Message Fields
| Field | Type | Description |
|---|---|---|
sender | address | Sender wallet address (lowercase hex) |
marketIndex | uint16 | Market index (see table below) |
side | uint8 | 0 = BUY, 1 = SELL |
size | uint128 | Order size in 18 decimals |
price | uint128 | Order price in 18 decimals |
nonce | uint64 | Unix timestamp in nanoseconds |
Market Index Mapping
| Index | Market |
|---|---|
| 1 | BTC-PERP |
| 2 | ETH-PERP |
| 3 | SOL-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
| Field | Type | Description |
|---|---|---|
sender | address | Sender wallet address |
token | address | Token contract address (e.g., USDC) |
amount | uint128 | Amount scaled to 18 decimals |
nonce | uint64 | Unix 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
| 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 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)
| 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
- 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