Skip to content

Commit 506a5a1

Browse files
committed
bolt12: Validate LSPS2 opening fees
Read fee limits from BOLT12 payment context metadata and reject unsupported withholding. Record accepted fees on inbound offer payments. Co-Authored-By: HAL 9000
1 parent 74b7455 commit 506a5a1

5 files changed

Lines changed: 187 additions & 15 deletions

File tree

src/event.rs

Lines changed: 115 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
use core::future::Future;
99
use core::task::{Poll, Waker};
10-
use std::collections::VecDeque;
10+
use std::collections::{BTreeMap, VecDeque};
1111
use std::ops::Deref;
1212
use std::sync::{Arc, Mutex};
1313

@@ -664,13 +664,40 @@ where
664664
}
665665
}
666666

667-
fn lsps2_max_total_opening_fee_msat(payment_metadata: &[u8], amount_msat: u64) -> Option<u64> {
667+
fn lsps2_max_total_opening_fee_msat(
668+
payment_metadata: &[u8], amount_msat: u64, counterparty_skimmed_fee_msat: u64,
669+
) -> Option<u64> {
668670
let metadata = PaymentMetadata::read(&mut &payment_metadata[..]).ok()?;
671+
Self::lsps2_max_total_opening_fee_msat_from_metadata(
672+
metadata,
673+
amount_msat,
674+
counterparty_skimmed_fee_msat,
675+
)
676+
}
677+
678+
fn lsps2_bolt12_max_total_opening_fee_msat(
679+
payment_metadata: &BTreeMap<u64, Vec<u8>>, amount_msat: u64,
680+
counterparty_skimmed_fee_msat: u64,
681+
) -> Option<u64> {
682+
let metadata = PaymentMetadata::decode_from_bolt12_payment_metadata(payment_metadata)?;
683+
Self::lsps2_max_total_opening_fee_msat_from_metadata(
684+
metadata,
685+
amount_msat,
686+
counterparty_skimmed_fee_msat,
687+
)
688+
}
689+
690+
fn lsps2_max_total_opening_fee_msat_from_metadata(
691+
metadata: PaymentMetadata, amount_msat: u64, counterparty_skimmed_fee_msat: u64,
692+
) -> Option<u64> {
669693
let lsps2_parameters = metadata.lsps2_parameters?;
670694
lsps2_parameters.max_total_opening_fee_msat.or_else(|| {
671695
lsps2_parameters.max_proportional_opening_fee_ppm_msat.and_then(|max_prop_fee| {
672-
// If it's a variable amount payment, compute the actual fee.
673-
compute_opening_fee(amount_msat, 0, max_prop_fee)
696+
// `PaymentClaimable::amount_msat` excludes the fee withheld by the LSP. LSPS2
697+
// proportional fees are calculated from the amount sent by the payer, so add the
698+
// withheld fee back before computing the permitted fee.
699+
let payment_size_msat = amount_msat.checked_add(counterparty_skimmed_fee_msat)?;
700+
compute_opening_fee(payment_size_msat, 0, max_prop_fee)
674701
})
675702
})
676703
}
@@ -864,15 +891,28 @@ where
864891
.as_ref()
865892
.and_then(|fields| fields.payment_metadata.as_ref())
866893
.and_then(|metadata| {
867-
Self::lsps2_max_total_opening_fee_msat(metadata, amount_msat)
894+
Self::lsps2_max_total_opening_fee_msat(
895+
metadata,
896+
amount_msat,
897+
counterparty_skimmed_fee_msat,
898+
)
868899
}),
900+
PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => {
901+
payment_context.payment_metadata.as_ref().and_then(|metadata| {
902+
Self::lsps2_bolt12_max_total_opening_fee_msat(
903+
metadata,
904+
amount_msat,
905+
counterparty_skimmed_fee_msat,
906+
)
907+
})
908+
},
869909
_ => None,
870910
};
871911

