Skip to content

simln-lib/feat: Surface send_to_route for SimGraph #268

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 211 additions & 1 deletion simln-lib/src/sim_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ use lightning::ln::msgs::{
};
use lightning::ln::{PaymentHash, PaymentPreimage};
use lightning::routing::gossip::{NetworkGraph, NodeId};
use lightning::routing::router::{find_route, Path, PaymentParameters, Route, RouteParameters};
use lightning::routing::router::{
build_route_from_hops, find_route, Path, PaymentParameters, Route, RouteParameters,
};
use lightning::routing::scoring::{ProbabilisticScorer, ProbabilisticScoringDecayParameters};
use lightning::routing::utxo::{UtxoLookup, UtxoResult};
use lightning::util::logger::{Level, Logger, Record};
Expand Down Expand Up @@ -553,6 +555,39 @@ fn find_payment_route<'a>(
.map_err(|e| SimulationError::SimulatedNetworkError(e.err))
}

fn build_payment_route(
sending_node_id: PublicKey,
hops: &[PublicKey],
network_graph: &NetworkGraph<&WrappedLog>,
amount_msat: u64,
) -> Result<Route, SimulationError> {
let last_hop = match hops.last() {
Some(last) => Ok(last),
None => Err(SimulationError::SimulatedNetworkError(
"No Last Hop".to_string(),
)),
}?;

build_route_from_hops(
&sending_node_id,
hops,
&RouteParameters {
payment_params: PaymentParameters::from_node_id(*last_hop, 0)
.with_max_total_cltv_expiry_delta(u32::MAX)
// TODO: set non-zero value to support MPP.
.with_max_path_count(1)
// Allow sending htlcs up to 50% of the channel's capacity.
.with_max_channel_saturation_power_of_half(1),
final_value_msat: amount_msat,
max_total_routing_fee_msat: None,
},
network_graph,
&WrappedLog {},
&[0; 32],
)
.map_err(|e| SimulationError::SimulatedNetworkError(e.err))
}

#[async_trait]
impl<T: SimNetwork> LightningNode for SimNode<'_, T> {
fn get_info(&self) -> &NodeInfo {
Expand Down Expand Up @@ -699,6 +734,9 @@ pub struct SimGraph {

/// trigger shutdown if a critical error occurs.
shutdown_trigger: Trigger,

/// Tracks the channel that will provide updates for payments by hash.
in_flight: HashMap<PaymentHash, Receiver<Result<PaymentResult, LightningError>>>,
}

impl SimGraph {
Expand Down Expand Up @@ -741,8 +779,74 @@ impl SimGraph {
channels: Arc::new(Mutex::new(channels)),
tasks,
shutdown_trigger,
in_flight: HashMap::new(),
})
}

pub async fn send_to_route(
&mut self,
sending_node_id: PublicKey,
hops: &[PublicKey],
routing_graph: Arc<NetworkGraph<&WrappedLog>>,
amount_msat: u64,
) -> Result<PaymentHash, LightningError> {
Comment on lines +786 to +792
Copy link
Contributor Author

@chuksys chuksys May 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still thinking which approach is better - 

  1. Passing in hops - which is an ordered list of pubkeys of the nodes to be used to build the LDK route.
  2. Passing in the LDK route directly.

Also, for the use-case of simulating channel jamming attacks, I'm thinking it might be a good idea to pass in a value that we could use to delay the removal of htlcs from channels being targeted - perhaps that could be a follow-up for this PR?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is a pretty advanced API (you want to do custom payments on top of the regular simulation's payments), I think that we can get away with passing the LDK route directly.

This also gives more flexibility. For example, in my channel jamming case I sometimes want to set a really high CLTV delta (that our pathfinding wouldn't sanely pick) as part of an attack.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also gives more flexibility. For example, in my channel jamming case I sometimes want to set a really high CLTV delta (that our pathfinding wouldn't sanely pick) as part of an attack.

I agree - passing the LDK route directly makes a lot of sense. I'll sort this out and push the changes asap!

let (sender, receiver) = channel();

let preimage = PaymentPreimage(rand::random());
let payment_hash = preimage.into();

// Check for payment hash collision, failing the payment if we happen to repeat one.
match self.in_flight.entry(payment_hash) {
Entry::Occupied(_) => {
return Err(LightningError::SendPaymentError(
"payment hash exists".to_string(),
));
},
Entry::Vacant(vacant) => {
vacant.insert(receiver);
},
}

let route = match build_payment_route(sending_node_id, hops, &routing_graph, amount_msat) {
Ok(route) => Ok(route),
Err(e) => Err(LightningError::SendPaymentError(format!(
"An Error occurred while building route - {}",
e
))),
}?;

