Skip to main content

Deposit Channels

Deposit channels let distribution partners offer swaps, limit orders, and trading credits to users whose wallets cannot attach a memo to a transfer. The partner registers the intended action ahead of time through a signed API call; when the matching plain transfer arrives, Harbor routes it as if the user had attached the registered memo.

A channel is keyed by the expected depositor — (chain, l1Address, fromAsset) — and stores the exact memo the deposit should be routed as. Channels are action-agnostic: any memo-driven Harbor action works, with its normal execution and refund semantics.

Partner onboarding

The channel API is permissioned. Harbor onboards each partner's signing key — an ETH or BTC address — out of band. One key can be active per (chain, address). Contact Harbor to register as a partner.

Lifecycle

  1. Register — the partner signs register_channel naming the expected depositor, deposit asset, and the action memo. Harbor validates the memo fully at registration, so a registered channel can always execute.
  2. User deposits — the user sends a single plain transfer (no memo) from the registered address, following the standard Deposit Transactions rules.
  3. Consume — the first matching memo-less deposit consumes the channel (one-shot) and is routed as if it carried the registered memo. For swap memos, the quoted output is scaled to the actual deposit amount first.
  4. Poll — the partner polls list_channels for consumption state and the linked execution's status.

If no channel matches — wrong asset, expired, already consumed, cancelled — a memo-less deposit follows the default rule and credits an order book trading account owned by the depositor. The funds are recoverable by the user with a signed withdrawal; they are never lost, but the registered action does not run.

Channel Model

PropertyRule
Match key(chain, l1Address, fromAsset)never amount. Address matching is canonical (EVM addresses are case-insensitive). A deposit in a different asset from the same address does not match.
ConsumptionOne-shot. The first matching deposit consumes the channel atomically; later deposits from the same address get the memo-less default. A failed execution does not resurrect the channel.
ValidityttlSeconds at registration, capped at and defaulting to 86400 (24 hours). An expired channel simply stops matching.
ReplacementRe-registering the same key replaces your prior active channel (it becomes cancelled). A key actively held by another partner is a conflict (409) until it expires, consumes, or cancels.
Statusesactive, consumed, cancelled, expired. An active channel past its expiry is reported as expired.
Sender modelSelf-custody wallets only. The partner must know the sending address in advance — deposits sent from exchange hot wallets have unpredictable senders and are out of scope.

Action Memos

actionMemo must be one of the three memo families below, in the exact wire format described in Memos. It is validated at registration — bad grammar, unroutable destinations, wrong-chain refund addresses, and unknown affiliate shortcodes are rejected up front instead of failing after the user has deposited.

PrefixActionNotes
=:Cross-chain swapMust carry quote_out/tolerance_bps (hard price protection) and requires fromAmountRef (see below). The destination must be routable. The bare gas memo =: is rejected.
o:Deposit-driven limit orderDeposit chain must be ETH or BTC. Placement failure follows the normal o: refund fallback.
d:Trading credit to a named EOACredits the trading account controlled by the named ETH or BTC address.

Refund addresses inside the memo must be on the deposit chain. Destination and refund addresses are screened at registration.

Swap Price Protection and Amount Scaling

