Balancing Hop Count and Liquidity
When a Lightning Network node operator decides where to open new payment channels, two competing forces are at play. Fewer hops mean lower fees and higher success rates, but a channel with too little liquidity can kill a route just as effectively as having no channel at all. This is a genuine network design problem: given a limited capital budget, which set of channels should you open to make the network route payments as reliably and efficiently as possible?
In this article we build a concrete, budget-constrained topology optimizer in Python. We model channel liquidity probabilistically, combine it with hop count into a single routing cost metric, and use a greedy marginal-value algorithm to decide which channels are worth opening first.
Problem Formulation
We treat the network as a graph where nodes are Lightning nodes and edges are payment channels. Each channel has a capacity $c$ (total sats locked in the channel) and a cost to open it (on-chain fee plus a distance-proportional setup cost, representing negotiation and monitoring overhead).
A well-known way to estimate the probability that a payment of amount $a$ succeeds through a channel — assuming no information about the current balance split — is the uniform balance assumption:
$$
p(c, a) = \max\left(0, \frac{c - a}{c}\right)
$$
To route a payment, we need every channel on the path to succeed, so the probability of a whole path succeeding is the product of the per-channel probabilities. To turn this into something a shortest-path algorithm can optimize, we convert it into an additive cost by taking the negative log, and add a fixed penalty $\beta$ per hop (representing routing fees and latency):
$$
w(e, a) = \beta - \ln p(e, a)
$$
Minimizing the sum of $w(e,a)$ along a path simultaneously keeps the hop count low (via $\beta$) and keeps liquidity risk low (via $-\ln p$). The routing cost between two nodes becomes:
$$
D(u,v) = \min_{\text{path } P:, u \to v} \sum_{e \in P} w(e, a)
$$
The topology design problem is then: choose a subset $S$ of candidate channels, within budget $B$, that minimizes the average routing cost across all node pairs:
$$
\min_{S \subseteq E_{cand}} ; \frac{1}{\binom{N}{2}} \sum_{i<j} D_S(i,j)
\quad \text{s.t.} \quad \sum_{e \in S} \text{cost}(e) \le B
$$
This is a budgeted network augmentation problem, a relative of the Steiner network and budgeted maximum coverage problems — both are NP-hard. We use a greedy marginal cost-effectiveness heuristic: at each step, add the candidate channel that reduces the average routing cost the most per sat spent:
$$
e^{*} = \arg\max_{e \in \text{pool},\ \text{cost}(e) \le B_{rem}} \frac{\bar D_{\text{current}} - \bar D_{\text{current} \cup {e}}}{\text{cost}(e)}
$$
This kind of greedy rule is standard for submodular-like coverage problems and tends to perform close to optimal in practice, even though it carries no formal guarantee here.
Full Source Code
1 | import numpy as np |
Channels opened: 16 Total capital used: 795,273 sat (budget 800,000 sat) Average routing cost: 2.974 -> 1.012 (66.0% reduction) + channel (4, 7) capacity=2,227,405 sat cumulative_cost=45,902 avg_cost=2.357 roi=0.00001 + channel (0, 7) capacity=5,701,530 sat cumulative_cost=92,301 avg_cost=2.002 roi=0.00001 + channel (0, 6) capacity=4,349,069 sat cumulative_cost=139,728 avg_cost=1.743 roi=0.00001 + channel (0, 12) capacity=5,163,390 sat cumulative_cost=174,270 avg_cost=1.626 roi=0.00000 + channel (0, 1) capacity=4,432,391 sat cumulative_cost=211,029 avg_cost=1.532 roi=0.00000 + channel (0, 14) capacity=4,501,325 sat cumulative_cost=279,312 avg_cost=1.378 roi=0.00000 + channel (0, 8) capacity=3,185,759 sat cumulative_cost=329,073 avg_cost=1.328 roi=0.00000 + channel (0, 13) capacity=4,148,821 sat cumulative_cost=384,111 avg_cost=1.275 roi=0.00000 + channel (0, 11) capacity=1,803,440 sat cumulative_cost=438,713 avg_cost=1.224 roi=0.00000 + channel (0, 3) capacity=2,650,981 sat cumulative_cost=481,506 avg_cost=1.184 roi=0.00000 + channel (0, 5) capacity=3,822,499 sat cumulative_cost=547,139 avg_cost=1.133 roi=0.00000 + channel (0, 4) capacity=2,134,546 sat cumulative_cost=613,814 avg_cost=1.081 roi=0.00000 + channel (0, 2) capacity=3,378,524 sat cumulative_cost=698,105 avg_cost=1.029 roi=0.00000 + channel (1, 6) capacity=5,161,299 sat cumulative_cost=732,998 avg_cost=1.023 roi=0.00000 + channel (3, 9) capacity=2,385,608 sat cumulative_cost=761,447 avg_cost=1.018 roi=0.00000 + channel (3, 11) capacity=3,778,234 sat cumulative_cost=795,273 avg_cost=1.012 roi=0.00000




