Skip to content
LogoLogo

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.

Binarynode-rs/crates/arrekta-channeld (arrekta-channeld)
Librarynode-rs/crates/arrekta-channels
P2P:9736 (post-quantum transport, own network magic)
REST127.0.0.1:9737, bearer token, OpenAPI (docs/api/channeld.openapi.json)
Stateone 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).

ConcernLightning practicearrekta-channeld
Process modelLND: one daemon with sub-servers. CLN: a master plus per-peer subdaemons. LDK: a library the app drivesOne daemon over a pure library, so the library is also usable from wallets and tests without the daemon
Peer transportBOLT 8 Noise_XK with secp256k1 node keysThe node's hybrid X25519 + ML-KEM-768 transport, then an ML-DSA signature over the session id (Init) for identity
Revocationper-commitment points (EC key tweaking)per-commitment hash secrets (shachain over SHA3); no key tweaking exists for ML-DSA
Onion routingSphinx (BOLT 4) over secp256k1Sphinx with ML-KEM-768 per hop, as in Outfox / KEM-Sphinx
GossipBOLT 7: channel_announcement, channel_update, node_announcementThe same three messages, ML-DSA-signed, checked against the funding output
PathfindingLDK ProbabilisticScorer (Pickhardt–Richter); LND mission controlA probabilistic liquidity model with decaying bounds, plus local exact balances
Reconnectchannel_reestablish with data-loss protectionReestablish with the commitment number, a proven last secret and the round in flight
Dual fundinginteractive-tx (tx_add_input…tx_signatures), BOLT 2The acceptor's inputs and change in Accept; the opener assembles; commitment signatures before FundingTxSigs
Splicingquiescence (stfu), splice_init/splice_ack, interactive-tx, parallel commitments until lockedQuiescent splice: SpliceInit…SpliceLocked; the old state is revoked only once the splice is buried; payments pause until then
Multi-path paymentsbasic_mpp: payment_secret + total_msat, the receiver holds parts, MPP timeoutSame, grouped on hash + secret with a uniform total, 90 s timeout
WatchtowersLND altruist towers: blob encrypted under the breach txid, located by a txid hintThe 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):

PathKey
m/9000'/0'/0'node identity (ML-DSA-65): signs Init, gossip, invoices
m/9000'/0'/1' → seedonion 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/0on-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

  1. 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.
  2. Each side sends Init {identity, onion key, features, sig}. The signature covers tagged_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).
  3. Then wire::Wire frames:
TypesGroup
1–12channel messages
16–18Init, Ping, Pong
32–36ChannelAnnouncement, ChannelUpdate, NodeAnnouncement, AnnouncementSignatures, GossipQuery
48–51TowerCreateSession, 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:

SituationAction
Our UpdateBatch was lostResend it
Our RevokeAndSign was lostResend it
Our RevokeAndAck was lostResend it
The peer proves it holds more of our secrets than we revealedData 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 behindFail 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

  1. 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.
  2. Ready. At min_depth confirmations both sides send Ready; the channel becomes Active. Each sends its ChannelUpdate to the peer directly, even for private channels (needed for invoice route hints).

  3. 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.
  4. Cooperative close.

    • Shutdown from 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.
  5. Force close. The latest commitment plus a CPFP child on our anchor, rebroadcast every 6 blocks until it confirms.

  6. Claims. After any close the watcher rebuilds the confirmed commitment and sweeps each matured claim into the wallet:

    • to_local after to_self_delay;
    • to_remote after 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

  1. POST /v1/payments decodes the invoice and checks its network, expiry, amount and payee.
  2. 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.
  3. It builds the onion and queues the first HTLC.
  4. 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.
  5. A failure reported by the payee itself ends the payment.
  6. Success records the preimage, which is the proof of payment.

6.2 Onions

Fixed-length Sphinx with ML-KEM-768 (library onion.rs):

ParameterValue
Maximum hops8
Per-hop blockpayload (64 B) ‖ next hop's KEM ciphertext (1,088 B) ‖ next MAC (32 B)
Packet size10,593 bytes at every hop and for every route length
Stream cipherSHAKE256
MACSHA3-256(key ‖ data)
Associated datathe 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):

  1. Try the whole amount first.
  2. When no route carries it, halve the part size, down to 1/32 of the amount.
  3. Parts go out concurrently over the cheapest routes the scorer allows. Liquidity already committed to a round still in flight counts as spent.
  4. 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.
  5. The payment succeeds when the parts add up.
  6. 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 carries amount (this part) and total. 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 + 72 blocks 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:

  1. builds the fully signed justice transaction, which takes:
    • to_local and every HTLC through the revocation branches;
    • to_remote;
    • our anchor on that commitment;
  2. encrypts it with ChaCha20-Poly1305 under H("arrekta/tower/key", txid), with a fresh nonce;
  3. 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:

  1. stores blobs per client within a session quota, keeping one blob per hint per client, so a peer cannot crowd out others;
  2. scans every block, computes each txid's hint, and on a match decrypts with the txid;
  3. checks that the result spends that transaction;
  4. 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:

