Rebuild the regtest dev fixture: bitcoind + 3 LND nodes + RTL

The setup in docker/ has been unable to start since Feb 2021. The boltz
service added in f817ae39 references BOLTZ_* variables that are not in .env
(so its ports render as ":" and compose rejects the file with "invalid
proto"), a ./boltz build context that has never existed in the repo, and
boltz_data/boltz_shared volumes that are never declared. Because compose
validates the whole project up front, this broke every service: "docker
compose up -d bitcoind", the first command in the README, failed too.

Rather than repair a five-year-old file pinned to bitcoind 0.19.0 and lnd
0.12.0-beta, this replaces it.

What changed:

- bitcoind 30.0 and 3x lnd 0.20.0-beta, using Polar's images. They are
  multi-arch, so nothing is built locally and this works on arm64. The old
  setup built bitcoind and lnd from local Dockerfiles.
- Three nodes, not one: alice -> bob -> carol. bob forwards, so RTL's
  routing and forwarding screens have data. Two nodes leave them empty.
- scripts/seed.sh funds the nodes, opens channels, and makes payments with
  fixed amounts. A fresh run reproduces identical state, so screenshots
  taken before and after a change differ only by the change. It is
  deliberately not idempotent and refuses to run against a seeded network,
  since re-running would double-fund it.
- rtl/RTL-Config.regtest.json configures all three nodes. RTL rewrites its
  config on startup, so an init container stages a copy into a volume: a
  read-only bind mount makes RTL exit with EROFS, and a writable one would
  let RTL modify a tracked file. It is not named RTL-Config.json because
  .gitignore matches that bare name at any depth.
- bin/ln-cli now takes a node name and passes --lnddir=/home/lnd/.lnd,
  because 'docker compose exec' lands as root while lnd's datadir is under
  /home/lnd. Both helpers use compose v2.
- README rewritten to match.

Boltz, Core Lightning and Eclair are left out of this pass. Polar publishes
multi-arch clightning and eclair images, so adding them later needs compose
services, config entries and seed adapters, but no image building.

Verified from a clean 'down -v': all nodes sync, channels go active, 5/5
payments route through bob, bob records 5 forwards, and RTL serves the UI
with all three nodes configured. Two independent from-scratch runs produced
identical balances.

The old bitcoind/ and lnd/ build contexts are now unreferenced but left in
place for a follow-up.
This commit is contained in:
saubyk 2026-07-15 23:09:03 -07:00 committed by Suheb
parent da25d16fb4
commit 4e2b7ca978
7 changed files with 619 additions and 212 deletions

View file

@ -1,18 +1,29 @@
BITCOIN_HOST=bitcoind
BITCOIN_PORT=18889
BITCOIN_RPC_USER=bitcoin
BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=18888
BITCOIN_ZMQ_TX_PORT=28888
BITCOIN_ZMQ_BLOCK_PORT=28889
LIGHTNING_HOST=lnd
LIGHTNING_PORT=9735
LIGHTNING_RPC_PORT=10009
LIGHTNING_REST_PORT=8080
LIGHTNING_LOOP_PORT=8081
RTL_PORT=3000
# Regtest dev fixture. NOT for production. Credentials here are throwaway.
COMPOSE_FILE=docker-compose.yml
COMPOSE_PROJECT_NAME=rtldev
# bitcoind. The rpcauth hash for these credentials is baked into docker-compose.yml;
# if you change the user/password here you must regenerate it (see README).
BITCOIN_HOST=bitcoind
BITCOIN_RPC_USER=rtldev
BITCOIN_RPC_PASSWORD=rtldev
BITCOIN_RPC_PORT=18443
BITCOIN_P2P_PORT=18444
BITCOIN_ZMQ_BLOCK_PORT=28334
BITCOIN_ZMQ_TX_PORT=28335
# LND. Ports are the container-internal ones (identical for every node);
# host-side mappings are assigned per node in docker-compose.yml.
LIGHTNING_REST_PORT=8080
LIGHTNING_RPC_PORT=10009
LIGHTNING_P2P_PORT=9735
# Host-side LND REST ports, one per node
ALICE_REST_PORT=8081
BOB_REST_PORT=8082
CAROL_REST_PORT=8083
# RTL
RTL_PORT=3000
RTL_PASSWORD=password

View file

@ -1,96 +1,115 @@
# 1) RTL Docker Dev Setup
# RTL regtest dev fixture
### This is not suitable for production deployments. ONLY FOR DEVELOPMENT.
### NOT suitable for production. Development only. Every credential here is throwaway.
This `docker-compose` template launches `bitcoind`, `lnd` and `rtl` containers.
It is configured to run in **regtest** mode but can be modified to suit your needs.
### 1.1) Notes
- `bitcoind` is built from an Ubuntu repository and should not be used in production.
- `lnd` will not sync to chain until Bitcoin regtest blocks are generated (see below).
- `rtl` image is from the Docker Hub repository but you can change this to your needs.
- Various ports and configs can be adjusted in the `.env` or `docker-compose.yml` files.
## 1.2) How to run
It may take several minutes if containers need to be built.
1.2.1) From the terminal in this folder:
A self-contained regtest network for developing and testing RTL: `bitcoind`, three
LND nodes, and RTL wired to all three.
```
$ docker-compose up -d bitcoind
$ bin/b-cli generate 101
$ docker-compose up -d lnd rtl
alice --[ 5,000,000 sat ]--> bob --[ 3,000,000 sat ]--> carol
```
1.2.2) Check containers are up and running with:
```
$ docker-compose ps
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.
Node images come from [Polar](https://lightningpolar.com), which publishes multi-arch
(amd64 + arm64) builds. Nothing is built locally, so this works on Apple Silicon.
## Requirements
Docker with Compose v2 (`docker compose`, not `docker-compose`).
## Quick start
From this directory:
```bash
docker compose up -d # bitcoind, alice, bob, carol, rtl
./scripts/seed.sh # fund, connect, open channels, make payments
```
1.2.3) Use the cli tools to get responses from the containers:
```
$ bin/ln-cli getinfo
$ bin/b-cli getblockchaininfo
Then open <http://localhost:3000> — password `password`. All three nodes appear in
the node switcher.
Tear down, discarding all state:
```bash
docker compose down -v
```
1.2.4) View daemon logs as follows:
```
$ docker-compose logs bitcoind lnd rtl
## What the seed creates
| | |
|---|---|
| On-chain | 10,000,000 sats per node, confirmed |
| Channels | alice→bob 5,000,000 sats · bob→carol 3,000,000 sats (1,000,000 pushed each) |
| 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 OPERATOR, carol MERCHANT |
## Determinism
Every amount and payment in `scripts/seed.sh` is fixed. A fresh run always produces
identical state, so screenshots taken before and after a change differ only by the
change. **Do not introduce randomness.**
The seed is deterministic but deliberately *not* idempotent — running it twice would
fund every node again and open a second set of channels. It refuses to run against an
already-seeded network. To start over:
```bash
docker compose down -v && docker compose up -d && ./scripts/seed.sh
```
Once the containers are running you can access the RTL UI at http://localhost:3000
## Helpers
- Default password is `password`.
- Default host, port and password can be changed in `.env`.
When you are done you can destroy containers with:
```
$ docker-compose down -v
```
---
# 2) Stand alone RTL Setup
This is suitable when you already have a LND node running and configured.
## 2.1) From docker image pull
```
RTL_VERSION=0.12.0
docker run --name rtl -d -it \
-e RTL_CONFIG_PATH=/RTLConfig \
-v /path/to/RTLConfig/dir:/RTLConfig \
-v /path/to/macaroon/dir:/path/as/specified/in/RTLConfig \
-v /path/to/database/dir:/RTL/database \
-p 3000:3000/tcp \
shahanafarooqui/rtl:${RTL_VERSION}
```bash
bin/b-cli getblockcount # bitcoin-cli
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
```
## 2.2) From local docker build
### 2.2.1) Build the image locally
```
RTL_VERSION=0.12.0
docker build -t rtl:${RTL_VERSION} -f dockerfiles/Dockerfile .
```
### 2.2.2) Create .env file
Create an environment file with your required configurations. Sample .env:
```
RTL_CONFIG_PATH=/RTLConfig
LN_IMPLEMENTATION=LND
MACAROON_PATH=/LNDMacaroon
LN_SERVER_URL=https://host.docker.internal:8080
Logs:
```bash
docker compose logs -f rtl
docker compose logs alice
```
### 2.2.3) Run the newly built image with .env configurations
```
RTL_VERSION=0.12.0
docker run -d -it \
-v /path/to/RTLConfig/dir:/RTLConfig \
-v /path/to/macaroon/dir:/LNDMacaroon \
-v /path/to/database/dir:/RTL/database \
--env-file=.env -p 3000:3000 rtl:${RTL_VERSION}
## Notes and gotchas
**RTL's config.** `rtl/RTL-Config.regtest.json` is the tracked template. RTL rewrites
its config on startup, so an init container copies it into a volume rather than
bind-mounting it — a read-only mount makes RTL exit with `EROFS`, and a writable one
would let RTL modify a version-controlled file. The name is not `RTL-Config.json`
because `.gitignore` matches that bare filename at any depth.
**`lncli` needs `--lnddir=/home/lnd/.lnd`.** `docker compose exec` lands as root,
whose HOME is `/root`, but lnd's datadir is `/home/lnd/.lnd`. `bin/ln-cli` handles this.
**Changing bitcoind credentials.** `docker-compose.yml` carries an `-rpcauth` hash for
the `BITCOIN_RPC_USER` / `BITCOIN_RPC_PASSWORD` in `.env`. Changing them there is not
enough; regenerate the hash:
```bash
python3 - <<'EOF'
import hmac, hashlib
user, password, salt = "rtldev", "rtldev", "8a1f2c3d4e5b6a7c8d9e0f1a2b3c4d5e"
print(f"{user}:{salt}${hmac.new(salt.encode(), password.encode(), hashlib.sha256).hexdigest()}")
EOF
```
Once the container is running you can access the RTL UI at http://localhost:3000
In `docker-compose.yml` the `$` must be written `$$` to escape Compose interpolation.
---
@hashamadeus on Twitter
**Payments right after channel open will fail.** The channel graph has to reach alice
before she can route to carol. The seed waits for this; anything you script yourself
should too.
## Not included
Core Lightning and Eclair nodes, and the Boltz swap service. Polar publishes
multi-arch `clightning` and `eclair` images, so adding them means compose services,
RTL config entries, and seeding adapters — no image building.

