Skip to content

Commit dc6a8f2

Browse files
Add Refund wrapper for FFI bindings
Implement Refund struct in ffi/types.rs 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 ef81e0d commit dc6a8f2

File tree

3 files changed

+246
-15
lines changed

3 files changed

+246
-15
lines changed

bindings/ldk_node.udl

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,22 @@ interface Offer {
760760
PublicKey? issuer_signing_pubkey();
761761
};
762762

763+
[Traits=(Debug, Display, Eq)]
764+
interface Refund {
765+
[Throws=NodeError, Name=from_str]
766+
constructor([ByRef] string refund_str);
767+
string description();
768+
u64? absolute_expiry_seconds();
769+
boolean is_expired();
770+
string? issuer();
771+
sequence<u8> payer_metadata();
772+
Network? chain();
773+
u64 amount_msats();
774+
u64? quantity();
775+
PublicKey payer_signing_pubkey();
776+
string? payer_note();
777+
};
778+
763779
[Custom]
764780
typedef string Txid;
765781

@@ -778,9 +794,6 @@ typedef string NodeId;
778794
[Custom]
779795
typedef string Address;
780796

781-
[Custom]
782-
typedef string Refund;
783-
784797
[Custom]
785798
typedef string Bolt12Invoice;
786799

src/ffi/types.rs

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

@@ -278,15 +278,123 @@ impl std::fmt::Display for Offer {
278278
}
279279
}
280280

