Skip to content

Commit 62c1ac9

Browse files
feat: Add Refund wrapper for FFI bindings
Implement Refund struct in uniffi_types to provide a wrapper around LDK's Refund 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 9eae2b6 commit 62c1ac9

File tree

3 files changed

+241
-14
lines changed

3 files changed

+241
-14
lines changed

bindings/ldk_node.udl

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,21 @@ interface Offer {
752752
Amount? amount();
753753
};
754754

755+
interface Refund {
756+
[Throws=NodeError, Name=from_str]
757+
constructor([ByRef] string refund_str);
758+
string description();
759+
u64? absolute_expiry_seconds();
760+
boolean is_expired();
761+
string? issuer();
762+
sequence<u8> payer_metadata();
763+
Network? chain();
764+
u64 amount_msats();
765+
u64? quantity();
766+
PublicKey payer_signing_pubkey();
767+
string? payer_note();
768+
};
769+
755770
[Custom]
756771
typedef string Txid;
757772

@@ -770,9 +785,6 @@ typedef string NodeId;
770785
[Custom]
771786
typedef string Address;
772787

773-
[Custom]
774-
typedef string Refund;
775-
776788
[Custom]
777789
typedef string Bolt12Invoice;
778790

src/payment/bolt12.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use lightning::ln::channelmanager::{PaymentId, Retry};
2121
use lightning::offers::invoice::Bolt12Invoice;
2222
use lightning::offers::offer::{Amount, Offer as LdkOffer, Quantity};
2323
use lightning::offers::parse::Bolt12SemanticError;
24-
use lightning::offers::refund::Refund;
24+
use lightning::offers::refund::Refund as LdkRefund;
2525
use lightning::util::string::UntrustedString;
2626