Code Walkthrough
Section 1 — Node placement. We scatter 15 nodes randomly in a 100×100 coordinate space. This isn’t meant to represent real-world geography; it’s a proxy for “distance” in the sense of negotiation effort, monitoring overhead, and on-chain fee variance between two nodes. The pairwise distance matrix drives channel opening cost via channel_cost.
Section 2 — Base network. We compute a minimum spanning tree over all 15 nodes. This represents the channels that are already open — the cheapest possible connected topology, but with no redundancy and no consideration of liquidity. Every real network starts from some existing state; here it’s the MST.
Section 3 — Candidate channels. All remaining node pairs become candidates the optimizer can choose to open, each with a random capacity and a distance-based cost.
Section 4 — The liquidity model. success_prob implements the uniform balance assumption formula from the introduction. edge_weight folds that probability, plus a fixed per-hop penalty, into a single additive cost. build_weight_matrix turns a dictionary of edges into an N×N cost matrix. One subtlety worth calling out explicitly: scipy’s dense csgraph representation treats a 0 entry as “no edge”, not np.inf. Since our weights are always strictly positive (HOP_PENALTY alone guarantees that), we can safely use a zero-initialized matrix — this avoids a common bug where people fill non-edges with np.inf and get incorrect results from dense-matrix algorithms.
Section 5 — Greedy optimization. For every remaining candidate that fits the remaining budget, we tentatively add it, recompute the network-wide average routing cost, and keep the one with the best improvement-per-sat ratio. This repeats until the budget runs out or no candidate improves the network. The history list captures the full decision trail: which channel was added, at what cumulative cost, and how much benefit it produced — this is what powers the diminishing-returns and ROI charts.
Visualization 1 draws the network before and after optimization, with edge thickness proportional to channel capacity, so you can see visually which parts of the graph gained new liquidity and connectivity.
Visualization 2 plots cumulative capital spent against the resulting average routing cost. The curve should flatten as budget increases — the first few channels close the biggest topological gaps, and later channels offer smaller and smaller improvements. This is the classic diminishing-returns signature of a well-behaved greedy augmentation process.
Visualization 3 ranks each purchased channel by its cost-effectiveness at the moment it was selected, showing which specific channel gave you the most routing improvement per sat.
Visualization 4 is the 3D surface. The X-axis sweeps payment amount, the Y-axis sweeps how much capital has been deployed (using network snapshots taken at points along the greedy trail), and the Z-axis is the resulting average routing success probability across all node pairs. This single plot answers the two questions operators actually care about simultaneously: “How much does more budget help?” and “At what payment size does my network start to strain?” You should see the surface sag toward zero for large payment amounts on the low-budget end, and stay high across a wider amount range as budget increases.
A Note on Performance
The heaviest part of this script is the greedy loop, which recomputes an all-pairs shortest-path calculation on every candidate, every iteration — on the order of a thousand calls for these parameters. Rather than using networkx‘s per-pair Dijkstra in a Python loop, the code routes all shortest-path work through scipy.sparse.csgraph.shortest_path with method='FW', which runs a compiled Floyd–Warshall implementation in C. For a graph this size (N=15), that keeps the entire script — greedy search plus the full parameter sweep for the 3D surface — running in a few seconds.
If you scale this up to hundreds of nodes, full Floyd–Warshall recomputation on every candidate becomes the bottleneck, since it costs $O(N^3)$ per evaluation. At that scale, two changes make the biggest difference: switch to method='D' (Dijkstra), which is much faster than Floyd–Warshall on sparse graphs, and avoid recomputing the entire distance matrix for every candidate — instead, recompute shortest paths only from the two endpoints of the newly tentative edge and check whether they created shortcuts elsewhere, which is the standard trick for incremental shortest-path updates.
Interpreting the Results
The core insight this model captures is that hop count alone is a misleading metric for Lightning Network topology design. A two-hop route through a poorly funded channel can be strictly worse than a three-hop route through well-capitalized ones, because the payment simply won’t go through. By collapsing both effects into a single weight and running a budget-aware greedy search, the optimizer naturally trades off “shorter” against “more reliable” — exactly the tradeoff a real channel-opening strategy has to make. The diminishing-returns curve is a useful sanity check for any real allocation: if you’re several channels past the knee of that curve, additional capital is probably better spent increasing capacity on existing channels rather than opening new ones.





























