Skip to content
Open
170 changes: 147 additions & 23 deletions lightning/src/blinded_path/payment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,6 @@ impl BlindedPaymentPath {
/// Errors if:
/// * [`BlindedPayInfo`] calculation results in an integer overflow
/// * any unknown features are required in the provided [`ForwardTlvs`]
// TODO: make all payloads the same size with padding + add dummy hops
pub fn new<ES: EntropySource, T: secp256k1::Signing + secp256k1::Verification>(
intermediate_nodes: &[PaymentForwardNode], payee_node_id: PublicKey,
local_node_receive_key: ReceiveAuthKey, payee_tlvs: ReceiveTlvs, htlc_maximum_msat: u64,
Expand All @@ -138,10 +137,7 @@ impl BlindedPaymentPath {
///
/// This improves privacy by making path-length analysis based on fee and CLTV delta
/// values less reliable.
///
/// TODO: Add end-to-end tests validating fee aggregation, CLTV deltas, and
/// HTLC bounds when dummy hops are present, before exposing this API publicly.
pub(crate) fn new_with_dummy_hops<
pub fn new_with_dummy_hops<
ES: EntropySource,
T: secp256k1::Signing + secp256k1::Verification,
>(
Expand Down Expand Up @@ -454,6 +450,23 @@ impl ForwardTlvsInfo for TrampolineForwardTlvs {
}
}

/// Default relay parameters used for dummy hops when there is no nearby
/// forwarding hop to mirror.
///
/// These values were chosen because they correspond to the most commonly
/// observed forwarding parameters on the public Lightning Network. They also
/// act as upper bounds when deriving relay parameters from nearby forwarding
/// data, ensuring dummy hops remain representative of typical network routing
/// behavior.
const DEFAULT_DUMMY_PAYMENT_RELAY: PaymentRelay =
PaymentRelay { cltv_expiry_delta: 40, fee_proportional_millionths: 1, fee_base_msat: 1_000 };

/// Default payment constraints used for dummy hops.
///
/// These values impose no additional CLTV or HTLC-minimum restrictions.
const DEFAULT_DUMMY_PAYMENT_CONSTRAINTS: PaymentConstraints =
PaymentConstraints { max_cltv_expiry: u32::MAX, htlc_minimum_msat: 0 };

/// TLVs carried by a dummy hop within a blinded payment path.
///
/// Dummy hops do not correspond to real forwarding decisions, but are processed
Expand All @@ -468,20 +481,54 @@ impl ForwardTlvsInfo for TrampolineForwardTlvs {
pub struct DummyTlvs {
/// Relay requirements (fees and CLTV delta) that must be satisfied when
/// processing this dummy hop.
pub payment_relay: PaymentRelay,
pub(crate) payment_relay: PaymentRelay,
/// Constraints that apply to the payment when relaying over this dummy hop.
pub payment_constraints: PaymentConstraints,
pub(crate) payment_constraints: PaymentConstraints,
}

impl Default for DummyTlvs {
fn default() -> Self {
let payment_relay =
PaymentRelay { cltv_expiry_delta: 0, fee_proportional_millionths: 0, fee_base_msat: 0 };
impl DummyTlvs {
/// Constructs a dummy-hop TLV set from explicit relay requirements and
/// payment constraints.
///
/// This is primarily intended for callers that need full control over the
/// relay parameters encoded into a dummy hop.
pub fn new(payment_relay: PaymentRelay, payment_constraints: PaymentConstraints) -> Self {
Self { payment_relay, payment_constraints }
}

/// Constructs dummy-hop TLVs by deriving relay parameters from a nearby
/// forwarding hop.
///
/// Fee and CLTV values are capped to the default dummy-hop relay policy so
/// that the resulting parameters remain representative of common network
/// routing behavior while avoiding unusually large relay requirements.
pub fn from_forward_tlvs(forward_tlvs: &ForwardTlvs) -> Self {
let forward_payment_relay = forward_tlvs.payment_relay;

let dummy_payment_relay = PaymentRelay {
cltv_expiry_delta: core::cmp::min(
forward_payment_relay.cltv_expiry_delta,
DEFAULT_DUMMY_PAYMENT_RELAY.cltv_expiry_delta,
),
fee_proportional_millionths: core::cmp::min(
forward_payment_relay.fee_proportional_millionths,
DEFAULT_DUMMY_PAYMENT_RELAY.fee_proportional_millionths,
),
fee_base_msat: core::cmp::min(
forward_payment_relay.fee_base_msat,
DEFAULT_DUMMY_PAYMENT_RELAY.fee_base_msat,
),
};

let payment_constraints =
PaymentConstraints { max_cltv_expiry: u32::MAX, htlc_minimum_msat: 0 };
Self::new(dummy_payment_relay, DEFAULT_DUMMY_PAYMENT_CONSTRAINTS)
}
}

Self { payment_relay, payment_constraints }
impl Default for DummyTlvs {
/// Returns a dummy-hop TLV set using the default relay policy and payment
/// constraints.
fn default() -> Self {
Self::new(DEFAULT_DUMMY_PAYMENT_RELAY, DEFAULT_DUMMY_PAYMENT_CONSTRAINTS)
}
}

Expand Down Expand Up @@ -693,6 +740,79 @@ pub struct Bolt12RefundContext {
pub payment_metadata: Option<BTreeMap<u64, Vec<u8>>>,
}

/// Common network-wide base fee buckets used when approximating forwarding policies.
const BASE_FEE_BUCKETS: &[u32] = &[
0,
500,
1_000,
2_000,
5_000,
10_000,
20_000,
50_000,
100_000,
200_000,
500_000,
1_000_000,
2_000_000,
5_000_000,
10_000_000,
20_000_000,
50_000_000,
100_000_000,
u32::MAX,
];

/// Common network-wide proportional fee buckets used when approximating forwarding policies.
const PROPORTIONAL_FEE_BUCKETS: &[u32] = &[
0,
1,
5,
10,
20,
50,
100,
200,
500,
1_000,
2_500,
5_000,
10_000,
20_000,
50_000,
100_000,
200_000,
500_000,
1_000_000,
u32::MAX,
];

/// Common CLTV expiry delta buckets used when approximating forwarding policies.
///
/// Values outside the supported range are rejected.
const CLTV_EXPIRY_DELTA_BUCKETS: &[u16] = &[40, 80, 144, 216];

fn bucket_cltv_expiry_delta(cltv_expiry_delta: u16) -> Result<u16, ()> {
ceil_bucket(cltv_expiry_delta, CLTV_EXPIRY_DELTA_BUCKETS)
}

fn bucket_fee_base_msat(fee_base_msat: u32) -> u32 {
ceil_bucket(fee_base_msat, BASE_FEE_BUCKETS).expect("fee buckets must include an upper bound")
}

fn bucket_fee_proportional_millionths(fee_proportional_millionths: u32) -> u32 {
ceil_bucket(fee_proportional_millionths, PROPORTIONAL_FEE_BUCKETS)
.expect("fee buckets must include an upper bound")
}

/// Rounds `value` upward to the nearest bucket.
///
/// This is used to avoid underfunding blinded forwarding fees while avoiding exposure of unusually
/// specific forwarding policy values.
fn ceil_bucket<T: Copy + Ord>(value: T, buckets: &[T]) -> Result<T, ()> {
buckets.iter().copied().find(|&bucket| value <= bucket).ok_or(())
}

impl TryFrom<CounterpartyForwardingInfo> for PaymentRelay {
type Error = ();

Expand All @@ -703,14 +823,10 @@ impl TryFrom<CounterpartyForwardingInfo> for PaymentRelay {
cltv_expiry_delta,
} = info;

// Avoid exposing esoteric CLTV expiry deltas
let cltv_expiry_delta = match cltv_expiry_delta {
0..=40 => 40,
41..=80 => 80,
81..=144 => 144,
145..=216 => 216,
_ => return Err(()),
};
let cltv_expiry_delta = bucket_cltv_expiry_delta(cltv_expiry_delta)?;
let fee_base_msat = bucket_fee_base_msat(fee_base_msat);
let fee_proportional_millionths =
bucket_fee_proportional_millionths(fee_proportional_millionths);

Ok(Self { cltv_expiry_delta, fee_proportional_millionths, fee_base_msat })
}
Expand Down Expand Up @@ -1026,6 +1142,10 @@ pub(super) fn compute_payinfo<F: ForwardTlvsInfo>(
)
.ok_or(())?; // If underflow occurs, we cannot send to this hop without exceeding their max
}
// `payee_htlc_maximum_msat` limits the final payment plus all dummy-hop fees. For example, a
// 100,000 msat channel maximum and a 1,000 msat dummy-hop fee allow a 99,000 msat final payment.
// Apply the channel maximum here; the loop below subtracts each dummy-hop fee from it.
htlc_maximum_msat = core::cmp::min(payee_htlc_maximum_msat, htlc_maximum_msat);
for dummy_tlvs in dummy_tlvs.iter() {
cltv_expiry_delta =
cltv_expiry_delta.checked_add(dummy_tlvs.payment_relay.cltv_expiry_delta).ok_or(())?;
Expand All @@ -1035,10 +1155,14 @@ pub(super) fn compute_payinfo<F: ForwardTlvsInfo>(
&dummy_tlvs.payment_relay,
)
.unwrap_or(1); // If underflow occurs, we definitely reached this node's min

// Track the amount left after this dummy hop deducts its fee. After all dummy hops, this is
// the largest final amount whose inbound HTLC does not exceed the payee's channel maximum.
htlc_maximum_msat =
amt_to_forward_msat(htlc_maximum_msat, &dummy_tlvs.payment_relay).ok_or(())?;
}
htlc_minimum_msat =
core::cmp::max(payee_tlvs.payment_constraints.htlc_minimum_msat, htlc_minimum_msat);
htlc_maximum_msat = core::cmp::min(payee_htlc_maximum_msat, htlc_maximum_msat);

if htlc_maximum_msat < htlc_minimum_msat {
return Err(());
Expand Down
18 changes: 18 additions & 0 deletions lightning/src/events/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,8 @@ pub enum Event {
///
/// [`ChannelConfig::accept_underpaying_htlcs`]: crate::util::config::ChannelConfig::accept_underpaying_htlcs
counterparty_skimmed_fee_msat: u64,
/// The value, in thousands of a satoshi, that was collected by locally-peeled dummy hops.
dummy_skimmed_fees_msat: u64,
/// Information for claiming this received payment, based on whether the purpose of the
/// payment is to pay an invoice or to send a spontaneous payment.
purpose: PaymentPurpose,
Expand Down Expand Up @@ -1081,6 +1083,8 @@ pub enum Event {
/// The value, in thousandths of a satoshi, that this payment is for. May be greater than the
/// invoice amount.
amount_msat: u64,
/// The value, in thousands of a satoshi, that was collected by locally-peeled dummy hops.
dummy_skimmed_fees_msat: u64,
/// The purpose of the claimed payment, i.e. whether the payment was for an invoice or a
/// spontaneous payment.
purpose: PaymentPurpose,
Expand Down Expand Up @@ -2041,6 +2045,7 @@ impl Writeable for Event {
ref payment_hash,
ref amount_msat,
counterparty_skimmed_fee_msat,
dummy_skimmed_fees_msat,
ref purpose,
ref receiver_node_id,
ref receiving_channel_ids,
Expand Down Expand Up @@ -2087,6 +2092,8 @@ impl Writeable for Event {
} else {
Some(counterparty_skimmed_fee_msat)
};
let dummy_skimmed_fees_opt =
(dummy_skimmed_fees_msat != 0).then_some(dummy_skimmed_fees_msat);

let (receiving_channel_id_legacy, receiving_user_channel_id_legacy) =
match receiving_channel_ids.last() {
Expand All @@ -2113,6 +2120,7 @@ impl Writeable for Event {
(11, payment_context, option),
(13, payment_id, option),
(15, *receiving_channel_ids, optional_vec),
(17, dummy_skimmed_fees_opt, option),
});
},
&Event::PaymentSent {
Expand Down Expand Up @@ -2338,6 +2346,7 @@ impl Writeable for Event {
&Event::PaymentClaimed {
ref payment_hash,
ref amount_msat,
dummy_skimmed_fees_msat,
ref purpose,
ref receiver_node_id,
ref htlcs,
Expand All @@ -2346,6 +2355,8 @@ impl Writeable for Event {
ref payment_id,
} => {
19u8.write(writer)?;
let dummy_skimmed_fees_msat_opt =
(dummy_skimmed_fees_msat != 0).then_some(dummy_skimmed_fees_msat);
write_tlv_fields!(writer, {
(0, payment_hash, required),
(1, receiver_node_id, option),
Expand All @@ -2355,6 +2366,7 @@ impl Writeable for Event {
(7, sender_intended_total_msat, option),
(9, onion_fields, option),
(11, payment_id, option),
(13, dummy_skimmed_fees_msat_opt, option),
});
},
&Event::ProbeSuccessful { ref payment_id, ref payment_hash, ref path } => {
Expand Down Expand Up @@ -2565,6 +2577,7 @@ impl MaybeReadable for Event {
let mut payment_secret = None;
let mut amount_msat = 0;
let mut counterparty_skimmed_fee_msat_opt = None;
let mut dummy_skimmed_fees_msat_opt = None;
let mut receiver_node_id = None;
let mut _user_payment_id = None::<u64>; // Used in 0.0.103 and earlier, no longer written in 0.0.116+.
let mut receiving_channel_id_legacy = None;
Expand All @@ -2589,6 +2602,7 @@ impl MaybeReadable for Event {
(11, payment_context, option),
(13, payment_id, option),
(15, receiving_channel_ids_opt, optional_vec),
(17, dummy_skimmed_fees_msat_opt, option),
});
let purpose = match payment_secret {
Some(secret) => {
Expand All @@ -2614,6 +2628,7 @@ impl MaybeReadable for Event {
amount_msat,
counterparty_skimmed_fee_msat: counterparty_skimmed_fee_msat_opt
.unwrap_or(0),
dummy_skimmed_fees_msat: dummy_skimmed_fees_msat_opt.unwrap_or(0),
purpose,
receiving_channel_ids,
claim_deadline,
Expand Down Expand Up @@ -2917,6 +2932,7 @@ impl MaybeReadable for Event {
let mut payment_hash = PaymentHash([0; 32]);
let mut purpose = UpgradableRequired(None);
let mut amount_msat = 0;
let mut dummy_skimmed_fees_msat = None;
let mut receiver_node_id = None;
let mut htlcs: Option<Vec<ClaimedHTLC>> = Some(vec![]);
let mut sender_intended_total_msat: Option<u64> = None;
Expand All @@ -2932,12 +2948,14 @@ impl MaybeReadable for Event {
(9, onion_fields, (option: ReadableArgs,
sender_intended_total_msat.unwrap_or(amount_msat))),
(11, payment_id, option),
(13, dummy_skimmed_fees_msat, option),
});
Ok(Some(Event::PaymentClaimed {
receiver_node_id,
payment_hash,
purpose: _init_tlv_based_struct_field!(purpose, upgradable_required),
amount_msat,
dummy_skimmed_fees_msat: dummy_skimmed_fees_msat.unwrap_or(0),
htlcs: htlcs.unwrap_or_default(),
sender_intended_total_msat,
onion_fields,
Expand Down
8 changes: 6 additions & 2 deletions lightning/src/ln/async_payments_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1507,8 +1507,10 @@ fn amount_doesnt_match_invreq() {
let mut allow_priv_chan_fwds_cfg = test_default_channel_config();
allow_priv_chan_fwds_cfg.accept_forwards_to_priv_channels = true;
// Make one blinded path's fees slightly higher so they are tried in a deterministic order.
// Use a fee that already matches a blinded relay fee bucket so the test's expected forwarding
// fee matches what blinded forwarding actually uses.
let mut higher_fee_chan_cfg = allow_priv_chan_fwds_cfg.clone();
higher_fee_chan_cfg.channel_config.forwarding_fee_base_msat += 5000;
higher_fee_chan_cfg.channel_config.forwarding_fee_base_msat = 2_000;
let node_chanmgrs = create_node_chanmgrs(
4,
&node_cfgs,
Expand Down Expand Up @@ -3137,7 +3139,9 @@ fn held_htlc_timeout() {
MIN_CLTV_EXPIRY_DELTA as u32
+ TEST_FINAL_CLTV
+ HTLC_FAIL_BACK_BUFFER
+ LATENCY_GRACE_PERIOD_BLOCKS,
+ LATENCY_GRACE_PERIOD_BLOCKS
// Each dummy hop adds a 40-block CLTV delta before the held HTLC can time out.
+ DEFAULT_PAYMENT_DUMMY_HOPS as u32 * 40,
);
sender_lsp.node.process_pending_htlc_forwards();

Expand Down
10 changes: 8 additions & 2 deletions lightning/src/ln/blinded_payment_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,12 +258,18 @@ fn one_hop_blinded_path_with_dummy_hops() {
let ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events);

let path = &[&nodes[1]];
let args = PassAlongPathArgs::new(&nodes[0], path, amt_msat, payment_hash, ev)
// Conservative aggregation of the two dummy-hop fees overpays the recipient by 1 msat.
let args = PassAlongPathArgs::new(&nodes[0], path, amt_msat + 1, payment_hash, ev)
.with_dummy_tlvs(&dummy_tlvs)
.with_payment_secret(payment_secret);

do_pass_along_path(args);
claim_payment(&nodes[0], &[&nodes[1]], payment_preimage);
// The sender reports the same 1-msat aggregation overpay as part of its total fee.
let expected_route = &[&[&nodes[1]][..]];
claim_payment_along_route(
ClaimAlongRouteArgs::new(&nodes[0], expected_route, payment_preimage)
.with_expected_extra_total_fees_msat(1),
);
}

#[test]
Expand Down
Loading