Skip to main content

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.

Chain support

Trading signatures support ETH, BSC, BTC, and TRON. Signed messages on other chains are rejected with signature verification not supported for chain <chain>.

Lifecycle​

1
Deposit
Send L1 assets to Harbor's vault — no memo required
2
Sign
Sign JSON messages (create / cancel / withdraw) with your L1 key
3
Trade
The order router validates your signature and places orders on the exchange
4
Withdraw
Sign a withdraw message at any time — funds go back to L1

Account Model​

A trading account is a portfolio that can be funded from multiple L1 addresses.

FieldDescription
accountIdOpaque numeric identifier returned in account responses
balancesPer-asset breakdown: amount, amountInOrders, amountInWithdrawals (decimal strings)
signersOne 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:

MemoResult
emptyExecutes 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.

11:12● ● ●
←Deposit to Trading
₮
USDTEthereum
10,000.00USDT
0x1f8C6A8e8…1bE7a3D
Send from Wallet
Wallet deposits USDT from the user's L1 address.

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 plain transfer; those tokens bypass deposit crediting.

Harbor returns the account state the next time the wallet calls POST /api/v1/trading/account.

Depositing from smart-contract wallets

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.

ParamDescription
fromAssetInbound asset, e.g. BTC.BTC or ETH.USDT.
toAssetDesired filled asset. fromAsset + toAsset must map to one Harbor CLOB book.
fromAmountSource amount in 1e8 chain precision.
toAmountDesired destination amount net of liquidity + affiliate fees. Excludes outbound gas.
priceLimit price in 1e8 chain precision. Requires priceAsset.
priceAssetAsset the price is quoted in; must be the resolved market's base or quote asset.
destinationL1 address for filled proceeds. Must be on toAsset's chain.
refundAddressOptional controller and refund destination. Must be on the source chain.
ttlSecondsOrder TTL. Default bounds are 60 seconds through 365 days.
affiliates / affiliatesBpsOptional 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:

  • memo is the exact string to attach to the L1 deposit.
  • qty is the tick-aligned base quantity that will rest on the book.
  • spotPrice is 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 / assetHeld describe the reserved balance. Any excess stays spendable in the trading account.
  • fees.affiliate and fees.liquidity are realized on fill. fees.outbound is charged later by the withdraw processor.
  • expiry is 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..."
}
}
FieldDescription
actionOne of create_order, cancel_order, cancel_and_withdraw, withdraw, get_account, list_orders, link_alias_initiate, revoke_alias, list_link_challenges.
payloadJsonCanonical JSON string for the action payload. It must be a string, not a nested object.
noncePositive int64. Writes are monotonic per controller. Reads require a positive nonce but do not persist it.
expiryUnix seconds. The server rejects expiry <= now; use a short window such as now + 60..120s.
l1AddressController signer address. deposit_only signers cannot call private reads or writes.
chainETH, BSC, BTC, or TRON.
signatureETH/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" }; chainId and verifyingContract are 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, and payload, with keys sorted recursively and no whitespace.
  • payloadJson is 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​

11:12● ● ●
←Place Limit Order
Market
₿
BTC-USDT
Mid: $67,890.50
Buy
Limit · AON
67,000.00USDT
0.10000000BTC
Order total6,700.00 USDT
Available (trading)12,000.00 USDT
Nonce12346
Review & Sign
1. User builds the order in-wallet.
11:12● ● ●
←Sign Order
₮
6,700 USDT
→
₿
0.1 BTC
ActionCreate Order
MarketBTC-USDT
Side / TypeBuy · Limit
Price67,000.00
Quantity0.10000000 BTC
Nonce12346
Sign & Submit
2. Wallet shows the labelled envelope fields.
11:12● ● ●
←Submitting Order
Placing order…
Harbor is validating your signature
Signed message built
POST /api/v1/trading/orders
Signature verified · nonce accepted
Funds held · order placed on exchange
Order resting on book
3. Signature verified → funds locked → resting on book.

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​

11:12● ● ●
Main Wallet
$26,392.67
+$142.18 (0.54%)
PortfolioOrdersHistory
BTC-USDTBUYlimit · AON
+0.10000000 BTC@$67,000.00
Total: 6,700.00 USDT · Open
11:12:04
BTC-USDTSELLlimit · AON
−0.02500000 BTC@$68,500.00
Total: 1,712.50 USDT · Open
10:58:41
Open orders in the wallet.
11:12● ● ●
←Cancel Order
BTC-USDTBUYlimit · AON
+0.10000000 BTC@$67,000.00
order_id trd-7f3a…b912
ActionCancel Order
order_idtrd-7f3a…b912
Nonce12347
Unlocks6,700.00 USDT
Sign & Cancel
Cancel signs against the clientOrderId.

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.

The account controller may sign this envelope. For auto-trading orders, the order's autoWithdrawDestination may also sign the same envelope to cancel that specific order. Destination-signed cancels are order-scoped: the destination cannot read the account, place orders, withdraw arbitrary balances, or change the refund address. Any unfilled reservation still refunds to RefundAddress; filled proceeds still route to autoWithdrawDestination.

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​

