Api

Changelog

API / proto changes by date

Changes to the .proto definitions and to the config.yaml schema, newest first. An entry is dated by the production release that ships it, so a client can act on a change from that date on, and everything one release ships is grouped under its date, however far apart the changes were made upstream. Entries before 2026-09-25 are dated by when they were documented here.

The proto files served from /proto and the per-service docs on this site track these changes. When in doubt, the .proto files are the schema of record, and the live rpc.discover method on a running Hydra App is authoritative for what that build actually serves.

Every entry opens with a Files: line naming every file it changes. A change reaches more than one service often enough that a title cannot name them all, and a title read alone is how a change gets missed — code written against the untouched half still compiles and fails later, live, against a rejection rather than a build error. Read the Files: line, not the title, to decide whether an entry touches you.


2026-09-30 — client_order_id after an order ends, market streams for every subscriber and their re-sync, 24-hour statistics on demand, time in force, self-trade prevention, post-only markets, strict price priority, swaps a restarting hub cannot keep, refusal codes, takers bounded by price, DEADLINE_EXCEEDED only when an operation may have completed, new config.yaml settings, and a node the whitelist refuses waits for an invite

Files: orderbook.proto, config.yaml, and the /proto download

Image: ghcr.io/offchain-dex/hydra-app-public:latest built from Hydra App 9e196c8.

One production release ships the changes below. Each opens with the files it changes.

Takers bounded by price: immediate or cancel, fill or kill, a market order's worst price

Files: orderbook.proto, and the /proto download

  • TimeInForce gains TIME_IN_FORCE_IOC (3) and TIME_IN_FORCE_FOK (4), for limit orders that only take: see Time in force. They pay taker fees, never rest, and are refused with market_state on a post-only market.
  • OrderRelease gains the reason immediate_or_cancel (4); CancelReason gains CANCEL_REASON_FILL_FAILED (12), for an IOC or FOK order a failed fill ends.
  • CreateOrderRequest's MarketOrder gains limit_price (5), the worst price it takes at; the persisted MarketOrder gains it too (9).
  • Two new placement rejections: nothing_to_take and fill_or_kill_unfilled, both FAILED_PRECONDITION. A market order over an empty side is now refused with nothing_to_take too, instead of an unprefixed INVALID_ARGUMENT.
  • No field changes for this one: a limit order sized in the other unit than the book holds it — a bid sized in base, an ask sized in quote — now takes in its own amount and never more than it asked. Before, crossing better prices, it could fill more than its own amount. See Orders sized in the other unit.
  • EstimateOrder now estimates a limit order at its price or better, in its own amount (for FOK, nothing unless it fills in full), and a market order within its limit_price.

Upgrade your node before sending these. Over gRPC, a node that predates a field drops it without a word: it would place an IOC as a plain limit order, or a capped market order uncapped. A client that does not know CANCEL_REASON_FILL_FAILED reads it as CANCEL_REASON_USER_REQUESTED; one that does not know immediate_or_cancel reads the release with no reason, and the release still stands.

A client_order_id names its order after the order ends

Files: orderbook.proto, and the /proto download

CreateOrder's client_order_id now names one order of the caller for a retention period after that order ends (24 hours by default; the hub's operator sets it), not only while the order is open. No field changes; what the id means does.

  • The same order sent again under its client_order_id gets that order's answer, live or ended, and places nothing. Before, once the order had completed or been cancelled, a resend placed a second order.
  • A different order under a client_order_id that names one is refused with ALREADY_EXISTS and the new client_order_id_taken prefix, whose order_id is the order the id names.
  • A request the hub refuses frees the id at once, so a retry places anew — even one whose order was announced on the event stream and ended with nothing filled.
  • A new placement rejection, maker_unavailable, answered with UNAVAILABLE: none of the makers an order matched can settle with it right now, so nothing of the order filled; retrying matches other liquidity.

Use a fresh client_order_id for every new order. Reusing one within the retention period returns the earlier order when the new one is identical — the same values, written the same way — and is refused otherwise; it never places the new order. GetOrder and GetOrderByClientId still answer only for open orders, so an id whose order has ended reads as empty there while it still names that order at CreateOrder.

A market stream that falls behind re-syncs

Files: orderbook.proto, and the /proto download

A SubscribeMarketEvents stream whose node fell so far behind the market feed that events were dropped now carries is_synced: false then is_synced: true, as after any resync. No field changes; when the pair arrives does.

Read the market again on is_synced: true, whatever caused it. The stream carries on with the events sent from then on; the book, trades and stats it missed are in what you read. Before, the dropped events were skipped silently, and a book kept from the stream could stay wrong until each order in it changed again.

Every subscriber gets the market's stream, opened with its sync state

Files: none — behaviour only; no proto or config changes.

A SubscribeMarketEvents stream is the market's own: every client subscribed to a pair gets every one of its events, and each stream opens with the market's sync state — is_synced: true when the market is synced, is_synced: false while it is not.

  • Behaviour change: a stream no longer depends on an InitMarket call over gRPC, nor on naming the pair exactly as that call's market info does. A second subscriber of a pair another client already watched, or one naming the pair the other way round, could stream nothing at all; and each further InitMarket sent every event to every subscriber once more.
  • Behaviour change: subscribing to a pair not initialised yet waits: the stream stays open, and opens once the market is — by any client's InitMarket, or by the node itself.

A market's 24-hour statistics on demand

Files: orderbook.proto, openrpc.json, and the /proto download

New RPC GetMarketDailyStats on OrderbookService (JSON-RPC orderbook_getMarketDailyStats), with GetMarketDailyStatsRequest (base, quote) and GetMarketDailyStatsResponse (stats, an optional MarketDailyStats): the statistics this node mirrors for the market, absent when it has not initialised the market. Purely additive. Until now a client could learn them only from daily_stats_update events, which the venue sends when a trade happens or leaves the 24-hour window.

Orders say how they meet the book: time in force and self-trade prevention

Files: orderbook.proto, and the /proto download