281-
impl UniffiCustomTypeConverter for Refund {
282-
type Builtin = String;
281+
/// A `Refund` is a request to send an [`Bolt12Invoice`] without a preceding [`Offer`].
282+
///
283+
/// Typically, after an invoice is paid, the recipient may publish a refund allowing the sender to
284+
/// recoup their funds. A refund may be used more generally as an "offer for money", such as with a
285+
/// bitcoin ATM.
286+
///
287+
/// [`Bolt12Invoice`]: lightning::offers::invoice::Bolt12Invoice
288+
/// [`Offer`]: lightning::offers::offer::Offer
289+
#[derive(Debug, Clone, PartialEq, Eq)]
290+
pub struct Refund {
291+
pub(crate) inner: LdkRefund,
292+
}
283293

284-
fn into_custom(val: Self::Builtin) -> uniffi::Result<Self> {
285-
Refund::from_str(&val).map_err(|_| Error::InvalidRefund.into())
294+
impl Refund {
295+
pub fn from_str(refund_str: &str) -> Result<Self, Error> {
296+
refund_str.parse()
286297
}
287298

288-
fn from_custom(obj: Self) -> Self::Builtin {
289-
obj.to_string()
299+
/// A complete description of the purpose of the refund.
300+
///
301+
/// Intended to be displayed to the user but with the caveat that it has not been verified in any way.
302+
pub fn description(&self) -> String {
303+
self.inner.description().to_string()
304+
}
305+
306+
/// Seconds since the Unix epoch when an invoice should no longer be sent.
307+
///
308+
/// If `None`, the refund does not expire.
309+
pub fn absolute_expiry_seconds(&self) -> Option<u64> {
310+
self.inner.absolute_expiry().map(|duration| duration.as_secs())
311+
}
312+
313+
/// Whether the refund has expired.
314+
pub fn is_expired(&self) -> bool {
315+
self.inner.is_expired()
316+
}
317+
318+
/// The issuer of the refund, possibly beginning with `user@domain` or `domain`.
319+
///
320+
/// Intended to be displayed to the user but with the caveat that it has not been verified in any way.
321+
pub fn issuer(&self) -> Option<String> {
322+
self.inner.issuer().map(|printable| printable.to_string())
323+
}
324+
325+
/// An unpredictable series of bytes, typically containing information about the derivation of
326+
/// [`payer_signing_pubkey`].
327+
///
328+
/// [`payer_signing_pubkey`]: Self::payer_signing_pubkey
329+
pub fn payer_metadata(&self) -> Vec<u8> {
330+
self.inner.payer_metadata().to_vec()
331+
}
332+
333+
/// A chain that the refund is valid for.
334+
pub fn chain(&self) -> Option<Network> {
335+
Network::try_from(self.inner.chain()).ok()
336+
}
337+
338+
/// The amount to refund in msats (i.e., the minimum lightning-payable unit for [`chain`]).
339+
///
340+
/// [`chain`]: Self::chain
341+
pub fn amount_msats(&self) -> u64 {
342+
self.inner.amount_msats()
343+
}
344+
345+
/// The quantity of an item that refund is for.
346+
pub fn quantity(&self) -> Option<u64> {
347+
self.inner.quantity()
348+
}
349+
350+
/// A public node id to send to in the case where there are no [`paths`].
351+
///
352+
/// Otherwise, a possibly transient pubkey.
353+
///
354+
/// [`paths`]: lightning::offers::refund::Refund::paths
355+
pub fn payer_signing_pubkey(&self) -> PublicKey {
356+
self.inner.payer_signing_pubkey()
357+
}
358+
359+
/// Payer provided note to include in the invoice.
360+
pub fn payer_note(&self) -> Option<String> {
361+
self.inner.payer_note().map(|printable| printable.to_string())
362+
}
363+
}
364+
365+
impl std::str::FromStr for Refund {
366+
type Err = Error;
367+
368+
fn from_str(refund_str: &str) -> Result<Self, Self::Err> {
369+
refund_str
370+
.parse::<LdkRefund>()
371+
.map(|refund| Refund { inner: refund })
372+
.map_err(|_| Error::InvalidRefund)
373+
}
374+
}
375+
376+
impl From<LdkRefund> for Refund {
377+
fn from(refund: LdkRefund) -> Self {
378+
Refund { inner: refund }
379+
}
380+
}
381+
382+
impl Deref for Refund {
383+
type Target = LdkRefund;
384+
fn deref(&self) -> &Self::Target {
385+
&self.inner
386+
}
387+
}
388+
389+
impl AsRef<LdkRefund> for Refund {
390+
fn as_ref(&self) -> &LdkRefund {
391+
self.deref()
392+
}
393+
}
394+
395+
impl std::fmt::Display for Refund {
396+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397+
write!(f, "{}", self.inner)
290398
}
291399
}
292400

@@ -818,9 +926,11 @@ mod tests {
818926
time::{SystemTime, UNIX_EPOCH},
819927
};
820928

821-
use lightning::offers::offer::{OfferBuilder, Quantity};
822-
823929
use super::*;
930+
use lightning::offers::{
931+
offer::{OfferBuilder, Quantity},
932+
refund::RefundBuilder,
933+
};
824934

825935
fn create_test_invoice() -> (LdkBolt11Invoice, Bolt11Invoice) {
826936
let invoice_string = "lnbc1pn8g249pp5f6ytj32ty90jhvw69enf30hwfgdhyymjewywcmfjevflg6s4z86qdqqcqzzgxqyz5vqrzjqwnvuc0u4txn35cafc7w94gxvq5p3cu9dd95f7hlrh0fvs46wpvhdfjjzh2j9f7ye5qqqqryqqqqthqqpysp5mm832athgcal3m7h35sc29j63lmgzvwc5smfjh2es65elc2ns7dq9qrsgqu2xcje2gsnjp0wn97aknyd3h58an7sjj6nhcrm40846jxphv47958c6th76whmec8ttr2wmg6sxwchvxmsc00kqrzqcga6lvsf9jtqgqy5yexa";
@@ -859,6 +969,28 @@ mod tests {
859969
(ldk_offer, wrapped_offer)
860970
}
861971

972+
fn create_test_refund() -> (LdkRefund, Refund) {
973+
let payer_key = bitcoin::secp256k1::PublicKey::from_str(
974+
"02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619",
975+
)
976+
.unwrap();
977+
978+
let expiry =
979+
(SystemTime::now() + Duration::from_secs(3600)).duration_since(UNIX_EPOCH).unwrap();
980+
981+
let builder = RefundBuilder::new("Test refund".to_string().into(), payer_key, 100_000)
982+
.unwrap()
983+
.description("Test refund description".to_string())
984+
.absolute_expiry(expiry)
985+
.quantity(3)
986+
.issuer("test_issuer".to_string());
987+
988+
let ldk_refund = builder.build().unwrap();
989+
let wrapped_refund = Refund::from(ldk_refund.clone());
990+
991+
(ldk_refund, wrapped_refund)
992+
}
993+
862994
#[test]
863995
fn test_invoice_description_conversion() {
864996
let hash = "09d08d4865e8af9266f6cc7c0ae23a1d6bf868207cf8f7c5979b9f6ed850dfb0".to_string();
@@ -1075,4 +1207,81 @@ mod tests {
10751207
},
10761208
}
10771209
}
1210+
1211+
#[test]
1212+
fn test_refund_roundtrip() {
1213+
let (ldk_refund, _) = create_test_refund();
1214+
1215+
let refund_str = ldk_refund.to_string();
1216+
1217+
let parsed_refund = Refund::from_str(&refund_str);
1218+
assert!(parsed_refund.is_ok(), "Failed to parse refund from string!");
1219+
1220+
let invalid_result = Refund::from_str("invalid_refund_string");
1221+
assert!(invalid_result.is_err());
1222+
assert!(matches!(invalid_result.err().unwrap(), Error::InvalidRefund));
1223+
}
1224+
1225+
#[test]
1226+
fn test_refund_properties() {
1227+
let (ldk_refund, wrapped_refund) = create_test_refund();
1228+
1229+
assert_eq!(ldk_refund.description().to_string(), wrapped_refund.description());
1230+
assert_eq!(ldk_refund.amount_msats(), wrapped_refund.amount_msats());
1231+
assert_eq!(ldk_refund.is_expired(), wrapped_refund.is_expired());
1232+
1233+
match (ldk_refund.absolute_expiry(), wrapped_refund.absolute_expiry_seconds()) {
1234+
(Some(ldk_expiry), Some(wrapped_expiry)) => {
1235+
assert_eq!(ldk_expiry.as_secs(), wrapped_expiry);
1236+
},
1237+
(None, None) => {
1238+
// Both fields are missing which is expected behaviour when converting
1239+
},
1240+
(Some(_), None) => {
1241+
panic!("LDK refund had an expiry but wrapped refund did not!");
1242+
},
1243+
(None, Some(_)) => {
1244+
panic!("Wrapped refund had an expiry but LDK refund did not!");
1245+
},
1246+
}
1247+
1248+
match (ldk_refund.quantity(), wrapped_refund.quantity()) {
1249+
(Some(ldk_expiry), Some(wrapped_expiry)) => {
1250+
assert_eq!(ldk_expiry, wrapped_expiry);
1251+
},
1252+
(None, None) => {
1253+
// Both fields are missing which is expected behaviour when converting
1254+
},
1255+
(Some(_), None) => {
1256+
panic!("LDK refund had an quantity but wrapped refund did not!");
1257+
},
1258+
(None, Some(_)) => {
1259+
panic!("Wrapped refund had an quantity but LDK refund did not!");
1260+
},
1261+
}
1262+
1263+
match (ldk_refund.issuer(), wrapped_refund.issuer()) {
1264+
(Some(ldk_issuer), Some(wrapped_issuer)) => {
1265+
assert_eq!(ldk_issuer.to_string(), wrapped_issuer);
1266+
},
1267+
(None, None) => {
1268+
// Both fields are missing which is expected behaviour when converting
1269+
},
1270+
(Some(_), None) => {
1271+
panic!("LDK refund had an issuer but wrapped refund did not!");
1272+
},
1273+
(None, Some(_)) => {
1274+
panic!("Wrapped refund had an issuer but LDK refund did not!");
1275+
},
1276+
}
1277+
1278+
assert_eq!(ldk_refund.payer_metadata().to_vec(), wrapped_refund.payer_metadata());
1279+
assert_eq!(ldk_refund.payer_signing_pubkey(), wrapped_refund.payer_signing_pubkey());
1280+
1281+
if let Ok(network) = Network::try_from(ldk_refund.chain()) {
1282+
assert_eq!(wrapped_refund.chain(), Some(network));
1283+
}
1284+
1285+
assert_eq!(ldk_refund.payer_note().map(|p| p.to_string()), wrapped_refund.payer_note());
1286+
}
10781287
}

src/payment/bolt12.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ use lightning::ln::channelmanager::{PaymentId, Retry};
2020
use lightning::offers::invoice::Bolt12Invoice;
2121
use lightning::offers::offer::{Amount, Offer as LdkOffer, Quantity};
2222
use lightning::offers::parse::Bolt12SemanticError;
23-
use lightning::offers::refund::Refund;
2423
use lightning::util::string::UntrustedString;
2524

2625
use rand::RngCore;
@@ -34,6 +33,11 @@ type Offer = LdkOffer;
3433
#[cfg(feature = "uniffi")]
3534
type Offer = Arc<crate::ffi::Offer>;
3635

36+
#[cfg(not(feature = "uniffi"))]
37+
type Refund = lightning::offers::refund::Refund;
38+
#[cfg(feature = "uniffi")]
39+
type Refund = Arc<crate::ffi::Refund>;
40+
3741
/// A payment handler allowing to create and pay [BOLT 12] offers and refunds.
3842
///
3943
/// Should be retrieved by calling [`Node::bolt12_payment`].
@@ -334,8 +338,11 @@ impl Bolt12Payment {
334338
///
335339
/// The returned [`Bolt12Invoice`] is for informational purposes only (i.e., isn't needed to
336340
/// retrieve the refund).
341+
///
342+
/// [`Refund`]: lightning::offers::refund::Refund
337343
pub fn request_refund_payment(&self, refund: &Refund) -> Result<Bolt12Invoice, Error> {
338-
let invoice = self.channel_manager.request_refund_payment(refund).map_err(|e| {
344+
let refund = maybe_deref(refund);
345+
let invoice = self.channel_manager.request_refund_payment(&refund).map_err(|e| {
339346
log_error!(self.logger, "Failed to request refund payment: {:?}", e);
340347
Error::InvoiceRequestCreationFailed
341348
})?;
@@ -366,6 +373,8 @@ impl Bolt12Payment {
366373
}
367374

368375
/// Returns a [`Refund`] object that can be used to offer a refund payment of the amount given.
376+
///
377+
/// [`Refund`]: lightning::offers::refund::Refund
369378
pub fn initiate_refund(
370379
&self, amount_msat: u64, expiry_secs: u32, quantity: Option<u64>,
371380
payer_note: Option<String>,
@@ -427,6 +436,6 @@ impl Bolt12Payment {
427436

428437
self.payment_store.insert(payment)?;
429438

430-
Ok(refund)
439+
Ok(maybe_wrap(refund))
431440
}
432441
}

0 commit comments

Comments
 (0)