Skip to content

Commit c9cdcad

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 d67f28d commit c9cdcad

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
OfferAmount? 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
@@ -19,7 +19,7 @@ use lightning::ln::channelmanager::{PaymentId, Retry};
1919
use lightning::offers::invoice::Bolt12Invoice;
2020
use lightning::offers::offer::{Amount, Offer as LdkOffer, Quantity};
2121
use lightning::offers::parse::Bolt12SemanticError;
22-
use lightning::offers::refund::Refund;
22+
use lightning::offers::refund::Refund as LdkRefund;
2323
use lightning::util::string::UntrustedString;
2424

2525
use rand::RngCore;
@@ -50,6 +50,30 @@ pub fn maybe_wrap_offer(offer: LdkOffer) -> Offer {
5050
pub fn maybe_wrap_offer(offer: LdkOffer) -> Offer {
5151
Arc::new(offer.into())
5252
}
53+
54+
#[cfg(not(feature = "uniffi"))]
55+
type Refund = LdkRefund;
56+
#[cfg(feature = "uniffi")]
57+
type Refund = Arc<crate::uniffi_types::Refund>;
58+
59+
#[cfg(not(feature = "uniffi"))]
60+
pub fn maybe_convert_refund(refund: &Refund) -> &LdkRefund {
61+
refund
62+
}
63+
#[cfg(feature = "uniffi")]
64+
pub fn maybe_convert_refund(refund: &Refund) -> &LdkRefund {
65+
&refund.inner
66+
}
67+
68+
#[cfg(not(feature = "uniffi"))]
69+
pub fn maybe_wrap_refund(refund: Refund) -> LdkRefund {
70+
refund
71+
}
72+
#[cfg(feature = "uniffi")]
73+
pub fn maybe_wrap_refund(refund: LdkRefund) -> Refund {
74+
Arc::new(refund.into())
75+
}
76+
5377
/// A payment handler allowing to create and pay [BOLT 12] offers and refunds.
5478
///
5579
/// Should be retrieved by calling [`Node::bolt12_payment`].
@@ -350,7 +374,10 @@ impl Bolt12Payment {
350374
///
351375
/// The returned [`Bolt12Invoice`] is for informational purposes only (i.e., isn't needed to
352376
/// retrieve the refund).
377+
///
378+
/// [`Refund`]: lightning::offers::refund::Refund
353379
pub fn request_refund_payment(&self, refund: &Refund) -> Result<Bolt12Invoice, Error> {
380+
let refund = maybe_convert_refund(refund);
354381
let invoice = self.channel_manager.request_refund_payment(refund).map_err(|e| {
355382
log_error!(self.logger, "Failed to request refund payment: {:?}", e);
356383
Error::InvoiceRequestCreationFailed
@@ -382,6 +409,8 @@ impl Bolt12Payment {
382409
}
383410

384411
/// Returns a [`Refund`] object that can be used to offer a refund payment of the amount given.
412+
///
413+
/// [`Refund`]: lightning::offers::refund::Refund
385414
pub fn initiate_refund(
386415
&self, amount_msat: u64, expiry_secs: u32, quantity: Option<u64>,
387416
payer_note: Option<String>,
@@ -443,6 +472,6 @@ impl Bolt12Payment {
443472

444473
self.payment_store.insert(payment)?;
445474

446-
Ok(refund)
475+
Ok(maybe_wrap_refund(refund))
447476
}
448477
}

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

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

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

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

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

@@ -736,9 +824,8 @@ impl UniffiCustomTypeConverter for DateTime {
736824
mod tests {
737825
use std::time::{SystemTime, UNIX_EPOCH};
738826

739-
use lightning::offers::offer::OfferBuilder;
740-
741827
use super::*;
828+
use lightning::offers::{offer::OfferBuilder, refund::RefundBuilder};
742829

743830
fn create_test_invoice() -> (LdkBolt11Invoice, Bolt11Invoice) {
744831
let invoice_string = "lnbc1pn8g249pp5f6ytj32ty90jhvw69enf30hwfgdhyymjewywcmfjevflg6s4z86qdqqcqzzgxqyz5vqrzjqwnvuc0u4txn35cafc7w94gxvq5p3cu9dd95f7hlrh0fvs46wpvhdfjjzh2j9f7ye5qqqqryqqqqthqqpysp5mm832athgcal3m7h35sc29j63lmgzvwc5smfjh2es65elc2ns7dq9qrsgqu2xcje2gsnjp0wn97aknyd3h58an7sjj6nhcrm40846jxphv47958c6th76whmec8ttr2wmg6sxwchvxmsc00kqrzqcga6lvsf9jtqgqy5yexa";
@@ -768,6 +855,28 @@ mod tests {
768855
(ldk_offer, wrapped_offer)
769856
}
770857

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

0 commit comments

Comments
 (0)