Orders and Signing

Placing, modifying and cancelling orders on the venue: headers, the Ed25519 signature over a borsh payload, field orders, enumerations and a working request.

Every write on the venue is Ed25519 signed and the signature is checked against the account it names. The API key identifies you; the signature proves the order came from your signer. TRUE never holds either.

The four things that block a first attempt

The apikey_ prefix (see Keys). The base URL is https://dex-prod.truefinance.ai, a different host from the app. The key goes in Authorization, not X-Api-Key. And you sign the hex text of the digest, not the digest bytes.

Headers on every signed request

Authorization:          Bearer apikey_<64 hex>
X-Signature:            <hex of the ed25519 signature>
X-Signature-Timestamp:  <ms, equals the timestamp inside the signed payload>
X-Signer-Pubkey:        <the key that signs, base58>
X-Account-Pubkey:       <the account owner, base58>
Content-Type:           application/json

On API key auth a read must NOT carry X-Signer-Pubkey or X-Account-Pubkey: the key already pins both identities, and sending them is rejected as ApiKeyIdentityConflict unless they byte match. Signed writes still carry them.

The timestamp is the last field of the borsh payload you sign and must equal X-Signature-Timestamp. That equality is the replay guard; a mismatch is rejected even when the signature is correct. It does not go in the JSON body.

The signature

message   = sha256( "true-trading" + ":place_order" + accountOwnerPubkey[32 raw bytes] + borsh(payload) )
signature = ed25519_sign( utf8( lowercase_hex(message) ) )

Commands: :place_order :cancel_order :modify_order :place_batch_orders :cancel_batch_orders.

Enumerations

FieldValues
sideBUY 0 SELL 1
order_typeMARKET 0 LIMIT 1 TAKE_PROFIT_MARKET 5 STOP_LOSS_MARKET 7
flags (bitfield)POST_ONLY 1 REDUCE_ONLY 2
tifGTC 0 IOC 1 FOK 2
trigger_sourceLastPrice 0 MarkPrice 1

place_order, borsh field order

Borsh carries no field names: a field in the wrong position is a different message.

#FieldTypeNote
1marketstringBTCUSDC
2sideu8BUY 0, SELL 1
3sizestringdecimal as text
4priceOption<string>null for market orders
5order_typeu8see above
6flagsu8bitfield
7tifu8GTC, IOC, FOK
8trigger_priceOption<string>stops and take profits
9trigger_sourceOption<u8>0x01 0x01 for MarkPrice, 0x00 for none
10timestampu64 LEmilliseconds, equals the header

Sizes and prices are strings. Whatever decimal formatting you sign has to be the byte identical string you send, so format the value once and reuse it. The JSON body uses time_in_force, not tif, and carries no timestamp.

modify_order, borsh field order

order_id     u64
market       str
side         u8
order_type   u8
size         str
price        Option<str>   0x00, or 0x01 then the string
timestamp    u64           == X-Signature-Timestamp

This is not the place_order layout with an id in front. Mirroring place_order produces “Invalid signature”.

A working request

Node, no dependencies
import crypto from "node:crypto";
// borsh writer, only what place_order needs
const buf = [];
const u8  = n => buf.push(n & 0xff);
const u32 = n => { for (let i = 0; i < 4; i++) buf.push((n >> (8 * i)) & 0xff); };
const u64 = n => { let v = BigInt(n); for (let i = 0; i < 8; i++) { buf.push(Number(v & 0xffn)); v >>= 8n; } };
const str = s => { const b = new TextEncoder().encode(s); u32(b.length); b.forEach(x => buf.push(x)); };
const opt = s => { if (s == null) u8(0); else { u8(1); str(s); } };

const ts = Date.now();
str("BTCUSDC"); u8(0); str("0.01"); opt("60000");
u8(1); u8(0); u8(0); opt(null); u8(0); u64(ts);

const owner   = base58decode(ACCOUNT_PUBKEY);                 // 32 bytes
const prefix  = new TextEncoder().encode("true-trading:place_order");
const message = Buffer.concat([Buffer.from(prefix), Buffer.from(owner), Buffer.from(Uint8Array.from(buf))]);
const hex     = crypto.createHash("sha256").update(message).digest("hex");

// sign the HEX TEXT, not the digest bytes
const pkcs8 = Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), Buffer.from(SIGNER_SEED_32)]);
const key   = crypto.createPrivateKey({ key: pkcs8, format: "der", type: "pkcs8" });
const sig   = crypto.sign(null, Buffer.from(hex), key);

const DEX = "https://dex-prod.truefinance.ai";
await fetch(DEX + "/v1/orders", {
  method: "POST",
  headers: {
    "Authorization":         "Bearer apikey_" + API_KEY,   // prefix REQUIRED
    "X-Signature":           sig.toString("hex"),
    "X-Signature-Timestamp": String(ts),
    "X-Signer-Pubkey":       SIGNER_PUBKEY,
    "X-Account-Pubkey":      ACCOUNT_PUBKEY,
    "Content-Type":          "application/json",
  },
  // time_in_force, NOT tif. No timestamp field: it is signed, not sent.
  body: JSON.stringify({ market: "BTCUSDC", side: "BUY", size: "0.01", price: "60000", order_type: "LIMIT", time_in_force: "GTC" }),
});

// A read, to check auth before signing anything. NO pubkey headers here.
await fetch(DEX + "/v1/accounts", { headers: { "Authorization": "Bearer apikey_" + API_KEY } });

Account reads

GET /v1/accounts
GET /v1/positions
GET /v1/orders
GET /v1/orders/{id}
GET /v1/transfers/search?account=<pubkey>

All with the API key only. The transfers ledger carries deposits and withdrawals with their on chain transaction hashes.

Retries

Treat any write without a 2xx within 5 s as unknown. Read GET /v1/orders before retrying, so a retry never doubles an order.

Last updated: