Skip to content

Commit 9eae2b6

Browse files
feat: Add Offer wrapper for FFI bindings
Implement Offer struct in uniffi_types to provide a wrapper around LDK's Offer for cross-language bindings. Modified payment handling in bolt12.rs to: - Support both native and FFI-compatible types via type aliasing - Implement conditional compilation for transparent FFI support - Update payment functions to handle wrapped types Added testing to verify that properties are preserved when wrapping/unwrapping between native and FFI types.
1 parent 5586b69 commit 9eae2b6

File tree

4 files changed

+226
-25
lines changed

4 files changed

+226
-25
lines changed

bindings/ldk_node.udl

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,22 @@ interface Bolt11Invoice {
736736
PublicKey recover_payee_pub_key();
737737
};
738738

739+
[Enum]
740+
interface Amount {
741+
Bitcoin(u64 amount_msats);
742+
Currency(string iso4217_code, u64 amount);
743+
};
744+
745+
interface Offer {
746+
[Throws=NodeError, Name=from_str]
747+
constructor([ByRef] string offer_str);
748+
OfferId id();
749+
boolean is_expired();
750+
string? description();
751+
string? issuer();
752+
Amount? amount();
753+
};
754+
739755
[Custom]
740756
typedef string Txid;
741757

@@ -754,9 +770,6 @@ typedef string NodeId;
754770
[Custom]
755771
typedef string Address;
756772

757-
[Custom]
758-
typedef string Offer;
759-
760773
[Custom]
761774
typedef string Refund;
762775

src/payment/bolt12.rs

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use crate::types::ChannelManager;
1919

2020
use lightning::ln::channelmanager::{PaymentId, Retry};
2121
use lightning::offers::invoice::Bolt12Invoice;
22-
use lightning::offers::offer::{Amount, Offer, Quantity};
22+
use lightning::offers::offer::{Amount, Offer as LdkOffer, Quantity};
2323
use lightning::offers::parse::Bolt12SemanticError;
2424
use lightning::offers::refund::Refund;
2525
use lightning::util::string::UntrustedString;
@@ -30,6 +30,28 @@ use std::num::NonZeroU64;
3030
use std::sync::{Arc, RwLock};
3131
use std::time::{Duration, SystemTime, UNIX_EPOCH};
3232

