Skip to content
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,64 @@ This is required for the case where you want to specify a channel that
will be used in the simulated network that is just responsible for
forwarding payments, but does not send or receive any payments itself.

### Channel Rebalancing

Since payment flows in the simulation are not perfectly circular, the
channels in a simulated network will progressively deplete to one side
over the course of a long-running simulation, and payment success rates
will decay accordingly. On the real network, node operators counteract
this with out-of-band actions such as circular rebalances, swaps and
splices. The simulator can optionally model the *effect* of these
actions (without their mechanics) by periodically "teleporting" the
balance of depleted channels back to an even split:

```
{
"sim_network": [ ... ],
"rebalance": {
"trigger_below": 0.1,
"interval_secs": 3600,
"exclude": [
"020a30431ce58843eedf8051214dbfadb65b107cc598b8277f14bb9b33c9cd026f"
]
}
}
```

* `trigger_below`: the fraction of a channel's capacity beneath which
either side's available balance triggers a rebalance of the channel,
exclusively between 0 and 0.5 (default: 0.1).
* `interval_secs`: the number of seconds between scans of the network's
channels (default: 3600).
* `exclude`: nodes that do not participate in rebalancing (default:
empty). Any channel that has a listed node as one of its peers will
never be rebalanced, regardless of which side of the channel has
depleted. This models operators that don't maintain their liquidity,
letting their channels drain naturally while the rest of the network
is kept liquid.

Rebalancing is opt-in and only available on simulated networks. Leave
it off to study how liquidity depletes under a traffic pattern; turn it
on for long-running simulations that need sustained payment success
(each rebalance is logged with the amount moved, so the volume of
intervention required to keep the network liquid is itself a useful
output). Rebalancing preserves determinism when running with a fixed
seed. Balance that is locked in in-flight HTLCs is never moved.

### Routing Fee Limit

Payments on simulated networks will not pay more than 5% of the
payment amount in routing fees, mirroring the fee limits that real
senders place on payments. This can be tuned with a top-level field in
the simulation file (only valid on simulated networks):

```
{
"sim_network": [ ... ],
"max_route_fee_pct": 0.5
}
```

### Inclusions and Limitations

The simulator will execute payments on the mocked out network as it
Expand Down
91 changes: 88 additions & 3 deletions sim-cli/src/parsing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,19 @@ use serde::{Deserialize, Serialize};
use simln_lib::clock::SimulationClock;
use simln_lib::sim_node::{
ln_node_from_graph, populate_network_graph, ChannelPolicy, CustomRecords, Interceptor,
SimGraph, SimNode, SimulatedChannel,
RebalanceConfig, SimGraph, SimNode, SimulatedChannel, DEFAULT_MAX_ROUTE_FEE_PCT,
};
use simln_lib::{
cln, cln::ClnNode, eclair, eclair::EclairNode, lnd, lnd::LndNode, serializers,
ActivityDefinition, Amount, Interval, LightningError, LightningNode, NodeId, NodeInfo,
Simulation, SimulationCfg, WriteResults,
};
use simln_lib::{ShortChannelID, SimulationError};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio_util::task::TaskTracker;

Expand Down Expand Up @@ -138,6 +139,18 @@ impl Cli {
}
}

if sim_params.rebalance.is_some() && sim_params.sim_network.is_empty() {
return Err(anyhow!(
"Rebalancing is only allowed on a simulated network"
));
}

if sim_params.max_route_fee_pct.is_some() && sim_params.sim_network.is_empty() {
return Err(anyhow!(
"A routing fee limit can only be set on a simulated network"
));
}

Ok(())
}
}
Expand All @@ -152,6 +165,42 @@ pub struct SimParams {
pub activity: Vec<ActivityParser>,
#[serde(default)]
pub exclude: Vec<PublicKey>,
#[serde(default)]
pub rebalance: Option<RebalanceParser>,
/// The maximum total routing fee that simulated nodes will pay for a payment, expressed as a percentage of
/// the payment amount (only used on simulated networks, where it defaults to 5%).
#[serde(default)]
pub max_route_fee_pct: Option<f64>,
}

/// Default fraction of channel capacity beneath which a channel side triggers a rebalance.
fn default_rebalance_trigger() -> f64 {
0.1
}

/// Default number of seconds between rebalancing scans of the simulated network (one hour).
fn default_rebalance_interval() -> u64 {
3600
}

/// Data structure that is used to parse rebalancing configuration for simulated networks from the simulation
/// file. When present, channels in the simulated network whose available liquidity has drawn down beneath a
/// trigger fraction of their capacity are periodically reset to an even split, modeling the out-of-band actions
/// (circular rebalances, swaps, splices) that node operators take to restore liquidity. When absent, channels
/// deplete naturally under the simulation's payment flows.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RebalanceParser {
/// The fraction of a channel's capacity beneath which a side's available balance triggers a rebalance,
/// exclusively between 0 and 0.5.
#[serde(default = "default_rebalance_trigger")]
pub trigger_below: f64,
/// The number of seconds between scans of the network's channels.
#[serde(default = "default_rebalance_interval")]
pub interval_secs: u64,
/// Nodes that do not participate in rebalancing. Any channel that has a node in this list as one of its
/// peers will never be rebalanced, regardless of which side of the channel has depleted.
#[serde(default)]
pub exclude: Vec<PublicKey>,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
Expand Down Expand Up @@ -260,6 +309,8 @@ pub async fn create_simulation_with_network(
sim_network,
activity: _activity,
exclude,
rebalance,
max_route_fee_pct,
} = sim_params;

// Convert nodes representation for parsing to SimulatedChannel
Expand Down Expand Up @@ -300,6 +351,32 @@ pub async fn create_simulation_with_network(
.map_err(|e| SimulationError::SimulatedNetworkError(format!("{:?}", e)))?,
));

// If the user has opted into rebalancing, spawn a task that will periodically reset channels whose
// liquidity has drawn down. Without this, the network's channels will progressively deplete under the
// simulation's payment flows.
if let Some(rebalance) = rebalance {
// Fail early on exclusions for nodes that aren't part of the simulated network, because they'd
// otherwise be silently ignored (most likely hiding a typo in the pubkey).
for pk in &rebalance.exclude {
if !nodes_info.contains_key(pk) {
return Err(anyhow!(
"Rebalance exclusion pubkey: {pk} not found in simulated network"
));
}
}

let rebalance_cfg = RebalanceConfig::new(
rebalance.trigger_below,
Duration::from_secs(rebalance.interval_secs),
HashSet::from_iter(rebalance.exclude.iter().copied()),
)?;

simulation_graph
.lock()
.await
.start_rebalancer(clock.clone(), rebalance_cfg);
}

// Copy all simulated channels into a read-only routing graph, allowing to pathfind for
// individual payments without locking the simulation graph (this is a duplication of the channels,
// but the performance tradeoff is worthwhile for concurrent pathfinding).
Expand All @@ -312,7 +389,13 @@ pub async fn create_simulation_with_network(
// custom actions on the simulated network. For the nodes we'll pass our simulation, cast them
// to a dyn trait and exclude any nodes that shouldn't be included in random activity
// generation.
let nodes = ln_node_from_graph(simulation_graph, routing_graph, clock.clone()).await?;
let nodes = ln_node_from_graph(
simulation_graph,
routing_graph,
clock.clone(),
max_route_fee_pct.unwrap_or(DEFAULT_MAX_ROUTE_FEE_PCT),
)
.await?;
let mut nodes_dyn: HashMap<_, Arc<Mutex<dyn LightningNode>>> = nodes
.iter()
.map(|(pk, node)| (*pk, Arc::clone(node) as Arc<Mutex<dyn LightningNode>>))
Expand Down Expand Up @@ -351,6 +434,8 @@ pub async fn create_simulation(
sim_network: _sim_network,
activity: _activity,
exclude: _,
rebalance: _,
max_route_fee_pct: _,
} = sim_params;

let (clients, clients_info) = get_clients(nodes.to_vec()).await?;
Expand Down
35 changes: 25 additions & 10 deletions simln-lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ impl std::fmt::Display for NodeId {
}

/// Represents a short channel ID, expressed as a struct so that we can implement display for the trait.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Copy, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Copy, Serialize, Deserialize)]
pub struct ShortChannelID(u64);

/// Utility function to easily convert from u64 to `ShortChannelID`.
Expand Down Expand Up @@ -2162,15 +2162,23 @@ mod tests {
"Simulation should have run at least for 25s, took {:?}",
elapsed
);
// The total number of payments made within the simulation's 25 second runtime depends on wall clock
// scheduling at the cutoff, so we only assert on a prefix of payments that will reliably complete in time.
let expected_payment_list = vec![
pk1, pk2, pk1, pk1, pk1, pk3, pk3, pk3, pk4, pk3, pk2, pk1, pk4,
pk1, pk2, pk1, pk1, pk3, pk3, pk3, pk4, pk2, pk4, pk3, pk2, pk3, pk2, pk4, pk4, pk1,
pk4, pk2, pk4,
];

assert!(
payments_list.lock().unwrap().as_ref() == expected_payment_list,
"The expected order of payments is not correct: {:?} vs {:?}",
payments_list.lock().unwrap(),
expected_payment_list,
let actual_payments: Vec<PublicKey> = payments_list
.lock()
.unwrap()
.iter()
.take(expected_payment_list.len())
.cloned()
.collect();
assert_eq!(
actual_payments, expected_payment_list,
"The expected order of payments is not correct"
);

// remove all the payments made in the previous execution
Expand All @@ -2189,9 +2197,16 @@ mod tests {
);
let _ = simulation2.run(&[]).await;

assert!(
payments_list.lock().unwrap().as_ref() != expected_payment_list,
"The expected order of payments shoud be different because a different is used"
let actual_payments: Vec<PublicKey> = payments_list
.lock()
.unwrap()
.iter()
.take(expected_payment_list.len())
.cloned()
.collect();
assert_ne!(
actual_payments, expected_payment_list,
"The expected order of payments should be different because a different seed is used"
);
}
}
Loading
Loading