View file

@ -1,10 +1,20 @@
#!/usr/bin/env bash
#
# bitcoin-cli against the regtest fixture.
#
# bin/b-cli getblockchaininfo
# bin/b-cli -rpcwallet=rtldev getbalance
# bin/b-cli -rpcwallet=rtldev generatetoaddress 6 <address>
set -euo pipefail
cd "$(dirname "$0")/.."
# shellcheck disable=SC1091
source .env
docker-compose exec bitcoind bitcoin-cli \
-datadir=/bitcoin \
-rpcuser=$BITCOIN_RPC_USER \
-rpcpassword=$BITCOIN_RPC_PASSWORD \
-rpcport=$BITCOIN_RPC_PORT \
"$@"
exec docker compose exec -T bitcoind bitcoin-cli \
-regtest \
-rpcuser="$BITCOIN_RPC_USER" \
-rpcpassword="$BITCOIN_RPC_PASSWORD" \
-rpcport="$BITCOIN_RPC_PORT" \
"$@"

View file

@ -1,8 +1,32 @@
#!/usr/bin/env bash
#
# lncli against one node of the regtest fixture. The node name is required,
# because the fixture runs three of them.
#
# bin/ln-cli alice getinfo
# bin/ln-cli bob listchannels
# bin/ln-cli carol addinvoice --amt=1000
#
# --lnddir is passed explicitly: 'docker compose exec' lands as root, whose HOME
# is /root, but lnd's datadir is /home/lnd/.lnd. Without it lncli looks for the
# TLS cert in the wrong place and fails.
source .env
set -euo pipefail
docker-compose exec lnd lncli \
--macaroonpath /shared/admin.macaroon \
--tlscertpath /shared/tls.cert \
"$@"
cd "$(dirname "$0")/.."
node="${1:-}"
case "$node" in
alice|bob|carol)
shift
;;
*)
echo "usage: $(basename "$0") <alice|bob|carol> [lncli args...]" >&2
exit 1
;;
esac
exec docker compose exec -T "$node" lncli \
--network=regtest \
--lnddir=/home/lnd/.lnd \
"$@"

