Skip to content

Commit 759597f

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 09f59fa commit 759597f

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

@@ -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+
// Both fields are missing which is expected behaviour when converting
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+
// Both fields are missing which is expected behaviour when converting
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+
// Both fields are missing which is expected behaviour when converting
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)