Skip to content

Commit 9cf9b48

Browse files
Add Offer wrapper for FFI bindings
Implement Offer struct in ffi/types.rs 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 22b21c6 commit 9cf9b48

File tree

4 files changed

+350
-24
lines changed

4 files changed

+350
-24
lines changed

bindings/ldk_node.udl

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

739+
[Enum]
740+
interface OfferAmount {
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+
OfferAmount? amount();
753+
boolean is_valid_quantity(u64 quantity);
754+
boolean expects_quantity();
755+
boolean supports_chain(Network chain);
756+
sequence<Network> chains();
757+
sequence<u8>? metadata();
758+
u64? absolute_expiry_seconds();
759+
PublicKey? issuer_signing_pubkey();
760+
};
761+
739762
[Custom]
740763
typedef string Txid;
741764

@@ -754,9 +777,6 @@ typedef string NodeId;
754777
[Custom]
755778
typedef string Address;
756779

757-
[Custom]
758-
typedef string Offer;
759-
760780
[Custom]
761781
typedef string Refund;
762782

src/ffi/types.rs

Lines changed: 297 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

@@ -114,15 +115,160 @@ impl UniffiCustomTypeConverter for Address {
114115
}
115116
}
116117

117-
impl UniffiCustomTypeConverter for Offer {
118-
type Builtin = String;
118+
#[derive(Debug, Clone, PartialEq, Eq)]
119+
pub enum OfferAmount {
120+
Bitcoin { amount_msats: u64 },
121+
Currency { iso4217_code: String, amount: u64 },
122+
}
119123

120-
fn into_custom(val: Self::Builtin) -> uniffi::Result<Self> {
121-
Offer::from_str(&val).map_err(|_| Error::InvalidOffer.into())
124+
impl From<LdkAmount> for OfferAmount {
125+
fn from(ldk_amount: LdkAmount) -> Self {
126+
match ldk_amount {
127+
LdkAmount::Bitcoin { amount_msats } => OfferAmount::Bitcoin { amount_msats },
128+
LdkAmount::Currency { iso4217_code, amount } => OfferAmount::Currency {
129+
iso4217_code: iso4217_code.iter().map(|&b| b as char).collect(),
130+
amount,
131+
},
132+
}
122133
}
134+
}
123135

124-
fn from_custom(obj: Self) -> Self::Builtin {
125-
obj.to_string()
136+
/// An `Offer` is a potentially long-lived proposal for payment of a good or service.
137+
///
138+
/// An offer is a precursor to an [`InvoiceRequest`]. A merchant publishes an offer from which a
139+
/// customer may request an [`Bolt12Invoice`] for a specific quantity and using an amount sufficient
140+
/// to cover that quantity (i.e., at least `quantity * amount`). See [`Offer::amount`].
141+
///
142+
/// Offers may be denominated in currency other than bitcoin but are ultimately paid using the
143+
/// latter.
144+
///
145+
/// Through the use of [`BlindedMessagePath`]s, offers provide recipient privacy.
146+
///
147+
/// [`InvoiceRequest`]: lightning::offers::invoice_request::InvoiceRequest
148+
/// [`Bolt12Invoice`]: lightning::offers::invoice::Bolt12Invoice
149+
/// [`Offer`]: lightning::offers::Offer:amount
150+
#[derive(Debug, Clone, PartialEq, Eq)]
151+
pub struct Offer {
152+
pub inner: LdkOffer,
153+
}
154+
155+
impl Offer {
156+
pub fn from_str(offer_str: &str) -> Result<Self, Error> {
157+
offer_str.parse()
158+
}
159+
160+
/// Returns the id of the offer.
161+
pub fn id(&self) -> OfferId {
162+
OfferId(self.inner.id().0)
163+
}
164+
165+
/// Whether the offer has expired.
166+
pub fn is_expired(&self) -> bool {
167+
self.inner.is_expired()
168+
}
169+
170+
/// A complete description of the purpose of the payment.
171+
///
172+
/// Intended to be displayed to the user but with the caveat that it has not been verified in any way.
173+
pub fn description(&self) -> Option<String> {
174+
self.inner.description().map(|printable| printable.to_string())
175+
}
176+
177+
/// The issuer of the offer, possibly beginning with `user@domain` or `domain`.
178+
///
179+
/// Intended to be displayed to the user but with the caveat that it has not been verified in any way.
180+
pub fn issuer(&self) -> Option<String> {
181+
self.inner.issuer().map(|printable| printable.to_string())
182+
}
183+
184+
/// The minimum amount required for a successful payment of a single item.
185+
pub fn amount(&self) -> Option<OfferAmount> {
186+
self.inner.amount().map(|amount| amount.into())
187+
}
188+
189+
/// Returns whether the given quantity is valid for the offer.
190+
pub fn is_valid_quantity(&self, quantity: u64) -> bool {
191+
self.inner.is_valid_quantity(quantity)
192+
}
193+
194+
/// Returns whether a quantity is expected in an [`InvoiceRequest`] for the offer.
195+
///
196+
/// [`InvoiceRequest`]: lightning::offers::invoice_request::InvoiceRequest
197+
pub fn expects_quantity(&self) -> bool {
198+
self.inner.expects_quantity()
199+
}
200+
201+
/// Returns whether the given chain is supported by the offer.
202+
pub fn supports_chain(&self, chain: Network) -> bool {
203+
self.inner.supports_chain(chain.chain_hash())
204+
}
205+
206+
/// The chains that may be used when paying a requested invoice (e.g., bitcoin mainnet).
207+
///
208+
/// Payments must be denominated in units of the minimal lightning-payable unit (e.g., msats)
209+
/// for the selected chain.
210+
pub fn chains(&self) -> Vec<Network> {
211+
self.inner.chains().into_iter().filter_map(Network::from_chain_hash).collect()
212+
}
213+
214+
/// Opaque bytes set by the originator.
215+
///
216+
/// Useful for authentication and validating fields since it is reflected in `invoice_request`
217+
/// messages along with all the other fields from the `offer`.
218+
pub fn metadata(&self) -> Option<Vec<u8>> {
219+
self.inner.metadata().cloned()
220+
}
221+
222+
/// Seconds since the Unix epoch when an invoice should no longer be requested.
223+
///
224+
/// If `None`, the offer does not expire.
225+
pub fn absolute_expiry_seconds(&self) -> Option<u64> {
226+
self.inner.absolute_expiry().map(|duration| duration.as_secs())
227+
}
228+
229+
/// The public key corresponding to the key used by the recipient to sign invoices.
230+
/// - If [`Offer::paths`] is empty, MUST be `Some` and contain the recipient's node id for
231+
/// sending an [`InvoiceRequest`].
232+
/// - If [`Offer::paths`] is not empty, MAY be `Some` and contain a transient id.
233+
/// - If `None`, the signing pubkey will be the final blinded node id from the
234+
/// [`BlindedMessagePath`] in [`Offer::paths`] used to send the [`InvoiceRequest`].
235+
///
236+
/// See also [`Bolt12Invoice::signing_pubkey`].
237+
///
238+
/// [`InvoiceRequest`]: lightning::offers::invoice_request::InvoiceRequest
239+
/// [`Bolt12Invoice::signing_pubkey`]: lightning::offers::invoice::Bolt12Invoice::signing_pubkey
240+
pub fn issuer_signing_pubkey(&self) -> Option<PublicKey> {
241+
self.inner.issuer_signing_pubkey()
242+
}
243+
}
244+
245+
impl std::str::FromStr for Offer {
246+
type Err = Error;
247+
248+
fn from_str(offer_str: &str) -> Result<Self, Self::Err> {
249+
offer_str
250+
.parse::<LdkOffer>()
251+
.map(|offer| Offer { inner: offer })
252+
.map_err(|_| Error::InvalidOffer)
253+
}
254+
}
255+
256+
impl From<LdkOffer> for Offer {
257+
fn from(offer: LdkOffer) -> Self {
258+
Offer { inner: offer }
259+
}
260+
}
261+
262+
impl Deref for Offer {
263+
type Target = LdkOffer;
264+
fn deref(&self) -> &Self::Target {
265+
&self.inner
266+
}
267+
}
268+
269+
impl AsRef<LdkOffer> for Offer {
270+
fn as_ref(&self) -> &LdkOffer {
271+
self.deref()
126272
}
127273
}
128274

@@ -679,6 +825,13 @@ impl UniffiCustomTypeConverter for DateTime {
679825

680826
#[cfg(test)]
681827
mod tests {
828+
use std::{
829+
num::NonZeroU64,
830+
time::{SystemTime, UNIX_EPOCH},
831+
};
832+
833+
use lightning::offers::offer::{OfferBuilder, Quantity};
834+
682835
use super::*;
683836

684837
fn create_test_invoice() -> (LdkBolt11Invoice, Bolt11Invoice) {
@@ -688,6 +841,36 @@ mod tests {
688841
(ldk_invoice, wrapped_invoice)
689842
}
690843

844+
fn create_test_offer() -> (LdkOffer, Offer) {
845+
let pubkey = bitcoin::secp256k1::PublicKey::from_str(
846+
"02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619",
847+
)
848+
.unwrap();
849+
850+
let expiry =
851+
(SystemTime::now() + Duration::from_secs(3600)).duration_since(UNIX_EPOCH).unwrap();
852+
853+
let quantity = NonZeroU64::new(10_000).unwrap();
854+
855+
let builder = OfferBuilder::new(pubkey)
856+
.description("Test offer description".to_string())
857+
.amount_msats(100_000)
858+
.issuer("Offer issuer".to_string())
859+
.absolute_expiry(expiry)
860+
.chain(Network::Bitcoin)
861+
.supported_quantity(Quantity::Bounded(quantity))
862+
.metadata(vec![
863+
0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab,
864+
0xcd, 0xef,
865+
])
866+
.unwrap();
867+
868+
let ldk_offer = builder.build().unwrap();
869+
let wrapped_offer = Offer::from(ldk_offer.clone());
870+
871+
(ldk_offer, wrapped_offer)
872+
}
873+
691874
#[test]
692875
fn test_invoice_description_conversion() {
693876
let hash = "09d08d4865e8af9266f6cc7c0ae23a1d6bf868207cf8f7c5979b9f6ed850dfb0".to_string();
@@ -797,4 +980,111 @@ mod tests {
797980
parsed_invoice.payment_hash().to_byte_array().to_vec()
798981
);
799982
}
983+
984+
#[test]
985+
fn test_offer() {
986+
let (ldk_offer, wrapped_offer) = create_test_offer();
987+
match (ldk_offer.description(), wrapped_offer.description()) {
988+
(Some(ldk_desc), Some(wrapped_desc)) => {
989+
assert_eq!(ldk_desc.to_string(), wrapped_desc);
990+
},
991+
(None, None) => {
992+
// Both fields are missing which is expected behaviour when converting
993+
},
994+
(Some(_), None) => {
995+
panic!("LDK offer had a description but wrapped offer did not!");
996+
},
997+
(None, Some(_)) => {
998+
panic!("Wrapped offer had a description but LDK offer did not!");
999+
},
1000+
}
1001+
1002+
match (ldk_offer.amount(), wrapped_offer.amount()) {
1003+
(Some(ldk_amount), Some(wrapped_amount)) => {
1004+
let ldk_amount: OfferAmount = ldk_amount.into();
1005+
assert_eq!(ldk_amount, wrapped_amount);
1006+
},
1007+
(None, None) => {
1008+
// Both fields are missing which is expected behaviour when converting
1009+
},
1010+
(Some(_), None) => {
1011+
panic!("LDK offer had an amount but wrapped offer did not!");
1012+
},
1013+
(None, Some(_)) => {
1014+
panic!("Wrapped offer had an amount but LDK offer did not!");
1015+
},
1016+
}
1017+
1018+
match (ldk_offer.issuer(), wrapped_offer.issuer()) {
1019+
(Some(ldk_issuer), Some(wrapped_issuer)) => {
1020+
assert_eq!(ldk_issuer.to_string(), wrapped_issuer);
1021+
},
1022+
(None, None) => {
1023+
// Both fields are missing which is expected behaviour when converting
1024+
},
1025+
(Some(_), None) => {
1026+
panic!("LDK offer had an issuer but wrapped offer did not!");
1027+
},
1028+
(None, Some(_)) => {
1029+
panic!("Wrapped offer had an issuer but LDK offer did not!");
1030+
},
1031+
}
1032+
1033+
assert_eq!(ldk_offer.is_expired(), wrapped_offer.is_expired());
1034+
assert_eq!(ldk_offer.id(), wrapped_offer.id());
1035+
assert_eq!(ldk_offer.is_valid_quantity(10_000), wrapped_offer.is_valid_quantity(10_000));
1036+
assert_eq!(ldk_offer.expects_quantity(), wrapped_offer.expects_quantity());
1037+
assert_eq!(
1038+
ldk_offer.supports_chain(Network::Bitcoin.chain_hash()),
1039+
wrapped_offer.supports_chain(Network::Bitcoin)
1040+
);
1041+
assert_eq!(
1042+
ldk_offer.chains(),
1043+
wrapped_offer.chains().iter().map(|c| c.chain_hash()).collect::<Vec<_>>()
1044+
);
1045+
match (ldk_offer.metadata(), wrapped_offer.metadata()) {
1046+
(Some(ldk_metadata), Some(wrapped_metadata)) => {
1047+
assert_eq!(ldk_metadata.clone(), wrapped_metadata);
1048+
},
1049+
(None, None) => {
1050+
// Both fields are missing which is expected behaviour when converting
1051+
},
1052+
(Some(_), None) => {
1053+
panic!("LDK offer had metadata but wrapped offer did not!");
1054+
},
1055+
(None, Some(_)) => {
1056+
panic!("Wrapped offer had metadata but LDK offer did not!");
1057+
},
1058+
}
1059+
1060+
match (ldk_offer.absolute_expiry(), wrapped_offer.absolute_expiry_seconds()) {
1061+
(Some(ldk_expiry), Some(wrapped_expiry)) => {
1062+
assert_eq!(ldk_expiry.as_secs(), wrapped_expiry);
1063+
},
1064+
(None, None) => {
1065+
// Both fields are missing which is expected behaviour when converting
1066+
},
1067+
(Some(_), None) => {
1068+
panic!("LDK offer had an absolute expiry but wrapped offer did not!");
1069+
},
1070+
(None, Some(_)) => {
1071+
panic!("Wrapped offer had an absolute expiry but LDK offer did not!");
1072+
},
1073+
}
1074+
1075+
match (ldk_offer.issuer_signing_pubkey(), wrapped_offer.issuer_signing_pubkey()) {
1076+
(Some(ldk_expiry_signing_pubkey), Some(wrapped_issuer_signing_pubkey)) => {
1077+
assert_eq!(ldk_expiry_signing_pubkey, wrapped_issuer_signing_pubkey);
1078+
},
1079+
(None, None) => {
1080+
// Both fields are missing which is expected behaviour when converting
1081+
},
1082+
(Some(_), None) => {
1083+
panic!("LDK offer had an issuer signing pubkey but wrapped offer did not!");
1084+
},
1085+
(None, Some(_)) => {
1086+
panic!("Wrapped offer had an issuer signing pubkey but LDK offer did not!");
1087+
},
1088+
}
1089+
}
8001090
}

0 commit comments

Comments
 (0)