View file

@ -1,132 +1,183 @@
version: "2.4"
# Regtest dev fixture for RTL: bitcoind + 3 LND nodes + RTL.
#
# NOT suitable for production. All credentials are throwaway.
#
# Topology is alice -> bob -> carol, so bob forwards payments and RTL's
# routing/forwarding screens have data in them. See README.md.
#
# Node images come from Polar (https://lightningpolar.com), which publishes
# multi-arch (amd64 + arm64) builds. Nothing is built locally.
volumes:
bitcoin_data:
lightning_data:
lightning_shared:
bitcoind_data:
alice_data:
bob_data:
carol_data:
rtl_db:
rtl_config:
x-lnd: &lnd
image: polarlightning/lnd:0.20.0-beta
restart: unless-stopped
depends_on:
- bitcoind
services:
bitcoind:
container_name: ${COMPOSE_PROJECT_NAME}_bitcoind
image: bitcoind:0.19.0
build: ./bitcoind
command: [
"bitcoind",
"-datadir=/bitcoin",
"-port=${BITCOIN_PORT}",
"-upnp=0",
"-dnsseed=0",
"-txindex=1",
"-listen=0",
"-onlynet=ipv4",
"-regtest=1",
"-regtest.rpcport=${BITCOIN_RPC_PORT}",
"-regtest.port=${BITCOIN_PORT}",
"-rpcport=${BITCOIN_RPC_PORT}",
"-rpcuser=${BITCOIN_RPC_USER}",
"-rpcpassword=${BITCOIN_RPC_PASSWORD}",
"-rpcallowip=0.0.0.0/0",
"-zmqpubrawtx=tcp://0.0.0.0:${BITCOIN_ZMQ_TX_PORT}",
"-zmqpubrawblock=tcp://0.0.0.0:${BITCOIN_ZMQ_BLOCK_PORT}",
"-zmqpubhashblock=tcp://0.0.0.0:${BITCOIN_ZMQ_BLOCK_PORT}"
]
ports:
- "${BITCOIN_PORT}:${BITCOIN_PORT}"
volumes:
- bitcoin_data:/bitcoin
lnd:
container_name: ${COMPOSE_PROJECT_NAME}_lnd
image: lnd:0.12.0-beta
build: ./lnd
image: polarlightning/bitcoind:30.0
restart: unless-stopped
command: [
"lnd",
"--noseedbackup",
"--rpclisten=0.0.0.0:${LIGHTNING_RPC_PORT}",
"--restlisten=0.0.0.0:${LIGHTNING_REST_PORT}",
"--adminmacaroonpath=/shared/admin.macaroon",
"--tlsextradomain=${LIGHTNING_HOST}",
"--tlsextraip=0.0.0.0",
"--tlscertpath=/shared/tls.cert",
"--datadir=/lnd",
"--bitcoin.active",
"--bitcoin.regtest",
"--bitcoin.node=bitcoind",
"--bitcoind.rpchost=${BITCOIN_HOST}:${BITCOIN_RPC_PORT}",
"--bitcoind.rpcuser=${BITCOIN_RPC_USER}",
"--bitcoind.rpcpass=${BITCOIN_RPC_PASSWORD}",
"--bitcoind.zmqpubrawtx=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_TX_PORT}",
"--bitcoind.zmqpubrawblock=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_BLOCK_PORT}"
]
depends_on:
command:
- bitcoind
- -server=1
- -regtest=1
# rpcauth hash for ${BITCOIN_RPC_USER}/${BITCOIN_RPC_PASSWORD}. '$$' escapes
# compose interpolation and reaches bitcoind as a single '$'.
- -rpcauth=rtldev:8a1f2c3d4e5b6a7c8d9e0f1a2b3c4d5e$$010df4b32c5e9a556cba1857eb5865990c983d8a56dadc0fdbf457cf90073c6c
- -zmqpubrawblock=tcp://0.0.0.0:${BITCOIN_ZMQ_BLOCK_PORT}
- -zmqpubrawtx=tcp://0.0.0.0:${BITCOIN_ZMQ_TX_PORT}
- -txindex=1
- -dnsseed=0
- -rpcbind=0.0.0.0
- -rpcallowip=0.0.0.0/0
- -rpcport=${BITCOIN_RPC_PORT}
- -listen=1
- -listenonion=0
- -fallbackfee=0.0002
ports:
- "${LIGHTNING_REST_PORT}:${LIGHTNING_REST_PORT}"
- "${BITCOIN_RPC_PORT}:${BITCOIN_RPC_PORT}"
volumes:
- lightning_data:/lnd
- lightning_shared:/shared
- bitcoind_data:/home/bitcoin/.bitcoin
boltz:
container_name: ${COMPOSE_PROJECT_NAME}_boltz
image: boltz:1.2.0
build: ./boltz
restart: unless-stopped
command: [
"boltz",
"--noseedbackup",
"--rpclisten=0.0.0.0:${BOLTZ_RPC_PORT}",
"--restlisten=0.0.0.0:${BOLTZ_REST_PORT}",
"--adminmacaroonpath=/shared/admin.macaroon",
"--tlsextradomain=${BOLTZ_HOST}",
"--tlsextraip=0.0.0.0",
"--tlscertpath=/shared/tls.cert",
"--datadir=/boltz",
"--bitcoin.active",
"--bitcoin.regtest",
"--bitcoin.node=bitcoind",
"--bitcoind.rpchost=${BITCOIN_HOST}:${BITCOIN_RPC_PORT}",
"--bitcoind.rpcuser=${BITCOIN_RPC_USER}",
"--bitcoind.rpcpass=${BITCOIN_RPC_PASSWORD}",
"--bitcoind.zmqpubrawtx=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_TX_PORT}",
"--bitcoind.zmqpubrawblock=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_BLOCK_PORT}"
]
depends_on:
- bitcoind
# --alias / --externalip / --tlsextradomain are per-node on purpose: the alias
# is what RTL displays, and the extradomain must match the hostname RTL dials
# (https://alice:8080) or TLS validation fails.
alice:
<<: *lnd
container_name: ${COMPOSE_PROJECT_NAME}_alice
command:
- lnd
- --noseedbackup
- --trickledelay=5000
- --alias=alice
- --externalip=alice
- --tlsextradomain=alice
- --tlsextradomain=${COMPOSE_PROJECT_NAME}_alice
- --tlsextradomain=host.docker.internal
- --listen=0.0.0.0:${LIGHTNING_P2P_PORT}
- --rpclisten=0.0.0.0:${LIGHTNING_RPC_PORT}
- --restlisten=0.0.0.0:${LIGHTNING_REST_PORT}
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=${BITCOIN_HOST}:${BITCOIN_RPC_PORT}
- --bitcoind.rpcuser=${BITCOIN_RPC_USER}
- --bitcoind.rpcpass=${BITCOIN_RPC_PASSWORD}
- --bitcoind.zmqpubrawblock=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_BLOCK_PORT}
- --bitcoind.zmqpubrawtx=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_TX_PORT}
- --accept-keysend
- --accept-amp
ports:
- "${BOLTZ_REST_PORT}:${BOLTZ_REST_PORT}"
- "${ALICE_REST_PORT}:${LIGHTNING_REST_PORT}"
volumes:
- boltz_data:/boltz
- boltz_shared:/shared
- alice_data:/home/lnd/.lnd
bob:
<<: *lnd
container_name: ${COMPOSE_PROJECT_NAME}_bob
command:
- lnd
- --noseedbackup
- --trickledelay=5000
- --alias=bob
- --externalip=bob
- --tlsextradomain=bob
- --tlsextradomain=${COMPOSE_PROJECT_NAME}_bob
- --tlsextradomain=host.docker.internal
- --listen=0.0.0.0:${LIGHTNING_P2P_PORT}
- --rpclisten=0.0.0.0:${LIGHTNING_RPC_PORT}
- --restlisten=0.0.0.0:${LIGHTNING_REST_PORT}
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=${BITCOIN_HOST}:${BITCOIN_RPC_PORT}
- --bitcoind.rpcuser=${BITCOIN_RPC_USER}
- --bitcoind.rpcpass=${BITCOIN_RPC_PASSWORD}
- --bitcoind.zmqpubrawblock=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_BLOCK_PORT}
- --bitcoind.zmqpubrawtx=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_TX_PORT}
- --accept-keysend
- --accept-amp
ports:
- "${BOB_REST_PORT}:${LIGHTNING_REST_PORT}"
volumes:
- bob_data:/home/lnd/.lnd
carol:
<<: *lnd
container_name: ${COMPOSE_PROJECT_NAME}_carol
command:
- lnd
- --noseedbackup
- --trickledelay=5000
- --alias=carol
- --externalip=carol
- --tlsextradomain=carol
- --tlsextradomain=${COMPOSE_PROJECT_NAME}_carol
- --tlsextradomain=host.docker.internal
- --listen=0.0.0.0:${LIGHTNING_P2P_PORT}
- --rpclisten=0.0.0.0:${LIGHTNING_RPC_PORT}
- --restlisten=0.0.0.0:${LIGHTNING_REST_PORT}
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=${BITCOIN_HOST}:${BITCOIN_RPC_PORT}
- --bitcoind.rpcuser=${BITCOIN_RPC_USER}
- --bitcoind.rpcpass=${BITCOIN_RPC_PASSWORD}
- --bitcoind.zmqpubrawblock=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_BLOCK_PORT}
- --bitcoind.zmqpubrawtx=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_TX_PORT}
- --accept-keysend
- --accept-amp
ports:
- "${CAROL_REST_PORT}:${LIGHTNING_REST_PORT}"
volumes:
- carol_data:/home/lnd/.lnd
# 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
# template is copied into a volume that 'down -v' discards, which keeps the
# source pristine and every run starting from identical config.
rtl-config-init:
container_name: ${COMPOSE_PROJECT_NAME}_rtl_config_init
image: busybox:1.36
command: >
sh -c "cp /template/RTL-Config.regtest.json /config/RTL-Config.json &&
chmod 644 /config/RTL-Config.json &&
echo 'config staged'"
volumes:
- ./rtl/RTL-Config.regtest.json:/template/RTL-Config.regtest.json:ro
- rtl_config:/config
rtl:
container_name: ${COMPOSE_PROJECT_NAME}_rtl
image: shahanafarooqui/rtl:0.12.0
image: shahanafarooqui/rtl:v0.15.8
restart: unless-stopped
depends_on:
- lnd
volumes:
- lightning_shared:/shared:ro
- rtl_db:/database
rtl-config-init:
condition: service_completed_successfully
alice:
condition: service_started
bob:
condition: service_started
carol:
condition: service_started
ports:
- "${RTL_PORT}:${RTL_PORT}"
environment:
PORT: ${RTL_PORT}
HOST: 192.168.0.27
MACAROON_PATH: /shared
LN_SERVER_URL: https://${LIGHTNING_HOST}:${LIGHTNING_REST_PORT}
CONFIG_PATH: ''
LN_IMPLEMENTATION: LND
SWAP_SERVER_URL: https://${LIGHTNING_HOST}:${LIGHTNING_LOOP_PORT}
SWAP_MACAROON_PATH: /shared
BOLTZ_SERVER_URL: https://${BOLTZ_HOST}:${BOLTZ_PORT}
BOLTZ_MACAROON_PATH: /shared
RTL_SSO: 0
RTL_COOKIE_PATH: ''
LOGOUT_REDIRECT_LINK: ''
RTL_CONFIG_PATH: /RTL
BITCOIND_CONFIG_PATH: ''
CHANNEL_BACKUP_PATH: /shared/lnd/backup
ENABLE_OFFERS: false
ENABLE_PEERSWAP: false
RTL_CONFIG_PATH: /RTL/config
volumes:
- rtl_config:/RTL/config
- alice_data:/lnd/alice:ro
- bob_data:/lnd/bob:ro
- carol_data:/lnd/carol:ro
- rtl_db:/RTL/database

