From 6e55059fe2605d1b7e1a5ac4e6b2a9a1521f873f Mon Sep 17 00:00:00 2001 From: saubyk <39208279+saubyk@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:23:08 -0700 Subject: [PATCH] Add an Eclair node to the regtest docker fixture Completes backend coverage of RTL's three implementations in the docker/ dev fixture: an eclair node (polarlightning/eclair 0.13.1) joins the three LND nodes and the CLN node, wired to RTL over its HTTP API with basic auth, and the seed opens an eclair->bob channel (3.5M sats, 1M pushed), sends two direct payments and leaves one open invoice. Non-obvious plumbing this needed: - polarlightning/eclair instead of acinq/eclair: the official image is amd64-only (useless on Apple Silicon) and its newest versioned tag is years stale; Polar builds the same ACINQ source multi-arch. - Eclair has no on-chain wallet of its own -- it drives a bitcoind wallet. A new eclair-wallet-init container creates a dedicated "eclair" wallet before the node starts; without it eclair attaches to "the default loaded wallet", i.e. the rtldev mining wallet. - bitcoind now also publishes a zmqpubhashblock endpoint (28336): eclair's bitcoind.zmqblock consumes the hashblock topic, not the rawblock one LND uses. Wired to rawblock, eclair never sees new blocks and channels hang in WAIT_FOR_FUNDING_CONFIRMED. - Eclair confirms channels at 8 blocks (channel.min-depth-blocks), not 6, and 'open' returns before the funding tx is broadcast -- the seed waits for the mempool and mines 8 blocks for this channel. Adds a bin/e-cli helper (eclair-cli with the API password), updates the README, and verified end-to-end: seed completes, the channel reaches NORMAL, both payments settle, and RTL's /rtl/api/ecl endpoints return the node, channel and invoice data. --- docker/README.md | 46 ++++++++------ docker/bin/e-cli | 20 +++++++ docker/docker-compose.yml | 80 +++++++++++++++++++++++++ docker/rtl/RTL-Config.regtest.json | 18 ++++++ docker/scripts/seed.sh | 86 ++++++++++++++++++++++++++- release-notes/Release-notes-0.15.9.md | 11 ++++ 6 files changed, 243 insertions(+), 18 deletions(-) create mode 100755 docker/bin/e-cli diff --git a/docker/README.md b/docker/README.md index a53ab082..d0e6cf9e 100644 --- a/docker/README.md +++ b/docker/README.md @@ -3,22 +3,25 @@ ### NOT suitable for production. Development only. Every credential here is throwaway. A self-contained regtest network for developing and testing RTL: `bitcoind`, three -LND nodes, a Core Lightning node, and RTL wired to all four. +LND nodes, a Core Lightning node, an Eclair node, and RTL wired to all five. ``` -alice --[ 5,000,000 sat ]--> bob --[ 3,000,000 sat ]--> carol -cln --[ 4,000,000 sat ]--> alice +alice --[ 5,000,000 sat ]--> bob --[ 3,000,000 sat ]--> carol +cln --[ 4,000,000 sat ]--> alice +eclair --[ 3,500,000 sat ]--> bob ``` bob sits in the middle so it accrues forwarding history, which is what gives RTL's routing screens something to show. Two nodes would leave them empty. The `cln` (Core Lightning) node gives RTL's CLN screens a real backend — it talks to RTL over -clnrest with rune auth. +clnrest with rune auth. The `eclair` node does the same for RTL's Eclair screens — +RTL talks to its HTTP API with basic auth. -LND and bitcoind images come from [Polar](https://lightningpolar.com); the Core +LND, bitcoind and Eclair images come from [Polar](https://lightningpolar.com); the Core Lightning image is the official [`elementsproject/lightningd`](https://hub.docker.com/r/elementsproject/lightningd). All are multi-arch (amd64 + arm64) and nothing is built locally, so this works on -Apple Silicon. +Apple Silicon. (The official `acinq/eclair` image is amd64-only and its versioned tags +are years stale, which is why Polar's build of the same source is used instead.) ## Requirements @@ -29,12 +32,12 @@ Docker with Compose v2 (`docker compose`, not `docker-compose`). From this directory: ```bash -docker compose up -d # bitcoind, alice, bob, carol, cln, rtl +docker compose up -d # bitcoind, alice, bob, carol, cln, eclair, rtl ./scripts/seed.sh # fund, connect, open channels, make payments ``` -Then open — password `rtldev`. All four nodes (alice, bob, -carol, cln) appear in the node switcher. +Then open — password `rtldev`. All five nodes (alice, bob, +carol, cln, eclair) appear in the node switcher. Tear down, discarding all state: @@ -46,12 +49,12 @@ docker compose down -v | | | |---|---| -| On-chain | 10,000,000 sats per node (LND) + 10,000,000 sats on cln, confirmed | -| Channels | alice→bob 5,000,000 sats · bob→carol 3,000,000 sats (1,000,000 pushed each) · cln→alice 4,000,000 sats | +| On-chain | 10,000,000 sats per node (LND) + 10,000,000 sats each on cln and eclair, confirmed | +| Channels | alice→bob 5,000,000 · bob→carol 3,000,000 · eclair→bob 3,500,000 sats (1,000,000 pushed each) · cln→alice 4,000,000 sats (no push) | | Routed payments | 5 × alice→carol via bob (10k, 25k, 50k, 75k, 100k sats) | -| Direct payments | 2 × alice→bob (5k, 15k sats) | -| Open invoices | 2 unpaid on carol (20k, 40k sats) | -| Personas | alice + bob + cln OPERATOR, carol MERCHANT | +| Direct payments | 2 × alice→bob (5k, 15k sats) · 2 × eclair→bob (8k, 18k sats) | +| Open invoices | 2 unpaid on carol (20k, 40k sats) · 1 unpaid on eclair (30k sats) | +| Personas | alice + bob + cln + eclair OPERATOR, carol MERCHANT | ## Determinism @@ -75,6 +78,8 @@ bin/b-cli -rpcwallet=rtldev getbalance bin/ln-cli alice getinfo # lncli, node name required bin/ln-cli bob listchannels bin/ln-cli bob fwdinghistory # forwarding history +bin/e-cli getinfo # eclair-cli +bin/e-cli channels docker compose exec cln lightning-cli --network=regtest listpeerchannels # Core Lightning ``` @@ -125,8 +130,15 @@ rather than wedging the stack. `--clnrest-host=0.0.0.0` is required for RTL (ano container) to reach clnrest; the default `127.0.0.1` would only be reachable from inside the node. +**Eclair has no wallet of its own.** It drives a bitcoind wallet over RPC. The +`eclair-wallet-init` service creates a dedicated `eclair` wallet before the node starts; +without it eclair would attach to "the default loaded wallet" — the `rtldev` mining +wallet — and report the miner's balance as its own. RTL authenticates to eclair with +`lnApiPassword` (HTTP basic auth), no file mount needed. Eclair also confirms channels +at 8 blocks (`channel.min-depth-blocks`), not 6 — the seed mines accordingly. And its +`bitcoind.zmqblock` must point at a `zmqpubhashblock` endpoint — wired to the rawblock +one LND uses, eclair never sees new blocks and channels never confirm. + ## Not included -Eclair nodes and the Boltz swap service. Polar publishes a multi-arch `eclair` image, so -adding an Eclair node means a compose service, an RTL config entry, and a seeding adapter -— no image building. +The Boltz swap service. diff --git a/docker/bin/e-cli b/docker/bin/e-cli new file mode 100755 index 00000000..e01dc8c8 --- /dev/null +++ b/docker/bin/e-cli @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# +# eclair-cli against the regtest fixture's eclair node. +# +# bin/e-cli getinfo +# bin/e-cli channels +# bin/e-cli createinvoice --amountMsat=1000000 --description=test +# +# The API password is passed explicitly because 'docker compose exec' does not +# read eclair's config for auth; it defaults to the fixture's throwaway value. + +set -euo pipefail + +cd "$(dirname "$0")/.." +# shellcheck disable=SC1091 +source .env + +exec docker compose exec -T eclair eclair-cli \ + -p "${ECLAIR_API_PASSWORD:-rtldev}" \ + "$@" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 21fd6642..55672171 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,6 +14,7 @@ volumes: bob_data: carol_data: cln_data: + eclair_data: rtl_db: rtl_config: @@ -37,6 +38,10 @@ services: - -rpcauth=rtldev:8a1f2c3d4e5b6a7c8d9e0f1a2b3c4d5e$$010df4b32c5e9a556cba1857eb5865990c983d8a56dadc0fdbf457cf90073c6c - -zmqpubrawblock=tcp://0.0.0.0:${BITCOIN_ZMQ_BLOCK_PORT} - -zmqpubrawtx=tcp://0.0.0.0:${BITCOIN_ZMQ_TX_PORT} + # eclair's zmqblock consumes the hashblock topic, not rawblock (LND uses + # rawblock/rawtx above); without this endpoint eclair never sees new + # blocks and channels stay in WAIT_FOR_FUNDING_CONFIRMED forever. + - -zmqpubhashblock=tcp://0.0.0.0:${BITCOIN_ZMQ_HASHBLOCK_PORT:-28336} - -txindex=1 - -dnsseed=0 - -rpcbind=0.0.0.0 @@ -187,6 +192,79 @@ services: timeout: 10s retries: 40 + # Eclair has no on-chain wallet of its own -- it drives a bitcoind wallet over + # RPC. Without a dedicated wallet it would grab "the default loaded wallet", + # which here is the rtldev mining wallet, and eclair's on-chain balance would + # show the miner's coins. This init container creates (or reloads) a wallet + # named "eclair" before the eclair node starts; load_on_startup survives + # bitcoind restarts. + eclair-wallet-init: + container_name: ${COMPOSE_PROJECT_NAME}_eclair_wallet_init + image: polarlightning/bitcoind:30.0 + depends_on: + - bitcoind + entrypoint: ["/bin/sh", "-c"] + command: + - | + bcli() { bitcoin-cli -regtest -rpcconnect=${BITCOIN_HOST} -rpcport=${BITCOIN_RPC_PORT} -rpcuser=${BITCOIN_RPC_USER} -rpcpassword=${BITCOIN_RPC_PASSWORD} "$$@"; } + i=0 + until bcli getblockchaininfo >/dev/null 2>&1; do + i=$$((i+1)); [ "$$i" -ge 60 ] && echo "bitcoind never came up" && exit 1 + sleep 1 + done + bcli -named createwallet wallet_name=eclair load_on_startup=true >/dev/null 2>&1 \ + || bcli loadwallet eclair true >/dev/null 2>&1 \ + || true + bcli -rpcwallet=eclair getwalletinfo >/dev/null + echo "eclair wallet ready" + + # Eclair node. Talks to RTL over its HTTP API with basic auth (the + # lnApiPassword in RTL's config). The polarlightning image is used because + # acinq/eclair on Docker Hub is amd64-only and its newest versioned tag is + # years stale; Polar builds the same ACINQ source multi-arch (amd64 + arm64). + # The image entrypoint translates each --key=value into -Declair.key=value. + # The entrypoint also overrides server.public-ips.0 with the container IP, but + # the arg must still be present for that substitution to happen. + eclair: + image: polarlightning/eclair:0.13.1 + container_name: ${COMPOSE_PROJECT_NAME}_eclair + restart: unless-stopped + depends_on: + bitcoind: + condition: service_started + eclair-wallet-init: + condition: service_completed_successfully + command: + - polar-eclair + - --node-alias=eclair + - --server.public-ips.0=eclair + - --server.port=9735 + - --api.enabled=true + - --api.binding-ip=0.0.0.0 + - --api.port=8080 + - --api.password=${ECLAIR_API_PASSWORD:-rtldev} + - --chain=regtest + - --bitcoind.host=${BITCOIN_HOST} + - --bitcoind.rpcport=${BITCOIN_RPC_PORT} + - --bitcoind.rpcuser=${BITCOIN_RPC_USER} + - --bitcoind.rpcpassword=${BITCOIN_RPC_PASSWORD} + # zmqblock must be bitcoind's *hashblock* endpoint (eclair subscribes to + # that topic); pointing it at the rawblock endpoint the LND nodes use + # leaves eclair blind to new blocks. + - --bitcoind.zmqblock=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_HASHBLOCK_PORT:-28336} + - --bitcoind.zmqtx=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_TX_PORT} + - --bitcoind.wallet=eclair + - --datadir=/home/eclair/.eclair + - --printToConsole=true + # Regtest feerates are far from mainnet estimates; without a wide + # tolerance eclair closes channels over feerate disagreements. + - --on-chain-fees.feerate-tolerance.ratio-low=0.00001 + - --on-chain-fees.feerate-tolerance.ratio-high=10000.0 + ports: + - "${ECLAIR_REST_PORT:-8281}:8080" + volumes: + - eclair_data:/home/eclair + # RTL rewrites its config file on startup, so it cannot be given the tracked # template directly: a bind mount would either be read-only (RTL exits with # EROFS) or would let RTL scribble into a version-controlled file. Instead the @@ -220,6 +298,8 @@ services: condition: service_started cln: condition: service_healthy + eclair: + condition: service_started ports: - "${RTL_PORT}:${RTL_PORT}" environment: diff --git a/docker/rtl/RTL-Config.regtest.json b/docker/rtl/RTL-Config.regtest.json index 70233d85..73f85f4d 100644 --- a/docker/rtl/RTL-Config.regtest.json +++ b/docker/rtl/RTL-Config.regtest.json @@ -80,6 +80,24 @@ "unannouncedChannels": false, "blockExplorerUrl": "https://mempool.space" } + }, + { + "index": 5, + "lnNode": "eclair", + "lnImplementation": "ECL", + "authentication": { + "lnApiPassword": "rtldev" + }, + "settings": { + "userPersona": "OPERATOR", + "themeMode": "DAY", + "themeColor": "PURPLE", + "logLevel": "ERROR", + "lnServerUrl": "http://eclair:8080", + "fiatConversion": false, + "unannouncedChannels": false, + "blockExplorerUrl": "https://mempool.space" + } } ] } diff --git a/docker/scripts/seed.sh b/docker/scripts/seed.sh index e503bc65..bbe5779e 100755 --- a/docker/scripts/seed.sh +++ b/docker/scripts/seed.sh @@ -8,7 +8,9 @@ # # Topology: # -# alice --[ 5,000,000 sat ]--> bob --[ 3,000,000 sat ]--> carol +# alice --[ 5,000,000 sat ]--> bob --[ 3,000,000 sat ]--> carol +# cln --[ 4,000,000 sat ]--> alice +# eclair --[ 3,500,000 sat ]--> bob # # bob sits in the middle so it accrues forwarding history, which is what # populates RTL's routing screens. @@ -31,6 +33,7 @@ fi BITCOIN_RPC_USER="${BITCOIN_RPC_USER:-rtldev}" BITCOIN_RPC_PASSWORD="${BITCOIN_RPC_PASSWORD:-rtldev}" +ECLAIR_API_PASSWORD="${ECLAIR_API_PASSWORD:-rtldev}" NODES=(alice bob carol) @@ -39,8 +42,10 @@ FUND_SATS=10000000 # on-chain funding per node CH_ALICE_BOB=5000000 # channel capacity alice -> bob CH_BOB_CAROL=3000000 # channel capacity bob -> carol CH_CLN_ALICE=4000000 # channel capacity cln -> alice (Core Lightning node) +CH_ECL_BOB=3500000 # channel capacity eclair -> bob (Eclair node) PUSH_SATS=1000000 # pushed to remote on open, so both sides have liquidity MINE_CONFIRM=6 # blocks to confirm a funding tx +ECL_MINE_CONFIRM=8 # eclair's channel.min-depth-blocks default is 8, not 6 log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; } info() { printf ' %s\n' "$*"; } @@ -63,6 +68,11 @@ clncli() { docker compose exec -T cln lightning-cli --network=regtest "$@" } +# Eclair cli. Runs inside the eclair container; auths with the API password. +ecli() { + docker compose exec -T eclair eclair-cli -p "$ECLAIR_API_PASSWORD" "$@" +} + # Extract the first value for a JSON key from lncli output. # 'first' matters: walletbalance reports confirmed_balance at the top level AND # again under account_balance.default, and lncli emits no --json flag we can use. @@ -266,6 +276,78 @@ for i in $(seq 1 90); do (( i == 90 )) && die "cln channel never reached CHANNELD_NORMAL" done +# ---------------------------------------------------------------- eclair + +# An Eclair node with one active channel to bob plus a couple of settled and +# open invoices, so RTL's Eclair screens have data. Eclair's on-chain wallet is +# the dedicated "eclair" bitcoind wallet (created by eclair-wallet-init), but +# funding still goes through eclair's own API so its balances update. +log "Waiting for Eclair node" +wait_for "eclair" 180 ecli getinfo + +log "Funding Eclair (${FUND_SATS} sats)" +ECL_ADDR=$(ecli getnewaddress | tr -d '"') +[ -n "$ECL_ADDR" ] || die "could not get address for eclair" +bcli -rpcwallet=rtldev sendtoaddress "$ECL_ADDR" "$BTC_AMOUNT" >/dev/null +info "eclair <- $BTC_AMOUNT BTC ($ECL_ADDR)" +bcli -rpcwallet=rtldev generatetoaddress "$MINE_CONFIRM" "$MINE_ADDR" >/dev/null +info "mined $MINE_CONFIRM blocks to confirm eclair funding" + +log "Waiting for eclair confirmed on-chain balance" +for i in $(seq 1 60); do + ecli onchainbalance | grep -q '"confirmed": *[1-9]' && { info "eclair funds confirmed"; break; } + sleep 1 + (( i == 60 )) && die "eclair never saw confirmed funds" +done + +log "Opening channel eclair -> bob" +ecli connect --uri="${BOB_PUB}@bob:9735" >/dev/null 2>&1 || info "eclair->bob already connected" +ecli open --nodeId="$BOB_PUB" --fundingSatoshis="$CH_ECL_BOB" --pushMsat=$(( PUSH_SATS * 1000 )) >/dev/null +info "eclair -> bob ${CH_ECL_BOB} sats (push ${PUSH_SATS})" + +# 'open' returns before eclair broadcasts the funding tx; mining too early would +# confirm nothing and leave the channel waiting forever. +for i in $(seq 1 30); do + bcli getrawmempool | grep -q '"' && { info "funding tx in mempool"; break; } + sleep 1 + (( i == 30 )) && die "eclair funding tx never reached the mempool" +done +bcli -rpcwallet=rtldev generatetoaddress "$ECL_MINE_CONFIRM" "$MINE_ADDR" >/dev/null +info "mined $ECL_MINE_CONFIRM blocks to confirm the eclair channel" + +log "Waiting for the eclair channel to become active" +for i in $(seq 1 90); do + if ecli channels | grep -q '"state" *: *"NORMAL"'; then + info "eclair channel is NORMAL"; break + fi + sleep 1 + (( i == 90 )) && die "eclair channel never reached NORMAL" +done + +# Direct payments over the eclair->bob channel; no routing, so no gossip wait. +# payinvoice is asynchronous -- confirm settlement on bob's side. +log "Sending direct payments (eclair -> bob)" +for amt in 8000 18000; do + inv_out=$(lncli bob addinvoice --amt="$amt" --memo="eclair payment ${amt} sats") + inv=$(echo "$inv_out" | json_first payment_request) + rhash=$(echo "$inv_out" | json_first r_hash) + ecli payinvoice --invoice="$inv" >/dev/null 2>&1 + paid="" + for i in $(seq 1 30); do + if lncli bob lookupinvoice "$rhash" | grep -q '"state": *"SETTLED"'; then + paid=1; break + fi + sleep 1 + done + [ -n "$paid" ] && info "eclair -> bob ${amt} sats" \ + || info "eclair -> bob ${amt} sats FAILED (never settled)" +done + +# An unpaid invoice, so the eclair invoice list shows more than one state. +log "Creating an open (unpaid) invoice on eclair" +ecli createinvoice --amountMsat=$(( 30000 * 1000 )) --description="open invoice 30000 sats" >/dev/null +info "eclair open invoice 30000 sats" + # ---------------------------------------------------------------- summary log "Seed complete" @@ -276,6 +358,8 @@ for n in "${NODES[@]}"; do done cln_chans=$(clncli listpeerchannels | grep -o '"state": "[A-Z_]*"' | grep -c "CHANNELD_NORMAL" || true) printf ' %-6s channels: %-3s (Core Lightning)\n' "cln" "${cln_chans:-0}" +ecl_chans=$(ecli channels | grep -o '"state" *: *"NORMAL"' | grep -c NORMAL || true) +printf ' %-6s channels: %-3s (Eclair)\n' "eclair" "${ecl_chans:-0}" # '|| echo 0' would be wrong here: grep -c already prints 0 when it finds nothing # and then exits 1, so the echo would append a second line. fwds=$(lncli bob fwdinghistory | grep -c '"chan_id_in"' || true) diff --git a/release-notes/Release-notes-0.15.9.md b/release-notes/Release-notes-0.15.9.md index 3cb451e6..a8b24216 100644 --- a/release-notes/Release-notes-0.15.9.md +++ b/release-notes/Release-notes-0.15.9.md @@ -133,3 +133,14 @@ this release should add its entry under the appropriate section below. a `cln→alice` channel. This gives RTL's Core Lightning screens a real backend for local development and testing — it was used to verify the CLN channel-connection fix above end-to-end. See `docker/README.md`. + +- **Added an Eclair node to the regtest docker fixture** + ([#TBD](https://github.com/Ride-The-Lightning/RTL/pull/TBD)). + The `docker/` fixture now runs an `eclair` node alongside the LND and Core Lightning nodes, + completing backend coverage of all three implementations RTL supports. RTL talks to its HTTP + API with basic auth (`lnApiPassword`), and the seed opens an `eclair→bob` channel plus + payments and an open invoice so RTL's Eclair screens have data. Polar's multi-arch + `polarlightning/eclair` image is used because the official `acinq/eclair` image is amd64-only + and its versioned tags are years stale. Since Eclair drives a bitcoind wallet rather than its + own, an init container creates a dedicated `eclair` wallet before the node starts — otherwise + it would attach to the fixture's mining wallet. A `bin/e-cli` helper wraps `eclair-cli`.