diff --git a/Cargo.lock b/Cargo.lock index 7e27930..0312a9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1188,7 +1188,7 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "lightning" version = "0.1.3" -source = "git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed#878619b2a95af44a5c5ab811805ae41846fb0cc4" +source = "git+https://github.com/vincenzopalazzo/rust-lightning?branch=2025-06-cherry-pick-exposed-offer-amount-fix#afa9bea123b15fe3f2b602a126de94f13080272f" dependencies = [ "bech32 0.11.0", "bitcoin", @@ -1204,7 +1204,7 @@ dependencies = [ [[package]] name = "lightning-background-processor" version = "0.1.0" -source = "git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed#878619b2a95af44a5c5ab811805ae41846fb0cc4" +source = "git+https://github.com/vincenzopalazzo/rust-lightning?branch=2025-06-cherry-pick-exposed-offer-amount-fix#afa9bea123b15fe3f2b602a126de94f13080272f" dependencies = [ "bitcoin", "bitcoin-io", @@ -1216,7 +1216,7 @@ dependencies = [ [[package]] name = "lightning-block-sync" version = "0.1.0" -source = "git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed#878619b2a95af44a5c5ab811805ae41846fb0cc4" +source = "git+https://github.com/vincenzopalazzo/rust-lightning?branch=2025-06-cherry-pick-exposed-offer-amount-fix#afa9bea123b15fe3f2b602a126de94f13080272f" dependencies = [ "bitcoin", "chunked_transfer", @@ -1228,7 +1228,7 @@ dependencies = [ [[package]] name = "lightning-dns-resolver" version = "0.2.0" -source = "git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed#878619b2a95af44a5c5ab811805ae41846fb0cc4" +source = "git+https://github.com/vincenzopalazzo/rust-lightning?branch=2025-06-cherry-pick-exposed-offer-amount-fix#afa9bea123b15fe3f2b602a126de94f13080272f" dependencies = [ "dnssec-prover", "lightning", @@ -1239,7 +1239,7 @@ dependencies = [ [[package]] name = "lightning-invoice" version = "0.33.2" -source = "git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed#878619b2a95af44a5c5ab811805ae41846fb0cc4" +source = "git+https://github.com/vincenzopalazzo/rust-lightning?branch=2025-06-cherry-pick-exposed-offer-amount-fix#afa9bea123b15fe3f2b602a126de94f13080272f" dependencies = [ "bech32 0.11.0", "bitcoin", @@ -1249,7 +1249,7 @@ dependencies = [ [[package]] name = "lightning-net-tokio" version = "0.1.0" -source = "git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed#878619b2a95af44a5c5ab811805ae41846fb0cc4" +source = "git+https://github.com/vincenzopalazzo/rust-lightning?branch=2025-06-cherry-pick-exposed-offer-amount-fix#afa9bea123b15fe3f2b602a126de94f13080272f" dependencies = [ "bitcoin", "lightning", @@ -1259,7 +1259,7 @@ dependencies = [ [[package]] name = "lightning-persister" version = "0.1.0" -source = "git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed#878619b2a95af44a5c5ab811805ae41846fb0cc4" +source = "git+https://github.com/vincenzopalazzo/rust-lightning?branch=2025-06-cherry-pick-exposed-offer-amount-fix#afa9bea123b15fe3f2b602a126de94f13080272f" dependencies = [ "bitcoin", "lightning", @@ -1269,7 +1269,7 @@ dependencies = [ [[package]] name = "lightning-rapid-gossip-sync" version = "0.1.0" -source = "git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed#878619b2a95af44a5c5ab811805ae41846fb0cc4" +source = "git+https://github.com/vincenzopalazzo/rust-lightning?branch=2025-06-cherry-pick-exposed-offer-amount-fix#afa9bea123b15fe3f2b602a126de94f13080272f" dependencies = [ "bitcoin", "bitcoin-io", @@ -1280,7 +1280,7 @@ dependencies = [ [[package]] name = "lightning-types" version = "0.2.0" -source = "git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed#878619b2a95af44a5c5ab811805ae41846fb0cc4" +source = "git+https://github.com/vincenzopalazzo/rust-lightning?branch=2025-06-cherry-pick-exposed-offer-amount-fix#afa9bea123b15fe3f2b602a126de94f13080272f" dependencies = [ "bitcoin", ] @@ -1702,7 +1702,7 @@ checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" [[package]] name = "possiblyrandom" version = "0.2.0" -source = "git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed#878619b2a95af44a5c5ab811805ae41846fb0cc4" +source = "git+https://github.com/vincenzopalazzo/rust-lightning?branch=2025-06-cherry-pick-exposed-offer-amount-fix#afa9bea123b15fe3f2b602a126de94f13080272f" dependencies = [ "getrandom 0.2.15", ] diff --git a/Cargo.toml b/Cargo.toml index 2478b05..ca59e0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,9 +23,10 @@ futures = "0.3.26" home = "0.5.5" # lightning = { version = "0.1.3", features = ["_test_utils"] } # Branch port commit https://github.com/lightningdevkit/rust-lightning/commit/928429833507eb98b1f9a3793da4fe4527e11435 -# from main branch on top of version 0.1.3. +# from main branch on top of version 0.1.3, plus offer_amount=0 fix from +# https://github.com/lightningdevkit/rust-lightning/pull/4487 (cherry-picked). # TODO: Use main crate when version 0.2.X is deployed. -lightning = { git = "https://github.com/a-mpch/rust-lightning", branch = "2025-06-cherry-pick-exposed", features = ["dnssec"] } +lightning = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "2025-06-cherry-pick-exposed-offer-amount-fix", features = ["dnssec"] } rand_chacha = "0.9.0" rand_core = { version = "0.9.3", features = ["os_rng"] } @@ -66,6 +67,20 @@ tonic-prost-build = "0.14.2" inherits = "release" lto = "thin" +# Ensure ldk-sample (and any other transitive dep) uses the same rust-lightning +# fork as lndk itself, avoiding duplicate crate versions in the lockfile. +[patch."https://github.com/a-mpch/rust-lightning"] +lightning = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "2025-06-cherry-pick-exposed-offer-amount-fix" } +lightning-invoice = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "2025-06-cherry-pick-exposed-offer-amount-fix" } +lightning-types = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "2025-06-cherry-pick-exposed-offer-amount-fix" } +lightning-net-tokio = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "2025-06-cherry-pick-exposed-offer-amount-fix" } +lightning-persister = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "2025-06-cherry-pick-exposed-offer-amount-fix" } +lightning-background-processor = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "2025-06-cherry-pick-exposed-offer-amount-fix" } +lightning-rapid-gossip-sync = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "2025-06-cherry-pick-exposed-offer-amount-fix" } +lightning-block-sync = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "2025-06-cherry-pick-exposed-offer-amount-fix" } +lightning-dns-resolver = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "2025-06-cherry-pick-exposed-offer-amount-fix" } +possiblyrandom = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "2025-06-cherry-pick-exposed-offer-amount-fix" } + [lints.rust.unexpected_cfgs] level = "forbid" # When adding a new cfg attribute, ensure that it is added to this list. diff --git a/src/cli.rs b/src/cli.rs index d149f76..c424566 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -183,7 +183,9 @@ enum Commands { }, /// CreateOffer creates a BOLT 12 offer. CreateOffer { - /// The amount of the offer in millisatoshis. + /// The amount of the offer in millisatoshis. Per BOLT 12, this must be + /// greater than zero when present. If omitted, the offer has no minimum + /// amount requirement. #[arg(long, required = false)] amount: Option, /// The description of the offer. diff --git a/src/offers/handler.rs b/src/offers/handler.rs index b451620..0160d77 100644 --- a/src/offers/handler.rs +++ b/src/offers/handler.rs @@ -92,8 +92,10 @@ pub struct SendPaymentParams { pub struct CreateOfferParams { /// LND tonic client used to query information from the node. pub client: Client, - /// The amount of the offer in millisatoshis. - pub amount_msats: u64, + /// The amount of the offer in millisatoshis. If None, the offer has no + /// minimum amount and the payer decides. Per BOLT 12 (bolts #1316), + /// offer_amount must be greater than zero when present. + pub amount_msats: Option, /// The chain the offer is valid on. pub chain: Network, /// Optional description of the offer. If not provided, the offer will have description "". diff --git a/src/offers/lnd_requests.rs b/src/offers/lnd_requests.rs index 643f722..ea12518 100644 --- a/src/offers/lnd_requests.rs +++ b/src/offers/lnd_requests.rs @@ -154,7 +154,7 @@ pub(super) async fn track_payment( } pub(super) struct CreateOfferArgs { - amount_msats: u64, + amount_msats: Option, chain: Network, description: Option, issuer: Option, @@ -197,9 +197,15 @@ pub(super) async fn create_offer( let mut builder = OfferBuilder::deriving_signing_pubkey(node_id, expanded_key, nonce, &secp_ctx) - .amount_msats(args.amount_msats) .chain(args.chain); + // Per BOLT 12 (bolts #1316): writers MUST set offer_amount greater than + // zero when present. When no amount is specified, we omit the field + // entirely so readers interpret it as "no minimum amount required". + if let Some(amount_msats) = args.amount_msats { + builder = builder.amount_msats(amount_msats); + } + builder = if let Some(path) = paths.first() { builder.path(path.clone()) } else { @@ -1427,7 +1433,7 @@ mod tests { let expanded_key = ExpandedKey::new([42; 32]); let args = CreateOfferArgs { - amount_msats: 1000, + amount_msats: Some(1000), chain: Network::Regtest, description: Some("Test offer".to_string()), issuer: Some("Test issuer".to_string()), @@ -1458,7 +1464,7 @@ mod tests { // Only provide required parameters let args = CreateOfferArgs { - amount_msats: 1000, + amount_msats: Some(1000), chain: Network::Regtest, description: None, issuer: None, @@ -1480,6 +1486,35 @@ mod tests { assert_eq!(offer.supported_quantity(), Quantity::One); } + #[tokio::test] + async fn test_create_offer_no_amount() { + let creator_mock = setup_create_offer_success_mock(); + + let entropy_source = MessengerUtilities::new([42; 32]); + let expanded_key = ExpandedKey::new([42; 32]); + + // When no amount is specified, the offer should omit offer_amount + // entirely rather than setting it to 0 (per BOLT 12, bolts #1316). + let args = CreateOfferArgs { + amount_msats: None, + chain: Network::Regtest, + description: Some("donations welcome".to_string()), + issuer: None, + quantity: None, + expiry: None, + }; + let result = create_offer(creator_mock, args, &entropy_source, &expanded_key).await; + + assert!(result.is_ok()); + let offer = result.unwrap(); + + // offer_amount must be None (omitted), not Some(Bitcoin { amount_msats: 0 }). + assert!( + offer.amount().is_none(), + "Offer created without amount must omit offer_amount, not set it to 0" + ); + } + #[tokio::test] async fn test_create_offer_with_expiry() { let creator_mock = setup_create_offer_success_mock(); @@ -1489,7 +1524,7 @@ mod tests { // Include expiry parameter let args = CreateOfferArgs { - amount_msats: 1000, + amount_msats: Some(1000), chain: Network::Regtest, description: None, issuer: None, @@ -1518,7 +1553,7 @@ mod tests { let expanded_key = ExpandedKey::new([42; 32]); let args = CreateOfferArgs { - amount_msats: 1000, + amount_msats: Some(1000), chain: Network::Regtest, description: None, issuer: None, @@ -1550,7 +1585,7 @@ mod tests { let expanded_key = ExpandedKey::new([42; 32]); let args = CreateOfferArgs { - amount_msats: 1000, + amount_msats: Some(1000), chain: Network::Regtest, description: None, issuer: None, @@ -1575,7 +1610,7 @@ mod tests { // Test with different quantity types let args = CreateOfferArgs { - amount_msats: 1000, + amount_msats: Some(1000), chain: Network::Regtest, description: None, issuer: None, @@ -1599,7 +1634,7 @@ mod tests { let expanded_key = ExpandedKey::new([42; 32]); let args = CreateOfferArgs { - amount_msats: 1000, + amount_msats: Some(1000), chain: Network::Regtest, description: None, issuer: None, @@ -1608,7 +1643,7 @@ mod tests { }; let args2 = CreateOfferArgs { - amount_msats: 1000, + amount_msats: Some(1000), chain: Network::Regtest, description: None, issuer: None, diff --git a/src/offers/parse.rs b/src/offers/parse.rs index 42d36be..d5d69b2 100644 --- a/src/offers/parse.rs +++ b/src/offers/parse.rs @@ -39,6 +39,16 @@ pub async fn validate_amount( Amount::Bitcoin { amount_msats: bitcoin_amt, } => { + // Per BOLT 12 (bolts #1316): readers MUST NOT respond to + // offers where offer_amount is zero. A zero amount when + // present is semantically invalid since omitting the field + // already indicates no minimum is required. + if bitcoin_amt == 0 { + return Err(OfferError::InvalidAmount( + "Offer has amount explicitly set to 0, which is invalid per BOLT 12 spec" + .to_string(), + )); + } if let Some(msats) = pay_amount_msats { if msats < bitcoin_amt { return Err(OfferError::InvalidAmount(format!( @@ -47,12 +57,6 @@ pub async fn validate_amount( } msats } else { - // If user didn't set amount, set it to the offer amount. - if bitcoin_amt == 0 { - return Err(OfferError::InvalidAmount( - "Offer doesn't set an amount, so user must specify one".to_string(), - )); - } bitcoin_amt } } @@ -110,12 +114,6 @@ mod tests { assert!(validate_amount(offer_amount.as_ref(), Some(20000)) .await .is_ok()); - - let offer = build_custom_offer(0); - let offer_amount = offer.amount(); - assert!(validate_amount(offer_amount.as_ref(), Some(20000)) - .await - .is_ok()); } #[tokio::test] @@ -126,10 +124,40 @@ mod tests { assert!(validate_amount(offer_amount.as_ref(), Some(1000)) .await .is_err()); + } - // Both user amount and offer amount can't be 0. + /// Per BOLT 12 spec clarification (https://github.com/lightning/bolts/pull/1316): + /// - Readers MUST NOT respond to offers where offer_amount is zero + /// - Writers MUST set offer_amount greater than zero when present + /// + /// An offer with amount=0 is semantically invalid because omitting + /// offer_amount already indicates no minimum is required. A zero value + /// when present is incorrect per the spec. + #[tokio::test] + async fn test_reject_offer_with_zero_amount() { + // With the rust-lightning fix (PR #4487), OfferBuilder::amount_msats(0) + // is silently normalized to None (no amount) at build time. Verify + // that building an offer with amount=0 produces amount=None. let offer = build_custom_offer(0); - let offer_amount = offer.amount(); - assert!(validate_amount(offer_amount.as_ref(), None).await.is_err()); + assert!( + offer.amount().is_none(), + "OfferBuilder must normalize amount_msats(0) to None per BOLT 12" + ); + + // Directly test that validate_amount rejects Amount::Bitcoin { amount_msats: 0 }. + // This covers the case where a raw TLV-decoded offer somehow has amount=0 + // (e.g. from a non-compliant implementation). The rust-lightning parser + // already rejects these, but we defend in depth. + let zero_amount = Amount::Bitcoin { amount_msats: 0 }; + assert!( + validate_amount(Some(&zero_amount), Some(20000)) + .await + .is_err(), + "Offers with amount=0 must be rejected per BOLT 12 spec (bolts #1316)" + ); + assert!( + validate_amount(Some(&zero_amount), None).await.is_err(), + "Offers with amount=0 must be rejected per BOLT 12 spec (bolts #1316)" + ); } } diff --git a/src/server.rs b/src/server.rs index 1404748..78b8f56 100644 --- a/src/server.rs +++ b/src/server.rs @@ -246,7 +246,7 @@ impl Offers for LNDKServer { let request = CreateOfferParams { client, - amount_msats: inner_request.amount.unwrap_or(0), + amount_msats: inner_request.amount, chain: network, description: inner_request.description.clone(), issuer: inner_request.issuer.clone(), diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 1fa8e3b..508277d 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1021,7 +1021,7 @@ async fn test_create_offer() { let create_offer_params = CreateOfferParams { client: lnd.client.clone().unwrap(), - amount_msats: 20_000, + amount_msats: Some(20_000), chain: Network::Regtest, description: None, issuer: None, @@ -1201,7 +1201,7 @@ async fn test_receive_payment_from_offer() { let create_offer_params = CreateOfferParams { client: lnd.client.clone().unwrap(), - amount_msats: 20_000, + amount_msats: Some(20_000), chain: Network::Regtest, description: None, issuer: None, @@ -1410,7 +1410,7 @@ async fn test_receive_payment_from_offer_with_multiple_blinded_paths() { let create_offer_params = CreateOfferParams { client: lnd.client.clone().unwrap(), - amount_msats: 20_000, + amount_msats: Some(20_000), chain: Network::Regtest, description: None, issuer: None,