From b715f3257b45792ac1ae2ef41cc1bb6c6db08587 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Wed, 3 Jun 2026 14:17:04 +0000 Subject: [PATCH 1/2] forward-builder: synthesize failed payment retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated peacetime traffic was all-settle, which is unrealistic — real nodes see many failed attempts per success. Add `--payment-attempts` (mean attempts per success, default 4.0) so the builder injects failed retries alongside each successful forward. Each real, routed forward is still recorded as settled; on top of it a geometric number of failed copies is synthesised on the same channel pair (retry-until-success with per-attempt success probability 1/payment_attempts), giving a mean of payment_attempts with natural retry spread. The failed attempts are staggered backward in time so they read as a time-ordered retry sequence ending in the success, rather than simultaneous duplicates. At the default this yields ~75% failed forwards (3 failed tries per success) while leaving settle volume completely untouched — failures are added, never substituted, so this is independent of the activity multiplier (which otherwise saturates and cannot preserve settles). Failed forwards carry `settled=false` on BootstrapForward (serde default true, so older traffic files still parse) and replay as failed during bootstrapping, contributing no fee to reputation. --- ln-simln-jamming/src/bin/forward_builder.rs | 178 ++++++++++++++++-- .../src/reputation_interceptor.rs | 11 +- ln-simln-jamming/src/test_utils.rs | 1 + 3 files changed, 172 insertions(+), 18 deletions(-) diff --git a/ln-simln-jamming/src/bin/forward_builder.rs b/ln-simln-jamming/src/bin/forward_builder.rs index 0b9656b0..3a25dc3b 100644 --- a/ln-simln-jamming/src/bin/forward_builder.rs +++ b/ln-simln-jamming/src/bin/forward_builder.rs @@ -10,6 +10,8 @@ use ln_simln_jamming::parsing::{ use ln_simln_jamming::reputation_interceptor::{BootstrapForward, ReputationInterceptor}; use ln_simln_jamming::{BoxError, ACCOUNTABLE_TYPE, UPGRADABLE_TYPE}; use log::LevelFilter; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; use sim_cli::parsing::{create_simulation_with_network, SimParams}; use simln_lib::batched_writer::BatchedWriter; use simln_lib::clock::{Clock, SimulationClock}; @@ -41,6 +43,23 @@ struct Cli { /// The attack that we're interested in running. #[arg(long, value_enum)] pub attack_type: Option, + + /// Mean number of payment attempts per successful forward, modelling failed retries that + /// precede a success. Each real (routed) forward is recorded as settled, then a geometric + /// number of failed copies is synthesised on the same channel pair (retry-until-success with + /// per-attempt success probability 1/payment_attempts), so the mean attempts equals this value + /// with natural retry spread. Failed copies are replayed as failed during bootstrapping and + /// contribute no fee to reputation. Defaults to 4.0 (~75% of forwards failed, i.e. 3 failed + /// tries per success) while leaving settle volume untouched. Set to 1.0 for all-settle. + #[arg(long, default_value_t = 4.0)] + pub payment_attempts: f64, + + /// sim-ln activity multiplier: scales total payment volume (each source sends + /// multiplier * capacity per month). Controls the number of *successful* forwards; failed + /// retries are synthesised separately via payment_attempts, so this no longer needs to be + /// raised to compensate for failures. + #[arg(long, default_value_t = 2.0)] + pub activity_multiplier: f64, } #[tokio::main] @@ -85,6 +104,7 @@ async fn main() -> Result<(), BoxError> { .unwrap() .to_string_lossy() .to_string(), + cli.payment_attempts, )?))), )?); let latency_interceptor = Arc::new(LatencyIntercepor::new_poisson(300.0)?); @@ -92,7 +112,7 @@ async fn main() -> Result<(), BoxError> { let sim_cfg = SimulationCfg::new( Some(cli.duration.as_secs() as u32), 3_800_000, - 2.0, + cli.activity_multiplier, None, Some(13995354354227336701), ); @@ -131,17 +151,46 @@ async fn main() -> Result<(), BoxError> { struct BootstrapWriter { clock: Arc, batch_writer: Mutex, + /// Per-attempt success probability, derived as 1/payment_attempts. The number of failed retries + /// synthesised before each recorded success is geometric with this probability. + success_prob: f64, + rng: Mutex, } +/// Hard cap on synthesised failed retries per forward, so an unlucky geometric draw can't blow up. +const MAX_SYNTHETIC_FAILURES: u32 = 100; + impl BootstrapWriter { - fn new(clock: Arc, dir: PathBuf, filename: String) -> Result { + fn new( + clock: Arc, + dir: PathBuf, + filename: String, + payment_attempts: f64, + ) -> Result { + if !payment_attempts.is_finite() || payment_attempts < 1.0 { + return Err(format!("payment_attempts must be >= 1.0, got {payment_attempts}").into()); + } Ok(BootstrapWriter { clock, batch_writer: Mutex::new(BatchedWriter::new(dir, filename, 500)?), + success_prob: 1.0 / payment_attempts, + // Seeded for reproducibility across runs with the same parameters. + rng: Mutex::new(StdRng::seed_from_u64(13995354354227336701)), }) } } +/// Counts failed retries before a success: Bernoulli(`success_prob`) trials until the first +/// success, returning the number of failures (geometric, mean = `1/success_prob - 1`, i.e. +/// `payment_attempts - 1`). Capped at [`MAX_SYNTHETIC_FAILURES`]. +fn sample_failed_retries(success_prob: f64, rng: &mut StdRng) -> u32 { + let mut failures = 0; + while failures < MAX_SYNTHETIC_FAILURES && rng.random::() >= success_prob { + failures += 1; + } + failures +} + #[async_trait] impl ForwardReporter for BootstrapWriter { async fn report_forward( @@ -158,20 +207,115 @@ impl ForwardReporter for BootstrapWriter { .duration_since(forward.added_at) .as_nanos() as u64; - self.batch_writer - .lock() - .await - .queue(BootstrapForward { - incoming_amt: forward.amount_in_msat, - outgoing_amt: forward.amount_out_msat, - incoming_expiry: forward.expiry_in_height, - outgoing_expiry: forward.expiry_out_height, - added_ns: settled_ns - nanos_since_added, - settled_ns, - forwarding_node, - channel_in_id: forward.incoming_ref.channel_id, - channel_out_id: forward.outgoing_channel_id, - }) - .map_err(|e| e.into()) + // The real, routed forward always settles; synthesised retries on the same channel pair + // model the failed attempts that preceded it, staggered back in time so they read as a + // time-ordered retry sequence ending in the success rather than simultaneous duplicates. + let failed_retries = sample_failed_retries(self.success_prob, &mut *self.rng.lock().await); + let make = |added_ns: u64, settled_ns: u64, settled: bool| BootstrapForward { + incoming_amt: forward.amount_in_msat, + outgoing_amt: forward.amount_out_msat, + incoming_expiry: forward.expiry_in_height, + outgoing_expiry: forward.expiry_out_height, + added_ns, + settled_ns, + forwarding_node, + channel_in_id: forward.incoming_ref.channel_id, + channel_out_id: forward.outgoing_channel_id, + settled, + }; + + let mut batch_writer = self.batch_writer.lock().await; + for (added_ns, settled_ns, settled) in + retry_timeline(settled_ns, nanos_since_added, failed_retries) + { + batch_writer.queue(make(added_ns, settled_ns, settled))?; + } + Ok(()) + } +} + +/// Builds the chronological `(added_ns, settled_ns, settled)` timeline for one forward: +/// `failed_retries` contiguous failed attempts, each of duration `gap` (= `hold`, or 1ns when +/// `hold` is 0 so attempts stay distinct), immediately preceding the settled forward +/// `[settled_ns - hold, settled_ns]`. The retries are shifted backward in time so the sequence +/// reads fail, fail, …, success — never overlapping. All timestamps are <= `settled_ns`, so they +/// stay within the bootstrap window's upper bound. +fn retry_timeline(settled_ns: u64, hold: u64, failed_retries: u32) -> Vec<(u64, u64, bool)> { + let gap = hold.max(1); + let success_added = settled_ns.saturating_sub(hold); + let mut timeline = Vec::with_capacity(failed_retries as usize + 1); + // Earliest attempt first (largest step back), so the returned vec is chronological. + for r in (1..=failed_retries).rev() { + let f_settled = success_added.saturating_sub((r as u64 - 1) * gap); + let f_added = f_settled.saturating_sub(gap); + timeline.push((f_added, f_settled, false)); + } + timeline.push((success_added, settled_ns, true)); + timeline +} + +#[cfg(test)] +mod tests { + use super::{retry_timeline, sample_failed_retries, MAX_SYNTHETIC_FAILURES}; + use rand::rngs::StdRng; + use rand::SeedableRng; + + #[test] + fn test_retry_timeline() { + let settled_ns = 1_000_000; + let hold = 300; + + // No failures -> just the success, occupying [settled - hold, settled]. + assert_eq!( + retry_timeline(settled_ns, hold, 0), + vec![(settled_ns - hold, settled_ns, true)] + ); + + // Three failures: chronological, contiguous, non-overlapping, ending in the success. + let tl = retry_timeline(settled_ns, hold, 3); + assert_eq!(tl.len(), 4); + // Last entry is the unchanged success. + assert_eq!(tl[3], (settled_ns - hold, settled_ns, true)); + // Earlier entries are failures. + assert!(tl[..3].iter().all(|&(_, _, settled)| !settled)); + // Strictly increasing added_ns, and each attempt's end == the next attempt's start. + for w in tl.windows(2) { + assert!(w[0].0 < w[1].0, "added_ns not increasing"); + assert_eq!(w[0].1, w[1].0, "attempts should be contiguous"); + } + // Every timestamp stays within the window's upper bound. + assert!(tl + .iter() + .all(|&(a, s, _)| a <= settled_ns && s <= settled_ns)); + + // Zero hold still yields distinct, ordered timestamps (1ns spacing). + let z = retry_timeline(settled_ns, 0, 2); + assert!(z[0].0 < z[1].0 && z[1].0 < z[2].0); + } + + #[test] + fn test_sample_failed_retries() { + let mut rng = StdRng::seed_from_u64(42); + + // payment_attempts = 1 (success_prob 1.0) -> never any failed retries (all-settle). + for _ in 0..1000 { + assert_eq!(sample_failed_retries(1.0, &mut rng), 0); + } + + // payment_attempts = 4 (success_prob 0.25) -> mean failures ~3 over many samples. + let n = 200_000; + let total: u64 = (0..n) + .map(|_| sample_failed_retries(0.25, &mut rng) as u64) + .sum(); + let mean = total as f64 / n as f64; + assert!( + (mean - 3.0).abs() < 0.1, + "mean failed retries {mean} != ~3.0" + ); + + // Never exceeds the cap, even with a near-zero success probability. + for _ in 0..1000 { + assert!(sample_failed_retries(1e-9, &mut rng) <= MAX_SYNTHETIC_FAILURES); + } } } diff --git a/ln-simln-jamming/src/reputation_interceptor.rs b/ln-simln-jamming/src/reputation_interceptor.rs index 8d4412fa..9632542c 100644 --- a/ln-simln-jamming/src/reputation_interceptor.rs +++ b/ln-simln-jamming/src/reputation_interceptor.rs @@ -72,6 +72,14 @@ pub struct BootstrapForward { pub forwarding_node: PublicKey, pub channel_in_id: u64, pub channel_out_id: u64, + /// Whether the forward settled successfully (true) or failed (false). Defaults to true so that + /// traffic files generated before this field existed parse as all-settled. + #[serde(default = "default_settled")] + pub settled: bool, +} + +fn default_settled() -> bool { + true } /// Functionality to monitor reputation values in a network. @@ -319,7 +327,7 @@ where ))?, )), ), - forward_resolution: ForwardResolution::Settled, + forward_resolution: ForwardResolution::from(h.settled), })); } @@ -1029,6 +1037,7 @@ mod tests { forwarding_node: bob_pk, channel_in_id: alice_to_bob, channel_out_id: bob_to_carol, + settled: true, }]; // Create an interceptor that is intended to general jam payments on Bob -> Carol in the three hop network diff --git a/ln-simln-jamming/src/test_utils.rs b/ln-simln-jamming/src/test_utils.rs index 0b20ef21..39fc03a4 100644 --- a/ln-simln-jamming/src/test_utils.rs +++ b/ln-simln-jamming/src/test_utils.rs @@ -171,6 +171,7 @@ pub fn test_bootstrap_forward( forwarding_node: get_random_keypair().1, channel_in_id, channel_out_id, + settled: true, } } From 77665c3fb22f5372596b2289d2328bcee758b235 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Wed, 3 Jun 2026 14:17:13 +0000 Subject: [PATCH 2/2] forward-builder/reputation-builder: loop a short traffic window to fill reputation Synthesising failed retries inflates the traffic file ~payment_attempts x. To avoid materialising a full-length (eg 6 month) file on disk, allow generating a short dense window and looping it at import. - forward-builder `--duration` now parses `Xd` as days and `Xm` as months (via parse_window), falling back to humantime for long forms like `6months`, so a short window is easy to express (eg `--duration 7d`). - reputation-builder gains `--allow-boost`: when set it reads the full short file and tiles it (boost_history) to cover the reputation window, shifting each repetition forward by the source span so timestamps stay strictly monotonic across seams (the decaying average rejects out-of-order updates). Off by default; a full-length file is used as-is. The loop happens in memory at import, so no duplicated data is written. --- ln-simln-jamming/src/bin/forward_builder.rs | 8 +- .../src/bin/reputation_builder.rs | 31 +++++-- ln-simln-jamming/src/parsing.rs | 91 ++++++++++++++++++- 3 files changed, 119 insertions(+), 11 deletions(-) diff --git a/ln-simln-jamming/src/bin/forward_builder.rs b/ln-simln-jamming/src/bin/forward_builder.rs index 3a25dc3b..55ec0779 100644 --- a/ln-simln-jamming/src/bin/forward_builder.rs +++ b/ln-simln-jamming/src/bin/forward_builder.rs @@ -5,7 +5,7 @@ use ln_resource_mgr::{AllocationCheck, ProposedForward}; use ln_simln_jamming::analysis::ForwardReporter; use ln_simln_jamming::clock::InstantClock; use ln_simln_jamming::parsing::{ - parse_duration, AttackType, NetworkParams, NetworkType, ReputationParams, + parse_window, AttackType, NetworkParams, NetworkType, ReputationParams, }; use ln_simln_jamming::reputation_interceptor::{BootstrapForward, ReputationInterceptor}; use ln_simln_jamming::{BoxError, ACCOUNTABLE_TYPE, UPGRADABLE_TYPE}; @@ -33,8 +33,10 @@ struct Cli { #[command(flatten)] network: NetworkParams, - /// The amount of time to generate forwarding history for. - #[arg(long, value_parser = parse_duration, default_value = DEFAULT_RUNTIME)] + /// The amount of time to generate forwarding history for. Accepts `Xd` (days) and `Xm` + /// (months); generate a short window (eg `7d`) and loop it at import with the reputation + /// builder's `--allow-boost` to cover the full reputation window without a large file. + #[arg(long, value_parser = parse_window, default_value = DEFAULT_RUNTIME)] pub duration: Duration, #[command(flatten)] diff --git a/ln-simln-jamming/src/bin/reputation_builder.rs b/ln-simln-jamming/src/bin/reputation_builder.rs index 52fb1986..892ad303 100644 --- a/ln-simln-jamming/src/bin/reputation_builder.rs +++ b/ln-simln-jamming/src/bin/reputation_builder.rs @@ -14,8 +14,8 @@ use ln_simln_jamming::{ analysis::BatchForwardWriter, clock::InstantClock, parsing::{ - get_history_for_bootstrap, history_from_file, parse_duration, AttackType, NetworkParams, - NetworkType, ReputationParams, + boost_history, get_history_for_bootstrap, history_from_file, parse_duration, AttackType, + NetworkParams, NetworkType, ReputationParams, }, reputation_interceptor::{BootstrapRecords, ReputationInterceptor, ReputationMonitor}, BoxError, @@ -41,6 +41,13 @@ struct Cli { /// for, expressed as human readable values (eg: 1w, 3d). #[arg(long, value_parser = parse_duration, requires = "attack_type")] pub attacker_bootstrap: Option, + + /// Loop ("boost") a short traffic file to fill the reputation window instead of requiring a + /// full-length file on disk. Generate a short dense window (eg `forward-builder --duration 7d`) + /// and set this flag to tile it — with forward-shifted, monotonic timestamps — up to the + /// reputation window. Off by default, so a full-length file is used as-is. + #[arg(long)] + pub allow_boost: bool, } #[tokio::main] @@ -63,11 +70,21 @@ async fn main() -> Result<(), BoxError> { let target_pubkey = network.target().1; let traffic_file = network.traffic_file(); - let unfiltered_history = history_from_file( - &traffic_file, - Some(forward_params.reputation_params.reputation_window()), - ) - .await?; + let reputation_window = forward_params.reputation_params.reputation_window(); + let unfiltered_history = if cli.allow_boost { + // Read the full (short) file, then loop it to fill the reputation window. + let history = history_from_file(&traffic_file, None).await?; + let boosted = boost_history(history, reputation_window); + log::info!( + "Boosted {:?} of traffic to fill the {:?} reputation window: {} forwards", + traffic_file, + reputation_window, + boosted.len(), + ); + boosted + } else { + history_from_file(&traffic_file, Some(reputation_window)).await? + }; // Filter bootstrap records if attacker alias and bootstrap provided. // Only add up revenue if attacker bootstrap is specified. diff --git a/ln-simln-jamming/src/parsing.rs b/ln-simln-jamming/src/parsing.rs index f07164fc..9aab3102 100644 --- a/ln-simln-jamming/src/parsing.rs +++ b/ln-simln-jamming/src/parsing.rs @@ -642,6 +642,26 @@ pub fn parse_duration(s: &str) -> Result { .into()) } +/// Number of seconds in a 30-day month, used by [`parse_window`]. +const SECS_PER_MONTH: u64 = 30 * 24 * 60 * 60; + +/// Parses a traffic-generation window where `Xd` means X days and `Xm` means X *months* (30 days). +/// +/// This deliberately diverges from [`parse_duration`]/humantime, where `m` means minutes — for a +/// generation window (always days-to-months) the friendlier `Xm = months` shorthand is wanted. Any +/// input not matching the `Xd`/`Xm` shorthand falls back to humantime, so long forms like +/// `"6months"`, `"2weeks"` or `"7days"` still parse as before. +pub fn parse_window(s: &str) -> Result { + let s = s.trim(); + if let Some(days) = s.strip_suffix('d').and_then(|n| n.parse::().ok()) { + return Ok(Duration::from_secs(days * 24 * 60 * 60)); + } + if let Some(months) = s.strip_suffix('m').and_then(|n| n.parse::().ok()) { + return Ok(Duration::from_secs(months * SECS_PER_MONTH)); + } + parse_duration(s) +} + fn find_next_newline(file: &mut BufReader, start: u64) -> Result { let mut position = start; file.seek(std::io::SeekFrom::Start(position))?; @@ -756,6 +776,46 @@ pub async fn history_from_file( Ok(forwards) } +/// Tiles a short window of bootstrap forwards (the "loopback"/boost) so it spans at least +/// `target` of wall time, without writing the duplicated data to disk. +/// +/// The source forwards (e.g. ~7 days of dense traffic) are repeated end-to-end, each repetition +/// shifted forward by the source's full span so the resulting timestamps stay strictly increasing +/// across tile seams — the decaying-average reputation update rejects out-of-order timestamps, so +/// monotonicity is load-bearing. Returns the input unchanged if it already covers `target` (or is +/// empty / single-instant). +pub fn boost_history(forwards: Vec, target: Duration) -> Vec { + let target_ns = target.as_nanos() as u64; + let min_added = match forwards.iter().map(|f| f.added_ns).min() { + Some(v) => v, + None => return forwards, + }; + let max_settled = forwards + .iter() + .map(|f| f.settled_ns) + .max() + .unwrap_or(min_added); + let span = max_settled.saturating_sub(min_added); + if span == 0 || span >= target_ns { + return forwards; + } + + // ceil(target / span) tiles to fully cover the window. + let tiles = target_ns.div_ceil(span); + let mut boosted = Vec::with_capacity(forwards.len() * tiles as usize); + for tile in 0..tiles { + // +tile to keep seams strictly monotonic when a forward sits exactly on the boundary. + let shift = tile.saturating_mul(span).saturating_add(tile); + for f in &forwards { + let mut copy = f.clone(); + copy.added_ns += shift; + copy.settled_ns += shift; + boosted.push(copy); + } + } + boosted +} + pub fn reputation_snapshot_from_file( file_path: &PathBuf, ) -> Result>, BoxError> { @@ -892,9 +952,38 @@ mod tests { use std::ops::Add; use std::time::{Duration, SystemTime, UNIX_EPOCH}; - use crate::parsing::get_history_for_bootstrap; + use crate::parsing::{boost_history, get_history_for_bootstrap}; use crate::test_utils::test_bootstrap_forward; + #[test] + fn test_boost_history() { + // Empty input is returned untouched. + assert!(boost_history(vec![], Duration::from_nanos(100)).is_empty()); + + // Source spans 10ns (min added 100 .. max settled 110). + let src = || { + vec![ + test_bootstrap_forward(100, 105, 1, 2), + test_bootstrap_forward(102, 110, 3, 4), + ] + }; + + // Target already covered by the source span -> returned unchanged. + assert_eq!(boost_history(src(), Duration::from_nanos(5)).len(), 2); + + // Target 100ns over a 10ns span -> ceil(100/10) = 10 tiles. + let boosted = boost_history(src(), Duration::from_nanos(100)); + assert_eq!(boosted.len(), 2 * 10); + + // Timestamps stay strictly increasing across tile seams (the decay-average requirement), + // and the boosted stream covers at least the requested window. + for pair in boosted.windows(2) { + assert!(pair[1].added_ns > pair[0].added_ns); + } + let span = boosted.last().unwrap().settled_ns - boosted.first().unwrap().added_ns; + assert!(span >= 100); + } + /// Tests the cases where filtering bootstrap data fails. #[test] fn test_get_history_for_bootstrap_errors() {