Rebalancing a Lightning Network Triangle
A Practical Guide to “Ring of Fire” Swaps
Anyone who has run a routing node on the Lightning Network for a while eventually hits the same wall: channels drift out of balance. Some fill up entirely with outbound liquidity you can’t push anywhere, others end up stuffed with inbound liquidity you can’t receive into. Submarine swaps and paid rebalancing services can fix this, but they cost money. A ring of fire is a way to fix it for free, by cooperating with other node operators to open a cycle of channels and then push payments around that cycle until everyone’s liquidity sits where they want it.
This article walks through the mechanics of a ring of fire rebalance on lnd, using the exact lncli primitives involved buildroute, sendtoroute, and a jq transformation in between and closes with a real worked example: a triangle rebalance between two peers, referred to here as Ciso and Zaba.
What a Ring of Fire Actually Is
A ring of fire is a group of node operators who agree to open channels with each other in a cycle: node A opens a channel to node B, B opens to C, and C opens back to A. Once the cycle exists, each participant can rebalance their own channels by sending a payment to themselves that loops all the way around the ring and back home.
The simplest version is a triangle: three nodes, three channels, forming a closed loop. That’s the case this article focuses on, and it’s also the most common ring of fire configuration in practice squares (four participants) and pentagons (five) follow the same logic, just with a longer hop list.
The reason this works at all comes down to how Lightning routing operates: a payment doesn’t need to go to someone else’s node. If you build a route that starts at you, passes through your peers, and ends back at your own node, and you hold both the invoice and the payment, you’ve just moved liquidity from your outbound channel to your inbound channel paying only the routing fees along the way.
Why You Can’t Just Use lncli payinvoice
Normally, paying an invoice is simple: lncli payinvoice <bolt11>, and lnd‘s pathfinding does everything for you. A ring of fire rebalance doesn’t work that way, for two reasons:
You need a specific path, not whatever the pathfinding algorithm decides is cheapest. The whole point is to force the payment out through one particular channel and back in through another.
The destination is yourself, and self-payments need a bit of manual assembly because the automatic pathfinder is built around paying other people.
This is where three lower-level lncli commands come in: buildroute, sendtoroute, and, sitting between them, a small jq transformation.
The Three Pieces
1. lncli buildroute
lncli buildroute --amt <AMOUNT_SAT> --hops <pubkey1>,<pubkey2>,...,<pubkeyN> [--outgoing_chan_id <CHAN_ID>]buildroute builds a fully specified route across a list of hop public keys, pulling real channel policies (fees, timelocks) from the network graph so the route is actually valid. Key details:
--amtis in satoshis.--hopsis an ordered, comma-separated list of node public keys, starting with your first-hop peer and ending with the final destination. For a ring of fire self-payment, the last pubkey in the list must be your own node’s identity pubkey — get it withlncli getinfo | jq -r .identity_pubkey.--outgoing_chan_idis optional but important in practice: if you have more than one channel to your first hop, or you specifically want to drain a particular channel, this pins the route to that channel rather than lettinglndpick one.
buildroute on its own doesn’t send anything — it just returns a JSON object describing the route (hops, fees, timelocks). This makes it useful as a dry run: if it fails to find a route, something’s wrong with the topology (a channel might be private, fees might make the route uneconomical, or gossip about a freshly opened channel hasn’t propagated yet) before you’ve risked anything.
2. The jq transformation
jq -r '(.route.hops[-1] | .mpp_record) |= {payment_addr:"<PAYMENT_ADDR>", total_amt_msat:"<AMOUNT_MSAT>"}'This is the part that trips people up, because it’s easy to skip and the payment will still look like it’s about to work until it’s rejected. buildroute has no idea what invoice you’re paying; it only knows pubkeys and amounts. But since BOLT 11 invoices carry a payment_addr (payment secret) that the receiving node checks, the route needs that secret attached to its final hop before it’s sent, or the receiving node will reject it as an unknown or incorrect payment.
The jq command reaches into the last element of .route.hops (the destination — you, in this case) and sets its mpp_record field to:
payment_addr: the payment secret from the invoice you generated for yourselftotal_amt_msat: the invoice amount, in millisatoshis (note the unit change from the satoshi amount used inbuildroute— this is a common source of mismatched numbers if copy-pasted carelessly)
3. lncli sendtoroute
lncli sendtoroute --payment_hash=<R_HASH> -sendtoroute sends a payment along a specific, pre-built route rather than letting lnd find one. The trailing - tells it to read the route JSON from standard input — which is exactly what lets you chain buildroute → jq → sendtoroute into a single pipeline. --payment_hash must match the r_hash of the invoice you generated.
Put together, the full pipeline looks like this:
lncli buildroute --amt <AMOUNT_SAT> --hops <hop1>,<hop2>,<hop3> --outgoing_chan_id <CHAN_ID> \
| jq -r '(.route.hops[-1] | .mpp_record) |= {payment_addr:"<PAYMENT_ADDR>", total_amt_msat:"<AMOUNT_MSAT>"}' \
| lncli sendtoroute --payment_hash=<R_HASH> -A Detail That Trips People Up: Channel IDs Aren’t the Same Everywhere
--outgoing_chan_id wants lnd‘s internal 64-bit integer channel ID. Most graphical tools (RTL, ThunderHub, Ride The Lightning dashboards) instead display the short channel ID in the human-readable form blockheight x transaction_index x output_index, e.g. 959111x2365x0.
These are two representations of the same underlying identifier, and converting between them is a straightforward bit-packing operation:
chan_id = (block_height << 40) | (tx_index << 16) | output_indexFor example, a channel shown in a dashboard as 959111x2365x0 converts to the numeric chan_id 1054553696982859776. If your tooling only gives you the human-readable form, you’ll need to do this conversion (or find a tool that does it for you) before passing it to --outgoing_chan_id.
Worked Example: A Real Triangle Rebalance
Here’s a concrete case, based on an actual triangle: three nodes, lets call them You, Ciso, and Zaba, each holding a direct channel to the next, forming a closed loop (You→Ciso, Ciso→Zaba, Zaba→You).
The starting imbalance:
Channel Capacity Local balance Remote balance Problem You ↔ Ciso 5,000,000 sats ~5,000,000 (all local) ~0 Can’t receive on this channel You ↔ Zaba 5,000,000 sats ~0 ~5,000,000 (all remote) Can’t send on this channel
The fix: push liquidity out through the Ciso channel and have it come back in through the Zaba channel, via a self-payment that loops through both peers.
After running the rebalance with an amount of 2,500,000 sats, the result looked like this:
Channel Local before Remote before Local after Remote after You ↔ Ciso ~5,000,000 ~0 ~2,500,000 ~2,500,000 You ↔ Zaba ~0 ~5,000,000 ~2,500,000 ~2,200,000
The Ciso channel is now split almost exactly down the middle. The Zaba channel is close but not identical: the small gap between local and remote there (2,500,000 vs. 2,200,000) reflects the routing fees paid to intermediate hops along the loop, which come out of the amount that actually lands back on the Zaba side.
Step 1 — Confirm your own identity pubkey, since it needs to be the last hop in the route:
lncli getinfo | jq -r .identity_pubkeyStep 2 — Convert the outgoing channel’s short ID to a numeric chan_id, since --outgoing_chan_id needs the integer form, not the block x tx x output form shown in most UIs:
959111x2365x0 → 1054553696982859776The formula used by LND for the chan_id is:
chan_id = (block_height << 40) | (tx_index << 16) | output_indexWhere 959111x2365x0 is so composed:
block_height= 959111 (block where funding transaction got confirmed)tx_index= 2365 (position of that transaction into that block)output_index= 0 (what transaction output is the channel funding output)
Step 3 — Build the hop list. Since Ciso and Zaba have a direct channel between them (that’s what makes it a triangle, not just two unconnected channels), the route is: your peer Ciso → your peer Zaba → back to you.
Step 4 — Assemble the full script:
#!/bin/bash
set -e
AMOUNT_SAT=2500000 # amount to move from Ciso-side to Zaba-side
AMOUNT_MSAT=$((AMOUNT_SAT * 1000))
HOPS="<Ciso_pubkey>,<Zaba_pubkey>,<your_own_pubkey>"
OUTGOING_CHAN_ID=1054553696982859776 # your channel with Ciso
# 1) Create an invoice payable to yourself
read R_HASH PAY_REQ PAY_ADDR < <(echo $(lncli addinvoice --amt $AMOUNT_SAT | jq -r '.r_hash, .payment_request, .payment_addr'))
echo "Amount: $AMOUNT_SAT sat"
echo "r_hash: $R_HASH"
echo "payment_addr: $PAY_ADDR"
# 2) Build the route (forcing it out through the Ciso channel), patch in the MPP record, send
lncli buildroute --amt $AMOUNT_SAT --hops $HOPS --outgoing_chan_id $OUTGOING_CHAN_ID | \
jq -r "(.route.hops[-1] | .mpp_record) |= {payment_addr:\"$PAY_ADDR\", total_amt_msat: \"$AMOUNT_MSAT\"}" | \
lncli sendtoroute --payment_hash=$R_HASH -Step 5 — Sanity-check before sending. It’s worth running just the buildroute half of the pipeline first, without piping into sendtoroute, to confirm a route actually exists and to inspect the total_fees field in its output. If Ciso or Zaba have non-trivial fees set on their side of the triangle, this is where you’ll see the real cost of the rebalance before committing to it and it’s a good moment to ask the other participants to lower fees if the cost looks too high.
Once the amount and fees look reasonable, run the full pipeline. A successful result returns a SUCCESS status along with the total fees actually paid; the local balance on the Ciso channel drops by the rebalanced amount (plus fees), and the local balance on the Zaba channel increases by the same amount — exactly the shift needed to fix the original imbalance.
Common Pitfalls
Forgetting the
jqstep, or using it but forgetting to convert to millisatoshis. A route without a correctly populatedmpp_recordor one wheretotal_amt_msatdoesn’t match the invoice amount in the right unit — gets rejected by the destination even though the route itself is valid.Wrong hop order. The last entry in
--hopsmust be your own node, not one of your peers. Confirm it before running the pipeline, not after.Stale gossip. If a channel in the triangle was opened very recently,
buildroutemay fail to find a route simply because the network hasn’t yet learned about it. Waiting an hour and retrying is often enough.Private channels. If any leg of the triangle was accidentally opened as a private channel,
buildroutewon’t be able to route through it as an intermediate hop, and the swap won’t work until it’s announced (or the topology is adjusted).Confusing short channel IDs with numeric chan_ids. Dashboards show one;
--outgoing_chan_idwants the other. Converting incorrectly will silently target the wrong channel or produce an error, not a helpful hint.
Interesting isn’t it?
A ring of fire costs nothing but routing fees and a bit of coordination with the other participants no swap service, no on-chain transaction, no third party to trust. The trade-off is that it requires understanding lnd‘s lower-level routing primitives rather than a single one-line command, since you’re deliberately overriding the automatic pathfinder to force a specific loop. Once the pattern clicks build the route, patch in the payment secret, send along that exact route it becomes a routine tool for keeping a small, cooperative group of channels balanced indefinitely.
This has been executed and completed on a nimblenode Node built in a denali.pro VPS with both clearnet and onion address.




