arrekta-channeld: the payment channel node
arrekta-channeld runs Arrekta's L2 (docs/CHANNELS.md): it opens and operates payment channels,
pays and receives invoices, forwards payments for others, keeps the public channel graph,
watches the chain to defend its channels, and can watch other people's channels as a
watchtower. It is built on the arrekta-channels protocol library (pure logic) and needs a Arrekta
node's REST API, nothing else.
| Binary | node-rs/crates/arrekta-channeld (arrekta-channeld) |
| Library | node-rs/crates/arrekta-channels |
| P2P | :9736 (post-quantum transport, own network magic) |
| REST | 127.0.0.1:9737, bearer token, OpenAPI (docs/api/channeld.openapi.json) |
| State | one redb file in ARREKTA_CHANNELD_DATA_DIR |
1. How the established implementations do it, and what we changed
We studied LND, Core Lightning (CLN) and LDK before writing a line (sources).
| Concern | Lightning practice | arrekta-channeld |
|---|---|---|
| Process model | LND: one daemon with sub-servers. CLN: a master plus per-peer subdaemons. LDK: a library the app drives | One daemon over a pure library, so the library is also usable from wallets and tests without the daemon |
| Peer transport | BOLT 8 Noise_XK with secp256k1 node keys | The node's hybrid X25519 + ML-KEM-768 transport, then an ML-DSA signature over the session id (Init) for identity |
| Revocation | per-commitment points (EC key tweaking) | per-commitment hash secrets (shachain over SHA3); no key tweaking exists for ML-DSA |
| Onion routing | Sphinx (BOLT 4) over secp256k1 | Sphinx with ML-KEM-768 per hop, as in Outfox / KEM-Sphinx |
| Gossip | BOLT 7: channel_announcement, channel_update, node_announcement | The same three messages, ML-DSA-signed, checked against the funding output |
| Pathfinding | LDK ProbabilisticScorer (Pickhardt–Richter); LND mission control | A probabilistic liquidity model with decaying bounds, plus local exact balances |
| Reconnect | channel_reestablish with data-loss protection | Reestablish with the commitment number, a proven last secret and the round in flight |
| Dual funding | interactive-tx (tx_add_input…tx_signatures), BOLT 2 | The acceptor's inputs and change in Accept; the opener assembles; commitment signatures before FundingTxSigs |
| Splicing | quiescence (stfu), splice_init/splice_ack, interactive-tx, parallel commitments until locked | Quiescent splice: SpliceInit…SpliceLocked; the old state is revoked only once the splice is buried; payments pause until then |
| Multi-path payments | basic_mpp: payment_secret + total_msat, the receiver holds parts, MPP timeout | Same, grouped on hash + secret with a uniform total, 90 s timeout |
| Watchtowers | LND altruist towers: blob encrypted under the breach txid, located by a txid hint | The same blob scheme. Asset channels use a reserved ARRK "justice coin" for the fee |
2. Architecture
REST (bearer) P2P (PQ transport + Init)
wallet ───────────────► api ◄─┐ ┌──► peer sessions ◄───► other channel nodes
│ │
┌──┴──────┴──┐
│ daemon │ State (one async mutex): channels, openings,
│ + switch │ peers, graph, scorer, outboxes, waiters
└──┬──────┬──┘
redb store ◄─┘ └─► onchain watcher ──► node REST (/v1/blocks, /v1/txs…)
daemon: channel lifecycle, message dispatch, gossip, watchtower server.switch: what happens to every irrevocably committed HTLC (settle, forward, or resolve our payment); the outbox that batches updates into rounds; payments and invoices.onchain: scans every block once, in order (rewinding on a reorg). It handles funding spends, spends of close outputs (learning preimages), and spends of announced channels, plus the tower match. Timers run after each scan.peer: sessions, keep-alive and redialling.store: tables (§10).
Durability rule
Every handler writes what changed before queueing the messages that depend on it, so a crash never leaves the peer holding something we forgot:
- a channel's new state is written before the reply that reveals a secret or carries signatures;
- an outgoing HTLC's link to its source (
LINKS) is written before the round that offers it; - an incoming HTLC's onion is written with the state that commits it;
- a settle decision is written before it is queued.
resume() re-queues whatever a crash interrupted, and Reestablish finishes the interrupted
round with the peer.
3. Keys
Everything derives from one 32-byte master seed (ARREKTA_CHANNELD_SEED_FILE):
| Path | Key |
|---|---|
m/9000'/0'/0' | node identity (ML-DSA-65): signs Init, gossip, invoices |
m/9000'/0'/1' → seed | onion key (ML-KEM-768 via keygen_from_seed) |
m/9000'/1'/i'/k' | channel i's funding, payment, delayed, revocation and anchor keys, and its shachain seed |
wallet m/…/0/0, m/…/1/0 | on-chain hot wallet (receive, change) |
Node id = SHA3-256(identity public key). Restoring the seed restores every key. Channel state (which commitments are revoked) is in the store and must be backed up; see §12.
4. Peer protocol
- The transport is the node's PQ handshake with a distinct magic
(
tagged_hash("arrekta/channeld/p2p", chain magic)[..4]), so chain nodes and channel nodes never confuse each other. - Each side sends
Init {identity, onion key, features, sig}. The signature coverstagged_hash("arrekta/peer/init", session_id ‖ initiator), which binds identity to this session (a replay on another connection fails) and to the direction (no reflection). - Then
wire::Wireframes:
| Types | Group |
|---|---|
| 1–12 | channel messages |
| 16–18 | Init, Ping, Pong |
| 32–36 | ChannelAnnouncement, ChannelUpdate, NodeAnnouncement, AnnouncementSignatures, GossipQuery |
| 48–51 | TowerCreateSession, TowerSessionAck, TowerStateUpdate, TowerStateAck |
Feature bits: routing, gossip queries, watchtower, asset channels.
Liveness: a ping every 60 s; a peer silent for 180 s is dropped. Channel peers, bootstrap peers and towers are redialled every 20 s.
Reconnecting (Reestablish)
Each side reports:
- its commitment number;
- how many of the peer's secrets it holds, proved by the last one;
- whether a round is in flight (proposed or responded).
Channel::on_reestablish then retransmits exactly what was lost:
| Situation | Action |
|---|---|
Our UpdateBatch was lost | Resend it |
Our RevokeAndSign was lost | Resend it |
Our RevokeAndAck was lost | Resend it |
| The peer proves it holds more of our secrets than we revealed | Data loss: we never broadcast our (revoked) commitment. We ask the peer to close and later claim to_remote, which pays a static key |
| The peer is behind | Fail the channel; close with our latest commitment |
A peer that claims secrets without the right last secret is refused; it cannot fake our data loss. Tests cover losing each of the three round messages, restoring a stale backup, and a lying peer.
5. Channel lifecycle
-
Opening. Messages:
Open→Accept→FundingCreated→FundingSigned.- The opener funds from its wallet.
- It broadcasts the funding transaction only after holding the acceptor's signature on its commitment 0.
- Nothing is stored before
FundingSigned, because nothing is at stake before it.
-
Ready. At
min_depthconfirmations both sides sendReady; the channel becomes Active. Each sends itsChannelUpdateto the peer directly, even for private channels (needed for invoice route hints). -
Public channels. At
announce_depth:- the opener sends
AnnouncementSignatures, and the acceptor answers with its own; - both assemble the
ChannelAnnouncement, add it to their graph and relay it with their updates and node announcement; - receivers check all four signatures and that the funding output exists, is unspent, and pays exactly the announced 2-of-2 policy.
- the opener sends
-
Cooperative close.
Shutdownfrom either side stops new HTLCs.- Once HTLCs drain and no round is in flight, both sign the zero-fee TRUC close, which is identical bytes on both sides.
- Whoever has an output attaches a CPFP child.
-
Force close. The latest commitment plus a CPFP child on our anchor, rebroadcast every 6 blocks until it confirms.
-
Claims. After any close the watcher rebuilds the confirmed commitment and sweeps each matured claim into the wallet:
to_localafterto_self_delay;to_remoteafter 1 block;- HTLC success with a preimage;
- HTLC timeout after expiry;
- justice at once.
The channel is Closed once nothing is left and the close is 6 blocks deep.
6. Payments and forwarding
6.1 Sending
POST /v1/paymentsdecodes the invoice and checks its network, expiry, amount and payee.- It asks the router for a route over channels of the invoice's asset. Routes are single-asset: every hop forwards the same asset, and fees are paid in it.
- It builds the onion and queues the first HTLC.
- On failure it reads the onion error, updates the scorer, excludes the failing channel direction (or the node, for node-level codes) and retries: up to 12 attempts within the timeout.
- A failure reported by the payee itself ends the payment.
- Success records the preimage, which is the proof of payment.
6.2 Onions
Fixed-length Sphinx with ML-KEM-768 (library onion.rs):
| Parameter | Value |
|---|---|
| Maximum hops | 8 |
| Per-hop block | payload (64 B) ‖ next hop's KEM ciphertext (1,088 B) ‖ next MAC (32 B) |
| Packet size | 10,593 bytes at every hop and for every route length |
| Stream cipher | SHAKE256 |
| MAC | SHA3-256(key ‖ data) |
| Associated data | the payment hash |
ML-KEM's implicit rejection means a foreign ciphertext yields an unrelated secret, so the MAC check fails. A hop only learns its predecessor, its successor and its instructions.
Failures (BOLT 4 codes, plus ASSET_MISMATCH):
- the failing hop authenticates a fixed-length 288-byte error with its secret;
- each hop on the way back re-encrypts it;
- only the sender can read which hop failed;
- a hop that cannot read the onion at all returns a "malformed" marker, and the previous hop
converts it into an authenticated
INVALID_ONION.
Replay protection: a per-hop tag derived from the shared secret is stored. The same onion on a different HTLC is refused.
6.3 The switch
When an HTLC from a peer is irrevocably committed, the node peels its layer and acts on it.
-
Final:
- the invoice must exist and be unexpired;
- the payment secret, asset and amount must match;
- the expiry must be at least
tip + min_final_expiry; - if all pass, fulfil with the preimage and mark the invoice settled.
Every mismatch returns the same
INCORRECT_PAYMENT_DETAILS, so probers learn nothing. -
Forward:
- the next channel must be ours, open, with its peer connected and the same asset;
- the fee must be at least
fee_base + amount·ppm/10⁶; - the incoming expiry must be at least the outgoing expiry plus
max(cltv_delta, our to_self_delay + 144); - the outgoing expiry must be more than 144 blocks away and within 45,000 blocks;
- the outgoing balance must cover the amount.
If all pass, the HTLC is queued on the outgoing channel. Its link (incoming channel, id, shared secret) is written before the round goes out.
-
Downstream settles:
- a fulfil goes upstream with the preimage;
- a failure is re-encrypted and goes upstream;
- an HTLC that timed out on chain fails upstream with
PERMANENT_CHANNEL_FAILURE; - a preimage revealed in an on-chain spend fulfils upstream.
6.5 Multi-path payments
A payment too large for any one route is split, as in BOLT 4 basic_mpp.
Sending (switch::pay):
- Try the whole amount first.
- When no route carries it, halve the part size, down to 1/32 of the amount.
- Parts go out concurrently over the cheapest routes the scorer allows. Liquidity already committed to a round still in flight counts as spent.
- A part that fails is re-routed around the failing channel direction. A refusal by our own channel (a race between parts) retries with a smaller part.
- The payment succeeds when the parts add up.
- The fee budget covers all parts together.
Receiving (mpp::Aggregator):
- Parts group on (payment hash, payment secret) and must agree on
total; the final onion payload carriesamount(this part) andtotal. A part that disagrees is failed on its own. - Nothing is fulfilled until the parts reach
total. Then the invoice is settled durably and every part is fulfilled. - If the rest does not arrive within 90 seconds, every part fails with
MPP_TIMEOUT. - Grouping on hash plus secret, and checking the total, closes the partial-payment hole Electrum once had. Overpaying more than twice the invoice is refused.
- Held parts are not persisted. After a restart their stored onions are re-processed, and the parts rejoin the set.
6.4 Deadlines (force-close in time)
The watcher closes a channel unilaterally when:
- an HTLC we offered has expired and the peer has not failed it; or
- an incoming HTLC whose preimage we know is within
to_self_delay + 72blocks of expiry. Our delayed success claim must confirm before the peer's timeout branch opens.
Payers pad the final expiry by 6 blocks (FINAL_EXPIRY_PADDING), because nodes' views of the
tip differ by a block or two.
7. Routing
router::find_route runs Dijkstra from the recipient backwards, so each hop's amount and
expiry already include everything downstream.
Edges, filtered to the payment's asset:
- announced channel directions with a fresh, enabled
ChannelUpdate; - the invoice's route hints (private last hops);
- our own channels, with their exact spendable balance.
Cost = fees + a reliability penalty + a small expiry penalty:
- Penalty:
base + 30,000 · −log₂ p, where p is the probability the channel can carry the amount. - Probability:
p = (upper − a)/(upper − lower)under a uniform prior. - Learning: bounds learned from failures (upper bound) and successes (lower bound).
- Decay: the bounds decay toward "unknown" with a 6-hour half-life.
This follows Pickhardt–Richter and LDK's ProbabilisticScorer.
Limits: the fee budget (default 1% + 10,000 units), 8 hops, and a 40,000-block total expiry delta.
8. Watchtowers
Client
For every remote commitment that becomes revoked, the client:
- builds the fully signed justice transaction, which takes:
to_localand every HTLC through the revocation branches;to_remote;- our anchor on that commitment;
- encrypts it with ChaCha20-Poly1305 under
H("arrekta/tower/key", txid), with a fresh nonce; - files it under the 16-byte locator
H("arrekta/tower/hint", txid)[..16].
Uploads are queued durably (TOWER_BLOBS \xffq… rows) and resent until each tower
acknowledges them.
Tower
The tower:
- stores blobs per client within a session quota, keeping one blob per hint per client, so a peer cannot crowd out others;
- scans every block, computes each txid's hint, and on a match decrypts with the txid;
- checks that the result spends that transaction;
- broadcasts it, and retries until it confirms.
It learns nothing before a breach. Neither the hint nor the blob reveals the txid, the balances or the parties.
Fees for asset channels
A justice transaction pays its fee in ARRK:
- ARRK channels: from the swept ARRK.
- Asset channels: sweep only the 10,000-unit anchor in ARRK, so the client reserves one
wallet coin per asset channel (
ARREKTA_CHANNELD_JUSTICE_COIN, default 0.01 ARRK). Every blob of that channel spends the same coin, which is safe because only one revoked commitment can ever confirm.
Tests: a consensus-validated library test for an asset channel, and an end-to-end regtest test where the victim is offline and the tower restores its funds.
9. The chain
All chain access goes through the node REST API:
| Need | Endpoint |
|---|---|
| Tip and raw blocks (watcher) | /v1/blocks/tip, /v1/blocks/{h}/raw |
| Confirmations | /v1/txs/{txid} |
| Submit a transaction | POST /v1/txs |
| Submit a zero-fee parent with its CPFP child | POST /v1/txs/package |
| Wallet coins, and "is this funding output unspent?" (via the funding lock's address) | /v1/addresses/{addr}/utxos |
| Fee rates | /v1/fees |
Reorgs: each scanned block must build on the last scanned hash. If not, the watcher rewinds 6 blocks and rescans; handlers are idempotent.
10. Storage
One redb database. Every write is its own committed transaction:
| Table | Key → value |
|---|---|
channels | channel id → JSON meta ‖ binary channel state |
past_states | id ‖ n → superseded state (one row per round, written once) |
revoked | revoked remote commitment txid → id ‖ n (breach lookup in O(1)) |
links | out channel ‖ htlc id → source (payment attempt or upstream HTLC) and resolution |
onions | in channel ‖ htlc id → onion until settled |
replay | onion replay tag → first HTLC |
invoices, payments | payment hash → JSON record |
graph | gossip, as wire bytes |
tower_blobs, tower_sessions | tower server data; client upload queue |
outpoints | reserved coins, submitted and spent claim outputs |
peers | node id → last address |
closed_channels, close_txs | with ARREKTA_CHANNELD_PUBLIC_GRAPH: the last 1,000 closed public channels (as announced, the closing txid and the later spends of its outputs), and closing txid → channel id |
11. Operating
Configuration
| Variable | Default | Meaning |
|---|---|---|
ARREKTA_CHANNELD_NODE_URL | http://127.0.0.1:18332 | node REST API |
ARREKTA_CHANNELD_SEED(_FILE) | required | 32-byte hex master seed |
ARREKTA_CHANNELD_API_TOKEN(_FILE) | unset = REST refuses everything | bearer token, ≥ 32 characters |
ARREKTA_CHANNELD_DATA_DIR | ./channeld-data | state |
ARREKTA_CHANNELD_API_ADDR / _P2P_ADDR | 127.0.0.1:9737 / 0.0.0.0:9736 | binds |
ARREKTA_CHANNELD_ANNOUNCE_ADDR | unset | host:port to announce |
ARREKTA_CHANNELD_ALIAS | arrekta-channeld | ≤ 32 bytes |
ARREKTA_CHANNELD_MIN_DEPTH / _ANNOUNCE_DEPTH | 3 / 6 | confirmations |
ARREKTA_CHANNELD_TO_SELF_DELAY / _MAX_OWN_DELAY | 720 / 2016 | blocks |
ARREKTA_CHANNELD_FEE_BASE / _FEE_PPM / _CLTV_DELTA | 1000 / 100 / delay+144 | forwarding policy |
ARREKTA_CHANNELD_ACCEPT_CHANNELS / _MIN_CAPACITY | true / 100000 | inbound channels |
ARREKTA_CHANNELD_TOWER | false | serve as a watchtower |
ARREKTA_CHANNELD_TOWERS / _PEERS | – | host:port lists |
ARREKTA_CHANNELD_JUSTICE_COIN | 1000000 | ARRK reserved per asset channel |
ARREKTA_CHANNELD_DUAL_FUND_MAX | 0 | most we put into a channel a peer opens and asks us to co-fund |
ARREKTA_CHANNELD_PUBLIC_GRAPH | false | serve the public graph read-only, without a token, CORS * (/public/v1/*), for explorers |
REST API
The full description is in docs/api/channeld.openapi.json (docs/api/channeld.openapi.json).
| Method | Path | |
|---|---|---|
| GET | /v1/info | node id, network, tip, counts |
| GET | /v1/wallet | addresses and balances |
| GET / POST | /v1/peers | list / connect {address, node_id?} |
| GET / POST | /v1/channels | list / open {node_id, capacity, asset?, public?} |
| GET | /v1/channels/{id} | one channel |
| POST | /v1/channels/{id}/close | {force?} |
| POST | /v1/channels/{id}/splice | {amount: "+N" or "-N", address?} |
| GET / POST | /v1/invoices | list / create {amount?, asset?, description?, expiry_secs?} |
| POST | /v1/invoices/decode | {invoice} |
| GET | /v1/invoices/{hash} | status (the preimage only once settled) |
| GET / POST | /v1/payments | list / pay {invoice, amount?, max_fee?, timeout_secs?} |
| GET | /v1/payments/{hash} | status, attempts, preimage |
| POST | /v1/routes | preview route and fee for an invoice |
| GET | /v1/graph | public channels and nodes |
| GET | /v1/tower | tower server and client status |
| GET | /healthz, /metrics | unauthenticated, Prometheus text |
| GET | /public/v1/graph, /public/v1/channels/{id}, /public/v1/nodes/{id} | unauthenticated, opt-in (ARREKTA_CHANNELD_PUBLIC_GRAPH): only what gossip already announces |
Wallets and the explorer
- Desktop, mobile and browser extension: each has an Instant payments screen backed by
wallet-core's
ChannelsWallet. They talk to the user's own channel node: the wallet holds no channel state. - Where the token lives:
- extension: sealed under the vault key (
sealedStorage, unreadable while locked); - mobile: the OS keystore;
- desktop: a separate store file.
- extension: sealed under the vault key (
- Before paying, every wallet decodes and verifies the invoice locally and shows network, expiry, amount, asset and fee.
- Explorer: the explorer's Channels pages read
/public/v1. Its transaction pages recognise channel closes, splices and claims from witnesses alone. - Closed channels stay visible: gossip drops a channel when its funding output is spent, so a node serving the public graph also keeps a record of the last 1,000 closes: the closing transaction and every later transaction that spent one of its outputs. The record holds facts only. The explorer reads the witnesses and labels each channel's life: opened, then closed together, force-closed, or penalty (a revoked state was published and punished).
- Demo network:
all_svc/channels-demo.shsets up a hub with three users on the devnet, sends payments between users who never connect to each other, and (cheat) reproduces a penalty close.
Metrics
arrekta_channeld_*:
- channels by status, peers, graph size;
- payments sent, failed and received;
- HTLCs forwarded, forward failures, fees earned;
- force closes, justice broadcasts, tower blobs;
- tip.
Running
Docker Compose profile channels (ops/). Expose :9736 to the internet only if you route for
others. Keep :9737 private: it moves money.
12. Safety notes for operators
- Back up the data directory continuously, not just the seed. A restored old backup is dangerous. If you broadcast an old commitment, the peer takes everything. The daemon detects this on reconnect (data-loss protection) and refuses to broadcast, but only if the peer is honest enough to say so.
- Stay online, or use a watchtower. A peer can publish a revoked state, and you have
to_self_delayblocks to punish it (720 by default, about a day at 2-minute blocks). - Keep ARRK in the wallet. Closes and sweeps pay fees by CPFP; an empty wallet cannot confirm a force close.
- Issuer controls apply. A frozen asset freezes channel outputs too (CHANNELS.md §7).
13. Verification
| Suite | What it proves |
|---|---|
arrekta-channels/tests/chain.rs | every transaction type against the real consensus code, including asset channels and tower justice with the reserved coin |
arrekta-channels/tests/protocol.rs | randomised two-party rounds with save/restore after each, simultaneous proposals, reconnect after each lost message, data loss and lying peers, cheating peers |
arrekta-channels/tests/network.rs | gossip verification, routes that every hop's forwarding check accepts, onions peeled along the route, scorer learning, route hints, invoices, wire messages |
arrekta-channels unit tests | onion for every route length, tamper and wrong-key rejection, failure attribution; forwarding and final-hop checks; tower blob sealing |
arrekta-channeld/tests/regtest.rs | four daemons on a real in-process node: gossip discovery, a two-hop payment with the right fee, restart and reconnect, cooperative close, force close with sweeps at the right heights, a breach punished by the online victim, and a breach punished by a watchtower while the victim is offline |
Sources
- LND architecture:
- Gossip:
- BOLT 7
- CLN gossip_store
- zero-timestamp gossip DoS disclosure: zero timestamps are rejected at parse time here
- Pathfinding:
- Channel operation and reconnects:
- Post-quantum onions:
- Dual funding, splicing and multi-path payments:
- BOLT 2 interactive transactions
- dual funding PR #851
- interactive-tx draft (lightning-dev)
- shared-transaction malleability (Delving Bitcoin)
- splicing merged, BOLTs #1160 (Optech)
- CLN splice_init
- Splicing explained (Spark)
- MPP in LND
- All paths lead to your destination (CLN)
- Electrum partial-MPP advisory
- Channel design and its own sources: CHANNELS.md