Skip to content

Commit ef81e0d

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 07b5471 commit ef81e0d

File tree

4 files changed

+357
-24
lines changed

4 files changed

+357
-24
lines changed

bindings/ldk_node.udl

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,30 @@ 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+
[Traits=(Debug, Display, Eq)]
746+
interface Offer {
747+
[Throws=NodeError, Name=from_str]
748+
constructor([ByRef] string offer_str);
749+
OfferId id();
750+
boolean is_expired();
751+
string? description();
752+
string? issuer();
753+
OfferAmount? amount();
754+
boolean is_valid_quantity(u64 quantity);
755+
boolean expects_quantity();
756+
boolean supports_chain(Network chain);
757+
sequence<Network> chains();
758+
sequence<u8>? metadata();
759+
u64? absolute_expiry_seconds();
760+
PublicKey? issuer_signing_pubkey();
761+
};
762+
739763
[Custom]
740764
typedef string Txid;
741765

@@ -754,9 +778,6 @@ typedef string NodeId;
754778
[Custom]
755779
typedef string Address;
756780

757-
[Custom]
758-
typedef string Offer;
759-
760781
[Custom]
761782
typedef string Refund;
762783

src/ffi/types.rs

Lines changed: 303 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,166 @@ 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(crate) 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()
272+
}
273+
}
274+
275+
impl std::fmt::Display for Offer {
276+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277+
write!(f, "{}", self.inner)
126278
}
127279
}
128280

@@ -661,6 +813,13 @@ impl UniffiCustomTypeConverter for DateTime {
661813

662814
#[cfg(test)]
663815
mod tests {
816+
use std::{
817+
num::NonZeroU64,
818+
time::{SystemTime, UNIX_EPOCH},
819+
};
820+
821+
use lightning::offers::offer::{OfferBuilder, Quantity};
822+
664823
use super::*;
665824

666825
fn create_test_invoice() -> (LdkBolt11Invoice, Bolt11Invoice) {
@@ -670,6 +829,36 @@ mod tests {
670829
(ldk_invoice, wrapped_invoice)
671830
}
672831

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

0 commit comments

Comments
 (0)