Order Book Trading
Harbor's order book lets wallets offer limit-order trading on top of L1-secured deposits and withdrawals. Users deposit to Harbor, then authorize orders, cancels, account reads, and withdrawals with L1-signed JSON messages.
Harbor acts as a delegated executor: it holds per-user trading accounts, verifies each L1 signature, and places orders on the user's behalf.
Trading signatures support ETH, BSC, BTC, and TRON. Signed messages on other chains are rejected with signature verification not supported for chain <chain>.
Lifecycle
Account Model
A trading account is a portfolio that can be funded from multiple L1 addresses.
| Field | Description |
|---|---|
accountId | Opaque numeric identifier returned in account responses |
balances | Per-asset breakdown: amount, amountInOrders, amountInWithdrawals (decimal strings) |
signers | One controller plus zero or more deposit_only aliases |
controller signers can read balances and sign orders, cancels, link requests, and withdrawals. deposit_only signers can receive deposits into the portfolio but cannot read or mutate it. A single (chain, l1Address) can be active on at most one account.
1. Deposit
Deposits use the standard Harbor Deposit Transactions rules. The memo decides which trading account receives the credit:
| Memo | Result |
|---|---|
| empty | Executes a matching active deposit channel first; otherwise credits the sender's trading account. If this is the first deposit, the sender becomes the controller. |
d:<CHAIN>:<L1_ADDRESS> | Credits the account controlled by the named EOA. Use this when the depositor is a smart-contract wallet, router, or any address that cannot sign orders. |
When a d: deposit targets an existing account, the sender is installed as a deposit_only alias on that account unless it is already bound elsewhere. Existing bindings are not silently rewired; use Linking Extra Addresses for explicit consolidation.
1.1 Resolve the inbound address
curl https://api.harbor.xyz/xnode/inbound_addresses
1.2 Broadcast the L1 transfer
- Native assets — direct L1 transfer to the inbound address, with the memo attached per chain.
- ERC-20 tokens — call the Router Contract's
depositWithExpiry. Do not send ERC-20s with a plaintransfer; those tokens bypass deposit crediting.
Harbor returns the account state the next time the wallet calls POST /api/v1/trading/account.
The account controller is the only address that can sign trading actions. If the depositor cannot sign, set the deposit memo to d:<CHAIN>:<EOA> naming an EOA the user controls.
2. Auto-Trading With o:
An o: memo lets a user deposit once and immediately rest a limit order without a follow-up signed API call. On fill, Harbor auto-withdraws proceeds to ToAddress. On TTL expiry or cancel-and-withdraw, Harbor refunds to RefundAddress or the depositor.
Use GET /api/v1/limit/quote to build the memo. Do not hand-encode it unless you have to; the endpoint resolves the book, validates TTL and ticks, sizes the order, estimates fees, and returns the exact memo to attach to the deposit.
See Memos for the wire format.
Endpoint · GET /api/v1/limit/quote
fromAmount is always required. Supply exactly one of toAmount or price.
| Param | Description |
|---|---|
fromAsset | Inbound asset, e.g. BTC.BTC or ETH.USDT. |
toAsset | Desired filled asset. fromAsset + toAsset must map to one Harbor CLOB book. |
fromAmount | Source amount in 1e8 chain precision. |
toAmount | Desired destination amount net of liquidity + affiliate fees. Excludes outbound gas. |
price | Limit price in 1e8 chain precision. Requires priceAsset. |
priceAsset | Asset the price is quoted in; must be the resolved market's base or quote asset. |
destination | L1 address for filled proceeds. Must be on toAsset's chain. |
refundAddress | Optional controller and refund destination. Must be on the source chain. |
ttlSeconds | Order TTL. Default bounds are 60 seconds through 365 days. |
affiliates / affiliatesBps | Optional repeated affiliate shortcodes and matching bps. |
Example
curl "https://api.harbor.xyz/api/v1/limit/quote?\
fromAsset=BTC.BTC&toAsset=ETH.USDT&fromAmount=10000000&\
price=6700000000000&priceAsset=ETH.USDT&ttlSeconds=3600&\
destination=0x56781111...&refundAddress=bc1q..."
Response Shape
{
"inboundAddress": "bc1q6cs...gp8",
"memo": "o:ETH.USDT:0x56781111.../bc1q...:6700000000000/3600::",
"expiry": "1700000120",
"fees": {
"asset": "ETH.USDT",
"affiliate": "0",
"outbound": "2500000",
"liquidity": "3350000",
"total": "5850000"
},
"symbol": "BTC-USDT",
"side": "sell",
"price": "6700000000000",
"spotPrice": "6695000000000",
"qty": "10000000",
"amountHeld": "10000000",
"assetHeld": "BTC.BTC"
}
Important fields:
memois the exact string to attach to the L1 deposit.qtyis the tick-aligned base quantity that will rest on the book.spotPriceis the current Harbor book mid price, in 1e8 quote-per-base precision. It may use a recent temporal snapshot if live bid/ask data is incomplete, and is empty when no mid price is available.amountHeld/assetHelddescribe the reserved balance. Any excess stays spendable in the trading account.fees.affiliateandfees.liquidityare realized on fill.fees.outboundis charged later by the withdraw processor.expiryis Unix seconds. Broadcast before this timestamp.
If an o: order cannot be placed, Harbor queues an automatic refund to the depositor or refundAddress. If refund queueing fails, the balance remains in the trading account for signed withdrawal.
3. Signed Message Protocol
All mutating calls and private reads carry a signed envelope inside a message wrapper:
{
"message": {
"action": "create_order",
"payloadJson": "{\"market\":\"BTC-USDT\",\"price\":\"67000.00\",\"quantity\":\"0.1\",\"side\":\"buy\",\"type\":\"limit\"}",
"nonce": 12346,
"expiry": 1700000120,
"l1Address": "0x742d35Cc6634C0532925a3b844Bc9e7595f8FbDc",
"chain": "ETH",
"signature": "0xabcd..."
}
}
| Field | Description |
|---|---|
action | One of create_order, cancel_order, cancel_and_withdraw, withdraw, get_account, list_orders, link_alias_initiate, revoke_alias, list_link_challenges. |
payloadJson | Canonical JSON string for the action payload. It must be a string, not a nested object. |
nonce | Positive int64. Writes are monotonic per controller. Reads require a positive nonce but do not persist it. |
expiry | Unix seconds. The server rejects expiry <= now; use a short window such as now + 60..120s. |
l1Address | Controller signer address. deposit_only signers cannot call private reads or writes. |
chain | ETH, BSC, BTC, or TRON. |
signature | ETH/BSC EIP-712, BTC BIP-137, or TRON signMessageV2 signature over the action envelope. |
Signing rules:
- ETH/BSC — use
eth_signTypedData_v4. Domain:{ name: "harbor.orderbook.trading", version: "1" };chainIdandverifyingContractare omitted. Each action has its own primary type, e.g.CreateOrder,CancelOrder,Withdraw. - BTC — sign canonical JSON with the Bitcoin Signed Message convention. The BTC preimage includes
action,chain,domain,expiry,l1_address,nonce, andpayload, with keys sorted recursively and no whitespace. payloadJsonis the inner payload canonicalized on its own.
Nonce rules:
- Writes consume the nonce after signature / role checks and action-specific validation. Some failed writes still burn the nonce; after any failed write, re-sign with a larger nonce.
- Reads do not consume nonces. Freshness comes from
expiry.
Signing Examples
Both examples place the same order. signature is the wallet output over the shown signing input.
ETH eth_signTypedData_v4
Sign this typed data:
{
"types": {
"EIP712Domain": [
{ "name": "name", "type": "string" },
{ "name": "version", "type": "string" }
],
"CreateOrder": [
{ "name": "action", "type": "string" },
{ "name": "chain", "type": "string" },
{ "name": "l1Address", "type": "string" },
{ "name": "nonce", "type": "uint256" },
{ "name": "expiry", "type": "uint256" },
{ "name": "payload", "type": "CreateOrderPayload" }
],
"CreateOrderPayload": [
{ "name": "market", "type": "string" },
{ "name": "side", "type": "string" },
{ "name": "type", "type": "string" },
{ "name": "timeInForce", "type": "string" },
{ "name": "price", "type": "string" },
{ "name": "quantity", "type": "string" },
{ "name": "replacesClientOrderId", "type": "string" }
]
},
"primaryType": "CreateOrder",
"domain": {
"name": "harbor.orderbook.trading",
"version": "1"
},
"message": {
"action": "create_order",
"chain": "ETH",
"l1Address": "0x742d35Cc6634C0532925a3b844Bc9e7595f8FbDc",
"nonce": 12346,
"expiry": 1700000120,
"payload": {
"market": "BTC-USDT",
"side": "buy",
"type": "limit",
"timeInForce": "aon",
"price": "67000.00",
"quantity": "0.1",
"replacesClientOrderId": ""
}
}
}
Submit the signed envelope:
{
"message": {
"action": "create_order",
"payloadJson": "{\"market\":\"BTC-USDT\",\"price\":\"67000.00\",\"quantity\":\"0.1\",\"replacesClientOrderId\":\"\",\"side\":\"buy\",\"timeInForce\":\"aon\",\"type\":\"limit\"}",
"nonce": 12346,
"expiry": 1700000120,
"l1Address": "0x742d35Cc6634C0532925a3b844Bc9e7595f8FbDc",
"chain": "ETH",
"signature": "0x..."
}
}
BTC Bitcoin Signed Message
Sign this canonical JSON string with the Bitcoin Signed Message convention:
{
"action": "create_order",
"chain": "BTC",
"domain": "harbor.orderbook.trading",
"expiry": 1700000120,
"l1_address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"nonce": 12346,
"payload": {
"market": "BTC-USDT",
"price": "67000.00",
"quantity": "0.1",
"side": "buy",
"timeInForce": "aon",
"type": "limit"
}
}
Submit the signed envelope:
{
"message": {
"action": "create_order",
"payloadJson": "{\"market\":\"BTC-USDT\",\"price\":\"67000.00\",\"quantity\":\"0.1\",\"side\":\"buy\",\"timeInForce\":\"aon\",\"type\":\"limit\"}",
"nonce": 12346,
"expiry": 1700000120,
"l1Address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"chain": "BTC",
"signature": "H..."
}
}
TRON signMessageV2
Use TronLink/TronWeb signMessageV2 to sign the same canonical JSON format as BTC, with chain: "TRON" and a case-sensitive Base58 l1_address. Do not use legacy signMessage, Ethereum signing prefixes, or a hash of the JSON.
For example, a signed account read (choose a fresh nonce and a short future expiry):
const address = tronWeb.defaultAddress.base58;
const nonce = Date.now();
const expiry = Math.floor(Date.now() / 1000) + 120;
// Keys are alphabetically ordered; nested payload keys must also be sorted.
const canonical = JSON.stringify({
action: "get_account",
chain: "TRON",
domain: "harbor.orderbook.trading",
expiry,
l1_address: address,
nonce,
payload: {},
});
const signature = await tronWeb.trx.signMessageV2(canonical);
const request = {
message: {
action: "get_account",
chain: "TRON",
l1Address: address,
nonce,
expiry,
payloadJson: "{}",
signature,
},
};
// POST request to https://quote.harbor.xyz/api/v1/trading/account.
Sign the exact canonical UTF-8 bytes produced by SignedMessage.CanonicalJSON for other actions. All object keys are sorted recursively and whitespace omitted; monetary values remain strings. Keep nonces within JavaScript's safe integer range when using JSON numbers. Writes require a strictly increasing nonce per signer.
For account-link completion, base64-decode the server's digest into a Uint8Array and pass those bytes to signMessageV2; signing the base64 text produces a different signature. Only signatures recovering the claimed address are supported; alternate Tron permission keys and multisig are not verified. This authentication does not inspect the account's current on-chain permissions: if the owner permission has been rotated away from the address-derived key, the old key can still authenticate to Harbor, so do not use that account for trading.
The existing CLI supports --chain TRON, with default derivation path m/44'/195'/0'/0/0. All existing account, order, withdrawal, link, and partner-channel commands use the same signer.
4. Create Order
Endpoint · POST /api/v1/trading/orders
Payload inside payloadJson:
{
"market": "BTC-USDT",
"side": "buy",
"type": "limit",
"timeInForce": "aon",
"price": "67000.00",
"quantity": "0.1"
}
Only type: "limit" and timeInForce: "aon" are accepted. quantity is base-asset quantity; price is quote-per-base. On success, Harbor reserves quote for buys or base for sells and returns a TradingOrder.
Use clientOrderId (trd-<uuid>) for cancels. The numeric id is opaque.
{
"order": {
"id": "42",
"clientOrderId": "trd-7f3ab912-9d01-4cb9-bb1a-9e0e4eeaacbb",
"symbol": "BTC-USDT",
"side": "buy",
"type": "limit",
"price": "67000.0",
"qty": "0.1",
"filledQty": "0.0",
"status": "open"
}
}