33+
#[cfg(not(feature = "uniffi"))]
34+
type Offer = LdkOffer;
35+
#[cfg(feature = "uniffi")]
36+
type Offer = Arc<crate::uniffi_types::Offer>;
37+
38+
#[cfg(not(feature = "uniffi"))]
39+
pub fn maybe_convert_offer(offer: &Offer) -> &LdkOffer {
40+
offer
41+
}
42+
#[cfg(feature = "uniffi")]
43+
pub fn maybe_convert_offer(offer: &Offer) -> &LdkOffer {
44+
&offer.inner
45+
}
46+
47+
#[cfg(not(feature = "uniffi"))]
48+
pub fn maybe_wrap_offer(offer: LdkOffer) -> Offer {
49+
offer
50+
}
51+
#[cfg(feature = "uniffi")]
52+
pub fn maybe_wrap_offer(offer: LdkOffer) -> Offer {
53+
Arc::new(offer.into())
54+
}
3355
/// A payment handler allowing to create and pay [BOLT 12] offers and refunds.
3456
///
3557
/// Should be retrieved by calling [`Node::bolt12_payment`].
@@ -61,6 +83,7 @@ impl Bolt12Payment {
6183
pub fn send(
6284
&self, offer: &Offer, quantity: Option<u64>, payer_note: Option<String>,
6385
) -> Result<PaymentId, Error> {
86+
let offer = maybe_convert_offer(offer);
6487
let rt_lock = self.runtime.read().unwrap();
6588
if rt_lock.is_none() {
6689
return Err(Error::NotRunning);
@@ -162,6 +185,7 @@ impl Bolt12Payment {
162185
pub fn send_using_amount(
163186
&self, offer: &Offer, amount_msat: u64, quantity: Option<u64>, payer_note: Option<String>,
164187
) -> Result<PaymentId, Error> {
188+
let offer = maybe_convert_offer(offer);
165189
let rt_lock = self.runtime.read().unwrap();
166190
if rt_lock.is_none() {
167191
return Err(Error::NotRunning);
@@ -256,11 +280,9 @@ impl Bolt12Payment {
256280
}
257281
}
258282

259-
/// Returns a payable offer that can be used to request and receive a payment of the amount
260-
/// given.
261-
pub fn receive(
283+
pub(crate) fn receive_inner(
262284
&self, amount_msat: u64, description: &str, expiry_secs: Option<u32>, quantity: Option<u64>,
263-
) -> Result<Offer, Error> {
285+
) -> Result<LdkOffer, Error> {
264286
let absolute_expiry = expiry_secs.map(|secs| {
265287
(SystemTime::now() + Duration::from_secs(secs as u64))
266288
.duration_since(UNIX_EPOCH)
@@ -293,6 +315,15 @@ impl Bolt12Payment {
293315
Ok(finalized_offer)
294316
}
295317

318+
/// Returns a payable offer that can be used to request and receive a payment of the amount
319+
/// given.
320+
pub fn receive(
321+
&self, amount_msat: u64, description: &str, expiry_secs: Option<u32>, quantity: Option<u64>,
322+
) -> Result<Offer, Error> {
323+
let offer = self.receive_inner(amount_msat, description, expiry_secs, quantity)?;
324+
Ok(maybe_wrap_offer(offer))
325+
}
326+
296327
/// Returns a payable offer that can be used to request and receive a payment for which the
297328
/// amount is to be determined by the user, also known as a "zero-amount" offer.
298329
pub fn receive_variable_amount(
@@ -314,7 +345,7 @@ impl Bolt12Payment {
314345
Error::OfferCreationFailed
315346
})?;
316347

317-
Ok(offer)
348+
Ok(maybe_wrap_offer(offer))
318349
}
319350

320351
/// Requests a refund payment for the given [`Refund`].

src/payment/unified_qr.rs

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
//! [BOLT 12]: https://github.com/lightning/bolts/blob/master/12-offer-encoding.md
1414
use crate::error::Error;
1515
use crate::logger::{log_error, LdkLogger, Logger};
16-
use crate::payment::{bolt11::maybe_wrap_invoice, Bolt11Payment, Bolt12Payment, OnchainPayment};
16+
use crate::payment::{
17+
bolt11::maybe_wrap_invoice, bolt12::maybe_wrap_offer, Bolt11Payment, Bolt12Payment,
18+
OnchainPayment,
19+
};
1720
use crate::Config;
1821

1922
use lightning::ln::channelmanager::PaymentId;
@@ -90,14 +93,14 @@ impl UnifiedQrPayment {
9093

9194
let amount_msats = amount_sats * 1_000;
9295

93-
let bolt12_offer = match self.bolt12_payment.receive(amount_msats, description, None, None)
94-
{
95-
Ok(offer) => Some(offer),
96-
Err(e) => {
97-
log_error!(self.logger, "Failed to create offer: {}", e);
98-
return Err(Error::OfferCreationFailed);
99-
},
100-
};
96+
let bolt12_offer =
97+
match self.bolt12_payment.receive_inner(amount_msats, description, None, None) {
98+
Ok(offer) => Some(offer),
99+
Err(e) => {
100+
log_error!(self.logger, "Failed to create offer: {}", e);
101+
return Err(Error::OfferCreationFailed);
102+
},
103+
};
101104

102105
let invoice_description = Bolt11InvoiceDescription::Direct(
103106
Description::new(description.to_string()).map_err(|_| Error::InvoiceCreationFailed)?,
@@ -142,6 +145,7 @@ impl UnifiedQrPayment {
142145
uri.clone().require_network(self.config.network).map_err(|_| Error::InvalidNetwork)?;
143146

144147
if let Some(offer) = uri_network_checked.extras.bolt12_offer {
148+
let offer = maybe_wrap_offer(offer);
145149
match self.bolt12_payment.send(&offer, None, None) {
146150
Ok(payment_id) => return Ok(QrPaymentResult::Bolt12 { payment_id }),
147151
Err(e) => log_error!(self.logger, "Failed to send BOLT12 offer: {:?}. This is part of a unified QR code payment. Falling back to the BOLT11 invoice.", e),

src/uniffi_types.rs

Lines changed: 160 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ pub use lightning::chain::channelmonitor::BalanceSource;
2626
pub use lightning::events::{ClosureReason, PaymentFailureReason};
2727
pub use lightning::ln::types::ChannelId;
2828
pub use lightning::offers::invoice::Bolt12Invoice;
29-
pub use lightning::offers::offer::{Offer, OfferId};
29+
pub use lightning::offers::offer::OfferId;
3030
pub use lightning::offers::refund::Refund;
3131
pub use lightning::routing::gossip::{NodeAlias, NodeId, RoutingFees};
3232
pub use lightning::util::string::UntrustedString;
@@ -57,6 +57,7 @@ use bitcoin::hashes::sha256::Hash as Sha256;
5757
use bitcoin::hashes::Hash;
5858
use bitcoin::secp256k1::PublicKey;
5959
use lightning::ln::channelmanager::PaymentId;
60+
use lightning::offers::offer::{Amount as LdkAmount, Offer as LdkOffer};
6061
use lightning::util::ser::Writeable;
6162
use lightning_invoice::{Bolt11Invoice as LdkBolt11Invoice, Bolt11InvoiceDescriptionRef};
6263

@@ -113,15 +114,89 @@ impl UniffiCustomTypeConverter for Address {
113114
}
114115
}
115116

116-
impl UniffiCustomTypeConverter for Offer {
117-
type Builtin = String;
117+
#[derive(Debug, Clone, PartialEq, Eq)]
118+
pub enum Amount {
119+
Bitcoin { amount_msats: u64 },
120+
Currency { iso4217_code: String, amount: u64 },
121+
}
122+
123+
impl From<LdkAmount> for Amount {
124+
fn from(ldk_amount: LdkAmount) -> Self {
125+
match ldk_amount {
126+
LdkAmount::Bitcoin { amount_msats } => Amount::Bitcoin { amount_msats },
127+
LdkAmount::Currency { iso4217_code, amount } => Amount::Currency {
128+
iso4217_code: iso4217_code.iter().map(|&b| b as char).collect(),
129+
amount,
130+
},
131+
}
132+
}
133+
}
118134

119-
fn into_custom(val: Self::Builtin) -> uniffi::Result<Self> {
120-
Offer::from_str(&val).map_err(|_| Error::InvalidOffer.into())
135+
/// An `Offer` is a potentially long-lived proposal for payment of a good or service.
136+
///
137+
/// An offer is a precursor to an [`InvoiceRequest`]. A merchant publishes an offer from which a
138+
/// customer may request an [`Bolt12Invoice`] for a specific quantity and using an amount sufficient
139+
/// to cover that quantity (i.e., at least `quantity * amount`). See [`Offer::amount`].
140+
///
141+
/// Offers may be denominated in currency other than bitcoin but are ultimately paid using the
142+
/// latter.
143+
///
144+
/// Through the use of [`BlindedMessagePath`]s, offers provide recipient privacy.
145+
///
146+
/// [`InvoiceRequest`]: lightning::offers::invoice_request::InvoiceRequest
147+
/// [`Bolt12Invoice`]: lightning::offers::invoice::Bolt12Invoice
148+
/// [`Offer`]: lightning::offers::Offer:amount
149+
#[derive(Debug, Clone, PartialEq, Eq)]
150+
pub struct Offer {
151+
pub inner: LdkOffer,
152+
}
153+
154+
impl Offer {
155+
pub fn from_str(offer_str: &str) -> Result<Self, Error> {
156+
offer_str.parse()
121157
}
122158

123-
fn from_custom(obj: Self) -> Self::Builtin {
124-
obj.to_string()
159+
/// Returns the id of the offer.
160+
pub fn id(&self) -> OfferId {
161+
OfferId(self.inner.id().0)
162+
}
163+
164+
/// Whether the offer has expired.
165+
pub fn is_expired(&self) -> bool {
166+
self.inner.is_expired()
167+
}
168+
169+
/// The minimum amount required for a successful payment of a single item.
170+
pub fn description(&self) -> Option<String> {
171+
self.inner.description().map(|printable| printable.to_string())
172+
}
173+
174+
/// The issuer of the offer, possibly beginning with `user@domain` or `domain`. Intended to be
175+
/// displayed to the user but with the caveat that it has not been verified in any way.
176+
pub fn issuer(&self) -> Option<String> {
177+
self.inner.issuer().map(|printable| printable.to_string())
178+
}
179+
180+
/// The minimum amount required for a successful payment of a single item.
181+
pub fn amount(&self) -> Option<Amount> {
182+
self.inner.amount().map(|amount| amount.into())
183+
}
184+
}
185+
186+
impl std::str::FromStr for Offer {
187+
type Err = Error;
188+
189+
fn from_str(offer_str: &str) -> Result<Self, Self::Err> {
190+
offer_str
191+
.parse::<LdkOffer>()
192+
.map(|offer| Offer { inner: offer })
193+
.map_err(|_| Error::InvalidOffer)
194+
}
195+
}
196+
197+
impl From<LdkOffer> for Offer {
198+
fn from(offer: LdkOffer) -> Self {
199+
Offer { inner: offer }
125200
}
126201
}
127202

@@ -658,6 +733,10 @@ impl UniffiCustomTypeConverter for DateTime {
658733

659734
#[cfg(test)]
660735
mod tests {
736+
use std::time::{SystemTime, UNIX_EPOCH};
737+
738+
use lightning::offers::offer::OfferBuilder;
739+
661740
use super::*;
662741

663742
fn create_test_invoice() -> (LdkBolt11Invoice, Bolt11Invoice) {
@@ -667,6 +746,27 @@ mod tests {
667746
(ldk_invoice, wrapped_invoice)
668747
}
669748

749+
fn create_test_offer() -> (LdkOffer, Offer) {
750+
let pubkey = bitcoin::secp256k1::PublicKey::from_str(
751+
"02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619",
752+
)
753+
.unwrap();
754+
755+
let expiry =
756+
(SystemTime::now() + Duration::from_secs(3600)).duration_since(UNIX_EPOCH).unwrap();
757+
758+
let builder = OfferBuilder::new(pubkey)
759+
.description("Test offer description".to_string())
760+
.amount_msats(100_000)
761+
.issuer("Offer issuer".to_string())
762+
.absolute_expiry(expiry);
763+
764+
let ldk_offer = builder.build().unwrap();
765+
let wrapped_offer = Offer::from(ldk_offer.clone());
766+
767+
(ldk_offer, wrapped_offer)
768+
}
769+
670770
#[test]
671771
fn test_invoice_description_conversion() {
672772
let hash = "09d08d4865e8af9266f6cc7c0ae23a1d6bf868207cf8f7c5979b9f6ed850dfb0".to_string();
@@ -776,4 +876,57 @@ mod tests {
776876
parsed_invoice.payment_hash().to_byte_array().to_vec()
777877
);
778878
}
879+
880+
#[test]
881+
fn test_offer() {
882+
let (ldk_offer, wrapped_offer) = create_test_offer();
883+
match (ldk_offer.description(), wrapped_offer.description()) {
884+
(Some(ldk_desc), Some(wrapped_desc)) => {
885+
assert_eq!(ldk_desc.to_string(), wrapped_desc);
886+
},
887+
(None, None) => {
888+
panic!("Both offers unexpectedly had no description!");
889+
},
890+
(Some(_), None) => {
891+
panic!("LDK offer had a description but wrapped offer did not!");
892+
},
893+
(None, Some(_)) => {
894+
panic!("Wrapped offer had a description but LDK offer did not!");
895+
},
896+
}
897+
898+
match (ldk_offer.amount(), wrapped_offer.amount()) {
899+
(Some(ldk_amount), Some(wrapped_amount)) => {
900+
let ldk_amount: Amount = ldk_amount.into();
901+
assert_eq!(ldk_amount, wrapped_amount);
902+
},
903+
(None, None) => {
904+
panic!("Both offers unexpectedly had no description!");
905+
},
906+
(Some(_), None) => {
907+
panic!("LDK offer had an amount but wrapped offer did not!");
908+
},
909+
(None, Some(_)) => {
910+
panic!("Wrapped offer had an amount but LDK offer did not!");
911+
},
912+
}
913+
914+
match (ldk_offer.issuer(), wrapped_offer.issuer()) {
915+
(Some(ldk_issuer), Some(wrapped_issuer)) => {
916+
assert_eq!(ldk_issuer.to_string(), wrapped_issuer);
917+
},
918+
(None, None) => {
919+
panic!("Both offers unexpectedly had no issuer!");
920+
},
921+
(Some(_), None) => {
922+
panic!("LDK offer had an issuer but wrapped offer did not!");
923+
},
924+
(None, Some(_)) => {
925+
panic!("Wrapped offer had an issuer but LDK offer did not!");
926+
},
927+
}
928+
929+
assert_eq!(ldk_offer.is_expired(), wrapped_offer.is_expired());
930+
assert_eq!(ldk_offer.id(), wrapped_offer.id());
931+
}
779932
}

0 commit comments

Comments
 (0)