Merge pull request #253 from vincenzopalazzo/claude/hardcore-mclaren

fix: reject offers with offer_amount=0 per BOLT 12
This commit is contained in:
orbitalturtle 2026-04-14 14:55:45 -05:00 committed by GitHub
commit e0b23440a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 126 additions and 44 deletions

20
Cargo.lock generated
View file

@ -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",
]

View file

@ -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.

View file

@ -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<u64>,
/// The description of the offer.

View file

@ -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<u64>,
/// The chain the offer is valid on.
pub chain: Network,
/// Optional description of the offer. If not provided, the offer will have description "".

View file

@ -154,7 +154,7 @@ pub(super) async fn track_payment(
}
pub(super) struct CreateOfferArgs {
amount_msats: u64,
amount_msats: Option<u64>,
chain: Network,
description: Option<String>,
issuer: Option<String>,
@ -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,

View file

@ -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)"
);
}
}

View file

@ -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(),

View file

@ -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,