self.dispatch_payment(sending_node_id, route, payment_hash, sender);

Ok(payment_hash)
}

/// track_payment_to_route blocks until a payment outcome is returned for the payment hash provided, or the shutdown listener
/// provided is triggered. This call will fail if the hash provided was not obtained by calling send_to_route first.
pub async fn track_payment_to_route(
&mut self,
hash: &PaymentHash,
listener: Listener,
) -> Result<PaymentResult, LightningError> {
match self.in_flight.remove(hash) {
Some(receiver) => {
select! {
biased;
_ = listener => Err(
LightningError::TrackPaymentError("shutdown during payment tracking".to_string()),
),

// If we get a payment result back, remove from our in flight set of payments and return the result.
res = receiver => {
res.map_err(|e| LightningError::TrackPaymentError(format!("channel receive err: {}", e)))?
},
}
},
None => Err(LightningError::TrackPaymentError(format!(
"payment hash {} not found",
hex::encode(hash.0),
))),
}
}
}

/// Produces a map of node public key to lightning node implementation to be used for simulations.
Expand Down Expand Up @@ -1888,4 +1992,110 @@ mod tests {
test_kit.graph.tasks.close();
test_kit.graph.tasks.wait().await;
}

#[tokio::test]
async fn test_send_and_track_successful_route_payment() {
let chan_capacity = 500_000_000;
let mut test_kit = DispatchPaymentTestKit::new(chan_capacity).await;

let sending_node = test_kit.nodes[0];
let hops = &[test_kit.nodes[1], test_kit.nodes[2], test_kit.nodes[3]];
let amount = 1_000_000;

let payment_hash = test_kit
.graph
.send_to_route(sending_node, hops, test_kit.routing_graph.clone(), amount)
.await
.expect("Failed to send payment");

let (_shutdown_trigger, shutdown_listener) = triggered::trigger();

// Track the payment
let payment_result = test_kit
.graph
.track_payment_to_route(&payment_hash, shutdown_listener)
.await
.expect("Failed to track payment");

// Assert the outcome
assert!(matches!(
payment_result.payment_outcome,
PaymentOutcome::Success
));

// Get the route built by `build_payment_route` to calculate expected final balances
let route_for_balance_check =
build_payment_route(sending_node, hops, &test_kit.routing_graph, amount)
.expect("Failed to build route for balance check");

let total_sent_by_alice = amount + route_for_balance_check.get_total_fees();
// The amount for Bob to Carol is the amount paid to Dave + fee from Carol to Dave
let hop_1_amount_received_by_bob =
amount + route_for_balance_check.paths[0].hops[1].fee_msat;

// Calculate expected final channel balances
let alice_to_bob_expected = (chan_capacity - total_sent_by_alice, total_sent_by_alice);
let bob_to_carol_expected = (
chan_capacity - hop_1_amount_received_by_bob,
hop_1_amount_received_by_bob,
);
let carol_to_dave_expected = (chan_capacity - amount, amount);

let expected_balances = vec![
alice_to_bob_expected,
bob_to_carol_expected,
carol_to_dave_expected,
];

// Verify channel balances after successful payment
assert_eq!(test_kit.channel_balances().await, expected_balances);

// Assert that the payment hash is removed from in_flight
assert!(!test_kit.graph.in_flight.contains_key(&payment_hash));
}

#[tokio::test]
async fn test_send_to_route_build_route_error() {
let chan_capacity = 100_000_000;
let mut test_kit = DispatchPaymentTestKit::new(chan_capacity).await;

let sending_node = test_kit.nodes[0];
let non_existent_node_pk = get_random_keypair().1;
let hops_invalid = &[non_existent_node_pk];
let amount = 10_000;

let result = test_kit
.graph
.send_to_route(
sending_node,
hops_invalid,
test_kit.routing_graph.clone(),
amount,
)
.await;

assert!(matches!(
result,
Err(LightningError::SendPaymentError(msg)) if msg.contains("An Error occurred while building route - ")
));
}

#[tokio::test]
async fn test_track_payment_to_route_hash_not_found() {
let chan_capacity = 100_000_000;
let mut test_kit = DispatchPaymentTestKit::new(chan_capacity).await;

let non_existent_hash = PaymentHash([99; 32]);
let (_shutdown_trigger, shutdown_listener) = triggered::trigger();

let result = test_kit
.graph
.track_payment_to_route(&non_existent_hash, shutdown_listener)
.await;

assert!(matches!(
result,
Err(LightningError::TrackPaymentError(msg)) if msg.contains("payment hash") && msg.contains("not found")
));
}
}