The channel is registered before the deposit amount is known, so swap channels pair the memo's quote_out with a reference input amount:

  • fromAmountRef is the source amount the quote was priced against, in 1e8 chain precision — the amount you passed to the swap quote endpoint. Use the quote's expectedAmountOut as the memo's quote_out.
  • At consumption, Harbor scales the quote linearly to the actual deposit: scaled_quote_out = quote_out × depositAmount ÷ fromAmountRef (integer floor). Everything else in the memo is preserved verbatim.
  • The swap then executes with the normal minimum-output check: it must deliver at least scaled_quote_out × (1 − tolerance_bps/10000), otherwise the deposit is refunded to the depositor (or the memo's refund address).

There is no re-quote at deposit time. If the market moves against the user beyond the tolerance between registration and deposit, the deposit refunds. Linear scaling also ignores price impact — a deposit much larger than fromAmountRef demands the same unit price for a bigger size, making refunds more likely. Register with a fromAmountRef close to what the user will actually send, and use a short TTL so quotes stay fresh.

fromAmountRef is required for =: memos and rejected for o: and d: memos (both are amount-agnostic).

Signed Message Protocol

Channel calls use the same signed envelope as order book trading — same wrapper, same signing domain (harbor.orderbook.trading, version 1), same ETH eth_signTypedData_v4 / BTC Bitcoin-Signed-Message rules — but signed with your partner key rather than a trading-account controller.

FieldDescription
actionregister_channel, cancel_channel, or list_channels.
payloadJsonCanonical JSON string of the action payload (a string, not a nested object).
noncePositive int64, strictly increasing per partner key for writes. UnixNano() is a safe default.
expiryUnix seconds; the server rejects expiry <= now. Use a short window such as now + 60..120s.
l1AddressYour partner signing address.
chainETH or BTC — the chain of your partner key, independent of the depositor's chain.
signatureEIP-712 (ETH) or BIP-137 (BTC) signature over the envelope.

Nonce rules match trading: writes consume the nonce after signature and payload validation, some failed writes still burn it (always re-sign with a larger nonce), and list_channels is a read — it requires a positive nonce but does not persist it.

The EIP-712 primary types are RegisterChannel, CancelChannel, and ListChannels, each over the standard envelope fields (action, chain, l1Address, nonce uint256, expiry uint256, payload) with these payload types:

{
"RegisterChannelPayload": [
{ "name": "chain", "type": "string" },
{ "name": "l1Address", "type": "string" },
{ "name": "fromAsset", "type": "string" },
{ "name": "actionMemo", "type": "string" },
{ "name": "fromAmountRef", "type": "string" },
{ "name": "ttlSeconds", "type": "int64" }
],
"CancelChannelPayload": [{ "name": "channelId", "type": "int64" }],
"ListChannelsPayload": [{ "name": "limit", "type": "int32" }]
}

All payload fields are present in the typed data even when unused — sign fromAmountRef as "" and ttlSeconds as 0 when omitted.

1. Register a Channel

Endpoint · POST /api/v1/channels/register · action register_channel

Payload inside payloadJson:

FieldRequiredDescription
chainYesThe depositor's chain, e.g. BTC.
l1AddressYesThe address the user will send from. Must belong to chain.
fromAssetYesThe exact asset the user will deposit, e.g. BTC.BTC, ETH.USDT. Must be on chain.
actionMemoYesThe memo to route the deposit as (see Action Memos).
fromAmountRefSwaps only1e8 chain-precision source amount the memo's quote_out was quoted against.
ttlSecondsNoValidity window, 1..86400. Omit or 0 for the 24-hour default.

Example — the partner quoted a 0.2 BTC → ETH.USDT swap and registers it for the user's address:

{
"message": {
"action": "register_channel",
"payloadJson": "{\"actionMemo\":\"=:ETH.USDT:0x90f2b1ae50e6018230e90a33f98c7844a0ab635a:224730000000/50\",\"chain\":\"BTC\",\"fromAmountRef\":\"20000000\",\"fromAsset\":\"BTC.BTC\",\"l1Address\":\"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh\",\"ttlSeconds\":3600}",
"nonce": 1744848724000000000,
"expiry": 1744848844,
"l1Address": "0x742d35Cc6634C0532925a3b844Bc9e7595f8FbDc",
"chain": "ETH",
"signature": "0x..."
}
}

Response:

{
"channel": {
"id": "12",
"chain": "BTC",
"l1Address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"fromAsset": "BTC.BTC",
"actionMemo": "=:ETH.USDT:0x90f2b1ae50e6018230e90a33f98c7844a0ab635a:224730000000/50",
"fromAmountRef": "2000000000000000",
"status": "active",
"createdAt": "1744848726",
"expiresAt": "1744852326"
}
}
Precision of fromAmountRef in responses

You submit fromAmountRef in 1e8 chain precision, but responses echo it in Harbor's 1e16 exchange precision (multiplied by 10^8). 20000000 in becomes "2000000000000000" out. Timestamps are Unix seconds; 64-bit integers are returned as JSON strings.

Registering again for the same (chain, l1Address, fromAsset) cancels and replaces your prior active channel in one step — use this to refresh a stale quote. If another partner actively holds the key, the call fails with HTTP 409.

2. Cancel a Channel

Endpoint · POST /api/v1/channels/cancel · action cancel_channel

Payload: { "channelId": 12 }

Response: { "cancelled": true }

Owner-only, and only active channels can be cancelled. Cancelling a channel whose deposit is already consumed has no effect on the execution.

3. List Channels and Outcomes

Endpoint · POST /api/v1/channels/list · action list_channels

Payload: { "limit": 50 } (default 50, capped at 500). Returns your channels newest first, each joined to the execution its consuming deposit produced:

{
"channels": [
{
"id": "12",
"chain": "BTC",
"l1Address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"fromAsset": "BTC.BTC",
"actionMemo": "=:ETH.USDT:0x90f2b1ae50e6018230e90a33f98c7844a0ab635a:224730000000/50",
"fromAmountRef": "2000000000000000",
"status": "consumed",
"createdAt": "1744848726",
"expiresAt": "1744852326",
"consumedAt": "1744849101",
"consumedL1InboundId": "5BC3C1506F87DC1B4A0C3CFFBB05BB3A4DEF3EDA93626AC55231EC9A4A72B748",
"executionType": "swap",
"executionStatus": "processing"
}
]
}
FieldDescription
consumedL1InboundIdL1 transaction hash of the consuming deposit.
executionTypeswap (for =: channels) or order (for o: channels), once the execution is resolvable. Empty for d: credits.
executionStatusSwap: queued, processing, complete, failed, refunded. Order: pending, open, partially_filled, filled, cancelled, failed.
executionIdThe order's clientOrderId for order executions. Swaps are identified by consumedL1InboundId.

Outcome visibility is polling-only; there are no webhooks. Unpopulated fields are omitted from the JSON.

User Instructions — Get These Right

The channel executes correctly only if the deposit matches exactly. Your user-facing flow must instruct the user to:

  • Send one transaction with the full amount. A split send executes the channel on the first tranche only; the remainder credits the user's trading account.
  • Send from the registered address, in the registered asset, before the channel expires.
  • Not send from an exchange withdrawal — the sending address won't match.
Every match consumes the channel

Any matching deposit consumes the channel — including one for a dust amount, since matching never considers the amount. Only the registered address can trigger this, and you can register a fresh channel for a follow-up deposit. A deposit too small to execute resolves through the normal failure/refund path.

Deposits that miss the channel (late, wrong asset, second deposit, split remainder) credit an order book trading account controlled by the depositor's address; the user can recover the funds with a signed withdrawal. Addresses that hold an xnode linked address are rewritten upstream and can never match a channel — this only affects node operators and is irrelevant for typical end users.

Error Handling

HTTPMeaning
400Validation or auth failure: bad envelope or signature, replayed nonce, unknown partner key, invalid memo, unroutable destination.
409The match key is actively held by another partner.
500Server fault; the body carries no detail. Safe to retry with a fresh nonce.

Unknown keys, disabled keys, and replayed nonces are deliberately indistinguishable in error responses.