Two new optional terms, both signed into the order; left unset, an order behaves exactly as before — and leave them unset for the default, since an explicit value is refused by a hub that predates the field. Unset and an explicit GTC or NONE are different orders to a resend under the same client_order_id.

  • A limit order takes time_in_force (OrderVariant.LimitOrder, field 6), a new TimeInForce enum: UNSPECIFIED (0, GTC), GTC (1), POST_ONLY (2). Market and swap orders have none. A post-only limit order never takes: one that would take on arrival is refused with the new post_only_would_cross prefix (FAILED_PRECONDITION, keys price, best, market) and nothing is created; one put back on the book across an order on offer later is cancelled with the new CANCEL_REASON_POST_ONLY_WOULD_CROSS (9).
  • CreateOrder takes self_trade_prevention (4), a new SelfTradePrevention enum: UNSPECIFIED (0, no prevention), NONE (1), CANCEL_TAKER (2), CANCEL_MAKER (3), CANCEL_BOTH (4) — what the order does when it would trade against another order of yours (your node's, or another key whose session carries the same identity), on every market it trades on. Of the two orders, the newer one's rule decides. See Self-trade prevention. An incoming order stopped before it filled anything is refused with the new self_trade_prevented prefix (FAILED_PRECONDITION, keys resting_order_id — your order it met — and market); an order a rule cancels is cancelled with the new CANCEL_REASON_SELF_TRADE_PREVENTED (10).
  • Each order type's view carries its own terms, as placed: LimitOrder gains time_in_force (7) and self_trade_prevention (8), MarketOrder gains self_trade_prevention (8), SwapOrder gains self_trade_prevention (11). A value your client does not know reads as the unspecified one.

A cancel reason or refusal prefix your client does not know is a cancel or a refusal all the same. Read an unknown CancelReason as USER_REQUESTED, and an unknown prefix as unclassified.

A post-only market state

Files: orderbook.proto, and the /proto download

MarketState gains MARKET_STATE_POST_ONLY (4), and CurrencyState gains CURRENCY_STATE_POST_ONLY (4). A post-only market takes new limit orders only, every one of them post-only; market and swap orders are refused (market_state with state=post_only), and no swap routes through it. A market allows only what both of its currencies' states allow, so one with a post-only side and a cancel-only side reads MARKET_STATE_FROZEN. See state.

Update your client before any market goes post-only, and compare states by name. A client that fails on an enum value it does not know cannot read such a market; read a state you do not know as FROZEN. The numbers follow no order.

Strict price priority, and a limit order placed at what it filled

Files: orderbook.proto, and the /proto download

Behaviour change: a taker walks the book best price first and stops at the first order it may take but is too small for, instead of skipping it to fill at a worse price. See Price priority.

  • A market or swap order keeps what it filled before that order, and the rest is dropped. One that filled nothing is refused with the new below_best_fill prefix (FAILED_PRECONDITION, keys side, got, min_fill, price, market): min_fill is the smallest fill of the order it met, resting at price, and got what the order had, both in the order's own unit, which side names. It is no reason to refetch MarketInfo: no minimum moved.
  • A new limit order whose remainder would rest across that order does not rest across it: with nothing filled it is refused the same way; after a fill it is placed at what it filled, and completes when those fills settle. The same holds when its own self-trade rule stops it after a fill.
  • CreateOrderResponse gains released (2), a new OrderRelease: what the order was not placed at, in its own amount, and why — below_best_fill { price, min_fill } or self_trade_prevented { resting_order_id }. OrderCreated carries it too (3).
  • A request sent again under the client_order_id of its order gets the answer that order's placement gave, released included: a retry after a lost answer reads as the answer it lost.
  • A limit order the hub puts back on the book (after a failed swap, a restart, or its owner shown again) that would rest across such an order is cancelled with the new CANCEL_REASON_BELOW_BEST_FILL (11).
  • Behaviour change: a limit buy sized in base, or a limit sell sized in quote, that filled at a better price than its own rests nothing more once its own amount is filled; fills already matched still settle. Before, what the better price left over kept resting and could trade after the order completed. One the hub's book runs out of while some of its own amount is left is cancelled with CANCEL_REASON_HOUSEKEEPING.

A hub that restarts fails the swaps it cannot keep, and says so

Files: none — behaviour and a new refusal prefix

When the hub stops, every swap it could not keep through the restart — one whose invoices or claim address were not all in yet, with nothing committed on it — now fails at once, while your event stream is still open: you receive SwapFailed (the hub's fault, no strike) and the orders it matched get their quantity back. Before, such a swap was dropped at the next start with no event, and your side of it timed out.

  • An order that reaches the hub while it is stopping is not matched into a swap: a market or swap order ends with nothing filled and is refused with the new venue_stopping prefix (UNAVAILABLE) — send it again once the hub is back. A limit order is placed and rests, and trades once the hub is back; one that cannot rest (its remainder below what may rest) ends with nothing filled and is refused the same way.

DEADLINE_EXCEEDED only when an operation may have completed

Files: none — behaviour only; no proto or config changes.

DEADLINE_EXCEEDED (JSON-RPC -32002) now means one thing: the operation may still have completed, so check a write before sending it again. A request that ran out of time without doing anything now answers UNAVAILABLE (-32008) instead, which is safe to retry.

A placement refusal keeps the hub's status code

Files: none — behaviour

A placement refusal the node recognises by its prefix now reaches you with the gRPC status code the hub answered it with — the code the table lists — and the JSON-RPC error code that maps to (FAILED_PRECONDITION → -32010). Before, the node derived the code again from the message text, and some refusals, market_state and the sub_economic_* ones among them, reached you as INTERNAL (-32603). A refusal without a known prefix is unchanged.

Metrics listen on loopback unless you say otherwise

Files: config.yaml

Behaviour change: the Prometheus server on settings.metrics_port now binds 127.0.0.1 unless the new settings.metrics_bind_address names another address. A scraper on another host or container needs one it can reach, such as metrics_bind_address: 0.0.0.0. Before, it bound every interface.

Bitcoin sync: what a Waterfalls request costs

Files: config.yaml

New optional waterfalls_request_cost beside waterfalls_url in the Bitcoin blockchain block (Esplora and Electrum alike): what one Waterfalls request costs, counted in standard Esplora or Electrum requests. Unset, it is 10, which is what Blockstream bills on its cheapest plan (5 on Advanced, 1 on Enterprise). A sync asks Waterfalls only when its requests cost no more than the standard requests they replace, so a lower cost hands Waterfalls more of the work. Set it to what your provider bills.

Lithium subgraph fallbacks

Files: config.yaml

New optional fallback_http_urls in a Lithium network's subgraph block: further HTTP subgraph endpoints, in order. Every indexer read tries http_url first and moves down the list only past an endpoint that failed it; each endpoint gets the block's proxy_auth. Each must index the same chain and contract as http_url: the node probes every endpoint at start, and one that reports another chain stops the boot. One that cannot be reached is only logged.

Which channels an invoice hints

Files: config.yaml

New optional invoice_route_hints in the lightning block: which of its channels the node hints in the BOLT 11 invoices it creates. A hint hands the payer the last hop into your node, so it can route over a channel its view of the network lacks or shows disabled.

  • unannounced (the default): every private channel, and every public channel with fewer than 7 confirmations.
  • all: every channel that can take a payment, public ones included, so a payer still has a route while its graph shows a public channel disabled, as it does for a while after your peer reconnects.

A node the whitelist refuses waits for an invite

Files: none — behaviour only; no proto or config changes.

Behaviour change: when the authentication service refuses your node's identity as not whitelisted and the node can redeem an invite (its referral service is configured), it no longer exits. It holds its start there and serves its API, so you can redeem an invite with RedeemInvite; once the invite is redeemed, the start resumes. In interactive mode the node prompts for the invite instead. A node that cannot redeem one still exits on the refusal. A call that needs the refused session answers UNAUTHENTICATED meanwhile. See Whitelisting for how an identity is admitted.

  • A new refusal: the service could not ask the service that admits invited identities, so whether yours is admitted is unknown for now. It is not "not whitelisted": try again later rather than redeeming another invite.

Fixes

Files: none — behaviour only; no proto or config changes.

  • Arbitrum: channel opens and updates now include the L1 data fee in their gas limit. Before, an update could run out of gas and revert, leaving its channel waiting on an update that never landed.
  • A channel update whose transaction reverted, or never reached a block, is sent again while it can still land, and rolled back when it cannot. Before, it could stay owed and never be sent again.
  • Channel operations now answer within the request's 30-second deadline; one that may still complete answers DEADLINE_EXCEEDED.
  • A routed swap never sends more than it was asked to. Before, one sized by the amount it sends could send one lot more than requested.
  • A swap is now checked against the venue's placement floor, the market's state and a maintenance window before anything is committed, and refused up front with those refusals.

2026-09-26 — Lease and liquidity expiries, lease slots, and one channel floor for every order

Files: channel.proto, liquidity.proto, orderbook.proto, and the /proto download

One production release ships the four changes below. Each opens with the files it changes.

The lease you paid for, and until when the liquidity stays

Files: channel.proto, liquidity.proto, and the /proto download

A leased asset channel now reports two expiries: lease_expiry, the lease you paid for, and the new liquidity_expiry, until when the provider's liquidity actually stays in the channel. Usage, or how little of the provider's capital the channel holds, keeps the liquidity in place past the lease; that time now moves liquidity_expiry and leaves lease_expiry where the paid lease ends.

  • AssetChannel — lease_expiry (8) is the lease you paid for, ending on a boundary of the provider's lease clock. New liquidity_expiry (14): the later of that lease and what usage buys it; absent while the provider has no plan to take its liquidity back.
  • GetLeases — AssetChannelLease.expiry (4) is the lease you paid for, and absent when the asset channel carries no lease; it no longer means "not yet calculated". New AssetChannelLease.liquidity_expiry (5), as on AssetChannel.
  • GetLeaseExpiries — ChannelAssetExpiries gains liquidity_expiries (2), keyed by asset id like assets (1), which keeps the paid lease.

Behaviour change: lease_expiry moves only when a lease is paid for. A client that read it to learn when the provider takes its liquidity back reads liquidity_expiry now.

Leases run on the provider's lease clock

Files: liquidity.proto, and the /proto download

Every lease ends on a boundary of the provider's lease clock, and a lease extension is sold in whole slots of it. GetLiquidityServiceInfo reports the slot as lease_slot_secs (8): one hour on the production service, 0 from a service that does not say.

  • Behaviour change: a lease runs its lease_duration_seconds from when the service funds it, not from when the funding confirms, and on to the next slot boundary; the time up to that boundary is not billed. EstimateRequestChannelLiquidityFee returns the window its fee pays for as lease_duration_seconds (5).
  • Behaviour change: RequestChannelLeaseExtension adds lease_extension_seconds to the lease you paid for (lease_expiry), rounded up to whole slots, and counts from the next slot boundary once that lease has ended. It rents exactly the time it adds; time usage held the liquidity in place past the lease is neither paid for nor counted.
  • EstimateRequestChannelLeaseExtensionFee — new lease_extension (4), a LeaseExtensionTerms: extension_seconds (1), the extension rounded up to whole slots; from_timestamp_seconds (2), where it counts from; expiry_timestamp_seconds (3), the lease expiry after it; rented_seconds (4), the time fee pays rent for. A request that passes the estimate's quote_id back reaches that expiry, for less rent when another paid operation carried the lease part of the way there meanwhile.

The provider's liquidity lands only in channels held to your node's terms

Files: none — behaviour only; no proto or config changes.

  • Behaviour change: on an EVM or Tron network the node raises min_dispute_period_secs of the open, deposit_any and open_or_deposit operations of RequestChannelLiquidity and its estimate to its own proposal (dispute_period.proposed on GetNodePolicy). An older, shorter channel is passed over and a new one opened.
  • Behaviour change: a channel a cooperative close drained is topped up again. On EVM and Tron such a channel only withdrew everything: its asset channels read Closed with force_closed: false yet stay updatable, and a deposit reopens them in place. A channel with nothing left open — every asset channel sealed by a settlement, or closed for good — is never topped up, even for an asset it does not carry yet.
  • Behaviour change: with offchain_fee_payment, the estimate and the request both refuse a fee above what the node can send off-chain on the payment network (max_sendable of the payment asset), before anything is committed.
  • Unchanged, now documented: a deposit into a channel the provider already leases also leases the capital already there for the new window, and its fee includes that rent.

One channel floor for every order, and the balance that falls short of it

Files: orderbook.proto, and the /proto download

The venue admits every order — resting, market or swap, on either side and over any route — against the same channels: those whose payment_deadline_bound_secs (GetChannelTerms) reaches the deadline of the last leg of the longest route it builds, 39 hours with the production ladder. A channel opened with a 48-hour dispute period counts; a 24-hour one does not. A Lightning channel reports no bound and always counts.

  • Behaviour change: a market or swap order is no longer sized against the channels that hold its own rungs (27 hours to pay and 33 to be paid on a two-pair swap). It needs the floor, as a resting order already did on its sending side, so an order that relied on shorter channels is now refused.
  • GetOrderbookBalances — CurrencyBalance.sending and receiving are exactly what an order is admitted against: what one payment over the channels that reach the floor can carry, less what your orders hold, with receiving also held to what the hub can still forward to you. unavailable_sending / unavailable_receiving now count balance being withdrawn.
  • New CurrencyBalance.ineligible_sending (9) and ineligible_receiving (10): the free balance on channels below the floor, or not learned by the venue yet. No order can use it; a channel opened with a long enough dispute period can. 0 from a node that predates these fields. A BalanceUpdate event carries the same CurrencyBalance.
  • The Simple Swap opens and leases channels that reach the floor on its own, and ignores shorter ones.

2026-09-25 — Dispute periods, channel terms, orderbook balance floors and two fee estimates

Files: node.proto, liquidity.proto, orderbook.proto, and the /proto download

One production release ships the five changes below. Each opens with the files it changes.

A channel's dispute period, and asking for one

Files: node.proto, liquidity.proto, and the /proto download

A node now reports the force-closure dispute period of each of its channels, per side, and the furthest deadline a payment on it may carry; a channel can be opened to carry a required period.

On Lithium a dispute opened against a channel is settled once its period has passed, and the settlement hands every payment without a preimage back to its sender. A payment whose deadline lies further ahead than the period could be settled back before its receiver can claim it, so a node refuses it on either end of the channel — a receiver will not hold it, and a sender will not offer it. A swap's legs carry deadlines set by the venue's claim ladder, one rung apart, so a channel that covers a direct swap's rungs may not cover a two-pair one's. A Lightning channel's period is its to_self_delay, one per side, and bounds no payment: an HTLC's own deadline is enforced on-chain whatever the channel does.

  • GetChannelTerms — dispute_period (DisputePeriod: own_secs, peer_secs), how long a force close by each side keeps that side's funds locked, and payment_deadline_bound_secs, how far past the present a pending payment's deadline may lie on the channel: on Lithium the period less a 30-minute latency allowance, absent on Lightning. Terms of the channel, fixed at open, so they are read with the channel's other terms and not on the channel listing.
  • GetNodePolicy — dispute_period (DisputePeriodPolicy: proposed_secs, max_secs), the period a node proposes for the channels it opens and the longest it accepts having to wait itself. Both always set; on Lightning they are the node's to_self_delay terms.
  • OpenChannel and EstimateOpenChannelFee — terms (ChannelOpenTerms): min_dispute_period_secs, which the node raises its proposal to cover and refuses when it exceeds what it accepts (zero asks for nothing); cltv_expiry_delta and per-asset AssetChannelOpenTerms (reserve, max_inbound_pending_payments, max_pending_payments_total, forwarding_fee), which the node announces on the channel in place of its configured terms, recorded at open for the channel's life (a zero cap keeps the configured value). The estimate refuses terms no channel can carry before anything is signed, on both protocols; no term changes the fee. On Lithium the proportional forwarding fee must be the configured one, since a Lithium forwarder charges its configured fee on every channel.
  • RequestChannelLiquidity / GetFeeQuote — min_dispute_period_secs on the open, deposit_any and open_or_deposit operations: an existing channel short of it is not topped up, and a channel opened instead is opened to carry it. Zero requires nothing.

The simple swap plans around this on its own. It reads the period each of its legs needs off the venue's ladder for the route it takes, ignores channels that fall short, opens or leases ones that cover it, and — since a route through more pairs needs more — plans every route the books offer and takes the best all-in outcome rather than the best match. It also leases receiving capacity for the swap's payment window plus the funding lead rather than a flat day.

A cooperative close on Lithium no longer rejects the payments a channel holds. It refuses while any payment on the closing slots is in flight, offered or held, as a Lightning close does; reject the held ones with RejectPayment first if they are not to be waited for.

A payment is held to the invoice it names. On Lithium a payment naming one of this node's invoice secrets must carry the invoice's hash, at least its amount and a deadline no earlier than the invoice's; one naming a secret the node never issued, or one it issued and that has since expired, is refused. A payer therefore carries every forwarding hop's delay on its first hop, so the payee receives the promised deadline on a routed payment as on a direct one. The route search itself crosses only channels whose dispute window can hold the deadline, so a node holding two channels to one peer, one short of a leg's deadline and one that reaches it, sends over the one that reaches it. Flag day for routed invoice payments: a payer on a build before this one puts the promised deadline on its first hop and every forwarder takes its delay off it, so a payee on this build refuses what such a payer routes to it; direct payments, which every swap leg is, are unaffected. A payment naming no invoice — a hashlock of a hash the payer cannot open on SendChannelPayment or SendPayment — is held by the payee as offered.

A force close is refused while the channel holds the inbound of a swap in flight. ForceCloseChannel and its estimate answer FAILED_PRECONDITION naming the swap, and so does a BatchChannelOperations carrying such a force close; a batch carrying a cooperative close is refused over in-flight payments as the single close is: on Lithium the peer may confirm the dispute at once, and the confirmation hands every payment without a preimage back to its sender. A pending payment the swap engine does not know is left to the caller.

A redeem that would settle a peer's dispute early over a held payment is refused. On Lithium the side that did not open a dispute may confirm its settlement at once, and the confirmation hands every payment without a preimage back to its sender. RedeemClosedChannel therefore refuses, naming the payment, while the peer's dispute holds a payment of this node's without a preimage on chain; the channel becomes redeemable once the preimage lands on chain or the payment's deadline passes.

The terms one channel carries, read in one call

Files: node.proto, and the /proto download

GetChannelTerms reads the terms one channel carries: those that hold for the channel as a whole, and each of its asset channels' own. It supersedes GetChannelPolicy, which is deprecated.

  • GetChannelTerms — GetChannelTermsRequest (network, channel_id) answers ChannelTerms: dispute_period, payment_deadline_bound_secs, and asset_channels, a map from asset id to AssetChannelTerms. AssetChannelTerms carries the fields of AssetChannelPolicy under the same names and meanings, without the counterparty, channel_id and asset_id the request and the map key already give. A channel the node does not know is NOT_FOUND.
  • GetChannelPolicy — deprecated. It keeps answering exactly as before until it is removed; move to GetChannelTerms, one call per channel, keyed by the id the channel listing reports.

Orderbook balances count only channels that can carry an order's legs

Files: orderbook.proto, and the /proto download

GetOrderbookBalances counts a channel toward sending and receiving only when its dispute window holds a one-pair swap's legs, the least any order asks of a channel. No field changes; what the two figures count does.

Every leg of a swap carries a deadline, and a Lithium channel refuses a payment whose deadline lies past its payment_deadline_bound_secs (see GetChannelTerms). A one-pair taker pays on the lowest rung any order pays on, 15 hours with the production ladder, and is paid on the lowest rung any order needs a channel to hold as its inbound, 21 hours. A channel whose bound falls short of these carries no order's leg on that side, so it adds nothing: an order sized against it would have been matched and then refused at settlement. A Lightning channel reports no bound and always counts.

  • CurrencyBalance.sending / CurrencyBalance.receiving — the channels behind each figure are those that hold a one-pair taker's leg on that side. The other fields are unchanged.
  • The venue still admits each order against the channels that hold its own legs. A market or swap order is sized against those that hold its rungs for the route it takes: a two-pair swap needs 27 hours to pay and 33 to be paid. A resting order can be matched into any position of any route, so its sending side needs the last maker's rung of a two-pair route, 39 hours: a limit order larger than those channels can back is refused at placement, and an order resting on channels that no longer carry it is cancelled with capacity_lost. A 24-hour Lithium channel (bound 23.5 h) therefore counts toward both figures and carries a direct swap, but backs no limit order.
  • A swap order or quote whose currency_path crosses more than two markets is refused: its last maker's leg would lie past what a 48-hour channel holds.
  • An order withdrawn because a settlement failure was blamed on its owner is now cancelled with an OrderCanceled whose reason is housekeeping — once no other fill of it is still settling — instead of disappearing from the book with no event.

A lease extension's estimate is the fee it is billed

Files: none — the fee and the billing change; no proto or config changes.

EstimateRequestChannelLeaseExtensionFee returns the whole fee: the setup fee plus the rental of the capital the provider lent into the asset channel, for the extension's window. It returned the setup fee alone, whatever the window, while the request was billed the rental as well.

  • Behaviour change: an extension rents what the provider lent into the asset channel, bounded by the channel's capacity — not the whole channel. Funds you put in yourself are not rented, as they already were not when a deposit renews a leased channel.
  • Behaviour change: a request carrying a quote_id is billed no more rental than its quote showed, even when the channel grows between the estimate and the request. This holds for the renewal a deposit into a leased channel pays as well.
  • Behaviour change: the estimate refuses an extension the request would refuse — an asset channel that is closed, has an on-chain update in flight, holds no capacity, or holds none of the provider's funds — instead of quoting it.

A fee estimate that cannot be funded now says so

Files: node.proto, and the /proto download

EstimateOpenChannelFee, EstimateDepositChannelFee and EstimateBatchChannelOperationsFee now FAIL when the amount plus its on-chain fee exceeds the spendable balance. They previously returned a number in that case, and the number was not a fee.

The old value was the input cost of a transaction that could not be built. An on-chain fee is paid on top of the amount, so an exact amount within a fee of the whole balance leaves nothing to pay the fee with and coin selection fails. Rather than surfacing that, the estimate reported what the failed selection had gathered — a figure that tracked the wallet's UTXO set rather than the amount asked about, and that moved between identical calls because the selector's fallback is randomised. On a small wallet it could exceed the balance itself.

Only the estimate was affected. OpenChannel and the other write paths always refused this case rather than broadcasting, so no transaction ever paid one of these figures.

To fund an entire balance, send DepositAmount.All. It takes the fee out of the amount instead of adding it, and returns the real fee for the resulting transaction — no client-side margin needed. Capping an exact request at some fraction of the balance is a workaround for the old behaviour and can be dropped.


2026-09-20 — The enums a client has to branch on

Files: app.proto, channel.proto, htlc.proto, transaction.proto

Documentation only — no wire change. An audit of every enum in the schema found six whose values this reference named nowhere, so a client could read the field and have nothing to match it against.

Blockchain — ChainTransaction had no field table at all. It now has one, including ChainTxStatus: IN_MEMPOOL (1), CONFIRMED (2), FAILED (3).

It is not the wallet's Transaction, and the two status enums are not interchangeable. ChainTransaction is a raw chain read of any transaction; the wallet's record covers one this wallet is party to and adds confirmations, the spent/received maps and the operation lists. ChainTxStatus has no PENDING_CONFIRMATIONS arm, because "enough confirmations" is a wallet policy rather than a chain fact.

App — the archive-prune job shapes are spelled out: ArchivePruneTrigger (MANUAL / AUTO), ArchivePrunePhase (PAYMENTS then TRANSACTIONS, sequential, payments only on channel-tier networks) and ArchivePruneFailureReason (NODE_ERROR / NODE_STOPPED / CHUNK_TIMEOUT), with ArchivePruneJob, ArchivePruneProgress and ArchivePruneFailure.

Two details worth having: job_id is process-local and resets on restart, so persisting one is a bug; and last_progress_at — not chunks — is what separates a stuck chunk from a phase that simply has nothing to delete.

HTLC — CreateHtlcSettlementTx takes a SettlementKind (CLAIM / REFUND) the caller has to supply, and an output whose empty value asks the node to pay the spending party's own key as a P2WPKH.

Channel — AssetChannelStatus.force_closing carries a Disputer: LOCAL (1) when this node opened the dispute, REMOTE (2) when the counterparty did.


2026-09-20 — Ask the node about a network instead of hardcoding it

Files: app.proto, primitives.proto, and the /proto download

New RPC GetNetworkInfo on AppService, with NetworkInfo and the OffchainProtocol enum. Purely additive; GetNetworks is unchanged and still lists bare identifiers. This is the same list with each network's own account of itself attached — name, ticker, explorer URL, finality depth, block interval — plus, for a network with payment channels, which off-chain protocol it speaks and the unit that protocol encodes an HTLC claim window in.

It never touches the chain. Every field is a constant the network carries, so the answer is identical whether the node is synced, syncing or offline. That is why it lives in AppService and not in BlockchainService, where every method queries on-chain data. For anything live, use GetSyncStatus.

cltv_encoding_granularity_secs is the field to read before validating anybody else's invoice. min_final_cltv_expiry is not seconds everywhere: BOLT-11 carries a block COUNT, so a Lightning invoice can only place its claim deadline on a grid of that size and always rounds UP; Lithium carries seconds and has no grid. So an invoice's deadline can legitimately sit up to one unit — 479 s on Bitcoin — past the rung you expect, and a validator has to forgive that.

Do not re-derive the encoded value and demand it back. A required min_final that lands exactly on a grid line, plus a second or two of ordinary timing drift, rounds to a whole extra unit; the honest invoice is then refused, every time, on that chain alone. Compare the deadline the invoice carries against the deadline you expect, using the granularity as the upper slack. It is also not block_time_ms — the block-count conversion uses a bound below the nominal interval so a fast-block period cannot shorten an HTLC, and on Bitcoin those two numbers are 480 s and 600 s.


2026-09-20 — A cancel that is waiting on a settling fill

Files: orderbook.proto, and the /proto download

CancelOrderResponse gained pending (2) and Order gained pending_cancel (4). Both additive; removed keeps its meaning. A cancel can only finish once every fill against the order has settled, so when one is still settling the orderbook accepts the cancel — the order leaves the book and matches no further — and finishes it later. Until now that answer was indistinguishable from an immediate cancel: removed: true, the order still listed, its balance still reserved, and the terminal OrderCanceled only when the fill settled. Now the response says pending: true, the listed order says pending_cancel: true, and only the in-flight portion stays reserved — the rest is released at once.

pending: true is a cancel, not a failure, and it is never undone. Wait for the terminal OrderCanceled — or OrderCompleted, when the settling fill consumes the whole order — before dropping it from your mirror. A cancel accepted this way also survives a hub restart: the order is not restored.


2026-09-19 — The terms a channel actually carries

Files: node.proto, and the /proto download

New RPC GetChannelPolicy on NodeService, with AssetChannelPolicy, DirectionalPolicy, ForwardingFee and ReserveTerms. Purely additive: no existing message changed, so nothing you already read moves. Sibling to GetNodePolicy, which answers what a node WILL negotiate before a channel exists; this answers what a channel DID — each side's announcement, already combined by whatever rule the protocol applies. Filter by counterparty, channel_id and asset_id, in that order; omit all three for every channel. It changes on announcement rather than on payment, so it is cacheable with a TTL, and balances stay on the channel listing rather than being repeated here.

A direction says what is ADMITTED; it does not say what it COSTS. A forwarder charges on the hop a payment leaves by, so the fee and the bounds of one direction are set by opposite ends of the channel and are reported in different places. What this channel earns you — and what a payment you forward out over it costs — is forwarding_fee_self. What a payment arriving over it paid is forwarding_fee_peer. What either side will accept is inbound / outbound. There is exactly one copy of each fee, so there is nothing to pick wrong and nothing to drift.

Absent is not zero. forwarding_fee_self / _peer are absent until that side has announced; a zero would value an un-gossiped channel at nothing and under-pay a forward through it. The two directions are absent independently, and your own inbound bounds are reported whether or not the peer has ever spoken.

proportional_millionths is a price, not a settlement figure. The two protocols apply the proportion to different amounts — one to the amount arriving at the forwarder, the other to the amount leaving, as BOLT 4 does. They agree to first order and differ by p²·A/(1+p), one millionth of the payment at the prevailing 1000 ppm. Price and value hops with it; do not re-derive an exact fee and reconcile against that.

ReserveTerms carries both a rate and a floor because the two protocols weight them differently. A protocol that recomputes the reserve from current liquidity has a live proportional term, so a deposit locks a proportion of what it adds. A protocol that sizes the reserve once at channel open and never recomputes it — not even on a splice — reports proportional_millionths: 0 and the realized figure as the floor, because a deposit adds nothing to it.


2026-09-19 — The balance already on its way out

Files: balance.proto, and the /proto download

OffchainBalance gained withdrawing_local (17) and withdrawing_remote (18): the part of a side leaving the channel in the on-chain update currently in flight — a Lithium updateChannel withdrawal, a Lightning splice-out.

It is not pending_local in a different hat. Both mean "waiting on a confirmation"; they wait in opposite directions. pending_local is arriving and becomes spendable off-chain once it lands. withdrawing_local is departing: it reaches the wallet when its transaction confirms and is never spendable off-chain again. It counts toward what you own and deliberately not toward what a close could still pay out, because it is already leaving by another route — so summing the two double-counts one balance headed two ways.

On the wire since 2026-09-16; documented here now.


2026-09-19 — Which side of a fill was yours

Files: orderbook.proto, and the /proto download

ClientMarketTrade gained role (12, TradeRole): TRADE_ROLE_MAKER when your resting order was filled, TRADE_ROLE_TAKER when your order crossed the book. The hub has always known which; until now it did not say, and a client could only guess from a fee's sign.

The two trade feeds are not mirrors, and the difference is a whole class of fill. GetAllSwapTrades records takers only — one record per swap, against the caller whose order crossed. A fill your resting order made has no record there and never will. GetAllMarketTrades has one record per fill in both roles, and is the feed to reconcile balances against.

A caller that only ever takes sees the two feeds agree on every fill, which is how this gets missed. One limit order that crosses on arrival and rests with its remainder is enough to break the symmetry: its taker fills appear in both, the later maker fill in only one, and a history built from the swap feed is short by exactly that fill.

ClientMarketTrade's two fee columns are documented, and they are not both yours. base_fee is charged to whoever received base and quote_fee to whoever received quote, so exactly one of them is the caller's — base_fee on a BUY, quote_fee on a SELL — and the other is the counterparty's, having moved no balance of yours. Neither the field's shape nor its value changed; only the documentation was wrong.

The sign is not the role. Positive means the fee was taken, negative that it was credited back, and nothing more. An operator sets a market's maker and taker rates independently — either may be negative, zero or positive, the only constraint being that the maker rate never exceeds the taker rate. role is what says which of the two your fee was struck at — the one on the side you received; the other column is your counterparty's, struck at the opposite role's rate. TRADE_ROLE_UNSPECIFIED (0), omitted from JSON entirely, means the role is not being reported rather than a role of either kind.

ClientSwapTrade.to_currency_amount is the final hop's output before that hop's fee: a taker receives to_currency_amount − to_currency_fee, and less again the claim-gas reserve the hub withholds when that hop settles on-chain and the hub sponsors the claim — that reserve is not part of to_currency_fee, it rides the matched order's route in the last SwapHop.receiving_fee. Every earlier hop's fee is already deducted, each hop having fed the next its net proceeds, so those must not be subtracted twice. to_currency_fee is the final hop's fee alone; the earlier ones are charged in their own hops' currencies and itemised by the per-hop ClientMarketTrade records sharing the swap id, so totalling this field across a route understates the cost. Unchanged on the wire — both were documented as if they were already net.

The private DEX stream now sends a taker its MarketTradeUpdate for every hop of a routed swap, matching what the trade-history RPCs return for the same swap. A multi-hop swap previously sent the taker only the SwapTradeUpdate, so a live mirror and a refetch disagreed until the next refetch. Key a live "my trades" mirror on (swap_id, order_id, role): one fill reaches you twice when you held both sides of it.


2026-09-17 — The withdrawal allowance, and the cursor's shape

Files: node.proto, liquidity.proto

Documentation only — both have been on the wire since the 16th, and this entry is the half of the withdrawal-ceiling change that got missed.

Node — FundingAllowance carries a third field, allowed_withdrawal (3): the most this node's own channel balance may be reduced by an on-chain update the peer initiates.

The other two bound your wallet; this one bounds your channel. allowed_deposit and allowed_payment govern funds entering — what a peer may fund on your behalf, and what it may take back out of that. allowed_withdrawal governs funds leaving a channel you already hold, on a transaction the peer authors.

They are separate on purpose: a node happy to have a channel funded on its behalf is not necessarily happy to have it emptied on its behalf. Setting the first two generously grants nothing here, and 0 is a real setting meaning no peer-initiated withdrawal at all.

allowed_payment already covers a fee carved out of a withdrawal the peer pays back, which is why this ceiling needs no payment field of its own.

Liquidity — FeePaymentCursor's two fields are now spelled out: created_at_timestamp_micros (int64) and payment_uuid. Still opaque in use — pass back the next you were handed — but documented so a client can persist and rebuild one across a restart. The instant is microseconds because payments committed inside one second still have to page deterministically, with the id as the tie-breaker.


2026-09-16 — Why an order was cancelled, whether the venue is open, and what a small swap costs the hub

Files: orderbook.proto, and the /proto download

Four gaps on the orderbook surface, each one something the hub knew and a client could only discover by being refused.

OrderUpdate.OrderCanceled gained reason (2, CancelReason). A cancel you did not ask for is the hub telling you something about your own settlement state, and until now every one of them looked identical to a cancel you requested.

HOUSEKEEPING and HUB_DISCONNECTED call for opposite reactions. The first is book hygiene — dust residue, a rolled-back match, boot reconciliation — and means nothing is wrong on your side. The second means your settlement node stayed disconnected past the hub's grace period: reconnect before re-placing, or the replacement is cancelled too. FEE_REBASELINE means a fee reload re-baselined the reservation past your capacity, so re-place at the new rates rather than retrying the old size. CANCEL_REASON_UNSPECIFIED (0) comes only from a hub predating the field — treat it as USER_REQUESTED rather than dropping the update, or you lose the order's terminal state.

MarketEvent gained venue_status (6) and market_info (7). The venue's maintenance windows and every hot-reloadable field of a market are now pushed instead of being invisible until a rejection.

Both describe state that changes with no restart and no disconnect. The operator edits the hub's config; nothing your client observes changes. A cached MarketInfo therefore goes stale silently, and a quoting bot with no venue_status subscription spends a maintenance window retrying placements that cannot succeed. venue_status is venue-wide and arrives on every subscribed market at once; resting orders survive the window and cancels keep working through it — what stops is placement.

MarketInfo gained state (16, MarketState): ENABLED, CANCEL_ONLY (resting orders and cancels still work, new orders do not) or FROZEN. It is the stricter of the market's two currencies' states, and both GetMarketsInfo and GetMarketInfo stamp it.

New RPC: GetSubEconomicPolicy. The venue bounds how much force-closure cost your concurrent small swaps can put at risk — never their notional. This publishes the inputs so you can predict a refusal instead of discovering one: see Get Sub-Economic Policy.

The one number the hub cannot publish is yours. A swap escapes the accounting when its notional clears K × (asset_channel_close_usd + htlc_resolution_usd), where K is how many channels you hold for that asset — until a payment is sent it may still be MPP-split across all of them, needing one closure in each. A single published "safe notional" would be correct only for a one-channel client and silently permissive for everyone else, so the pricing is published per network and the multiplication is yours.

The three sub-economic placement rejections were renamed, and their keys with them. below_floor_budget and below_floor_count are gone; the set is now sub_economic_client_limit (force_close_usd, added_usd, allowance_usd), sub_economic_htlc_cap (network, node, current, addition, max) and sub_economic_venue_limit (force_close_usd, added_usd, admissible_usd, standing). See Placement rejections.

A parser matching the old prefixes now falls through to unclassified. That is safe — the order is still refused and the refusal is still retryable — but it loses the numbers that say what to do. All three amounts are force-closure costs, not trade notionals.


2026-09-16 — The withdrawal ceiling, the unilateral-exit flag, and the proto download

Files: balance.proto, channel.proto, and the /proto download itself

Two fields that had been on the wire without an entry here, and the reason they were easy to miss.

Balance — OffchainBalance gained max_withdrawable_local (15) and max_withdrawable_remote (16): the most an on-chain withdrawal can take from each side while the channel keeps existing.

Size a withdrawal by this, not by free_local, and never by free_local + unspendable_local_reserve. The reserve is what a surviving channel has to keep, so every backend refuses a withdrawal that eats it — only a cooperative close pays it out. max_withdrawable_local equals free_local unless the protocol bounds it lower: a Lightning splice-out pays its own transaction fee out of the balance it is splicing, so there it sits that much below. max_withdrawable_remote is what the other side could take out in an operation this one initiates — zero on Lightning, where a splice-out pays out only the side that broadcasts it.

Channel — AssetChannel gained is_force_closable (13): whether a unilateral close could be submitted.

Not the negation of is_closable, and the two diverge exactly when it matters. A cooperative close needs the counterparty to sign, so is_closable goes false the moment the peer is unreachable — which is precisely when a unilateral exit is the only one left. is_force_closable does not answer to the peer. It reports that the node holds something a unilateral close could submit and that no close is already recorded, not that the chain will accept it; estimating the force-close is what answers that.

The /proto download was stale. app.proto, balance.proto, channel.proto, liquidity.proto, node.proto and transaction.proto in hydra-protos.zip lagged the running build, so a client vendoring its stubs from it decoded the new fields as unknown and silently dropped them — RequestChannelLiquidityResponse.fee, Transaction.fee_payer, GetFeePayments and GetSyncStatus among them. All 23 files are now byte-identical to the schema the node serves.

If a field documented here does not appear in your client, re-vendor before anything else. An unknown field is dropped without an error by every protobuf runtime — that is the specified behaviour, and it looks exactly like the server not sending it. rpc.discover on a running node is authoritative for what that build actually serves.


2026-09-15 — What an operation cost, and whether the node is following the chain

Files: transaction.proto, node.proto, liquidity.proto, app.proto

Five reporting gaps, each one an amount or a state the app knew and the API did not return.

Transaction — Transaction gained effective_fee (14, optional DecimalString) and fee_payer (15, FeePayer): the fee the transaction paid, read from the chain's own accounting rather than from the operations, and who bore it.

effective_fee is the one fee figure that exists whatever the transaction's shape. A UTXO transaction carries no fee operation at all, and on an account-based chain the AccountOperation::Fee entry is a deliberate upper-bound reserve until the transaction is mined — so neither was a fee to reconcile against. It is absent while a transaction is unpriced (still in the mempool); its presence is what says the figure is final.

It is the transaction's fee, not automatically yours. An incoming transfer's fee was paid by whoever sent it. fee_payer is FEE_PAYER_WALLET (this wallet funded it and paid the whole fee), FEE_PAYER_COUNTERPARTY (it paid nothing), FEE_PAYER_SHARED (funded by more than one party — a dual-funded open, or any spend of a jointly-owned output, a channel closure among them), or FEE_PAYER_UNSPECIFIED, which means not recorded, not "nobody paid". On EVM and Tron it is exact and never SHARED: one account funds a transaction.

Bitcoin: this is how you separate a payment from its fee. UtxoOperations lists only this wallet's own inputs and change — the counterparty's output belongs to somebody else and was never in it — so the amount paid out is spent − received − effective_fee, valid where fee_payer is FEE_PAYER_WALLET. The proto now says so.

Transaction (EVM, behaviour) — a native channel deposit is no longer counted twice in spent. The transaction's own value and the channel operation's credit are two views of one movement; spent took both, inflating every native deposit by exactly the deposit. Token deposits were always right — they move by transferFrom inside the call, so the channel operation is their only record — and they are unchanged.

Transaction (EVM/Tron, behaviour) — every channel-contract call is now decoded, where previously four were not.

A redemption shows what it recovered: the payout arrives as an internal transfer with no value and, for a native asset, no transfer event of its own, so the claim is read from the contract's AssetClaimed log and received carries the amount instead of being empty next to a bare ContractCall. Batched calls are decoded entry by entry — which is how a redemption arrives whenever a settlement confirmation and a claim are both due, so it was previously the common case that reported nothing. Revocation penalties now report their settlement, and liquidity-pool deposits and withdrawals their movement.

An escrowed settlement is no longer counted as received. A settlement pays the confirming sender directly and escrows every other beneficiary's share behind a claim; a confirmation this wallet did not send puts nothing in it. The balance shows as redeemable and reaches the wallet only when the claim that follows is made — which is now its own row.

Node — RedeemClosedChannelResponse gained redeemed (2): map<asset_id, DecimalString>, what the redemption settles back to this wallet.

Read from the channel as the redemption is built — the redemption is what takes that balance off the channel, so after it lands there is nothing left to report it from. Assets with nothing to redeem are absent rather than zero.

Liquidity — the three completion responses gained the fee the service actually billed: RequestChannelLiquidityResponse.fee (3), RequestChannelReleaseResponse.fee (2), RequestChannelLeaseExtensionResponse.fee (4), each a DecimalString in whole units of the request's payment_asset_id.

Settled, not quoted: no diffing channel balances before and after a request to learn what it cost. On the rails the service broadcasts (dual_fund_fee_payment, sponsored_deposit_fee_payment, sponsored_withdrawal_fee_payment) the fee is carved out of the round and never leaves a wallet — the figure is still what was billed.

Liquidity — new RPC GetFeePayments(network, statuses?, created_after_timestamp_seconds?, before?, limit) → { payments, next? }: what this node has been billed on a network, newest first.

The reconciliation surface for fees already paid. A request's own response reports the fee for that one request and nothing re-reads it afterwards — this returns the rest, including payments that expired, failed or were refunded, which no response ever returned. Each FeePayment carries the rail, the payment network and asset, the fee, any refund, the lifecycle status, and the operations it covered — as a FeePaymentOperation oneof over the same shapes a request carries: provision (a ChannelLiquidityRequestOperation with the lease window it bought), release (a ChannelReleaseOperation), or lease_extension (the new ChannelLeaseExtensionOperation). A record therefore states the amounts it was priced on, not merely which assets it touched.

Page by cursor, not by offset. Pass the response's next back as before; stop when it comes back absent. New payments arrive at the newest end, which is exactly where an offset would shift under you. limit is clamped to 200.

Liquidity (documentation) — RequestChannelLiquidityRequest now states what it cannot provision. A round the client funds alone is served only by the rails the service broadcasts (DualFundFeePayment, SponsoredDepositFeePayment); and the native asset cannot be deposited on the client's behalf at all, on any rail, because a native contribution rides the broadcaster's own transaction value and on those rails the broadcaster is the service. Between the two rules a client-funded native deposit has no rail here — use NodeService.OpenChannel / DepositChannel / UpdateChannel, where the client broadcasts and the value is its own. No shape change; the server always behaved this way.

App — new RPC GetSyncStatus(networks?) → { networks }, one NetworkSyncStatus each: client_synced, node_synced, last_tick_at, last_heartbeat_at, last_synced_at, consecutive_sync_errors, last_error, sync_task_active.

The pull counterpart of the Syncing / SyncProgress / SyncError / Synced events. A caller that was not subscribed when a network degraded, or that reconnected since, reads the current state here instead of reconstructing it from an event history it does not have — and GetNetworks answering normally has never meant a network is following the chain.

Answered locally. The figures come from state the sync loop keeps as it runs, so this responds while the chain connection is exactly what is broken — which is when it is needed, and when every call that reaches the chain would hang or fail. A stale last_tick_at next to a fresh last_heartbeat_at is "the task is alive, its provider is stuck"; a stale last_heartbeat_at, or sync_task_active: false, is the task itself.

See Wallet → Transactions, Node → Redeem Closed Channel, Lease and General.


2026-09-13 — Predict a channel reserve from the rate, not a bound

Files: node.proto, liquidity.proto

Node — ReservePolicy gained proposed_proportional_millionths (5, optional): the reserve ratio this node proposes for a channel holding an asset. A channel carries the larger of the two sides' proposals, so a caller holding both policies knows the rate before the channel exists.

counterparty_proportional_millionths is documented for what it is: a lower bound on what this node accepts, not a rate it imposes — except on a protocol that fixes each side's reserve from the other's config and negotiates nothing. No shape change, but the meaning is narrower than the old wording implied.

Size against the proposal. The two bounds are what is acceptable; the proposal is what a channel will take. Sizing a lease off the local ceiling over-sizes it — 10% against a channel that takes 1% — and sizing off the provider's floor under-predicts by the same order, which is the direction that hangs a swap waiting for a balance that never arrives.

Absent ≠ zero. 0 is a real configuration meaning no proportional reserve at all. Fall back to max_self_proportional_millionths only when the field is absent, i.e. against a node that predates it.

Liquidity (wire-compatible) — ChannelReleaseOperation.Withdraw.channel_id is now optional string. A swap quotes its exit before the channel exists, and the absent case was being flattened into "" — which the service's ownership check can never satisfy, so the quote came back priced for another channel and the exit fell to the local rail, leaving a gas-less wallet holding proceeds it could not withdraw.

optional is the same bytes on the wire as an unset string, so clients either side of the change read each other correctly. What it removes is the ability to forget the case — omit the field, never send "".

Swap (behaviour, no shape change) — the deposit and exit rail decisions are now made against the gas the chain reserves (units × max_fee_per_unit) rather than the quoted fee, which on EVM can be half as much. A wallet between the two now takes the service rail instead of being put on the local rail and parked. The quotes a caller is shown are unchanged.

See Node → Get Node Policy, Lease → ChannelReleaseOperation and Swap → ExitRail.


2026-09-11 — A generic channel update

Files: node.proto

Node — two new RPCs, UpdateChannel(network, channel_id, asset_updates, fee_option) → { txid } and EstimateUpdateChannelFee → { fee }, taking a ChannelUpdateAmount per asset. DepositChannel and WithdrawChannel are unchanged — they are the two shapes of this common enough to deserve their own call; UpdateChannel reaches every other combination, including an exit that carves its own fee out of what it withdraws.

New in balance.proto:

  • ChannelContribution — oneof { deposit: Amount | withdraw: Amount }, what one side's wallet contributes. Unset means that side's wallet does not move, which costs less gas than naming a zero.
  • BalanceCredit — oneof { to_peer: DecimalString | to_self: DecimalString }, balance crossing between the sides beyond what either wallet moved. It is settled in the co-signed state that follows the transaction: it touches no wallet and reaches no chain.
  • ChannelUpdateAmount — { self_contribution, peer_contribution, credit }, all three independently optional.

Amount.all resolves per direction: against that side's channel balance for withdraw, against the local wallet for deposit — so deposit.all is rejected on peer_contribution, where that wallet is not visible. On a side that also pays a credit, withdraw.all resolves to its withdrawable balance minus the credit.

The two contributions must not cancel: the chain refuses an update that leaves the channel holding what it already held. Contributions that cancel are a payment, not an update.

Node — BatchChannelOperation gains a seventh arm, update (7), carrying BatchUpdateChannel { channel_id, asset_updates }, so the atomic path is not a step behind the single one.

Node — no shape change to FundingAllowance, but allowed_payment now bounds more than it did: every way your balance can cross to a peer without a payment of your own, including the BalanceCredit in a co-signed update. The field keeps its name and number, so nothing to migrate — but an allowance sized only against deposits is now sized against the wrong thing.

See Node → Update Channel and Set Funding Allowance.


2026-09-11 — Token permits: an allowance without gas

Files: allowance.proto, blockchain.proto, client.proto, swap.proto, liquidity.proto, wallet.proto

A token allowance can now be authorised by an off-chain signature and submitted by somebody else, who pays the fee. That is what lets a wallet holding only a token — no native asset at all — grant an allowance, fund a channel, and swap.

Allowance — new message TokenPermit: { token_id, owner, spender, value (U256String, smallest unit), deadline (unix seconds), terms (bytes), signature (bytes) }.

Blockchain — new RPC GetTokenPermitTerms(network, token_id, owner) → { terms?: bytes }. Returns what the token discloses before an owner signs — the bytes that go into TokenPermit.terms — or nothing when the token offers no signature-authorised allowance to that owner. Reading the terms is how a caller learns whether the permit path exists at all. The terms fold in the owner's current permit nonce, so read them immediately before signing.

Client — three new RPCs for the submitting side:

  • EstimateTokenPermitFee(network, asset_id, owner, spender, value, fee_option) → { fee }. What submitting would cost this wallet, priced against the owner's live allowance and nonce. Takes no signature — the permit does not exist yet; the owner signs once the price is known. value here is a whole-unit DecimalString, unlike TokenPermit.value.
  • CreateTokenPermitTransaction(network, permit, fee_option) → { transaction_request } — the unsigned submission, for an external signer.
  • SubmitTokenPermit(network, permit, fee_option) → { txid } — create, sign and broadcast in one call.

Swap — new enum DepositRail (UNSPECIFIED / LOCAL / LIQUIDITY_SERVICE), carried as SendingChannelDeposit.rail (7): who broadcasts the sending side's channel funding and pays its gas. The entry-side counterpart of ExitRail. New SimpleSwapUpdate variant funding_sending_channel_via_liquidity_service (35) — { fee, fee_payment_currency } — emitted when the deposit takes the service rail.

Liquidity — new fee-payment variant sponsored_deposit_fee_payment (9) on RequestChannelLiquidityRequest: the service funds the channel from this wallet's tokens on its own transaction, authorised by a permit Hydra App signs internally, and takes its fee out of the deposit. Provisioning only.

Wallet — new RPC RescanTransactions(network, from_block?) → { from_block, to_block, adopted, skipped }. Operator-driven recovery for a transaction the live subscriptions never delivered: the ordinary sync reaches back only a confirmation window, so anything older is never revisited on its own. Balances are read from the chain and are unaffected. skipped > 0 is what says the range was not fully adopted — it does not end the pass.

See Client → Token permits, Blockchain → Get Token Permit Terms, Swap → Deposit rails, Lease → SponsoredDepositFeePayment and Wallet → Rescan Transactions.


2026-09-11 — A release rail that needs no gas behind it

Files: liquidity.proto

Liquidity — new fee-payment variant sponsored_withdrawal_fee_payment (8) on RequestChannelReleaseRequest. The service broadcasts this wallet's withdrawal on its own transaction and takes its fee out of what is released, credited to the service in the state co-signed alongside it. Hydra App arms the funding allowance internally.

This closes a gap where a channel could be stranded: release offered on-chain payment, which needs a funded wallet, and off-chain payment, which needs the channel balance that withdraw all is about to take away. A client holding a balance and no gas could use neither.

The wallet never pays a fee here, so the flow takes the funding-allowance confirmation path a dual-funded open takes rather than the settlement path — and a request that is never confirmed takes its allowance back rather than leaving consent standing.

Release-only. RequestChannelLiquidity has its own sponsored_deposit_fee_payment; RequestChannelLeaseExtension accepts neither.

See Lease → SponsoredWithdrawalFeePayment.


2026-09-11 — An estimate that refuses for gas, not for liquidity

Files: swap.proto

Swap — new SimpleSwapEstimate variant insufficient_native_balance (8) — { required, available }. The wallet holds the sending asset but cannot pay the sending network's native gas for the funding transaction the swap needs.

Gas and the sending asset are separate resources, so this is neither insufficient_sending_balance (the wallet is not short of what it is selling) nor no_liquidity (the market can serve the pair). Nothing about the swap has to change to clear it: fund the wallet with required and estimate again. required includes the broadcast envelope the chain's admission check reserves on top of the effective fee — a balance that merely equals the expected fee is still rejected — so topping up to exactly that figure is what clears it.

Only a funded deposit or channel open raises it: a swap served entirely from channel balances signs nothing on chain, and a native-asset sender short of gas is short of what it is sending, which is insufficient_sending_balance.

Add a branch for it. Without one it falls through your default case and you will report "no liquidity" for a wallet that just needs gas.

See Swap → InsufficientNativeBalance.


2026-09-11 — Lease quotes: pin the price you showed

Files: liquidity.proto

Liquidity — EstimateRequestFeeResponse gained quote_id (2) and valid_until_timestamp_seconds (3). Pass quote_id back on the matching request — RequestChannelLiquidityRequest.quote_id (10), RequestChannelReleaseRequest.quote_id (7), RequestChannelLeaseExtensionRequest.quote_id (9) — and the service bills at that quote's price for as long as the quote is valid.

A quote that has expired, or that no longer fits the request, causes the request to be refused rather than silently re-priced: the user agreed to a figure, and a request that can no longer honour it should come back for a fresh estimate instead of quietly costing more. Omitting quote_id keeps the old behaviour — the service prices the request when it executes.

GetLiquidityServiceInfoResponse gained quote_validity_secs (7), so a client can size its confirmation window before asking for a quote.

A quote fixes the price, not the capacity. A lease that no longer fits the provider's available liquidity is refused even inside its validity window.

See Lease → Quotes.


2026-09-11 — Documentation corrections

Files: none — documentation only.

No proto change. Three things this reference stated that the server does not do:

Config and endpoints, audited against the production deployment:

  • Mainnet does use alchemy_rpc / alchemy_ws — proxy-fronted, on both EVM networks. This page said "not used". They are now in the mainnet template, at the URLs the Hydranet node itself runs with.
  • gossip_sync has a third variant, hybrid (RGS snapshot then live P2P), and no default — it is required and tagged.
  • Storage backends are now documented in full: fjall and redb tuning keys with their defaults, durability: immediate | buffered and when buffered is safe, the *_migrate_from one-time migration fields, and the fact that compression defaults to none rather than zstd.
  • server_port is optional, and omitting it starts no server at all. Both it and metrics_port bind 0.0.0.0.
  • Also filled in: Electrum validate_tls, the Esplora variant's own waterfalls_url / proxy_auth, lithium.subgraph.ws_url / proxy_auth, the *_relay_url wasm tunnelling fields, the api_key vs custom_url forms of the Alchemy blocks, and that backup_config without authentication_config fails at boot.

The mainnet peer-port warning was right but explained wrong: the hub does still listen on 19736 / 29981 / 29983 at its origin for clients migrating off them — Cloudflare simply cannot proxy those ports, so the public hostname answers on 443 only.

Every mainnet service endpoint, Lithium contract address, deploy block, token contract and peer identity in the Setup Guide was checked against the production deployment and is correct.

  • JSON-RPC field names are camelCase, not snake_case — responses always, requests accepting either. DecimalString / U256String are { "value": … } objects, uint64 is a JSON string, and a field at its default is omitted rather than sent. See Wire encoding.
  • The by-name params envelope is request, not req. rpc.discover has always named it that way; the req form never parsed. Positional params are unaffected.
  • Streaming works over JSON-RPC — as WebSocket subscriptions on the same URL, not only over gRPC. What fails is a one-shot HTTP POST, which cannot carry a stream. See Subscriptions over WebSocket.

Also corrected across the reference: Hashlock is a known/unknown oneof (not a payment_hash field); FeeOption is a oneof message (not a LOW/MEDIUM/HIGH enum) and Amount is a oneof (not { value, asset_id }); MatchedOrder carries swap_role, not is_taker; GetChannelsWithCounterparty takes counterparty_pubkey; GetTradeHistory returns trade_history; GetFeeEstimates returns a FeeEstimate of ChainFees; GetBlockHeader.block_number is required and its response is block_header with prev_hash. SignerService now has its own page, and SubscribeClientEvents / SubscribeNodeEvents have full event catalogues.


2026-09-04 — A discount per role (breaking)

Files: orderbook.proto

Orderbook — the hub prices a client's two roles from two columns of its rung, so every discount figure that used to be one value is now two. A fee and a rebate move in opposite directions under the same discount, which is why one figure could not describe both.

  • MarketInfo.max_fee_discount (14) → max_taker_discount (14) + max_maker_discount (15).
  • VolumeDiscountTier.discount and HoldingDiscountTier.discount → maker_discount (2) + taker_discount (3) on each.
  • The active-standing message was renamed FeeDiscountStanding → ActiveFeeDiscount, and its combined_discount (1) became taker_discount (1) + maker_discount (2); volume and holdings renumbered 2→3 and 3→4. VolumeDiscountStanding / HoldingDiscountStanding carry the same pair per rung, plus a next_tier of the matching type.
  • FeeDiscountStanding is now the name of the outer wrapper — the oneof { no_program | excluded | active } that GetFeeDiscountResponse.standing and the fee_discount_update event both carry.
  • GetFeeDiscountProgramsResponse gained max_taker_discount (3) / max_maker_discount (4) — the operator's default ceilings, which a market's own terms may replace.
  • MarketFeeRates gained taker_discount (5) / maker_discount (6), each already bounded by that market's own maximum.

The arithmetic is now stated on the wire: effective = listed − discount × |listed|. Applying it to a signed rate is what makes a fee shrink and a rebate deepen. The older listed × (1 − discount) form is wrong for a negative rate — it makes a rebate shallower.

The single-figure shapes (max_fee_discount, a bare discount per rung, combined_discount) existed in the app source between 2026-08-24 and 2026-09-04 but never shipped in a published proto set — nothing served from /proto ever carried them. If you generated stubs from a proto pulled straight out of the app source in that window, re-pull.

See Orderbook → Fee discounts.


2026-09-04 — Watchtower listing

Files: node.proto, config.yaml

Node — new read-only RPC GetConnectedWatchtowers(network) → node_ids[]: the watchtowers this node currently has a session with. The counterpart to ConnectToWatchtower, which until now you could call but never verify.

Config — each EVM / Tron network block gained a watchtowers list of <node_id>@<host>:<port> strings. The node dials them itself once its initial chain sync completes and keeps them attached, so coverage survives a restart without an operator re-issuing ConnectToWatchtower. A watchtower acts on evidence against a channel's counterparty, so it cannot defend a channel whose counterparty is the watchtower itself — coverage applies to channels with other peers.

See Node API → Get Connected Watchtowers and the Setup Guide.


2026-09-03 — Fee rates for this node

Files: orderbook.proto

Orderbook — new RPC GetMarketFeeRates(first_currency, other_currency) → { fee_rates?: MarketFeeRates }: a market's four fee ratios with this node's own discounts already struck off, each role at its own figure, read from the same market copy the node's estimates price against. Absent when the pair is not a market.

MarketInfo keeps advertising list rates — the same for every client — which is what it is for. Prefer GetMarketFeeRates for anything that prices your own order: a fee shrinks with a discount while a rebate deepens, so deriving it from MarketInfo by hand gets the sign wrong.

Orderbook — DexEvent.update gained fee_discount_update (field 9), carrying the full FeeDiscountStanding. The hub opens the private stream with your standing and pushes the whole standing again whenever it moves, so silence means unchanged and the last message received is the one to hold. Nothing polls; a dropped stream heals by reconnecting into a fresh opening standing.

Swap / Orderbook (server-side) — estimates used to price every fill at list rates, so a node on any tier quoted itself a fee it would not be charged and a rebate it would not earn, with the error growing with the tier. Estimates now price this node's taker side at its own standing. Only the taker side: the makers an estimate walks belong to other clients, whose tiers are not knowable here. A market snapshot also carries the tier it was taken at, so a family of probes against one snapshot (a minimum, a maximum, a re-seeded range) stays one coherent answer.

No request/response shape change for estimates — but if you compared an estimate against a fee you computed from MarketInfo, the two will now disagree whenever you hold a rung. The estimate is right.


2026-09-03 — Declared token symbols; oracle prices by deployment

Files: asset.proto, config.yaml, pricing.proto

Asset — AddTokenRequest gained optional symbol (3) and name (4): the ticker and name to show for a token in place of the ones its contract reports. Re-applied on every registration of the token; omit to keep the chain's own. A contract's symbol() is its deployer's choice — a bridged deployment often carries a variant of the asset's real ticker — while every consumer expects the asset's own identity. Decimals are never declared; they define the token's units and are only ever read from the chain.

Config — a tokens[] entry may now be either the bare id string it always was, or a map declaring the overrides:

tokens:
  - "erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831"   # USDC — chain's own symbol
  - id: "erc20:0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"
    symbol: USDT                                          # contract reports "USD₮0"
    name: "Tether USD"

A declared symbol must be a single word with no whitespace; a declared name must be non-blank. An id listed twice on one network fails at boot.

Pricing — no shape change. The oracle is now asked for the specific deployment of an asset, falling back to the symbol the node holds it under only when the oracle does not list that deployment. A price the oracle flags as stale (not refreshed within its maximum age) is reported as unavailable rather than served — so handle "no price" as a normal outcome, not an error.


2026-09-03 — Channel terms are configurable per network

Files: config.yaml

Config — the terms a node proposes, accepts and advertises on its channels are now settable per network. Every key is optional and defaults to what the node used before, so an existing config keeps its behaviour.

Bitcoin networks take a lightning block (block units): channel_handshake (what we propose — our_to_self_delay_blocks, their_channel_reserve_millionths, our_max_accepted_htlcs, minimum_depth_blocks, announce_for_forwarding), channel_handshake_limits (what we accept — their_to_self_delay_blocks, max_self_reserve_millionths, min_funding_satoshis, max_minimum_depth_blocks) and channel_policy (ours alone — cltv_expiry_delta_blocks, forwarding_fee_base_msat, forwarding_fee_proportional_millionths).

EVM / Tron networks take the same three blocks inside lithium (whole seconds): channel_handshake.dispute_period_secs + safety, channel_handshake_limits.max_dispute_period_secs + reserve bounds, channel_policy.cltv_expiry_delta_secs + routing, plus a per_asset map of overrides keyed by canonical asset id.

A channel opens with the longer of the two peers' dispute_period_secs proposals, within both peers' ceilings — so your proposal is the shortest window you can end up with, and your limit is the longest. A value outside the protocol floors fails at boot, naming the key.

See the Setup Guide → Channel terms.


2026-09-02 — Orderbook currency listing & fee-discount programs

Files: orderbook.proto

Orderbook — three new read-only RPCs expose what the hub charges and what it lists. A client could not previously predict what a fill would cost.

  • GetCurrencies() → ListedCurrency[] (ordered by network then ticker) — the operator's asset allowlist. Each carries the OrderbookCurrency, its ticker (the identity fee rules and holding ladders are keyed on — the same ticker on two networks is one asset bridged across chains, and it is distinct from the symbol the chain reports), a class (STANDARD / STABLECOIN), a state (ENABLED / CANCEL_ONLY / FROZEN), and optional min_notional_usd / min_native_amount policy floors. A currency found in a persisted market but no longer listed has no ticker, is always CANCEL_ONLY, and carries none of the other fields.
  • GetFeeDiscountPrograms() → the ladders as configured: an optional volume program (rolling weighted settled USD volume over window_days) and any number of holdings programs (holding an asset above a threshold), each naming exactly the currencies whose balances count.
  • GetFeeDiscount() → this node's own standing: no_program, excluded, or active with the rungs it has reached.

Both listing enums reserve their zero value (CURRENCY_CLASS_UNSPECIFIED, CURRENCY_STATE_UNSPECIFIED) and are never sent: JSON drops a default-valued field, so a real variant at zero would be indistinguishable from an absent one. Treat a zero there as a conversion error.

Orderbook — MarketInfo.min_place_base_amount (11), min_place_quote_amount (12) and order_min_notional_usd (13) became optional, matching what their documentation promised since 2026-08-15. A server that omits them is now distinguishable from one reporting "0".

A holding ladder counts the channel balance with the hub plus, on networks whose node ids are wallet addresses, the on-chain balance. A chain's own native currency cannot have its on-chain holding read — CurrencyHolding.onchain_amount is absent on Bitcoin and whenever the last read failed with no earlier figure to carry.

See Orderbook → Fee discounts.


2026-08-29 — Amount grids, per-payment sizing & channel ceilings

Files: currency.proto, balance.proto, swap.proto

Currency — SwapAmount now documents a hard precision rule. Whichever variant is set must carry no more decimal places than the orderbook market side it names allows — from measured on the first hop's sending side, to on the last hop's receiving side, against that market's base_precision or quote_precision. The orderbook rejects an over-precise amount rather than rounding it, since the direction to round is the caller's to choose. SwapAmount.To is additionally defined as the amount to receive net of the taker fee: the match is sized so you net at least that figure.

Balance — OffchainBalance gained max_sendable (13) and max_receivable (14): the largest amount a single payment can move in each direction right now. Equal to free_local / free_remote unless the channel protocol enforces a per-payment ceiling below the balance — on a Lightning channel whose value grew past the max_htlc_value_in_flight negotiated at open, free_local keeps growing with deposits while max_sendable stays near the original channel value.

Size single payments off max_sendable, not free_local. A balance check that passes while the payment still fails is almost always this.

Swap — SendingChannelDeposit.deposit_channel_id sharpened: absent now means a fresh channel is opened — either because none existed, or because every candidate's per-payment ceiling already binds and growing one cannot raise what a single payment carries.


2026-08-26 — Archive prune: self-paced passes + retention-lagging event

Files: app.proto, config.yaml

App — ArchivePruneEvent.kind gained retention_lagging (field 8), carrying ArchivePruneRetentionLagging { lag_secs }: the network is past its retention deadline, the archive is not draining fast enough for the configured policy, and the node is escalating how hard it prunes — at the cost of foreground latency — until the backlog clears. Repeated periodically while it persists. Add a branch for it only if you surface prune progress; it is informational, not a failure.

Config — breaking — settings.auto_prune.interval_secs was removed. The config is now the retention policy alone (max_age_secs / max_entries); everything about how pruning runs — how often each network is visited, how long a chunk may hold the archive write lock, what share of the node's time goes to pruning — is derived at runtime from what the node measures about itself, and escalates automatically when retention falls behind.

Action: delete interval_secs from any auto_prune block. At least one of max_age_secs / max_entries is still required, and max_age_secs still has a 48-hour floor (172800) — pruning a settled payment deletes its stored preimage, which an in-flight swap may still need.


2026-08-19 — Invite listing & eligibility

Files: app.proto

App — two new RPCs alongside CreateInvite / RedeemInvite, so a UI can render quota usage without a doomed mint round-trip.

  • ListInvites(status_filter?) → Invite[], newest-minted first. Each carries the bearer code, created_epoch, created_at, an optional expires_at (unset = never expires) and a oneof status { pending | redeemed | expired } — InviteRedeemed structurally carries redeemed_by_public_key and redeemed_at. Filter with InviteStatusFilter (UNSPECIFIED = unfiltered, PENDING, REDEEMED, EXPIRED). The application proves ownership of its identity key to the referral service before the list is returned: an unredeemed code is a bearer secret, shown only to its creator.
  • GetInviteEligibility() → { eligibility?: InviteEligibility, current_epoch }. InviteEligibility carries joined_epoch, is_seed (provisioned by the operator rather than onboarded through an invite), invite_quota, invites_minted and created_at. An unset eligibility means the identity cannot mint invites at all (it can still redeem one). Unauthenticated on the referral service side — quotas and epochs are not bearer secrets — so unlike ListInvites there is no signature round-trip.

Minting gates on current_epoch > joined_epoch and invites_minted < invite_quota. Quota is spent at mint, not at redeem — an expired or never-redeemed code still counts against it.

Also corrected in the shared OpenRPC schema: protobuf timestamps serialize as RFC 3339 strings, not {seconds, nanos} objects.

See General API → Invites & referral.


2026-08-15 — Advisory placement floor on MarketInfo

Files: orderbook.proto

Orderbook — MarketInfo gained three fields and min_base_amount gained a sharper definition.

  • min_base_amount is now documented as the minimum fill amount and the minimum order size of any type — the market's structural grid minimum. A partially consumed maker whose remainder falls below it is evicted at match time, so any resting size ≥ this minimum is fully takeable.
  • New min_place_base_amount (11) / min_place_quote_amount (12) — an advisory snapshot of the hub's USD placement floor converted to base/quote units at its last oracle read. Absent when the floor is disabled, a side is unpriceable, or the serving endpoint does not stamp it (notably GetMarketsInfo, the market-list RPC).
  • New order_min_notional_usd (13) — the pair's USD placement floor itself. Absent when disabled.

Do not size purely off min_place_*. It moves with oracle prices; the authoritative value arrives on the rejection as min_place= (see Placement rejections). A cached snapshot will intermittently under-size as prices move.

Fields 11/12 briefly carried min_order_base_amount / min_order_quote_amount between 2026-08-13 and 2026-08-15; those names never shipped in a published proto set and were replaced by min_place_*.

See Orderbook → Two different minimums.


2026-08-13 — Structured placement rejections

Files: orderbook.proto

Orderbook — the hub now encodes order-placement refusals in a stable, SDK-parseable form instead of opaque prose:

<prefix>: key=value key=value — human tail

Closed prefix set: below_minimum, asset_not_listed, market_state, below_floor_budget, below_floor_count, matcher_overloaded. Match on the prefix and key=value tokens only — never the human tail, and not the gRPC status code (the prefix is the contract). Any other shape, including everything a pre-2026-08-13 hub emits, is unclassified and behaves exactly as before.

below_minimum carries market, side, and exactly one of min_place / min_fill — whichever is the larger, binding minimum. It is also the only class that invalidates a cached MarketInfo: refetch GetMarketInfo on it. (market_state does not — MarketInfo carries no market state.)

See Errors → Placement rejections.


2026-08-05 — Config reference refreshed

Files: config.yaml

Config — the Setup Guide template now mirrors the current staging config.yaml. Newly documented (the fields themselves are older; the docs simply never covered them):

  • pricing_config.price_oracle_url — the price oracle used for fiat conversion, plus fiat_currencies. Note: the upstream config.yaml.sample still shows cmc_url / cmc_api_key; that sample is stale — the application reads price_oracle_url.
  • alchemy_rpc / alchemy_ws (optional, per EVM network) — enriched Alchemy HTTP / WebSocket endpoints alongside the plain web3_provider. Each takes custom_url (or api_key) plus proxy_auth.
  • gossip_sync.rgs_server_url is proxy-fronted on staging, so it takes proxy_auth: true — it is no longer the public Rapid Gossip Sync server.
  • Token entries use the lowercase erc20: prefix.

2026-08-05 — Dual-fund eligibility excludes a native sending asset

Files: swap.proto

Swap — server-side behavior; no request/response shape change. A simple swap whose sending asset is the network's native asset is no longer quoted as DualFundDeferred; it takes the ordinary deposit-and-lease path (Deferred) instead. The liquidity service broadcasts the dual-fund transaction and must pull the client's leg out of the client wallet — a native balance increase is settled from the broadcaster's own value, so it cannot be pulled, and quoting a dual fund there would promise a shape that fails at broadcast.

If you branch on EstimateSimpleSwap returning dual_fund_deferred for native-asset sends (ETH → USDC on the same network, say), expect deferred now. No code change is required — just don't assume dual-fund availability.


2026-08-03 — Channel payability & funding state

Files: channel.proto

Channel — AssetChannel gained four booleans that answer "can I use this channel right now" without inferring it from status:

  • can_send (9) — an outbound payment can be originated or routed over this asset channel right now.
  • can_receive (10) — an inbound payment can be received right now.
  • funding_credited (11) — the funds committed by the most recent on-chain operation are reflected in the balance both peers co-sign. Distinct from is_updatable: a zero-conf open has its funding credited while still admitting no further on-chain update.
  • onchain_operation_in_flight (12) — a transaction changing this channel's funding is awaiting confirmation.

Prefer can_send / can_receive over hand-rolled checks on status + balances before routing a payment. See Watch-Only Node API → Get Channels.


2026-08-02 — Watchtower attachment

Files: node.proto

Node — new RPC ConnectToWatchtower(network, watchtower_url). A watchtower holds the states and revocations this node signs, so a node that loses its local history can be handed back the coverage it gave away, and so a channel stays defended while this node is offline.

watchtower_url takes the same <node_id>@<host>:<port> form as ConnectToPeer — a watchtower is reached over the same transport. The watchtower session is independent of the ordinary peer session: the same remote node may be both a channel peer and a watchtower, and the two connections are tracked separately. Response is empty.

See Node API → Connect to Watchtower.


2026-08-01 — On-chain preimage settlement split out (breaking)

Files: preimage.proto

Preimage — the two settlement facets are now two RPCs, because they fail independently and only the on-chain one broadcasts transactions (and therefore costs a fee).

  • SettlePreimage is narrowed to CHANNEL legs only. It persists the preimage, claims held channel hashlock payments, and arms the on-chain settlement path. It no longer claims on-chain HTLCs. Costs no fee of its own.
  • New SettleHtlcPreimage(network, payment_preimage, fee_option?) claims every on-chain HTLC the preimage unlocks — each one still Locked, paying this node, under sha256(preimage). Returns txids[], one per HTLC claimed (empty when the secret unlocks none here). Each claim is its own transaction; one that cannot be built (already swept, expired and refunded, unfunded for its fee) is skipped rather than withholding the others. FAILED_PRECONDITION on a network with no on-chain HTLC facet. Optional fee_option — absent = the network's default ("medium") HTLC fee rate. Idempotent.

Action required: if you relied on SettlePreimage also sweeping on-chain HTLCs (its 2026-06-11 behavior), call both. On a network with both facets, a caller drives both RPCs.

See HTLC & Preimage API → Settle Preimage and Settle HTLC Preimage.


2026-07-30 — Lithium reads from contract logs by default

Files: config.yaml

Config — lithium.state_backend now defaults to logs (it was subgraph), and lithium.contract_deploy_block was added.

  • logs — contract event logs plus verified contract reads are authoritative; the subgraph is optional and only bootstraps the routing graph.
  • subgraph — the indexer becomes the sole, unverified source. Needed only for a contract that predates the channel lifecycle events.

contract_deploy_block sets the first block to index from, so a first sync does not scan from genesis. Leaving it at 0 is valid but slow on a long-lived chain.

Action: if your config pins state_backend: subgraph, drop it (or set logs) unless you are on a pre-lifecycle-events contract — logs verifies state against the chain rather than trusting the indexer.


2026-07-23 — Liquidity-service exit rail (breaking)

Files: swap.proto, liquidity.proto

Gasless channel exits: the liquidity-service hub can broadcast a post-swap withdrawal and pay the gas, with the fee folded into the swap totals — so a user with no native balance can still exit a channel.

Swap

  • New enum ExitRail: EXIT_RAIL_UNSPECIFIED (0, treated as local), EXIT_RAIL_LOCAL (1 — the local node broadcasts and pays gas from its on-chain native balance), EXIT_RAIL_LIQUIDITY_SERVICE (2 — the hub broadcasts and pays gas).
  • WithdrawalFee gained exit_rail (field 3) — which rail performs that side's exit. It appears on the sending_withdrawal_fee / receiving_withdrawal_fee of every SimpleSwapEstimate variant that carries one.
  • SimpleSwapUpdate.update gained withdrawing_funds_via_liquidity_service (field 34): { is_sending_side, fee, fee_payment_currency } — one side's exit fee is being settled off-chain with the liquidity service.

Liquidity — breaking — ChannelReleaseOperation.Withdraw replaced repeated string asset_ids = 2 with map<string, AssetWithdrawAmounts> asset_amounts = 2, where AssetWithdrawAmounts { optional Amount server_amount, optional Amount client_amount } splits the withdrawal per side: server_amount releases the service's own balance to the service wallet, client_amount pays the client's balance out to the client's wallet. An absent side withdraws nothing; at least one side must be present. channel_id may be empty on fee estimates only, and every requested side must then be an exact amount. CooperativeClose is unchanged (still asset_ids).

Action required: rewrite any withdraw release operation from a list of asset IDs to a map of asset ID → { server_amount?, client_amount? }. See Lease API → ChannelReleaseOperation.


2026-07-20 — Peer connection blacklist

Files: node.proto

Node — three new RPCs for a runtime peer-connection blacklist (also file-configurable):

  • GetBlacklist(network) → node_ids[] (hex-encoded public keys of blacklisted peers).
  • AddPeerToBlacklist(network, node_id) — empty response.
  • RemovePeerFromBlacklist(network, node_id) — empty response.

A blacklisted peer is refused connections. This is the inverse of the zero-conf whitelist and is independent of it. See Node API → Peer Blacklist.


2026-07-12 — Lease expiry on the channel

Files: channel.proto

Channel — AssetChannel gained lease_expiry (optional Timestamp, field 8): when the liquidity lease on that asset channel expires. Absent when the asset channel is not leased.

This is the cheapest way to monitor a lease: it rides along on every channel read (watchOnlyNode.GetChannels / GetChannel) and every channel update event, so you no longer need a separate liquidity.GetLeaseExpiries poll to know when a lease is running out. Leases extend automatically as a channel is used, so a busy channel's lease_expiry moves forward on its own — watch it rather than assuming the duration you originally paid for.


2026-07-11 — Tron protocol

Files: primitives.proto, config.yaml

Primitives — Protocol gained PROTOCOL_TRON (3) (Tron mainnet, Shasta, Nile — TVM). Anything that match/switches exhaustively on Protocol needs a Tron arm.

Config — a protocol: tron network block takes provider.url (Tron node HTTP API), optional indexer.url, rpc.url (eth-compatible JSON-RPC), a base58check htlc_factory_address (required for on-chain HTLC settlement on Tron — omit and that network settles through channels only), the usual lithium block with a base58check contract_address, and trc20: token entries.

Tron Shasta is live on staging — see the Setup Guide for the network block and Staging peers for the peer string.


2026-07-08 — Archive-prune redesign (breaking)

Files: app.proto

App — the synchronous PruneArchive RPC was removed and replaced by an asynchronous job model. PruneArchive / PruneArchiveRequest / PruneArchiveResponse (with total_pruned / per_table) no longer exist.

New RPCs:

  • StartArchivePrune — starts (or attaches to) the prune job for a network; returns an ArchivePruneJob descriptor + newly_started. At most one job runs per network. Filters (max_age_secs, max_items) moved into ArchivePruneParams; at least one must be set and max_items must be ≥ 1.
  • GetArchivePruneStatus — the authoritative state: the currently running job (with an ArchivePruneProgress snapshot) and the last finished ArchivePruneRecord.
  • CancelArchivePrune — requests cancellation (asynchronous; stops at the next chunk boundary). Idempotent.
  • SubscribeArchivePruneEvents — a stream of ArchivePruneEvent (started / progress / completed / failed / cancelled) across all networks. Best-effort delivery — reconcile with GetArchivePruneStatus on stream end.

Config also gained an optional settings.auto_prune block (periodic auto-prune). Pruning a settled payment still deletes its stored preimage, so GetPreimage stops serving pruned payments. See General API → Archive Prune.

Action required: replace any PruneArchive / app_pruneArchive call with StartArchivePrune + a poll of GetArchivePruneStatus (or a SubscribeArchivePruneEvents subscription).

App (invite / referral) — three new RPCs: CreateInvite (mint a bearer invite code), RedeemInvite (redeem a peer's code; returns the inviter's public key), and GetReferral (the currently configured referrer, if any). A new config.yaml referral_config.referral_service_url wires the referral service.


2026-07-06 — HTLC lock types

Files: htlc.proto

HTLC — the on-chain HTLC RPCs gained a lock_type field (canonical protocol-defined script-kind token, e.g. Bitcoin "taproot" / "p2wsh"; empty = the node's signer-derived default). It appears on CreateHtlcRequest, CreateHtlcLockTxRequest, DeriveHtlcAddressRequest, BatchCreateHtlcsRequest, and — as a required-match pin on the receiving side — HtlcExpectation.lock_type. A config.yaml htlc_factory_address (EVM) and htlc_script_type knob gate on-chain HTLC availability.


2026-07-02 — HTLC event handling refactor + transaction HTLC operations (breaking)

Files: event.proto, transaction.proto

Event — the 2026-06-04 NodeEvent.HtlcUpdate / HtlcSnapshot design was replaced. NodeEvent no longer carries an HTLC variant. Instead EventService gained a dedicated stream SubscribeHtlcEvents(SubscribeHtlcEventsRequest{ network }) → stream Htlc: it emits the full Htlc on every lifecycle transition, and the HTLC's status (Locked / Claimed / Refunded) conveys what happened — Claimed reveals the preimage. Dedupe key: (htlc-id, status).

If you subscribed to NodeEvent for HtlcUpdate (added 2026-06-04), switch to SubscribeHtlcEvents. The HtlcSnapshot message is gone; use Htlc.

Transaction — TransactionOperation gained repeated HtlcOperation htlc_operations (field 13): on-chain HTLC lock / claim / refund operations observed within a wallet transaction (HtlcLock / HtlcClaim — reveals the preimage — / HtlcRefund). TransactionRequest.raw_data was renamed to signable_data with a documented per-protocol encoding (EVM: UTF-8 JSON eth_sendTransaction params; Bitcoin: base64 PSBT); SignedTransactionRequest documents its broadcastable encoding likewise.


2026-06-24 — On-chain HTLC swap milestones

Files: swap.proto

Swap — SimpleSwapUpdate.update (the SubscribeSimpleSwaps stream) gained seven on-chain-settlement milestone variants (fields 27–33): locking_onchain_htlc, onchain_htlc_locked, counterparty_lock_confirming ({ txid, current, required }), counterparty_lock_observed, claiming_onchain_htlc, onchain_htlc_claimed, refunding_onchain_htlc. They appear when a swap leg settles on-chain rather than through a channel (see the settlement model). Existing channel-path milestones are unchanged; add branches for the new variants only if you render on-chain progress.


2026-06-17 — Redeemable channel balances + unique HTLC keys

Files: balance.proto, htlc.proto

Balance — AssetChannelBalance gained redeemable_local and redeemable_remote (fields 11–12): the local/remote amounts redeemable on-chain when the channel is redeemable (zero otherwise). These are a view into unavailable_local / unavailable_remote, not additional balance categories — don't double-count.

HTLC — new RPC GetUniqueHtlcPubkey: reserves a fresh HTLC public key (UTXO protocols return a never-before-used key per call; account-model protocols return their single stable key). GetHtlcPubkey returns the node's canonical key.


2026-06-16 — Signer: sign-and-broadcast

Files: signer.proto

Signer — new RPC SignAndBroadcastTx: signs (or authorizes) an unsigned transaction and ensures it reaches the chain, returning the txid. The universal path — an offline signer signs and the client broadcasts, or a self-broadcasting authority (e.g. MetaMask) signs and broadcasts in one step. SignTransactionResponse.signed_tx changed from a structured SignedTransactionRequest to serialized bytes (ready to broadcast); SignTransaction now fails for broadcast-only signers that can't produce standalone signed bytes — use SignAndBroadcastTx for those.


2026-06-15 — Orderbook on-chain settlement wiring

Files: orderbook.proto

Orderbook — the swap-routing messages gained on-chain-settlement plumbing:

  • SwapHop gained optional sending_onchain (OnchainSendSettlement) and receiving_onchain (OnchainRecvSettlement) — unset means channel settlement (the default). New TimelockSpec (absolute block-height / unix-seconds) mirrors the route's on-chain HTLC timelock.
  • SwapPath (in MatchedOrder) gained settlement (OrderSettlement) per leg. On-chain is valid only for taker (market/swap) orders; resting maker orders are channel-only.
  • ORDER_TYPE_LIQUIDITY (2) is a real enum value (previously an internal-reserved slot).

2026-06-11 — Preimage service, order settlement & HTLC service rework (breaking)

Files: preimage.proto, currency.proto, swap.proto, orderbook.proto, htlc.proto

A large change introducing hybrid (channel + on-chain) swap settlement.

Preimage — a new top-level PreimageService (JSON-RPC namespace preimage) with one RPC SettlePreimage(network, payment_preimage): registers a revealed preimage and settles every leg it unlocks — held channel hashlock payments (claim + arm force-close) and, on HTLC-capable networks, matching on-chain HTLCs. NodeService.RegisterPreimage was removed — its behavior is now SettlePreimage (which additionally claims on-chain HTLCs).

Action required: replace node.RegisterPreimage with preimage.SettlePreimage. Request fields are identical (network, hex payment_preimage). See the new HTLC & Preimage API.

Currency — new LegSettlement enum (CHANNEL (0, default) / ONCHAIN (1) / CHANNEL_OR_ONCHAIN (2)) and OrderSettlement message (sending / receiving LegSettlement + taker-only route_filter bitmask). Absent = channel on both legs.

Swap — SwapRequest gained settlement (OrderSettlement) (field 4); the orderbook OrderVariant / SwapOrder creation paths accept per-leg settlement. Absent keeps the previous channel-only behavior.

Orderbook — breaking — add_liquidity was removed from CreateOrder's OrderVariant oneof. The creatable variants are now limit_order, market_order, swap_order only. Provide passive / maker liquidity by placing limit orders. The LiquidityOrder message and ORDER_TYPE_LIQUIDITY remain as the persisted / returned form (PairOrder.liquidity_order) for positions created that way; you can still read, hold, and cancel them.

Action required: delete any CreateOrder { order_variant: { add_liquidity: … } } path. Replace range provision (min_buy_price / mid_price / max_sell_price / remove_on_fill) with one or more limit orders.

HTLC — HtlcService (namespace htlc) was reworked from a stub into a full on-chain HTLC surface: external-signer transaction builders (CreateHtlcLockTx / CreateHtlcClaimTx / CreateHtlcRefundTx / CreateHtlcSettlementTx / BroadcastHtlcSettlement), GetChainHtlc, VerifyHtlcByLockTxid, DeriveHtlcAddress, WatchHtlc / UnwatchHtlc, GetHtlcPubkey. The old HtlcState / HtlcStatus-enum / HtlcEvent shapes were replaced by a unified Htlc message (chain-native id, display-unit amount, Timelock + LedgerDepth with explicit kind, and a HtlcStatus oneof { Locked | Claimed | Refunded }). Amounts are now in the asset's display unit and timelocks are absolute (Unix-seconds / block-height), not block counts. See the HTLC & Preimage API.


2026-06-04 — HTLC update events (superseded 2026-07-02)

Files: event.proto

Superseded. This NodeEvent.HtlcUpdate / HtlcSnapshot design was replaced on 2026-07-02 by the dedicated EventService.SubscribeHtlcEvents stream, which emits the full Htlc. Kept here for history; do not build against HtlcUpdate or HtlcSnapshot.

Event — NodeEvent.update gained a new variant HtlcUpdate { HtlcSnapshot htlc } (field 15). The HtlcSnapshot carries protocol-agnostic on-chain HTLC state: identifier, asset, amount, payment hash, recipient / refund addresses, absolute expiry, lock txid + block height, and a oneof status { Locked | Claimed | Refunded }. Claimed status reveals the preimage — critical for atomic-swap takers waiting on the maker's claim. Dedupe key: (htlc.htlc_id, status).


2026-06-03 — EstimateSimpleSwappableAmounts response now non-optional

Files: swap.proto

Swap — EstimateSimpleSwappableAmountsResponse.amounts changed from optional SimpleSwappableAmounts to always-present SimpleSwappableAmounts. When no feasible swap exists, all four fields collapse to "0" rather than the field being absent. Bots that branched on "amounts field unset → no liquidity" must switch to "max_sending == "0" → no swap currently feasible."


2026-05-26 — Archive retention (redesigned 2026-07-08)

Files: app.proto

Superseded. The synchronous PruneArchive introduced here was replaced on 2026-07-08 by the asynchronous StartArchivePrune job model. Kept for history.

App — new RPC PruneArchive: operator-driven retention. Prunes archive-side settled wallet transactions and settled payments for one network, filtered by max age and/or max count. Pending entries are never pruned. With both filters unset the call is a no-op.

See General API → Archive Prune for the current shape.


2026-05-21 — Node policy introspection

Files: node.proto

Node — new read-only RPC GetNodePolicy for one (network, asset_id). The response is an aggregate of policy sub-messages; the first category exposed is ReservePolicy (channel reserve parameters: counterparty proportional rate in millionths, max self proportional rate in millionths, absolute minimum, and a fixed channel-reserve fee). Liquidity providers and any party sizing on-chain deposits accurately should call this before negotiating a channel.

New sub-messages will be added in a backwards-compatible way as more NodeConfig categories are exposed — treat each as optional.

See Node API → Get Node Policy.


2026-05-19

Files: swap.proto

Swap — counterparty invoice-window handling and convergence retry in the simple-swap flow (server-side behavior; no request/response shape change for clients).


2026-05-10 — Payment timing (CLTV) overhaul

Files: node.proto, payment.proto

Breaking field renames on the Node API payment RPCs. If you send keysend payments or create invoices, update your code.

Node

  • SendChannelPaymentRequest: expiry_timeout_secs → cltv_buffer_secs. (KeySend has no separate invoice-validity knob; the buffer alone bounds the HTLC.)
  • EstimateSendPaymentFeeRequest: expiry_timeout_secs → cltv_buffer_secs; new optional max_total_cltv_secs (clamp the route's max-total-CLTV; for atomic-swap-correct timing pass the invoice's cltv_buffer_secs, otherwise omit).
  • SendPaymentRequest: same change as EstimateSendPaymentFeeRequest.
  • CreateInvoiceRequest: expiry_timeout_secs keeps its meaning (BOLT-11 x — invoice validity window) and gains a new optional cltv_buffer_secs (BOLT-11 c — extra HTLC lifetime past invoice expiry the receiver requires).
  • EstimatePayInvoiceFeeRequest, PayInvoiceRequest, EstimatePayEmptyInvoiceFeeRequest, PayEmptyInvoiceRequest: new optional max_total_cltv_secs.

Payment

  • Invoice: new field min_final_cltv_expiry_secs — the minimum CLTV buffer (seconds) the receiver requires for the incoming HTLC. The HTLC's effective deadline is expiry_timestamp + min_final_cltv_expiry_secs.

2026-05-06 — Lease API: read-only discovery

Files: liquidity.proto

Liquidity — four new read-only RPCs (no funds move):

  • GetLiquidityServiceInfo — server-wide capacity bounds, durations, per-asset fee config, and the LP's per-network node pubkeys.
  • GetLeaseableAssetInfo — per-asset liquidity bounds + fee ratio for one (network, asset).
  • GetLeases — the caller's active leases on a network.
  • GetLeaseExpiries — lease expiry per (channel_id, asset_id) from the local cache (no LP round-trip).

Also: ChannelReleaseOperation.CooperativeClose gained asset_ids — empty closes every asset channel; otherwise only the listed ones.


2026-05-05 — Lease API: tx_fee_rate removed (breaking)

Files: liquidity.proto, swap.proto

Liquidity / Swap — the liquidity service now prices the underlying transaction work itself. The client no longer supplies a chain fee rate.

  • RequestChannelLiquidityRequest: removed tx_fee_rate (remaining fields renumbered).
  • RequestChannelReleaseRequest: removed tx_fee_rate (remaining fields renumbered).
  • ReceivingChannelLease (swap): removed tx_fee_rate (remaining fields renumbered).

Action required: delete tx_fee_rate / txFeeRate / TxFeeRate from any Lease request you build. Sending it now returns -32602 unknown field 'txFeeRate'. The Lease API examples have been updated.


2026-05-03 — Channel close flag + client order IDs

Files: channel.proto, orderbook.proto

Channel

  • AssetChannelStatus.Closed gained force_closed (bool). false = cooperative close (in lithium the channel slot can be reused for further deposits); true = unilateral / disputed close (slot is permanently dead — open a new channel).

Orderbook

  • CreateOrderRequest gained optional client_order_id (max 64 chars). When set and unique among your open orders, the orderbook stores it, returns the original order_id on retries with the same value (idempotent order creation), and exposes it on subsequent reads.
  • Order gained optional client_order_id.
  • New RPC GetOrderByClientId — fetch an order by the client_order_id you supplied at creation.
  • SwapRole enum reordered: SWAP_ROLE_TAKER moved from 0 to 3. SWAP_ROLE_UNSPECIFIED is 0. If you persisted raw enum integers, re-map them.

2026-04-29 — Swappable-amount estimation

Files: swap.proto

Swap — new RPC EstimateSimpleSwappableAmounts: given two currencies, returns the smallest and largest amounts that can currently be simple-swapped, accounting for wallet balances, orderbook liquidity, and the LP's leaseable capacity. Returns an empty amounts field when the pair has no orderbook liquidity; all-zero fields when the pair has liquidity but no swap is currently feasible.


2026-04-27 — New SimpleSwap estimate variant

Files: swap.proto

Swap — SimpleSwapEstimate gained the InsufficientSendingBalance variant ({ available: DecimalString }). Distinct from NoLiquidity: the market is fine, but the wallet has nothing to send (no active channel and no usable on-chain funds after fees). If you match/switch on the estimate one-of, add a branch for it.


Earlier

The 2026-04 reconciliation aligned every doc with the then-current proto, including the rename of the old RentalService to LiquidityService (the Lease API). The rental_* JSON-RPC namespace no longer exists — it is liquidity_*. See the Lease API.


How this list is maintained

Each entry corresponds to a proto-touching commit in the Hydra App source. When the protos in /proto are refreshed, this page and the affected per-service docs are updated together. The single source of truth for a running server is its rpc.discover output — see JSON-RPC: Discovering methods.

Every entry names each file it changes on a Files: line directly under its heading, including files the title does not imply — one sync commonly touches several services, and a reader scanning titles alone has no way to see the ones it does not name. An entry that changes no schema says so.


Copyright © 2025