Skip to content

Commit c245cf6

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 e1173ea commit c245cf6

File tree

4 files changed

+239
-17
lines changed

4 files changed

+239
-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
@@ -1,13 +1,13 @@
11
mod uniffi_impl {
22
use std::sync::Arc;
33

4-
use lightning::offers::offer::Offer as LdkOffer;
4+
use lightning::offers::{offer::Offer as LdkOffer, refund::Refund as LdkRefund};
55
use lightning_invoice::{
66
Bolt11Invoice as LdkBolt11Invoice, Bolt11InvoiceDescription as LdkBolt11InvoiceDescription,
77
};
88

99
use crate::error::Error;
10-
use crate::ffi::{Bolt11Invoice, Bolt11InvoiceDescription, Offer};
10+
use crate::ffi::{Bolt11Invoice, Bolt11InvoiceDescription, Offer, Refund};
1111

1212
pub trait UniffiType {
1313
type LdkType;
@@ -65,6 +65,20 @@ mod uniffi_impl {
6565
}
6666
}
6767

68+
impl UniffiType for Arc<Refund> {
69+
type LdkType = LdkRefund;
70+
71+
fn from_ldk(ldk_value: Self::LdkType) -> Self {
72+
Arc::new(Refund { inner: ldk_value })
73+
}
74+
}
75+
76+
impl UniffiConversionType for Arc<Refund> {
77+
fn as_ldk(&self) -> Self::LdkType {
78+
self.inner.clone()
79+
}
80+
}
81+
6882
pub fn maybe_convert<T: UniffiConversionType>(value: &T) -> T::LdkType {
6983
value.as_ldk()
7084
}

src/ffi/types.rs

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

@@ -253,15 +253,101 @@ impl From<LdkOffer> for Offer {
253253
}
254254
}
255255

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

259-
fn into_custom(val: Self::Builtin) -> uniffi::Result<Self> {
260-
Refund::from_str(&val).map_err(|_| Error::InvalidRefund.into())
269+
impl Refund {
270+
pub fn from_str(refund_str: &str) -> Result<Self, Error> {
271+
refund_str.parse()
261272
}
262273

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

@@ -791,9 +877,11 @@ mod tests {
791877
time::{SystemTime, UNIX_EPOCH},
792878
};
793879

794-
use lightning::offers::offer::{OfferBuilder, Quantity};
795-
796880
use super::*;
881+
use lightning::offers::{
882+
offer::{OfferBuilder, Quantity},
883+
refund::RefundBuilder,
884+
};
797885

798886
fn create_test_invoice() -> (LdkBolt11Invoice, Bolt11Invoice) {
799887
let invoice_string = "lnbc1pn8g249pp5f6ytj32ty90jhvw69enf30hwfgdhyymjewywcmfjevflg6s4z86qdqqcqzzgxqyz5vqrzjqwnvuc0u4txn35cafc7w94gxvq5p3cu9dd95f7hlrh0fvs46wpvhdfjjzh2j9f7ye5qqqqryqqqqthqqpysp5mm832athgcal3m7h35sc29j63lmgzvwc5smfjh2es65elc2ns7dq9qrsgqu2xcje2gsnjp0wn97aknyd3h58an7sjj6nhcrm40846jxphv47958c6th76whmec8ttr2wmg6sxwchvxmsc00kqrzqcga6lvsf9jtqgqy5yexa";
@@ -832,6 +920,28 @@ mod tests {
832920
(ldk_offer, wrapped_offer)
833921
}
834922

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

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)