From da322639429faa5ddc06e8d1451cfff39547d2ed Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 May 2024 16:33:50 +0200 Subject: [PATCH] Implement and test `Refund` flow --- bindings/ldk_node.udl | 12 +++++ src/error.rs | 6 +++ src/event.rs | 37 +-------------- src/payment/bolt12.rs | 81 +++++++++++++++++++++++++++++++++ src/uniffi_types.rs | 32 +++++++++++++ tests/integration_tests_rust.rs | 43 +++++++++++++++++ 6 files changed, 175 insertions(+), 36 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index dd09cf88a..9380d23bb 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -109,6 +109,10 @@ interface Bolt12Payment { Offer receive(u64 amount_msat, [ByRef]string description); [Throws=NodeError] Offer receive_variable_amount([ByRef]string description); + [Throws=NodeError] + Bolt12Invoice request_refund([ByRef]Refund refund); + [Throws=NodeError] + Refund offer_refund(u64 amount_msat, u32 expiry_secs); }; interface SpontaneousPayment { @@ -136,6 +140,7 @@ enum NodeError { "InvoiceCreationFailed", "InvoiceRequestCreationFailed", "OfferCreationFailed", + "RefundCreationFailed", "PaymentSendingFailed", "ProbeSendingFailed", "ChannelCreationFailed", @@ -161,6 +166,7 @@ enum NodeError { "InvalidAmount", "InvalidInvoice", "InvalidOffer", + "InvalidRefund", "InvalidChannelId", "InvalidNetwork", "DuplicatePayment", @@ -393,6 +399,12 @@ typedef string Bolt11Invoice; [Custom] typedef string Offer; +[Custom] +typedef string Refund; + +[Custom] +typedef string Bolt12Invoice; + [Custom] typedef string OfferId; diff --git a/src/error.rs b/src/error.rs index b51941012..824bde192 100644 --- a/src/error.rs +++ b/src/error.rs @@ -17,6 +17,8 @@ pub enum Error { InvoiceRequestCreationFailed, /// Offer creation failed. OfferCreationFailed, + /// Refund creation failed. + RefundCreationFailed, /// Sending a payment has failed. PaymentSendingFailed, /// Sending a payment probe has failed. @@ -67,6 +69,8 @@ pub enum Error { InvalidInvoice, /// The given offer is invalid. InvalidOffer, + /// The given refund is invalid. + InvalidRefund, /// The given channel ID is invalid. InvalidChannelId, /// The given network is invalid. @@ -95,6 +99,7 @@ impl fmt::Display for Error { Self::InvoiceCreationFailed => write!(f, "Failed to create invoice."), Self::InvoiceRequestCreationFailed => write!(f, "Failed to create invoice request."), Self::OfferCreationFailed => write!(f, "Failed to create offer."), + Self::RefundCreationFailed => write!(f, "Failed to create refund."), Self::PaymentSendingFailed => write!(f, "Failed to send the given payment."), Self::ProbeSendingFailed => write!(f, "Failed to send the given payment probe."), Self::ChannelCreationFailed => write!(f, "Failed to create channel."), @@ -122,6 +127,7 @@ impl fmt::Display for Error { Self::InvalidAmount => write!(f, "The given amount is invalid."), Self::InvalidInvoice => write!(f, "The given invoice is invalid."), Self::InvalidOffer => write!(f, "The given offer is invalid."), + Self::InvalidRefund => write!(f, "The given refund is invalid."), Self::InvalidChannelId => write!(f, "The given channel ID is invalid."), Self::InvalidNetwork => write!(f, "The given network is invalid."), Self::DuplicatePayment => { diff --git a/src/event.rs b/src/event.rs index 6586323f1..b49fc96e8 100644 --- a/src/event.rs +++ b/src/event.rs @@ -549,42 +549,7 @@ where } payment_preimage }, - PaymentPurpose::Bolt12RefundPayment { - payment_preimage, - payment_secret, - .. - } => { - let payment = PaymentDetails { - id: payment_id, - kind: PaymentKind::Bolt12Refund { - hash: Some(payment_hash), - preimage: payment_preimage, - secret: Some(payment_secret), - }, - amount_msat: Some(amount_msat), - direction: PaymentDirection::Inbound, - status: PaymentStatus::Pending, - }; - match self.payment_store.insert(payment) { - Ok(false) => (), - Ok(true) => { - log_error!( - self.logger, - "Bolt12RefundPayment with ID {} was previously known", - payment_id, - ); - debug_assert!(false); - }, - Err(e) => { - log_error!( - self.logger, - "Failed to insert payment with ID {}: {}", - payment_id, - e - ); - debug_assert!(false); - }, - } + PaymentPurpose::Bolt12RefundPayment { payment_preimage, .. } => { payment_preimage }, PaymentPurpose::SpontaneousPayment(preimage) => { diff --git a/src/payment/bolt12.rs b/src/payment/bolt12.rs index ffcaa4c5d..d4cc04a8a 100644 --- a/src/payment/bolt12.rs +++ b/src/payment/bolt12.rs @@ -11,12 +11,15 @@ use crate::payment::store::{ use crate::types::ChannelManager; use lightning::ln::channelmanager::{PaymentId, Retry}; +use lightning::offers::invoice::Bolt12Invoice; use lightning::offers::offer::{Amount, Offer}; use lightning::offers::parse::Bolt12SemanticError; +use lightning::offers::refund::Refund; use rand::RngCore; use std::sync::{Arc, RwLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; /// A payment handler allowing to create and pay [BOLT 12] offers and refunds. /// @@ -264,4 +267,82 @@ impl Bolt12Payment { Ok(offer) } + + /// Requests a refund payment for the given [`Refund`]. + /// + /// The returned [`Bolt12Invoice`] is for informational purposes only (i.e., isn't needed to + /// retrieve the refund). + pub fn request_refund(&self, refund: &Refund) -> Result { + let invoice = self.channel_manager.request_refund_payment(refund).map_err(|e| { + log_error!(self.logger, "Failed to request refund payment: {:?}", e); + Error::InvoiceRequestCreationFailed + })?; + + let payment_hash = invoice.payment_hash(); + let payment_id = PaymentId(payment_hash.0); + + let payment = PaymentDetails { + id: payment_id, + kind: PaymentKind::Bolt12Refund { + hash: Some(payment_hash), + preimage: None, + secret: None, + }, + amount_msat: Some(refund.amount_msats()), + direction: PaymentDirection::Inbound, + status: PaymentStatus::Pending, + }; + + self.payment_store.insert(payment)?; + + Ok(invoice) + } + + /// Returns a [`Refund`] that can be used to offer a refund payment of the amount given. + pub fn offer_refund(&self, amount_msat: u64, expiry_secs: u32) -> Result { + let mut random_bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut random_bytes); + let payment_id = PaymentId(random_bytes); + + let expiration = (SystemTime::now() + Duration::from_secs(expiry_secs as u64)) + .duration_since(UNIX_EPOCH) + .unwrap(); + let retry_strategy = Retry::Timeout(LDK_PAYMENT_RETRY_TIMEOUT); + let max_total_routing_fee_msat = None; + + let refund = self + .channel_manager + .create_refund_builder( + amount_msat, + expiration, + payment_id, + retry_strategy, + max_total_routing_fee_msat, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to create refund builder: {:?}", e); + Error::RefundCreationFailed + })? + .build() + .map_err(|e| { + log_error!(self.logger, "Failed to create refund: {:?}", e); + Error::RefundCreationFailed + })?; + + log_info!(self.logger, "Offering refund of {}msat", amount_msat); + + let kind = PaymentKind::Bolt12Refund { hash: None, preimage: None, secret: None }; + + let payment = PaymentDetails { + id: payment_id, + kind, + amount_msat: Some(amount_msat), + direction: PaymentDirection::Outbound, + status: PaymentStatus::Pending, + }; + + self.payment_store.insert(payment)?; + + Ok(refund) + } } diff --git a/src/uniffi_types.rs b/src/uniffi_types.rs index ef22e61ec..99e72e31c 100644 --- a/src/uniffi_types.rs +++ b/src/uniffi_types.rs @@ -2,7 +2,9 @@ pub use crate::payment::store::{LSPFeeLimits, PaymentDirection, PaymentKind, Pay pub use lightning::events::{ClosureReason, PaymentFailureReason}; pub use lightning::ln::{ChannelId, PaymentHash, PaymentPreimage, PaymentSecret}; +pub use lightning::offers::invoice::Bolt12Invoice; pub use lightning::offers::offer::{Offer, OfferId}; +pub use lightning::offers::refund::Refund; pub use lightning::util::string::UntrustedString; pub use lightning_invoice::Bolt11Invoice; @@ -21,6 +23,7 @@ use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; use lightning::ln::channelmanager::PaymentId; +use lightning::util::ser::Writeable; use lightning_invoice::SignedRawBolt11Invoice; use std::convert::TryInto; @@ -88,6 +91,35 @@ impl UniffiCustomTypeConverter for Offer { } } +impl UniffiCustomTypeConverter for Refund { + type Builtin = String; + + fn into_custom(val: Self::Builtin) -> uniffi::Result { + Refund::from_str(&val).map_err(|_| Error::InvalidRefund.into()) + } + + fn from_custom(obj: Self) -> Self::Builtin { + obj.to_string() + } +} + +impl UniffiCustomTypeConverter for Bolt12Invoice { + type Builtin = String; + + fn into_custom(val: Self::Builtin) -> uniffi::Result { + if let Some(bytes_vec) = hex_utils::to_vec(&val) { + if let Ok(invoice) = Bolt12Invoice::try_from(bytes_vec) { + return Ok(invoice); + } + } + Err(Error::InvalidInvoice.into()) + } + + fn from_custom(obj: Self) -> Self::Builtin { + hex_utils::to_string(&obj.encode()) + } +} + impl UniffiCustomTypeConverter for OfferId { type Builtin = String; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 7e0b5a41c..0dc32f566 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -487,4 +487,47 @@ fn simple_bolt12_send_receive() { }, } assert_eq!(node_b_payments.first().unwrap().amount_msat, Some(expected_amount_msat)); + + // Now node_b refunds the amount node_a just overpaid. + let overpaid_amount = expected_amount_msat - offer_amount_msat; + let refund = node_b.bolt12_payment().offer_refund(overpaid_amount, 3600).unwrap(); + let invoice = node_a.bolt12_payment().request_refund(&refund).unwrap(); + expect_payment_received_event!(node_a, overpaid_amount); + + let node_b_payment_id = node_b + .list_payments_with_filter(|p| p.amount_msat == Some(overpaid_amount)) + .first() + .unwrap() + .id; + expect_payment_successful_event!(node_b, Some(node_b_payment_id), None); + + let node_b_payments = node_b.list_payments_with_filter(|p| p.id == node_b_payment_id); + assert_eq!(node_b_payments.len(), 1); + match node_b_payments.first().unwrap().kind { + PaymentKind::Bolt12Refund { hash, preimage, secret: _ } => { + assert!(hash.is_some()); + assert!(preimage.is_some()); + //TODO: We should eventually set and assert the secret sender-side, too, but the BOLT12 + //API currently doesn't allow to do that. + }, + _ => { + panic!("Unexpected payment kind"); + }, + } + assert_eq!(node_b_payments.first().unwrap().amount_msat, Some(overpaid_amount)); + + let node_a_payment_id = PaymentId(invoice.payment_hash().0); + let node_a_payments = node_a.list_payments_with_filter(|p| p.id == node_a_payment_id); + assert_eq!(node_a_payments.len(), 1); + match node_a_payments.first().unwrap().kind { + PaymentKind::Bolt12Refund { hash, preimage, secret } => { + assert!(hash.is_some()); + assert!(preimage.is_some()); + assert!(secret.is_some()); + }, + _ => { + panic!("Unexpected payment kind"); + }, + } + assert_eq!(node_a_payments.first().unwrap().amount_msat, Some(overpaid_amount)); }