NeedEndpoint
Tip and raw blocks (watcher)/v1/blocks/tip, /v1/blocks/{h}/raw
Confirmations/v1/txs/{txid}
Submit a transactionPOST /v1/txs
Submit a zero-fee parent with its CPFP childPOST /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:

TableKey → value
channelschannel id → JSON meta ‖ binary channel state
past_statesid ‖ n → superseded state (one row per round, written once)
revokedrevoked remote commitment txid → id ‖ n (breach lookup in O(1))
linksout channel ‖ htlc id → source (payment attempt or upstream HTLC) and resolution
onionsin channel ‖ htlc id → onion until settled
replayonion replay tag → first HTLC
invoices, paymentspayment hash → JSON record
graphgossip, as wire bytes
tower_blobs, tower_sessionstower server data; client upload queue
outpointsreserved coins, submitted and spent claim outputs
peersnode id → last address
closed_channels, close_txswith 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

VariableDefaultMeaning
ARREKTA_CHANNELD_NODE_URLhttp://127.0.0.1:18332node REST API
ARREKTA_CHANNELD_SEED(_FILE)required32-byte hex master seed
ARREKTA_CHANNELD_API_TOKEN(_FILE)unset = REST refuses everythingbearer token, ≥ 32 characters
ARREKTA_CHANNELD_DATA_DIR./channeld-datastate
ARREKTA_CHANNELD_API_ADDR / _P2P_ADDR127.0.0.1:9737 / 0.0.0.0:9736binds
ARREKTA_CHANNELD_ANNOUNCE_ADDRunsethost:port to announce
ARREKTA_CHANNELD_ALIASarrekta-channeld≤ 32 bytes
ARREKTA_CHANNELD_MIN_DEPTH / _ANNOUNCE_DEPTH3 / 6confirmations
ARREKTA_CHANNELD_TO_SELF_DELAY / _MAX_OWN_DELAY720 / 2016blocks
ARREKTA_CHANNELD_FEE_BASE / _FEE_PPM / _CLTV_DELTA1000 / 100 / delay+144forwarding policy
ARREKTA_CHANNELD_ACCEPT_CHANNELS / _MIN_CAPACITYtrue / 100000inbound channels
ARREKTA_CHANNELD_TOWERfalseserve as a watchtower
ARREKTA_CHANNELD_TOWERS / _PEERS–host:port lists
ARREKTA_CHANNELD_JUSTICE_COIN1000000ARRK reserved per asset channel
ARREKTA_CHANNELD_DUAL_FUND_MAX0most we put into a channel a peer opens and asks us to co-fund
ARREKTA_CHANNELD_PUBLIC_GRAPHfalseserve 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).

MethodPath
GET/v1/infonode id, network, tip, counts
GET/v1/walletaddresses and balances
GET / POST/v1/peerslist / connect {address, node_id?}
GET / POST/v1/channelslist / 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/invoiceslist / create {amount?, asset?, description?, expiry_secs?}
POST/v1/invoices/decode{invoice}
GET/v1/invoices/{hash}status (the preimage only once settled)
GET / POST/v1/paymentslist / pay {invoice, amount?, max_fee?, timeout_secs?}
GET/v1/payments/{hash}status, attempts, preimage
POST/v1/routespreview route and fee for an invoice
GET/v1/graphpublic channels and nodes
GET/v1/towertower server and client status
GET/healthz, /metricsunauthenticated, 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.
  • 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.sh sets 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_delay blocks 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

SuiteWhat it proves
arrekta-channels/tests/chain.rsevery transaction type against the real consensus code, including asset channels and tower justice with the reserved coin
arrekta-channels/tests/protocol.rsrandomised 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.rsgossip verification, routes that every hop's forwarding check accepts, onions peeled along the route, scorer learning, route hints, invoices, wire messages
arrekta-channels unit testsonion for every route length, tamper and wrong-key rejection, failure attribution; forwarding and final-hop checks; tower blob sealing
arrekta-channeld/tests/regtest.rsfour 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