2727
use rand::RngCore;
@@ -52,6 +52,30 @@ pub fn maybe_wrap_offer(offer: LdkOffer) -> Offer {
5252
pub fn maybe_wrap_offer(offer: LdkOffer) -> Offer {
5353
Arc::new(offer.into())
5454
}
55+
56+
#[cfg(not(feature = "uniffi"))]
57+
type Refund = LdkRefund;
58+
#[cfg(feature = "uniffi")]
59+
type Refund = Arc<crate::uniffi_types::Refund>;
60+
61+
#[cfg(not(feature = "uniffi"))]
62+
pub fn maybe_convert_refund(refund: &Refund) -> &LdkRefund {
63+
refund
64+
}
65+
#[cfg(feature = "uniffi")]
66+
pub fn maybe_convert_refund(refund: &Refund) -> &LdkRefund {
67+
&refund.inner
68+
}
69+
70+
#[cfg(not(feature = "uniffi"))]
71+
pub fn maybe_wrap_refund(refund: Refund) -> LdkRefund {
72+
refund
73+
}
74+
#[cfg(feature = "uniffi")]
75+
pub fn maybe_wrap_refund(refund: LdkRefund) -> Refund {
76+
Arc::new(refund.into())
77+
}
78+
5579
/// A payment handler allowing to create and pay [BOLT 12] offers and refunds.
5680
///
5781
/// Should be retrieved by calling [`Node::bolt12_payment`].
@@ -352,7 +376,10 @@ impl Bolt12Payment {
352376
///
353377
/// The returned [`Bolt12Invoice`] is for informational purposes only (i.e., isn't needed to
354378
/// retrieve the refund).
379+
///
380+
/// [`Refund`]: lightning::offers::refund::Refund
355381
pub fn request_refund_payment(&self, refund: &Refund) -> Result<Bolt12Invoice, Error> {
382+
let refund = maybe_convert_refund(refund);
356383
let invoice = self.channel_manager.request_refund_payment(refund).map_err(|e| {
357384
log_error!(self.logger, "Failed to request refund payment: {:?}", e);
358385
Error::InvoiceRequestCreationFailed
@@ -384,6 +411,8 @@ impl Bolt12Payment {
384411
}
385412

386413
/// Returns a [`Refund`] object that can be used to offer a refund payment of the amount given.
414+
///
415+
/// [`Refund`]: lightning::offers::refund::Refund
387416
pub fn initiate_refund(
388417
&self, amount_msat: u64, expiry_secs: u32, quantity: Option<u64>,
389418
payer_note: Option<String>,
@@ -445,6 +474,6 @@ impl Bolt12Payment {
445474

446475
self.payment_store.insert(payment)?;
447476

448-
Ok(refund)
477+
Ok(maybe_wrap_refund(refund))
449478
}
450479
}

src/uniffi_types.rs

Lines changed: 195 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ pub use lightning::events::{ClosureReason, PaymentFailureReason};
2727
pub use lightning::ln::types::ChannelId;
2828
pub use lightning::offers::invoice::Bolt12Invoice;
2929
pub use lightning::offers::offer::OfferId;
30-
pub use lightning::offers::refund::Refund;
3130
pub use lightning::routing::gossip::{NodeAlias, NodeId, RoutingFees};
3231
pub use lightning::util::string::UntrustedString;
3332

@@ -58,6 +57,7 @@ use bitcoin::hashes::Hash;
5857
use bitcoin::secp256k1::PublicKey;
5958
use lightning::ln::channelmanager::PaymentId;
6059
use lightning::offers::offer::{Amount as LdkAmount, Offer as LdkOffer};
60+
use lightning::offers::refund::Refund as LdkRefund;
6161
use lightning::util::ser::Writeable;
6262
use lightning_invoice::{Bolt11Invoice as LdkBolt11Invoice, Bolt11InvoiceDescriptionRef};
6363

@@ -200,15 +200,103 @@ impl From<LdkOffer> for Offer {
200200
}
201201
}
202202

203-
impl UniffiCustomTypeConverter for Refund {
204-
type Builtin = String;
203+
/// A `Refund` is a request to send an [`Bolt12Invoice`] without a preceding [`Offer`].
204+
///
205+
/// Typically, after an invoice is paid, the recipient may publish a refund allowing the sender to
206+
/// recoup their funds. A refund may be used more generally as an "offer for money", such as with a
207+
/// bitcoin ATM.
208+
///
209+
/// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice
210+
/// [`Offer`]: crate::offers::offer::Offer
211+
#[derive(Debug, Clone, PartialEq, Eq)]
212+
pub struct Refund {
213+
pub inner: LdkRefund,
214+
}
205215

206-
fn into_custom(val: Self::Builtin) -> uniffi::Result<Self> {
207-
Refund::from_str(&val).map_err(|_| Error::InvalidRefund.into())
216+
impl Refund {
217+
pub fn from_str(refund_str: &str) -> Result<Self, Error> {
218+
refund_str.parse()
208219
}
209220

210-
fn from_custom(obj: Self) -> Self::Builtin {
211-
obj.to_string()
221+
/// A complete description of the purpose of the refund. Intended to be displayed to the user
222+
/// but with the caveat that it has not been verified in any way.
223+
pub fn description(&self) -> String {
224+
self.inner.description().to_string()
225+
}
226+
227+
/// Seconds since the Unix epoch when an invoice should no longer be sent.
228+
///
229+
/// If `None`, the refund does not expire.
230+
pub fn absolute_expiry_seconds(&self) -> Option<u64> {
231+
self.inner.absolute_expiry().map(|duration| duration.as_secs())
232+
}
233+
234+
/// Whether the refund has expired.
235+
pub fn is_expired(&self) -> bool {
236+
self.inner.is_expired()
237+
}
238+
239+
/// The issuer of the refund, possibly beginning with `user@domain` or `domain`. Intended to be
240+
/// displayed to the user but with the caveat that it has not been verified in any way.
241+
pub fn issuer(&self) -> Option<String> {
242+
self.inner.issuer().map(|printable| printable.to_string())
243+
}
244+
245+
/// An unpredictable series of bytes, typically containing information about the derivation of
246+
/// [`payer_signing_pubkey`].
247+
///
248+
/// [`payer_signing_pubkey`]: Self::payer_signing_pubkey
249+
pub fn payer_metadata(&self) -> Vec<u8> {
250+
self.inner.payer_metadata().to_vec()
251+
}
252+
253+
/// A chain that the refund is valid for.
254+
pub fn chain(&self) -> Option<Network> {
255+
Network::try_from(self.inner.chain()).ok()
256+
}
257+
258+
/// The amount to refund in msats (i.e., the minimum lightning-payable unit for [`chain`]).
259+
///
260+
/// [`chain`]: Self::chain
261+
pub fn amount_msats(&self) -> u64 {
262+
self.inner.amount_msats()
263+
}
264+
265+
// pub fn features(&self)
266+
267+
/// The quantity of an item that refund is for.
268+
pub fn quantity(&self) -> Option<u64> {
269+
self.inner.quantity()
270+
}
271+
272+
/// A public node id to send to in the case where there are no [`paths`]. Otherwise, a possibly
273+
/// transient pubkey.
274+
///
275+
/// [`paths`]: Self::paths
276+
pub fn payer_signing_pubkey(&self) -> PublicKey {
277+
self.inner.payer_signing_pubkey()
278+
}
279+
280+
/// Payer provided note to include in the invoice.
281+
pub fn payer_note(&self) -> Option<String> {
282+
self.inner.payer_note().map(|printable| printable.to_string())
283+
}
284+
}
285+
286+
impl std::str::FromStr for Refund {
287+
type Err = Error;
288+
289+
fn from_str(refund_str: &str) -> Result<Self, Self::Err> {
290+
refund_str
291+
.parse::<LdkRefund>()
292+
.map(|refund| Refund { inner: refund })
293+
.map_err(|_| Error::InvalidRefund)
294+
}
295+
}
296+
297+
impl From<LdkRefund> for Refund {
298+
fn from(refund: LdkRefund) -> Self {
299+
Refund { inner: refund }
212300
}
213301
}
214302

@@ -735,9 +823,8 @@ impl UniffiCustomTypeConverter for DateTime {
735823
mod tests {
736824
use std::time::{SystemTime, UNIX_EPOCH};
737825

738-
use lightning::offers::offer::OfferBuilder;
739-
740826
use super::*;
827+
use lightning::offers::{offer::OfferBuilder, refund::RefundBuilder};
741828

742829
fn create_test_invoice() -> (LdkBolt11Invoice, Bolt11Invoice) {
743830
let invoice_string = "lnbc1pn8g249pp5f6ytj32ty90jhvw69enf30hwfgdhyymjewywcmfjevflg6s4z86qdqqcqzzgxqyz5vqrzjqwnvuc0u4txn35cafc7w94gxvq5p3cu9dd95f7hlrh0fvs46wpvhdfjjzh2j9f7ye5qqqqryqqqqthqqpysp5mm832athgcal3m7h35sc29j63lmgzvwc5smfjh2es65elc2ns7dq9qrsgqu2xcje2gsnjp0wn97aknyd3h58an7sjj6nhcrm40846jxphv47958c6th76whmec8ttr2wmg6sxwchvxmsc00kqrzqcga6lvsf9jtqgqy5yexa";
@@ -767,6 +854,28 @@ mod tests {
767854
(ldk_offer, wrapped_offer)
768855
}
769856

857+
fn create_test_refund() -> (LdkRefund, Refund) {
858+
let payer_key = bitcoin::secp256k1::PublicKey::from_str(
859+
"02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619",
860+
)
861+
.unwrap();
862+
863+
let expiry =
864+
(SystemTime::now() + Duration::from_secs(3600)).duration_since(UNIX_EPOCH).unwrap();
865+
866+
let builder = RefundBuilder::new("Test refund".to_string().into(), payer_key, 100_000)
867+
.unwrap()
868+
.description("Test refund description".to_string())
869+
.absolute_expiry(expiry)
870+
.quantity(3)
871+
.issuer("test_issuer".to_string());
872+
873+
let ldk_refund = builder.build().unwrap();
874+
let wrapped_refund = Refund::from(ldk_refund.clone());
875+
876+
(ldk_refund, wrapped_refund)
877+
}
878+
770879
#[test]
771880
fn test_invoice_description_conversion() {
772881
let hash = "09d08d4865e8af9266f6cc7c0ae23a1d6bf868207cf8f7c5979b9f6ed850dfb0".to_string();
@@ -929,4 +1038,81 @@ mod tests {
9291038
assert_eq!(ldk_offer.is_expired(), wrapped_offer.is_expired());
9301039
assert_eq!(ldk_offer.id(), wrapped_offer.id());
9311040
}
1041+
1042+
#[test]
1043+
fn test_refund_roundtrip() {
1044+
let (ldk_refund, _) = create_test_refund();
1045+
1046+
let refund_str = ldk_refund.to_string();
1047+
1048+
let parsed_refund = Refund::from_str(&refund_str);
1049+
assert!(parsed_refund.is_ok(), "Failed to parse refund from string!");
1050+
1051+
let invalid_result = Refund::from_str("invalid_refund_string");
1052+
assert!(invalid_result.is_err());
1053+
assert!(matches!(invalid_result.err().unwrap(), Error::InvalidRefund));
1054+
}
1055+
1056+
#[test]
1057+
fn test_refund_properties() {
1058+
let (ldk_refund, wrapped_refund) = create_test_refund();
1059+
1060+
assert_eq!(ldk_refund.description().to_string(), wrapped_refund.description());
1061+
assert_eq!(ldk_refund.amount_msats(), wrapped_refund.amount_msats());
1062+
assert_eq!(ldk_refund.is_expired(), wrapped_refund.is_expired());
1063+
1064+
match (ldk_refund.absolute_expiry(), wrapped_refund.absolute_expiry_seconds()) {
1065+
(Some(ldk_expiry), Some(wrapped_expiry)) => {
1066+
assert_eq!(ldk_expiry.as_secs(), wrapped_expiry);
1067+
},
1068+
(None, None) => {
1069+
panic!("Both refunds unexpectedly had no expiry!");
1070+
},
1071+
(Some(_), None) => {
1072+
panic!("LDK refund had an expiry but wrapped refund did not!");
1073+
},
1074+
(None, Some(_)) => {
1075+
panic!("Wrapped refund had an expiry but LDK refund did not!");
1076+
},
1077+
}
1078+
1079+
match (ldk_refund.quantity(), wrapped_refund.quantity()) {
1080+
(Some(ldk_expiry), Some(wrapped_expiry)) => {
1081+
assert_eq!(ldk_expiry, wrapped_expiry);
1082+
},
1083+
(None, None) => {
1084+
panic!("Both refunds unexpectedly had no quantity!");
1085+
},
1086+
(Some(_), None) => {
1087+
panic!("LDK refund had an quantity but wrapped refund did not!");
1088+
},
1089+
(None, Some(_)) => {
1090+
panic!("Wrapped refund had an quantity but LDK refund did not!");
1091+
},
1092+
}
1093+
1094+
match (ldk_refund.issuer(), wrapped_refund.issuer()) {
1095+
(Some(ldk_issuer), Some(wrapped_issuer)) => {
1096+
assert_eq!(ldk_issuer.to_string(), wrapped_issuer);
1097+
},
1098+
(None, None) => {
1099+
panic!("Both refunds unexpectedly had no issuer!");
1100+
},
1101+
(Some(_), None) => {
1102+
panic!("LDK refund had an issuer but wrapped refund did not!");
1103+
},
1104+
(None, Some(_)) => {
1105+
panic!("Wrapped refund had an issuer but LDK refund did not!");
1106+
},
1107+
}
1108+
1109+
assert_eq!(ldk_refund.payer_metadata().to_vec(), wrapped_refund.payer_metadata());
1110+
assert_eq!(ldk_refund.payer_signing_pubkey(), wrapped_refund.payer_signing_pubkey());
1111+
1112+
if let Ok(network) = Network::try_from(ldk_refund.chain()) {
1113+
assert_eq!(wrapped_refund.chain(), Some(network));
1114+
}
1115+
1116+
assert_eq!(ldk_refund.payer_note().map(|p| p.to_string()), wrapped_refund.payer_note());
1117+
}
9321118
}

0 commit comments

Comments
 (0)