Skip to content

Commit bf89d9f

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 3990ea0 commit bf89d9f

File tree

4 files changed

+242
-17
lines changed

4 files changed

+242
-17
lines changed

bindings/ldk_node.udl

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,21 @@ interface Offer {
759759
PublicKey? issuer_signing_pubkey();
760760
};
761761

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

@@ -777,9 +792,6 @@ typedef string NodeId;
777792
[Custom]
778793
typedef string Address;
779794

780-
[Custom]
781-
typedef string Refund;
782-
783795
[Custom]
784796
typedef string Bolt12Invoice;
785797

src/ffi/conversions.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88
mod uniffi_impl {
99
use std::sync::Arc;
1010

11-
use lightning::offers::offer::Offer as LdkOffer;
11+
use lightning::offers::{offer::Offer as LdkOffer, refund::Refund as LdkRefund};
1212
use lightning_invoice::{
1313
Bolt11Invoice as LdkBolt11Invoice, Bolt11InvoiceDescription as LdkBolt11InvoiceDescription,
1414
};
1515

1616
use crate::error::Error;
17-
use crate::ffi::{Bolt11Invoice, Bolt11InvoiceDescription, Offer};
17+
use crate::ffi::{Bolt11Invoice, Bolt11InvoiceDescription, Offer, Refund};
1818

1919
pub trait UniffiType {
2020
type LdkType;
@@ -72,6 +72,20 @@ mod uniffi_impl {
7272
}
7373
}
7474

75+
impl UniffiType for Arc<Refund> {
76+
type LdkType = LdkRefund;
77+
78+
fn from_ldk(ldk_value: Self::LdkType) -> Self {
79+
Arc::new(Refund { inner: ldk_value })
80+
}
81+
}
82+
83+
impl UniffiConversionType for Arc<Refund> {
84+
fn as_ldk(&self) -> Self::LdkType {
85+
self.inner.clone()
86+
}
87+
}
88+
7589
pub fn maybe_convert<T: UniffiConversionType>(value: &T) -> T::LdkType {
7690
value.as_ldk()
7791
}

src/ffi/types.rs

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

@@ -258,15 +258,104 @@ impl From<LdkOffer> for Offer {
258258
}
259259
}
260260