View file

@ -0,0 +1,67 @@
{
"multiPass": "password",
"port": "3000",
"defaultNodeIndex": 1,
"dbDirectoryPath": "/RTL/database",
"SSO": {
"rtlSSO": 0,
"rtlCookiePath": "",
"logoutRedirectLink": ""
},
"nodes": [
{
"index": 1,
"lnNode": "alice",
"lnImplementation": "LND",
"authentication": {
"macaroonPath": "/lnd/alice/data/chain/bitcoin/regtest"
},
"settings": {
"userPersona": "OPERATOR",
"themeMode": "DAY",
"themeColor": "PURPLE",
"logLevel": "ERROR",
"lnServerUrl": "https://alice:8080",
"fiatConversion": false,
"unannouncedChannels": false,
"blockExplorerUrl": "https://mempool.space"
}
},
{
"index": 2,
"lnNode": "bob",
"lnImplementation": "LND",
"authentication": {
"macaroonPath": "/lnd/bob/data/chain/bitcoin/regtest"
},
"settings": {
"userPersona": "OPERATOR",
"themeMode": "DAY",
"themeColor": "PURPLE",
"logLevel": "ERROR",
"lnServerUrl": "https://bob:8080",
"fiatConversion": false,
"unannouncedChannels": false,
"blockExplorerUrl": "https://mempool.space"
}
},
{
"index": 3,
"lnNode": "carol",
"lnImplementation": "LND",
"authentication": {
"macaroonPath": "/lnd/carol/data/chain/bitcoin/regtest"
},
"settings": {
"userPersona": "MERCHANT",
"themeMode": "DAY",
"themeColor": "PURPLE",
"logLevel": "ERROR",
"lnServerUrl": "https://carol:8080",
"fiatConversion": false,
"unannouncedChannels": false,
"blockExplorerUrl": "https://mempool.space"
}
}
]
}