872912
let Some(max_total_opening_fee_msat) = max_total_opening_fee_msat else {
873913
log_info!(
874914
self.logger,
875-
"Refusing inbound payment with hash {} as the counterparty withheld {}msat without valid BOLT11 LSPS2 payment metadata",
915+
"Refusing inbound payment with hash {} as the counterparty withheld {}msat without valid LSPS2 payment metadata",
876916
hex_utils::to_string(&payment_hash.0),
877917
counterparty_skimmed_fee_msat,
878918
);
@@ -894,7 +934,7 @@ where
894934

895935
if let Some(info) = payment_info.as_ref() {
896936
match &info.kind {
897-
PaymentKind::Bolt11 { .. } => {
937+
PaymentKind::Bolt11 { .. } | PaymentKind::Bolt12Offer { .. } => {
898938
let update = PaymentDetailsUpdate {
899939
counterparty_skimmed_fee_msat: Some(Some(counterparty_skimmed_fee_msat)),
900940
..PaymentDetailsUpdate::new(payment_id)
@@ -907,7 +947,7 @@ where
907947
},
908948
};
909949
},
910-
_ => debug_assert!(false, "We only expect the counterparty to get away with withholding fees for BOLT11 payments."),
950+
_ => debug_assert!(false, "We only expect the counterparty to withhold fees for LSPS2 invoice payments."),
911951
}
912952
}
913953
}
@@ -976,6 +1016,8 @@ where
9761016
hash: Some(payment_hash),
9771017
preimage: payment_preimage,
9781018
secret: Some(payment_secret),
1019+
counterparty_skimmed_fee_msat: (counterparty_skimmed_fee_msat > 0)
1020+
.then_some(counterparty_skimmed_fee_msat),
9791021
offer_id,
9801022
payer_note,
9811023
quantity,
@@ -2038,12 +2080,33 @@ mod tests {
20382080
assert_eq!(
20392081
EventHandler::<Arc<TestLogger>>::lsps2_max_total_opening_fee_msat(
20402082
&metadata.encode(),
2041-
100_000
2083+
100_000,
2084+
0,
20422085
),
20432086
Some(42_000)
20442087
);
20452088
}
20462089

2090+
#[test]
2091+
fn lsps2_proportional_fee_uses_gross_payment_amount() {
2092+
let metadata = PaymentMetadata {
2093+
lsps2_parameters: Some(LSPS2Parameters {
2094+
max_total_opening_fee_msat: None,
2095+
max_proportional_opening_fee_ppm_msat: Some(10_000),
2096+
}),
2097+
lsps2_lease_parameters: None,
2098+
};
2099+
2100+
assert_eq!(
2101+
EventHandler::<Arc<TestLogger>>::lsps2_max_total_opening_fee_msat(
2102+
&metadata.encode(),
2103+
198_000,
2104+
2_000,
2105+
),
2106+
Some(2_000)
2107+
);
2108+
}
2109+
20472110
#[test]
20482111
fn lsps2_payment_metadata_missing_or_malformed_limit_is_rejected() {
20492112
let empty_metadata =
@@ -2060,23 +2123,63 @@ mod tests {
20602123
assert_eq!(
20612124
EventHandler::<Arc<TestLogger>>::lsps2_max_total_opening_fee_msat(
20622125
&empty_metadata,
2063-
100_000
2126+
100_000,
2127+
0,
20642128
),
20652129
None
20662130
);
20672131
assert_eq!(
2068-
EventHandler::<Arc<TestLogger>>::lsps2_max_total_opening_fee_msat(&[0xff], 100_000),
2132+
EventHandler::<Arc<TestLogger>>::lsps2_max_total_opening_fee_msat(&[0xff], 100_000, 0,),
20692133
None
20702134
);
20712135
assert_eq!(
20722136
EventHandler::<Arc<TestLogger>>::lsps2_max_total_opening_fee_msat(
20732137
&metadata_without_fee_limit,
2074-
100_000
2138+
100_000,
2139+
0,
20752140
),
20762141
None
20772142
);
20782143
}
20792144

2145+
#[test]
2146+
fn bolt12_lsps2_payment_metadata_decodes_fee_limit() {
2147+
let metadata = PaymentMetadata {
2148+
lsps2_parameters: Some(LSPS2Parameters {
2149+
max_total_opening_fee_msat: None,
2150+
max_proportional_opening_fee_ppm_msat: Some(10_000),
2151+
}),
2152+
lsps2_lease_parameters: None,
2153+
}
2154+
.encode_as_bolt12_payment_metadata();
2155+
2156+
assert_eq!(
2157+
EventHandler::<Arc<TestLogger>>::lsps2_bolt12_max_total_opening_fee_msat(
2158+
&metadata, 100_000, 0,
2159+
),
2160+
Some(1_000)
2161+
);
2162+
}
2163+
2164+
#[test]
2165+
fn bolt12_lsps2_payment_metadata_requires_fee_limit() {
2166+
let empty_metadata = BTreeMap::new();
2167+
let metadata_without_fee_limit =
2168+
PaymentMetadata { lsps2_parameters: None, lsps2_lease_parameters: None }
2169+
.encode_as_bolt12_payment_metadata();
2170+
let mut malformed_metadata = metadata_without_fee_limit.clone();
2171+
*malformed_metadata.values_mut().next().unwrap() = vec![0xff];
2172+
2173+
for metadata in [empty_metadata, malformed_metadata, metadata_without_fee_limit] {
2174+
assert_eq!(
2175+
EventHandler::<Arc<TestLogger>>::lsps2_bolt12_max_total_opening_fee_msat(
2176+
&metadata, 100_000, 0,
2177+
),
2178+
None
2179+
);
2180+
}
2181+
}
2182+
20802183
#[tokio::test]
20812184
async fn event_queue_persistence() {
20822185
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));