261-
impl UniffiCustomTypeConverter for Refund {
262-
type Builtin = String;
261+
/// A `Refund` is a request to send an [`Bolt12Invoice`] without a preceding [`Offer`].
262+
///
263+
/// Typically, after an invoice is paid, the recipient may publish a refund allowing the sender to
264+
/// recoup their funds. A refund may be used more generally as an "offer for money", such as with a
265+
/// bitcoin ATM.
266+
///
267+
/// [`Bolt12Invoice`]: lightning::offers::invoice::Bolt12Invoice
268+
/// [`Offer`]: lightning::offers::offer::Offer
269+
#[derive(Debug, Clone, PartialEq, Eq)]
270+
pub struct Refund {
271+
pub inner: LdkRefund,
272+
}
263273

264-
fn into_custom(val: Self::Builtin) -> uniffi::Result<Self> {
265-
Refund::from_str(&val).map_err(|_| Error::InvalidRefund.into())
274+
impl Refund {
275+
pub fn from_str(refund_str: &str) -> Result<Self, Error> {
276+
refund_str.parse()
266277
}
267278

268-
fn from_custom(obj: Self) -> Self::Builtin {
269-
obj.to_string()
279+
/// A complete description of the purpose of the refund.
280+
///
281+
/// Intended to be displayed to the user but with the caveat that it has not been verified in any way.
282+
pub fn description(&self) -> String {
283+
self.inner.description().to_string()
284+
}
285+
286+
/// Seconds since the Unix epoch when an invoice should no longer be sent.
287+
///
288+
/// If `None`, the refund does not expire.
289+
pub fn absolute_expiry_seconds(&self) -> Option<u64> {
290+
self.inner.absolute_expiry().map(|duration| duration.as_secs())
291+
}
292+
293+
/// Whether the refund has expired.
294+
pub fn is_expired(&self) -> bool {
295+
self.inner.is_expired()
296+
}
297+
298+
/// The issuer of the refund, possibly beginning with `user@domain` or `domain`.
299+
///
300+
/// Intended to be displayed to the user but with the caveat that it has not been verified in any way.
301+
pub fn issuer(&self) -> Option<String> {
302+
self.inner.issuer().map(|printable| printable.to_string())
303+
}
304+
305+
/// An unpredictable series of bytes, typically containing information about the derivation of
306+
/// [`payer_signing_pubkey`].
307+
///
308+
/// [`payer_signing_pubkey`]: Self::payer_signing_pubkey
309+
pub fn payer_metadata(&self) -> Vec<u8> {
310+
self.inner.payer_metadata().to_vec()
311+
}
312+
313+
/// A chain that the refund is valid for.
314+
pub fn chain(&self) -> Option<Network> {
315+
Network::try_from(self.inner.chain()).ok()
316+
}
317+
318+
/// The amount to refund in msats (i.e., the minimum lightning-payable unit for [`chain`]).
319+
///
320+
/// [`chain`]: Self::chain
321+
pub fn amount_msats(&self) -> u64 {
322+
self.inner.amount_msats()
323+
}
324+
325+
/// The quantity of an item that refund is for.
326+
pub fn quantity(&self) -> Option<u64> {
327+
self.inner.quantity()
328+
}
329+
330+
/// A public node id to send to in the case where there are no [`paths`].
331+
///
332+
/// Otherwise, a possibly transient pubkey.
333+
///
334+
/// [`paths`]: lightning::offers::refund::Refund::paths
335+
pub fn payer_signing_pubkey(&self) -> PublicKey {
336+
self.inner.payer_signing_pubkey()
337+
}
338+
339+
/// Payer provided note to include in the invoice.
340+
pub fn payer_note(&self) -> Option<String> {
341+
self.inner.payer_note().map(|printable| printable.to_string())
342+
}
343+
}
344+
345+
impl std::str::FromStr for Refund {
346+
type Err = Error;
347+
348+
fn from_str(refund_str: &str) -> Result<Self, Self::Err> {
349+
refund_str
350+
.parse::<LdkRefund>()
351+
.map(|refund| Refund { inner: refund })
352+
.map_err(|_| Error::InvalidRefund)
353+
}
354+
}
355+
356+
impl From<LdkRefund> for Refund {
357+
fn from(refund: LdkRefund) -> Self {
358+
Refund { inner: refund }
270359
}
271360
}
272361

@@ -796,9 +885,11 @@ mod tests {
796885
time::{SystemTime, UNIX_EPOCH},
797886
};
798887

799-
use lightning::offers::offer::{OfferBuilder, Quantity};
800-
801888
use super::*;
889+
use lightning::offers::{
890+
offer::{OfferBuilder, Quantity},
891+
refund::RefundBuilder,
892+
};
802893

803894
fn create_test_invoice() -> (LdkBolt11Invoice, Bolt11Invoice) {
804895
let invoice_string = "lnbc1pn8g249pp5f6ytj32ty90jhvw69enf30hwfgdhyymjewywcmfjevflg6s4z86qdqqcqzzgxqyz5vqrzjqwnvuc0u4txn35cafc7w94gxvq5p3cu9dd95f7hlrh0fvs46wpvhdfjjzh2j9f7ye5qqqqryqqqqthqqpysp5mm832athgcal3m7h35sc29j63lmgzvwc5smfjh2es65elc2ns7dq9qrsgqu2xcje2gsnjp0wn97aknyd3h58an7sjj6nhcrm40846jxphv47958c6th76whmec8ttr2wmg6sxwchvxmsc00kqrzqcga6lvsf9jtqgqy5yexa";
@@ -837,6 +928,28 @@ mod tests {
837928
(ldk_offer, wrapped_offer)
838929
}
839930

931+
fn create_test_refund() -> (LdkRefund, Refund) {
932+
let payer_key = bitcoin::secp256k1::PublicKey::from_str(
933+
"02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619",
934+
)
935+
.unwrap();
936+
937+
let expiry =
938+
(SystemTime::now() + Duration::from_secs(3600)).duration_since(UNIX_EPOCH).unwrap();
939+
940+
let builder = RefundBuilder::new("Test refund".to_string().into(), payer_key, 100_000)
941+
.unwrap()
942+
.description("Test refund description".to_string())
943+
.absolute_expiry(expiry)
944+
.quantity(3)
945+
.issuer("test_issuer".to_string());
946+
947+
let ldk_refund = builder.build().unwrap();
948+
let wrapped_refund = Refund::from(ldk_refund.clone());
949+
950+
(ldk_refund, wrapped_refund)
951+
}
952+
840953
#[test]
841954
fn test_invoice_description_conversion() {
842955
let hash = "09d08d4865e8af9266f6cc7c0ae23a1d6bf868207cf8f7c5979b9f6ed850dfb0".to_string();
@@ -1053,4 +1166,81 @@ mod tests {
10531166
},
10541167
}
10551168
}
1169+
1170+
#[test]
1171+
fn test_refund_roundtrip() {
1172+
let (ldk_refund, _) = create_test_refund();
1173+
1174+
let refund_str = ldk_refund.to_string();
1175+
1176+
let parsed_refund = Refund::from_str(&refund_str);
1177+
assert!(parsed_refund.is_ok(), "Failed to parse refund from string!");
1178+
1179+
let invalid_result = Refund::from_str("invalid_refund_string");
1180+
assert!(invalid_result.is_err());
1181+
assert!(matches!(invalid_result.err().unwrap(), Error::InvalidRefund));
1182+
}
1183+
1184+
#[test]
1185+
fn test_refund_properties() {
1186+
let (ldk_refund, wrapped_refund) = create_test_refund();
1187+
1188+
assert_eq!(ldk_refund.description().to_string(), wrapped_refund.description());
1189+
assert_eq!(ldk_refund.amount_msats(), wrapped_refund.amount_msats());
1190+
assert_eq!(ldk_refund.is_expired(), wrapped_refund.is_expired());
1191+
1192+
match (ldk_refund.absolute_expiry(), wrapped_refund.absolute_expiry_seconds()) {
1193+
(Some(ldk_expiry), Some(wrapped_expiry)) => {
1194+
assert_eq!(ldk_expiry.as_secs(), wrapped_expiry);
1195+
},
1196+
(None, None) => {
1197+
// Both fields are missing which is expected behaviour when converting
1198+
},
1199+
(Some(_), None) => {
1200+
panic!("LDK refund had an expiry but wrapped refund did not!");
1201+
},
1202+
(None, Some(_)) => {
1203+
panic!("Wrapped refund had an expiry but LDK refund did not!");
1204+
},
1205+
}
1206+
1207+
match (ldk_refund.quantity(), wrapped_refund.quantity()) {
1208+
(Some(ldk_expiry), Some(wrapped_expiry)) => {
1209+
assert_eq!(ldk_expiry, wrapped_expiry);
1210+
},
1211+
(None, None) => {
1212+
// Both fields are missing which is expected behaviour when converting
1213+
},
1214+
(Some(_), None) => {
1215+
panic!("LDK refund had an quantity but wrapped refund did not!");
1216+
},
1217+
(None, Some(_)) => {
1218+
panic!("Wrapped refund had an quantity but LDK refund did not!");
1219+
},
1220+
}
1221+
1222+
match (ldk_refund.issuer(), wrapped_refund.issuer()) {
1223+
(Some(ldk_issuer), Some(wrapped_issuer)) => {
1224+
assert_eq!(ldk_issuer.to_string(), wrapped_issuer);
1225+
},
1226+
(None, None) => {
1227+
// Both fields are missing which is expected behaviour when converting
1228+
},
1229+
(Some(_), None) => {
1230+
panic!("LDK refund had an issuer but wrapped refund did not!");
1231+
},
1232+
(None, Some(_)) => {
1233+
panic!("Wrapped refund had an issuer but LDK refund did not!");
1234+
},
1235+
}
1236+
1237+
assert_eq!(ldk_refund.payer_metadata().to_vec(), wrapped_refund.payer_metadata());
1238+
assert_eq!(ldk_refund.payer_signing_pubkey(), wrapped_refund.payer_signing_pubkey());
1239+
1240+
if let Ok(network) = Network::try_from(ldk_refund.chain()) {
1241+
assert_eq!(wrapped_refund.chain(), Some(network));
1242+
}
1243+
1244+
assert_eq!(ldk_refund.payer_note().map(|p| p.to_string()), wrapped_refund.payer_note());
1245+
}
10561246
}

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_convert(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)