11:12● ● ●
←Withdraw
Asset
₮
USDT
Available: 12,000.00
Trading
0x742d35Cc…95f8FbDcYour wallet
1,000.00USDT
ActionWithdraw
Network fee~5.00 USDT
Nonce12348
You receive995.00 USDT
Sign & Withdraw
User selects asset, destination, and amount.
11:12● ● ●
←Withdrawing
Exchange withdrawal
Queued for L1 broadcast via vault signing pipeline
Signed message submitted
POST /api/v1/trading/withdraw
Balance debited · exchange withdraw queued
Vault signing L1 transaction
L1 broadcast · destination credited
Signed envelope queued → exchange withdrawal → L1 broadcast.

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​

11:12● ● ●
Main Wallet
$26,392.67
+$142.18 (0.54%)
↑
Send
↓
Receive
⇄
Trade
PortfolioOrdersHistory
Holdings (Wallet)
₿
BTC
Bitcoin
0.15000000
$10,183.58
₮
USDT
Ethereum
4,250.00
$4,250.00
Trading Account (on Harbor)
₮
USDT (trading)
8,000.00 in orders
12,000.00
$12,000.00
Trading account view returned by /api/v1/trading/account.

Private reads use the same signed envelope format as writes. The server verifies expiry, requires a positive nonce, and does not persist read nonces.

ActionPathReturns
get_accountPOST /api/v1/trading/accountBalances, pending / open orders, active signers
list_ordersPOST /api/v1/trading/orders/listRecent 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.

  1. Controller signs link_alias_initiate:
{
"newChain": "BTC",
"newL1Address": "bc1q...",
"role": "deposit_only"
}

Response:

{
"challengeId": "e6b7...9f",
"digest": "AQID...base64",
"expiresAt": 1700001234
}
  1. 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:

ActionPathPayload
list_link_challengesPOST /api/v1/trading/link/challenges{ "limit": 50 }
revoke_aliasPOST /api/v1/trading/link/revoke{ "signerId": 7 }

Controllers cannot be revoked through revoke_alias.

9. Public Order Feed​

Use REST to load order state and the public WebSocket to follow live changes. Both cover signed trading and deposit-initiated limit orders; swap orders and raw exchange market maker orders are excluded.

9.1 REST Snapshot​

GET /api/v1/trading/limitOrders is public and unauthenticated. It returns limit orders across accounts, newest first.

ParamDescription
statusOptional. One of open, partially_filled, cancelled, filled, pending, failed.
addressOptional. Matches deposit sender, bound account addresses, refund address, or destination address.
limitOptional. Defaults to 50; capped at 100.
offsetOptional. 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.

9.2 Live Order Updates​

Connect to the WebSocket endpoint:

wss://quote.harbor.xyz/trading/fills?address=<your-address>&symbol=BTC-USDT

No API key or signed message is required. URL-encode query values. Both filters are optional and can be combined:

ParamDescription
addressMatches the same deposit sender, linked account, refund, or destination addresses as the REST feed.
symbolMatches an exact market symbol, such as BTC-USDT.

Use your address to follow your orders. Omitting both filters subscribes to all public order updates; filters select public data and do not authenticate the subscriber.

Each JSON text message is an order-state snapshot, whether your order executed as maker or taker. It contains your clientOrderId, symbol, status, cumulative filled quantity, and average execution price. There are no trade IDs, counterparty order IDs, or individual fill records.

For example, an open sell order after a partial fill:

{
"clientOrderId": "trd-example",
"symbol": "BTC-USDT",
"side": "sell",
"type": "limit",
"timeInForce": "gtc",
"qty": "1.0",
"price": "100.0",
"status": "open",
"filledQty": "0.25",
"averagePrice": "101.0",
"createdAt": "1782925100000000000",
"updatedAt": "1782925200000000000"
}

Merge these order-state fields into your local order by clientOrderId. filledQty is cumulative: replace its previous value instead of adding it. averagePrice is the cumulative execution value divided by filled quantity, or "0.0" before any fills. Amounts and prices are decimal strings; timestamps are Unix nanosecond decimal strings.

A GTC order with partial fills remains open while its unfilled quantity can still trade. Completion changes its status to filled. Cancellation produces cancelled when nothing filled, or the terminal partially_filled status when some quantity filled before cancellation. Messages reflect persisted order state after processing; repeated snapshots are possible.

9.3 Initial Load And Reconnect​

The WebSocket sends no initial snapshot and provides no replay history:

  1. Connect and buffer incoming order updates.
  2. Fetch your current orders through GET /api/v1/trading/limitOrders?address=... (paginate as needed), or signed list_orders. Use GET /api/v1/trading/limitOrder/{client_order_id} for one public order.
  3. Merge buffered and subsequent snapshots by clientOrderId, comparing updatedAt as timestamps so older updates do not overwrite newer state. Preserve REST-only details, such as deposits, fees, and withdrawals.
  4. Repeat the snapshot reconciliation after every reconnect.

The server disconnects subscribers if an update cannot be processed or read, the upstream account stream disconnects, or a subscriber falls behind. During an upstream or processing outage, REST returns the last persisted state; refresh it again after recovery.

See the Trading Order Update WebSocket reference for the message contract and delivery behavior.

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 expiry short, 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.