mirror of
https://github.com/lndk-org/lndk.git
synced 2026-08-13 12:33:05 +02:00
Merge pull request #253 from vincenzopalazzo/claude/hardcore-mclaren
fix: reject offers with offer_amount=0 per BOLT 12
This commit is contained in:
commit
e0b23440a3
8 changed files with 126 additions and 44 deletions
20
Cargo.lock
generated
20
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
19
Cargo.toml
19
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 "".
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue