Changelog
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
/protoand the per-service docs on this site track these changes. When in doubt, the.protofiles are the schema of record, and the liverpc.discovermethod 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 theFiles: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
TimeInForcegainsTIME_IN_FORCE_IOC(3) andTIME_IN_FORCE_FOK(4), for limit orders that only take: see Time in force. They pay taker fees, never rest, and are refused withmarket_stateon a post-only market.OrderReleasegains the reasonimmediate_or_cancel(4);CancelReasongainsCANCEL_REASON_FILL_FAILED(12), for anIOCorFOKorder a failed fill ends.CreateOrderRequest'sMarketOrdergainslimit_price(5), the worst price it takes at; the persistedMarketOrdergains it too (9).- Two new placement rejections:
nothing_to_takeandfill_or_kill_unfilled, bothFAILED_PRECONDITION. A market order over an empty side is now refused withnothing_to_taketoo, instead of an unprefixedINVALID_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.
EstimateOrdernow estimates a limit order at its price or better, in its own amount (forFOK, nothing unless it fills in full), and a market order within itslimit_price.
Upgrade your node before sending these. Over gRPC, a node that predates a field drops it without a word: it would place an
IOCas a plain limit order, or a capped market order uncapped. A client that does not knowCANCEL_REASON_FILL_FAILEDreads it asCANCEL_REASON_USER_REQUESTED; one that does not knowimmediate_or_cancelreads 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_idgets 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_idthat names one is refused withALREADY_EXISTSand the newclient_order_id_takenprefix, whoseorder_idis 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 withUNAVAILABLE: 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_idfor 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.GetOrderandGetOrderByClientIdstill answer only for open orders, so an id whose order has ended reads as empty there while it still names that order atCreateOrder.
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
InitMarketcall 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 furtherInitMarketsent 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 newTimeInForceenum: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 newpost_only_would_crossprefix (FAILED_PRECONDITION, keysprice,best,market) and nothing is created; one put back on the book across an order on offer later is cancelled with the newCANCEL_REASON_POST_ONLY_WOULD_CROSS(9). CreateOrdertakesself_trade_prevention(4), a newSelfTradePreventionenum: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 newself_trade_preventedprefix (FAILED_PRECONDITION, keysresting_order_id— your order it met — andmarket); an order a rule cancels is cancelled with the newCANCEL_REASON_SELF_TRADE_PREVENTED(10).- Each order type's view carries its own terms, as placed:
LimitOrdergainstime_in_force(7) andself_trade_prevention(8),MarketOrdergainsself_trade_prevention(8),SwapOrdergainsself_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
CancelReasonasUSER_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_fillprefix (FAILED_PRECONDITION, keysside,got,min_fill,price,market):min_fillis the smallest fill of the order it met, resting atprice, andgotwhat the order had, both in the order's own unit, whichsidenames. It is no reason to refetchMarketInfo: 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.
CreateOrderResponsegainsreleased(2), a newOrderRelease: what the order was not placed at, in its own amount, and why —below_best_fill { price, min_fill }orself_trade_prevented { resting_order_id }.OrderCreatedcarries it too (3).- A request sent again under the
client_order_idof its order gets the answer that order's placement gave,releasedincluded: 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_stoppingprefix (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. Newliquidity_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". NewAssetChannelLease.liquidity_expiry(5), as onAssetChannel.GetLeaseExpiries—ChannelAssetExpiriesgainsliquidity_expiries(2), keyed by asset id likeassets(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_secondsfrom 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.EstimateRequestChannelLiquidityFeereturns the window itsfeepays for aslease_duration_seconds(5). - Behaviour change:
RequestChannelLeaseExtensionaddslease_extension_secondsto 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— newlease_extension(4), aLeaseExtensionTerms: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 timefeepays rent for. A request that passes the estimate'squote_idback 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_secsof theopen,deposit_anyandopen_or_depositoperations ofRequestChannelLiquidityand its estimate to its own proposal (dispute_period.proposedonGetNodePolicy). 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
Closedwithforce_closed: falseyet 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_sendableof 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.sendingandreceivingare exactly what an order is admitted against: what one payment over the channels that reach the floor can carry, less what your orders hold, withreceivingalso held to what the hub can still forward to you.unavailable_sending/unavailable_receivingnow count balance being withdrawn.- New
CurrencyBalance.ineligible_sending(9) andineligible_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.0from a node that predates these fields. ABalanceUpdateevent carries the sameCurrencyBalance. - 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, andpayment_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'sto_self_delayterms.OpenChannelandEstimateOpenChannelFee—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_deltaand per-assetAssetChannelOpenTerms(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_secson theopen,deposit_anyandopen_or_depositoperations: 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) answersChannelTerms:dispute_period,payment_deadline_bound_secs, andasset_channels, a map from asset id toAssetChannelTerms.AssetChannelTermscarries the fields ofAssetChannelPolicyunder the same names and meanings, without thecounterparty,channel_idandasset_idthe request and the map key already give. A channel the node does not know isNOT_FOUND.GetChannelPolicy— deprecated. It keeps answering exactly as before until it is removed; move toGetChannelTerms, 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(seeGetChannelTerms). 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_pathcrosses 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
OrderCanceledwhose reason ishousekeeping— 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_idis 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
exactamount 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.
OpenChanneland 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.ChainTransactionis 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.ChainTxStatushas noPENDING_CONFIRMATIONSarm, 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_idis process-local and resets on restart, so persisting one is a bug; andlast_progress_at— notchunks— 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
AppServiceand not inBlockchainService, where every method queries on-chain data. For anything live, useGetSyncStatus.
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_finalthat 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 notblock_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: trueis a cancel, not a failure, and it is never undone. Wait for the terminalOrderCanceled— orOrderCompleted, 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 isforwarding_fee_peer. What either side will accept isinbound/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_localin a different hat. Both mean "waiting on a confirmation"; they wait in opposite directions.pending_localis arriving and becomes spendable off-chain once it lands.withdrawing_localis 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.
GetAllSwapTradesrecords 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.GetAllMarketTradeshas 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.
roleis 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_depositandallowed_paymentgovern funds entering — what a peer may fund on your behalf, and what it may take back out of that.allowed_withdrawalgoverns 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
0is a real setting meaning no peer-initiated withdrawal at all.
allowed_paymentalready 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.
HOUSEKEEPINGandHUB_DISCONNECTEDcall 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_REBASELINEmeans 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 asUSER_REQUESTEDrather 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
MarketInfotherefore goes stale silently, and a quoting bot with novenue_statussubscription spends a maintenance window retrying placements that cannot succeed.venue_statusis 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), whereKis 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 byfree_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_localequalsfree_localunless 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_remoteis 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, sois_closablegoes false the moment the peer is unreachable — which is precisely when a unilateral exit is the only one left.is_force_closabledoes 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.discoveron 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_payerisFEE_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), orFEE_PAYER_UNSPECIFIED, which means not recorded, not "nobody paid". On EVM and Tron it is exact and neverSHARED: one account funds a transaction.
Bitcoin: this is how you separate a payment from its fee.
UtxoOperationslists 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 isspent − received − effective_fee, valid wherefee_payerisFEE_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
nextback asbefore; stop when it comes back absent. New payments arrive at the newest end, which is exactly where an offset would shift under you.limitis 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_atnext to a freshlast_heartbeat_atis "the task is alive, its provider is stuck"; a stalelast_heartbeat_at, orsync_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.
0is a real configuration meaning no proportional reserve at all. Fall back tomax_self_proportional_millionthsonly 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.
optionalis the same bytes on the wire as an unsetstring, 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.valuehere is a whole-unitDecimalString, unlikeTokenPermit.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.
RequestChannelLiquidityhas its ownsponsored_deposit_fee_payment;RequestChannelLeaseExtensionaccepts 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_synchas 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 | bufferedand whenbufferedis safe, the*_migrate_fromone-time migration fields, and the fact thatcompressiondefaults tononerather than zstd. server_portis optional, and omitting it starts no server at all. Both it andmetrics_portbind0.0.0.0.- Also filled in: Electrum
validate_tls, the Esplora variant's ownwaterfalls_url/proxy_auth,lithium.subgraph.ws_url/proxy_auth, the*_relay_urlwasm tunnelling fields, theapi_keyvscustom_urlforms of the Alchemy blocks, and thatbackup_configwithoutauthentication_configfails 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/U256Stringare{ "value": … }objects,uint64is a JSON string, and a field at its default is omitted rather than sent. See Wire encoding. - The by-name params envelope is
request, notreq.rpc.discoverhas always named it that way; thereqform 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.discountandHoldingDiscountTier.discount→maker_discount(2) +taker_discount(3) on each.- The active-standing message was renamed
FeeDiscountStanding→ActiveFeeDiscount, and itscombined_discount(1) becametaker_discount(1) +maker_discount(2);volumeandholdingsrenumbered 2→3 and 3→4.VolumeDiscountStanding/HoldingDiscountStandingcarry the same pair per rung, plus anext_tierof the matching type. FeeDiscountStandingis now the name of the outer wrapper — theoneof { no_program | excluded | active }thatGetFeeDiscountResponse.standingand thefee_discount_updateevent both carry.GetFeeDiscountProgramsResponsegainedmax_taker_discount(3) /max_maker_discount(4) — the operator's default ceilings, which a market's own terms may replace.MarketFeeRatesgainedtaker_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 barediscountper 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/protoever 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_secsproposals, 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 theOrderbookCurrency, itsticker(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), aclass(STANDARD/STABLECOIN), astate(ENABLED/CANCEL_ONLY/FROZEN), and optionalmin_notional_usd/min_native_amountpolicy floors. A currency found in a persisted market but no longer listed has no ticker, is alwaysCANCEL_ONLY, and carries none of the other fields.GetFeeDiscountPrograms()→ the ladders as configured: an optionalvolumeprogram (rolling weighted settled USD volume overwindow_days) and any number ofholdingsprograms (holding an asset above a threshold), each naming exactly the currencies whose balances count.GetFeeDiscount()→ this node's own standing:no_program,excluded, oractivewith 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_amountis 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, notfree_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_secsfrom anyauto_pruneblock. At least one ofmax_age_secs/max_entriesis still required, andmax_age_secsstill 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 bearercode,created_epoch,created_at, an optionalexpires_at(unset = never expires) and aoneof status { pending | redeemed | expired }—InviteRedeemedstructurally carriesredeemed_by_public_keyandredeemed_at. Filter withInviteStatusFilter(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 }.InviteEligibilitycarriesjoined_epoch,is_seed(provisioned by the operator rather than onboarded through an invite),invite_quota,invites_mintedandcreated_at. An unseteligibilitymeans 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 unlikeListInvitesthere 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_amountis 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 (notablyGetMarketsInfo, 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 asmin_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_amountbetween 2026-08-13 and 2026-08-15; those names never shipped in a published proto set and were replaced bymin_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, plusfiat_currencies. Note: the upstreamconfig.yaml.samplestill showscmc_url/cmc_api_key; that sample is stale — the application readsprice_oracle_url.alchemy_rpc/alchemy_ws(optional, per EVM network) — enriched Alchemy HTTP / WebSocket endpoints alongside the plainweb3_provider. Each takescustom_url(orapi_key) plusproxy_auth.gossip_sync.rgs_server_urlis proxy-fronted on staging, so it takesproxy_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
EstimateSimpleSwapreturningdual_fund_deferredfor native-asset sends (ETH → USDC on the same network, say), expectdeferrednow. 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 fromis_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).
SettlePreimageis 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 stillLocked, paying this node, undersha256(preimage). Returnstxids[], 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_PRECONDITIONon a network with no on-chain HTLC facet. Optionalfee_option— absent = the network's default ("medium") HTLC fee rate. Idempotent.
Action required: if you relied on
SettlePreimagealso 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 setlogs) unless you are on a pre-lifecycle-events contract —logsverifies 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). WithdrawalFeegainedexit_rail(field 3) — which rail performs that side's exit. It appears on thesending_withdrawal_fee/receiving_withdrawal_feeof everySimpleSwapEstimatevariant that carries one.SimpleSwapUpdate.updategainedwithdrawing_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
withdrawrelease 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 anArchivePruneJobdescriptor +newly_started. At most one job runs per network. Filters (max_age_secs,max_items) moved intoArchivePruneParams; at least one must be set andmax_itemsmust be ≥ 1.GetArchivePruneStatus— the authoritative state: the currentlyrunningjob (with anArchivePruneProgresssnapshot) and thelastfinishedArchivePruneRecord.CancelArchivePrune— requests cancellation (asynchronous; stops at the next chunk boundary). Idempotent.SubscribeArchivePruneEvents— a stream ofArchivePruneEvent(started/progress/completed/failed/cancelled) across all networks. Best-effort delivery — reconcile withGetArchivePruneStatuson 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_pruneArchivecall withStartArchivePrune+ a poll ofGetArchivePruneStatus(or aSubscribeArchivePruneEventssubscription).
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
NodeEventforHtlcUpdate(added 2026-06-04), switch toSubscribeHtlcEvents. TheHtlcSnapshotmessage is gone; useHtlc.
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:
SwapHopgained optionalsending_onchain(OnchainSendSettlement) andreceiving_onchain(OnchainRecvSettlement) — unset means channel settlement (the default). NewTimelockSpec(absolute block-height / unix-seconds) mirrors the route's on-chain HTLC timelock.SwapPath(inMatchedOrder) gainedsettlement(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.RegisterPreimagewithpreimage.SettlePreimage. Request fields are identical (network, hexpayment_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/HtlcSnapshotdesign was replaced on 2026-07-02 by the dedicatedEventService.SubscribeHtlcEventsstream, which emits the fullHtlc. Kept here for history; do not build againstHtlcUpdateorHtlcSnapshot.
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
PruneArchiveintroduced here was replaced on 2026-07-08 by the asynchronousStartArchivePrunejob 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 optionalmax_total_cltv_secs(clamp the route's max-total-CLTV; for atomic-swap-correct timing pass the invoice'scltv_buffer_secs, otherwise omit).SendPaymentRequest: same change asEstimateSendPaymentFeeRequest.CreateInvoiceRequest:expiry_timeout_secskeeps its meaning (BOLT-11x— invoice validity window) and gains a new optionalcltv_buffer_secs(BOLT-11c— extra HTLC lifetime past invoice expiry the receiver requires).EstimatePayInvoiceFeeRequest,PayInvoiceRequest,EstimatePayEmptyInvoiceFeeRequest,PayEmptyInvoiceRequest: new optionalmax_total_cltv_secs.
Payment
Invoice: new fieldmin_final_cltv_expiry_secs— the minimum CLTV buffer (seconds) the receiver requires for the incoming HTLC. The HTLC's effective deadline isexpiry_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: removedtx_fee_rate(remaining fields renumbered).RequestChannelReleaseRequest: removedtx_fee_rate(remaining fields renumbered).ReceivingChannelLease(swap): removedtx_fee_rate(remaining fields renumbered).
Action required: delete
tx_fee_rate/txFeeRate/TxFeeRatefrom 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.Closedgainedforce_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
CreateOrderRequestgained optionalclient_order_id(max 64 chars). When set and unique among your open orders, the orderbook stores it, returns the originalorder_idon retries with the same value (idempotent order creation), and exposes it on subsequent reads.Ordergained optionalclient_order_id.- New RPC
GetOrderByClientId— fetch an order by theclient_order_idyou supplied at creation. SwapRoleenum reordered:SWAP_ROLE_TAKERmoved from0to3.SWAP_ROLE_UNSPECIFIEDis0. 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.