src/payment/bolt12/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ impl Bolt12Payment {
163163
hash: None,
164164
preimage: None,
165165
secret: None,
166+
counterparty_skimmed_fee_msat: None,
166167
offer_id: offer.id(),
167168
payer_note: payer_note.map(UntrustedString),
168169
quantity,
@@ -188,6 +189,7 @@ impl Bolt12Payment {
188189
hash: None,
189190
preimage: None,
190191
secret: None,
192+
counterparty_skimmed_fee_msat: None,
191193
offer_id: offer.id(),
192194
payer_note: payer_note.map(UntrustedString),
193195
quantity,
@@ -344,6 +346,7 @@ impl Bolt12Payment {
344346
hash: None,
345347
preimage: None,
346348
secret: None,
349+
counterparty_skimmed_fee_msat: None,
347350
offer_id: offer.id(),
348351
payer_note: payer_note.map(UntrustedString),
349352
quantity,
@@ -369,6 +372,7 @@ impl Bolt12Payment {
369372
hash: None,
370373
preimage: None,
371374
secret: None,
375+
counterparty_skimmed_fee_msat: None,
372376
offer_id: offer.id(),
373377
payer_note: payer_note.map(UntrustedString),
374378
quantity,

src/payment/metadata.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,19 @@ mod tests {
7777

7878
assert_eq!(metadata, decoded);
7979
}
80+
81+
#[test]
82+
fn bolt12_metadata_roundtrips() {
83+
let metadata = PaymentMetadata {
84+
lsps2_parameters: Some(LSPS2Parameters {
85+
max_total_opening_fee_msat: Some(42_000),
86+
max_proportional_opening_fee_ppm_msat: None,
87+
}),
88+
lsps2_lease_parameters: None,
89+
};
90+
91+
let encoded = metadata.encode_as_bolt12_payment_metadata();
92+
93+
assert_eq!(PaymentMetadata::decode_from_bolt12_payment_metadata(&encoded), Some(metadata));
94+
}
8095
}

src/payment/store.rs

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,12 +253,13 @@ impl StorableObject for PaymentDetails {
253253

254254
if let Some(skimmed_fee_msat) = update.counterparty_skimmed_fee_msat {
255255
match self.kind {
256-
PaymentKind::Bolt11 { ref mut counterparty_skimmed_fee_msat, .. } => {
256+
PaymentKind::Bolt11 { ref mut counterparty_skimmed_fee_msat, .. }
257+
| PaymentKind::Bolt12Offer { ref mut counterparty_skimmed_fee_msat, .. } => {
257258
update_if_necessary!(*counterparty_skimmed_fee_msat, skimmed_fee_msat);
258259
},
259260
_ => debug_assert!(
260261
false,
261-
"We should only ever override counterparty_skimmed_fee_msat for BOLT11 payments"
262+
"We should only override counterparty_skimmed_fee_msat for invoice payments"
262263
),
263264
}
264265
}
@@ -545,6 +546,17 @@ pub enum PaymentKind {
545546
preimage: Option<PaymentPreimage>,
546547
/// The secret used by the payment.
547548
secret: Option<PaymentSecret>,
549+
/// The value, in thousands of a satoshi, that was deducted from this payment as an extra
550+
/// fee taken by our channel counterparty.
551+
///
552+
/// Will only ever be `Some` for inbound payments received via an [bLIP-52 / LSPS 2]
553+
/// just-in-time channel, and only after the payment is observed; `None` otherwise.
554+
///
555+
/// This will always be `None` for payments serialized by versions that did not record
556+
/// BOLT12 LSPS2 fees.
557+
///
558+
/// [bLIP-52 / LSPS 2]: https://github.com/lightning/blips/blob/master/blip-0052.md
559+
counterparty_skimmed_fee_msat: Option<u64>,
548560
/// The ID of the offer this payment is for.
549561
offer_id: OfferId,
550562
/// The payer note for the payment.
@@ -617,6 +629,7 @@ impl_writeable_tlv_based_enum!(PaymentKind,
617629
(2, preimage, option),
618630
(3, quantity, option),
619631
(4, secret, option),
632+
(5, counterparty_skimmed_fee_msat, option),
620633
(6, offer_id, required),
621634
},
622635
(8, Spontaneous) => {
@@ -732,7 +745,8 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate {
732745
};
733746

734747
let counterparty_skimmed_fee_msat = match value.kind {
735-
PaymentKind::Bolt11 { counterparty_skimmed_fee_msat, .. } => {
748+
PaymentKind::Bolt11 { counterparty_skimmed_fee_msat, .. }
749+
| PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => {
736750
Some(counterparty_skimmed_fee_msat)
737751
},
738752
_ => None,
@@ -1022,6 +1036,38 @@ mod tests {
10221036
}
10231037
}
10241038

1039+
#[test]
1040+
fn bolt12_offer_records_counterparty_skimmed_fee() {
1041+
let payment_id = PaymentId([41; 32]);
1042+
let mut payment = PaymentDetails::new(
1043+
payment_id,
1044+
PaymentKind::Bolt12Offer {
1045+
hash: Some(PaymentHash([42; 32])),
1046+
preimage: None,
1047+
secret: None,
1048+
counterparty_skimmed_fee_msat: None,
1049+
offer_id: OfferId([43; 32]),
1050+
payer_note: None,
1051+
quantity: None,
1052+
},
1053+
Some(100_000),
1054+
None,
1055+
PaymentDirection::Inbound,
1056+
PaymentStatus::Pending,
1057+
);
1058+
1059+
assert!(payment.update(PaymentDetailsUpdate {
1060+
counterparty_skimmed_fee_msat: Some(Some(1_000)),
1061+
..PaymentDetailsUpdate::new(payment_id)
1062+
}));
1063+
assert!(matches!(
1064+
payment.kind,
1065+
PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat: Some(1_000), .. }
1066+
));
1067+
let encoded = payment.encode();
1068+
assert_eq!(payment, PaymentDetails::read(&mut &*encoded).unwrap());
1069+
}
1070+
10251071
#[derive(Clone, Debug, PartialEq, Eq)]
10261072
struct LegacyBolt11JitKind {
10271073
hash: PaymentHash,

tests/integration_tests_rust.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2332,12 +2332,14 @@ async fn simple_bolt12_send_receive() {
23322332
hash,
23332333
preimage,
23342334
secret: _,
2335+
counterparty_skimmed_fee_msat,
23352336
offer_id,
23362337
quantity: ref qty,
23372338
payer_note: ref note,
23382339
} => {
23392340
assert!(hash.is_some());
23402341
assert!(preimage.is_some());
2342+
assert_eq!(counterparty_skimmed_fee_msat, None);
23412343
assert_eq!(offer_id, offer.id());
23422344
assert_eq!(&expected_quantity, qty);
23432345
assert_eq!(expected_payer_note.unwrap(), note.clone().unwrap().0);
@@ -2399,12 +2401,14 @@ async fn simple_bolt12_send_receive() {
23992401
hash,
24002402
preimage,
24012403
secret: _,
2404+
counterparty_skimmed_fee_msat,
24022405
offer_id,
24032406
quantity: ref qty,
24042407
payer_note: ref note,
24052408
} => {
24062409
assert!(hash.is_some());
24072410
assert!(preimage.is_some());
2411+
assert_eq!(counterparty_skimmed_fee_msat, None);
24082412
assert_eq!(offer_id, offer.id());
24092413
assert_eq!(&expected_quantity, qty);
24102414
assert_eq!(expected_payer_note.unwrap(), note.clone().unwrap().0);

0 commit comments

Comments
 (0)