225
docker/scripts/seed.sh Executable file
View file

@ -0,0 +1,225 @@
#!/usr/bin/env bash
#
# Seed the regtest fixture with a deterministic scenario.
#
# Every amount, capacity and payment below is fixed on purpose. Re-running this
# against a fresh network must produce the same state, so that screenshots taken
# now and after a redesign differ only by the design. Do not introduce randomness.
#
# Topology:
#
# alice --[ 5,000,000 sat ]--> bob --[ 3,000,000 sat ]--> carol
#
# bob sits in the middle so it accrues forwarding history, which is what
# populates RTL's routing screens.
#
# Usage: ./scripts/seed.sh (from the docker/ directory)
set -euo pipefail
cd "$(dirname "$0")/.."
BITCOIN_RPC_USER="${BITCOIN_RPC_USER:-rtldev}"
BITCOIN_RPC_PASSWORD="${BITCOIN_RPC_PASSWORD:-rtldev}"
NODES=(alice bob carol)
# Deterministic scenario constants
FUND_SATS=10000000 # on-chain funding per node
CH_ALICE_BOB=5000000 # channel capacity alice -> bob
CH_BOB_CAROL=3000000 # channel capacity bob -> carol
PUSH_SATS=1000000 # pushed to remote on open, so both sides have liquidity
MINE_CONFIRM=6 # blocks to confirm a funding tx
log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; }
info() { printf ' %s\n' "$*"; }
die() { printf '\n\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
bcli() {
docker compose exec -T bitcoind bitcoin-cli -regtest \
-rpcuser="$BITCOIN_RPC_USER" -rpcpassword="$BITCOIN_RPC_PASSWORD" "$@"
}
# 'docker compose exec' lands as root, whose HOME is /root, but lnd's datadir is
# /home/lnd/.lnd -- so lncli must be told where to find the cert and macaroon.
lncli() {
local node=$1; shift
docker compose exec -T "$node" lncli --network=regtest --lnddir=/home/lnd/.lnd "$@"
}
# 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.
json_first() {
grep -o "\"$1\": *\"[^\"]*\"" | head -1 | sed -e 's/^[^:]*: *"//' -e 's/"$//'
}
# Wait for a command to succeed, up to N attempts.
wait_for() {
local desc=$1 attempts=$2; shift 2
local i=1
while (( i <= attempts )); do
if "$@" >/dev/null 2>&1; then
info "$desc ready (${i}s)"
return 0
fi
sleep 1
(( i++ ))
done
die "timed out after ${attempts}s waiting for: $desc"
}
# ---------------------------------------------------------------- bitcoind
log "Waiting for bitcoind"
wait_for "bitcoind RPC" 60 bcli getblockchaininfo
log "Preparing wallet"
if ! bcli listwallets | grep -q '"rtldev"'; then
bcli createwallet rtldev >/dev/null 2>&1 || bcli loadwallet rtldev >/dev/null
fi
info "wallet rtldev present"
MINE_ADDR=$(bcli -rpcwallet=rtldev getnewaddress)
info "mining address: $MINE_ADDR"
HEIGHT=$(bcli getblockcount)
if (( HEIGHT < 101 )); then
log "Mining 101 blocks (coinbase maturity)"
bcli -rpcwallet=rtldev generatetoaddress 101 "$MINE_ADDR" >/dev/null
else
info "chain already at height $HEIGHT, skipping initial mine"
fi
# ---------------------------------------------------------------- lnd nodes
log "Waiting for LND nodes"
for n in "${NODES[@]}"; do
wait_for "$n" 120 lncli "$n" getinfo
done
# This script is deterministic, not idempotent: running it twice would fund every
# node again and open a second set of channels. Refuse rather than corrupt the
# fixture, since the whole point is that a fresh run reproduces identical state.
if lncli alice listchannels | grep -q '"chan_id"'; then
die "network is already seeded -- re-running would double-fund it.
Reset with: docker compose down -v && docker compose up -d && ./scripts/seed.sh"
fi
log "Funding nodes (${FUND_SATS} sats each)"
BTC_AMOUNT=$(awk "BEGIN{printf \"%.8f\", $FUND_SATS/100000000}")
for n in "${NODES[@]}"; do
addr=$(lncli "$n" newaddress p2wkh | json_first address)
[ -n "$addr" ] || die "could not get address for $n"
bcli -rpcwallet=rtldev sendtoaddress "$addr" "$BTC_AMOUNT" >/dev/null
info "$n <- $BTC_AMOUNT BTC ($addr)"
done
bcli -rpcwallet=rtldev generatetoaddress "$MINE_CONFIRM" "$MINE_ADDR" >/dev/null
info "mined $MINE_CONFIRM blocks to confirm funding"
log "Waiting for confirmed on-chain balances"
for n in "${NODES[@]}"; do
for i in $(seq 1 60); do
bal=$(lncli "$n" walletbalance | json_first confirmed_balance)
bal=${bal:-0}
(( bal > 0 )) && { info "$n confirmed balance: $bal sats"; break; }
sleep 1
(( i == 60 )) && die "$n never saw confirmed funds"
done
done
# ---------------------------------------------------------------- peers
pubkey_of() {
lncli "$1" getinfo | json_first identity_pubkey
}
log "Connecting peers"
BOB_PUB=$(pubkey_of bob)
CAROL_PUB=$(pubkey_of carol)
info "bob pubkey: $BOB_PUB"
info "carol pubkey: $CAROL_PUB"
lncli alice connect "${BOB_PUB}@bob:9735" >/dev/null 2>&1 || info "alice->bob already connected"
lncli bob connect "${CAROL_PUB}@carol:9735" >/dev/null 2>&1 || info "bob->carol already connected"
info "peers connected"
# ---------------------------------------------------------------- channels
log "Opening channels"
lncli alice openchannel --node_key="$BOB_PUB" \
--local_amt="$CH_ALICE_BOB" --push_amt="$PUSH_SATS" >/dev/null
info "alice -> bob ${CH_ALICE_BOB} sats (push ${PUSH_SATS})"
lncli bob openchannel --node_key="$CAROL_PUB" \
--local_amt="$CH_BOB_CAROL" --push_amt="$PUSH_SATS" >/dev/null
info "bob -> carol ${CH_BOB_CAROL} sats (push ${PUSH_SATS})"
bcli -rpcwallet=rtldev generatetoaddress "$MINE_CONFIRM" "$MINE_ADDR" >/dev/null
info "mined $MINE_CONFIRM blocks to confirm channels"
log "Waiting for channels to become active"
for n in alice bob; do
for i in $(seq 1 60); do
active=$(lncli "$n" listchannels | grep -c '"active": *true' || true)
(( active > 0 )) && { info "$n has $active active channel(s)"; break; }
sleep 1
(( i == 60 )) && die "$n has no active channels"
done
done
# ---------------------------------------------------------------- payments
# alice can only route to carol once the bob->carol channel has been announced and
# reached her graph. Channels are confirmed by now, but gossip is not instant --
# --trickledelay alone is 5s. Paying before this lands fails with "no route".
log "Waiting for the channel graph to reach alice"
for i in $(seq 1 90); do
edges=$(lncli alice describegraph | grep -c '"channel_id"' || true)
(( ${edges:-0} >= 2 )) && { info "alice sees ${edges} channels in her graph"; break; }
sleep 1
(( i == 90 )) && die "channel graph never propagated to alice"
done
# Fixed amounts. alice -> carol routes through bob, generating forwarding history.
log "Sending payments (alice -> carol, routed via bob)"
for amt in 10000 25000 50000 75000 100000; do
inv=$(lncli carol addinvoice --amt="$amt" --memo="seed payment ${amt} sats" \
| json_first payment_request)
if lncli alice payinvoice --force --pay_req="$inv" >/dev/null 2>&1; then
info "alice -> carol ${amt} sats (routed)"
else
info "alice -> carol ${amt} sats FAILED (route not ready?)"
fi
done
log "Sending direct payments (alice -> bob)"
for amt in 5000 15000; do
inv=$(lncli bob addinvoice --amt="$amt" --memo="direct payment ${amt} sats" \
| json_first payment_request)
lncli alice payinvoice --force --pay_req="$inv" >/dev/null 2>&1 \
&& info "alice -> bob ${amt} sats" \
|| info "alice -> bob ${amt} sats FAILED"
done
# Unsettled invoices, so the invoice list shows more than one state.
log "Creating open (unpaid) invoices on carol"
for amt in 20000 40000; do
lncli carol addinvoice --amt="$amt" --memo="open invoice ${amt} sats" >/dev/null
info "carol open invoice ${amt} sats"
done
# ---------------------------------------------------------------- summary
log "Seed complete"
for n in "${NODES[@]}"; do
chans=$(lncli "$n" listchannels | grep -c '"active": *true' || true)
bal=$(lncli "$n" walletbalance | json_first confirmed_balance)
printf ' %-6s channels: %-3s on-chain: %s sats\n' "$n" "${chans:-0}" "${bal:-0}"
done
# '|| 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)
printf ' bob forwarded %s payment(s)\n' "${fwds:-0}"
printf '\n RTL: http://localhost:%s (password: %s)\n\n' "${RTL_PORT:-3000}" "${RTL_PASSWORD:-password}"