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 currently support ETH and BTC only. 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 | 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",
"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.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 or BTC. |
signature | ETH EIP-712 signature or BTC BIP-137 signature over the action envelope. |
Signing rules:
- ETH — 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..."
}
}
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"
}
}
5. Cancel And Withdraw
Cancel Order
Endpoint · POST /api/v1/trading/orders/cancel
Payload:
{ "orderId": "trd-7f3ab912-9d01-4cb9-bb1a-9e0e4eeaacbb" }
Response: { "cancelled": true }
orderId is the clientOrderId, not the numeric id. Cancelled funds return to the trading account's withdrawable balance. To move them back to L1, call withdraw.
Cancel And Withdraw
Endpoint · POST /api/v1/trading/orders/cancel_and_withdraw
For o: orders only. Cancels the order and queues a refund outbound to the order's RefundAddress.
Payload:
{ "orderId": "trd-7f3ab912-9d01-4cb9-bb1a-9e0e4eeaacbb" }
Response:
{
"cancelled": true,
"withdrawId": "42",
"clientIdPrefix": "trade-refund-100-1700000000000000000"
}
Plain signed orders are rejected with order was not opened via o: memo; use cancel_order + withdraw.
6. Withdraw
Endpoint · POST /api/v1/trading/withdraw
Payload:
{
"asset": "ETH.USDT",
"amount": "1000.00",
"destination": "0x56781111..."
}
Response:
{
"withdrawId": "128",
"clientIdPrefix": "trade-17-1744848724000000000"
}
The destination can differ from the signer, but it must be valid for the withdrawn asset's chain. Status, fee, and ETA are not returned; watch amountInWithdrawals via get_account.
7. Account And Orders
Private reads use the same signed envelope format as writes. The server verifies expiry, requires a positive nonce, and does not persist read nonces.
| Action | Path | Returns |
|---|---|---|
get_account | POST /api/v1/trading/account | Balances, pending / open orders, active signers |
list_orders | POST /api/v1/trading/orders/list | Recent orders for the account |
get_account payload: {}
{
"accountId": 17,
"balances": [
{
"asset": "ETH.USDT",
"amount": "20000.0",
"amountInOrders": "8000.0",
"amountInWithdrawals": "0.0"
}
],
"openOrders": [],
"signers": [
{
"id": 1,
"chain": "ETH",
"l1Address": "0x742d...F8FbDc",
"role": "controller",
"linkedVia": "first_deposit",
"linkedAt": 1744891200
}
]
}
list_orders payload: { "limit": 50 }. Status values are pending, open, partially_filled, filled, cancelled, and failed.
8. Linking Extra Addresses
Use linking when a controller wants to pre-authorize another deposit address or consolidate an existing per-address account.
- Controller signs
link_alias_initiate:
{
"newChain": "BTC",
"newL1Address": "bc1q...",
"role": "deposit_only"
}
Response:
{
"challengeId": "e6b7...9f",
"digest": "AQID...base64",
"expiresAt": 1700001234
}
- The new address signs the decoded
digest, then anyone can relay:
{
"challengeId": "e6b7...9f",
"signature": "..."
}
Endpoint · POST /api/v1/trading/link/complete
Response:
{
"accountId": 42,
"signerId": 7,
"mergedFromAccountId": 13
}
role is restricted to deposit_only. Merging is rejected if the source account has open orders (pending | open) or pending withdrawals (queued | processing | submitted).
Other link endpoints:
| Action | Path | Payload |
|---|---|---|
list_link_challenges | POST /api/v1/trading/link/challenges | { "limit": 50 } |
revoke_alias | POST /api/v1/trading/link/revoke | { "signerId": 7 } |
Controllers cannot be revoked through revoke_alias.
9. Public Order Feed
GET /api/v1/trading/limitOrders is public and unauthenticated. It returns limit orders across accounts, newest first.
| Param | Description |
|---|---|
status | Optional. One of open, partially_filled, cancelled, filled, pending, failed. |
address | Optional. Matches deposit sender, bound account addresses, refund address, or destination address. |
limit | Optional. Defaults to 50; capped at 100. |
offset | Optional. 0-based pagination offset. |
curl "https://api.harbor.xyz/api/v1/trading/limitOrders?status=open&address=0x742d...F8FbDc&limit=20"
Response fields use exchange precision for decimal amounts and Unix nanoseconds for createdAt / updatedAt. deposit is populated for o: auto-deposit orders when the inbound can be resolved. fees contains fill-time order fees and outbound-time withdraw fees.
Safety Notes
- Store a monotonic write nonce per controller account.
UnixNano()is a safe default. - After any failed write, re-sign with a larger nonce.
- Keep
expiryshort, usually 60-120 seconds. - Signed orders, cancels, and withdrawals have no on-chain transaction cost. Fills and withdrawals still incur normal trading and outbound fees.