diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index 9de61c63..d493436d 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -28,7 +28,7 @@ use ldk_server_client::ldk_server_grpc::api::{ }; use ldk_server_client::ldk_server_grpc::events::event_envelope::Event; use ldk_server_client::ldk_server_grpc::events::{ - ChannelClosureInitiator, ChannelState, ChannelStateChangeReasonKind, + ChannelClosureInitiator, ChannelState, ChannelStateChangeReasonKind, PaymentFailureReason, }; use ldk_server_client::ldk_server_grpc::types::{ bolt11_invoice_description, Bolt11InvoiceDescription, @@ -1515,9 +1515,17 @@ async fn test_hodl_invoice_fail() { // Fail the payment on B using CLI run_cli(&server_b, &["bolt11-fail-for-hash", &payment_hash_hex]); - // Verify PaymentFailed on A + // Verify PaymentFailed on A and its failure reason. let event_a = wait_for_event(&mut events_a, |e| matches!(e, Event::PaymentFailed(_))).await; - assert!(matches!(&event_a.event, Some(Event::PaymentFailed(_)))); + match &event_a.event { + Some(Event::PaymentFailed(payment_failed)) => { + assert_eq!( + payment_failed.reason, + Some(PaymentFailureReason::RecipientRejected as i32) + ); + }, + other => panic!("expected PaymentFailed event, got {other:?}"), + } } #[tokio::test] diff --git a/ldk-server-grpc/src/events.rs b/ldk-server-grpc/src/events.rs index f13a3186..9fb50a8e 100644 --- a/ldk-server-grpc/src/events.rs +++ b/ldk-server-grpc/src/events.rs @@ -175,6 +175,13 @@ pub struct PaymentFailed { /// The payment details for the payment in event. #[prost(message, optional, tag = "1")] pub payment: ::core::option::Option, + /// The reason the payment failed, if known. + /// + /// This is only available on the emitted event; `GetPaymentDetails` cannot + /// recover it as LDK Node does not currently persist the failure reason in + /// `PaymentDetails`. + #[prost(enumeration = "PaymentFailureReason", optional, tag = "2")] + pub reason: ::core::option::Option, } /// PaymentClaimable indicates a payment has arrived and is waiting to be manually claimed or failed. /// This event is only emitted for payments created via `Bolt11ReceiveForHash`. @@ -275,6 +282,87 @@ impl ChannelClosureInitiator { } } } +/// PaymentFailureReason mirrors LDK's `lightning::events::PaymentFailureReason`, +/// indicating why a sent payment failed. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum PaymentFailureReason { + Unspecified = 0, + /// The intended recipient rejected our payment. + RecipientRejected = 1, + /// The user chose to abandon this payment by calling `abandon_payment`. + UserAbandoned = 2, + /// We exhausted all of our retry attempts while trying to send the payment, + /// or we exhausted the configured retry timeout. + RetriesExhausted = 3, + /// Either the BOLT12 invoice was expired by the time we received it or the + /// payment expired while retrying. + PaymentExpired = 4, + /// We failed to find a route while sending or retrying the payment. + RouteNotFound = 5, + /// An unexpected error occurred, generally indicating a problem with the router. + UnexpectedError = 6, + /// An invoice was received that required unknown features. + UnknownRequiredFeatures = 7, + /// A BOLT12 invoice was not received in a reasonable amount of time. + InvoiceRequestExpired = 8, + /// An invoice request for the payment was rejected by the recipient. + InvoiceRequestRejected = 9, + /// Failed to create a blinded path back to ourselves. + BlindedPathCreationFailed = 10, +} +impl PaymentFailureReason { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + PaymentFailureReason::Unspecified => "PAYMENT_FAILURE_REASON_UNSPECIFIED", + PaymentFailureReason::RecipientRejected => "PAYMENT_FAILURE_REASON_RECIPIENT_REJECTED", + PaymentFailureReason::UserAbandoned => "PAYMENT_FAILURE_REASON_USER_ABANDONED", + PaymentFailureReason::RetriesExhausted => "PAYMENT_FAILURE_REASON_RETRIES_EXHAUSTED", + PaymentFailureReason::PaymentExpired => "PAYMENT_FAILURE_REASON_PAYMENT_EXPIRED", + PaymentFailureReason::RouteNotFound => "PAYMENT_FAILURE_REASON_ROUTE_NOT_FOUND", + PaymentFailureReason::UnexpectedError => "PAYMENT_FAILURE_REASON_UNEXPECTED_ERROR", + PaymentFailureReason::UnknownRequiredFeatures => { + "PAYMENT_FAILURE_REASON_UNKNOWN_REQUIRED_FEATURES" + }, + PaymentFailureReason::InvoiceRequestExpired => { + "PAYMENT_FAILURE_REASON_INVOICE_REQUEST_EXPIRED" + }, + PaymentFailureReason::InvoiceRequestRejected => { + "PAYMENT_FAILURE_REASON_INVOICE_REQUEST_REJECTED" + }, + PaymentFailureReason::BlindedPathCreationFailed => { + "PAYMENT_FAILURE_REASON_BLINDED_PATH_CREATION_FAILED" + }, + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "PAYMENT_FAILURE_REASON_UNSPECIFIED" => Some(Self::Unspecified), + "PAYMENT_FAILURE_REASON_RECIPIENT_REJECTED" => Some(Self::RecipientRejected), + "PAYMENT_FAILURE_REASON_USER_ABANDONED" => Some(Self::UserAbandoned), + "PAYMENT_FAILURE_REASON_RETRIES_EXHAUSTED" => Some(Self::RetriesExhausted), + "PAYMENT_FAILURE_REASON_PAYMENT_EXPIRED" => Some(Self::PaymentExpired), + "PAYMENT_FAILURE_REASON_ROUTE_NOT_FOUND" => Some(Self::RouteNotFound), + "PAYMENT_FAILURE_REASON_UNEXPECTED_ERROR" => Some(Self::UnexpectedError), + "PAYMENT_FAILURE_REASON_UNKNOWN_REQUIRED_FEATURES" => { + Some(Self::UnknownRequiredFeatures) + }, + "PAYMENT_FAILURE_REASON_INVOICE_REQUEST_EXPIRED" => Some(Self::InvoiceRequestExpired), + "PAYMENT_FAILURE_REASON_INVOICE_REQUEST_REJECTED" => Some(Self::InvoiceRequestRejected), + "PAYMENT_FAILURE_REASON_BLINDED_PATH_CREATION_FAILED" => { + Some(Self::BlindedPathCreationFailed) + }, + _ => None, + } + } +} #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] diff --git a/ldk-server-grpc/src/proto/events.proto b/ldk-server-grpc/src/proto/events.proto index 6f593088..bc1c3cf0 100644 --- a/ldk-server-grpc/src/proto/events.proto +++ b/ldk-server-grpc/src/proto/events.proto @@ -29,6 +29,34 @@ enum ChannelClosureInitiator { CHANNEL_CLOSURE_INITIATOR_UNKNOWN = 3; } +// PaymentFailureReason mirrors LDK's `lightning::events::PaymentFailureReason`, +// indicating why a sent payment failed. +enum PaymentFailureReason { + PAYMENT_FAILURE_REASON_UNSPECIFIED = 0; + // The intended recipient rejected our payment. + PAYMENT_FAILURE_REASON_RECIPIENT_REJECTED = 1; + // The user chose to abandon this payment by calling `abandon_payment`. + PAYMENT_FAILURE_REASON_USER_ABANDONED = 2; + // We exhausted all of our retry attempts while trying to send the payment, + // or we exhausted the configured retry timeout. + PAYMENT_FAILURE_REASON_RETRIES_EXHAUSTED = 3; + // Either the BOLT12 invoice was expired by the time we received it or the + // payment expired while retrying. + PAYMENT_FAILURE_REASON_PAYMENT_EXPIRED = 4; + // We failed to find a route while sending or retrying the payment. + PAYMENT_FAILURE_REASON_ROUTE_NOT_FOUND = 5; + // An unexpected error occurred, generally indicating a problem with the router. + PAYMENT_FAILURE_REASON_UNEXPECTED_ERROR = 6; + // An invoice was received that required unknown features. + PAYMENT_FAILURE_REASON_UNKNOWN_REQUIRED_FEATURES = 7; + // A BOLT12 invoice was not received in a reasonable amount of time. + PAYMENT_FAILURE_REASON_INVOICE_REQUEST_EXPIRED = 8; + // An invoice request for the payment was rejected by the recipient. + PAYMENT_FAILURE_REASON_INVOICE_REQUEST_REJECTED = 9; + // Failed to create a blinded path back to ourselves. + PAYMENT_FAILURE_REASON_BLINDED_PATH_CREATION_FAILED = 10; +} + enum ChannelStateChangeReasonKind { CHANNEL_STATE_CHANGE_REASON_KIND_UNSPECIFIED = 0; CHANNEL_STATE_CHANGE_REASON_KIND_COUNTERPARTY_FORCE_CLOSED = 1; @@ -110,6 +138,12 @@ message PaymentSuccessful { message PaymentFailed { // The payment details for the payment in event. types.Payment payment = 1; + // The reason the payment failed, if known. + // + // This is only available on the emitted event; `GetPaymentDetails` cannot + // recover it as LDK Node does not currently persist the failure reason in + // `PaymentDetails`. + optional PaymentFailureReason reason = 2; } // PaymentClaimable indicates a payment has arrived and is waiting to be manually claimed or failed. diff --git a/ldk-server/src/main.rs b/ldk-server/src/main.rs index 28ea10de..126e8a5c 100644 --- a/ldk-server/src/main.rs +++ b/ldk-server/src/main.rs @@ -26,7 +26,7 @@ use hyper::server::conn::http2; use hyper_util::rt::{TokioExecutor, TokioIo}; use ldk_node::bitcoin::Network; use ldk_node::config::{Config, ElectrumSyncConfig, EsploraSyncConfig}; -use ldk_node::lightning::events::ClosureReason; +use ldk_node::lightning::events::{ClosureReason, PaymentFailureReason}; use ldk_node::lightning::ln::channelmanager::PaymentId; use ldk_node::lightning::ln::types::ChannelId; use ldk_node::{Builder, CustomTlvRecord, Event, Node}; @@ -534,12 +534,14 @@ fn main() { metrics.update_all_balances(&event_node); } }, - Event::PaymentFailed {payment_id, ..} => { + Event::PaymentFailed {payment_id, reason, ..} => { let payment_id = payment_id.expect("PaymentId expected for ldk-server >=0.1"); + let proto_reason = reason.as_ref().map(payment_failure_reason_to_proto); send_event_and_upsert_payment(&payment_id, - |payment_ref| event_envelope::Event::PaymentFailed(events::PaymentFailed { + move |payment_ref| event_envelope::Event::PaymentFailed(events::PaymentFailed { payment: Some(payment_ref.clone()), + reason: proto_reason.map(|r| r as i32), }), &event_node, &event_sender, @@ -766,6 +768,29 @@ fn closure_initiator_from_reason( } } +fn payment_failure_reason_to_proto(reason: &PaymentFailureReason) -> events::PaymentFailureReason { + match reason { + PaymentFailureReason::RecipientRejected => events::PaymentFailureReason::RecipientRejected, + PaymentFailureReason::UserAbandoned => events::PaymentFailureReason::UserAbandoned, + PaymentFailureReason::RetriesExhausted => events::PaymentFailureReason::RetriesExhausted, + PaymentFailureReason::PaymentExpired => events::PaymentFailureReason::PaymentExpired, + PaymentFailureReason::RouteNotFound => events::PaymentFailureReason::RouteNotFound, + PaymentFailureReason::UnexpectedError => events::PaymentFailureReason::UnexpectedError, + PaymentFailureReason::UnknownRequiredFeatures => { + events::PaymentFailureReason::UnknownRequiredFeatures + }, + PaymentFailureReason::InvoiceRequestExpired => { + events::PaymentFailureReason::InvoiceRequestExpired + }, + PaymentFailureReason::InvoiceRequestRejected => { + events::PaymentFailureReason::InvoiceRequestRejected + }, + PaymentFailureReason::BlindedPathCreationFailed => { + events::PaymentFailureReason::BlindedPathCreationFailed + }, + } +} + fn closure_reason_to_proto(reason: &ClosureReason) -> events::ChannelStateChangeReason { events::ChannelStateChangeReason { kind: closure_reason_kind(reason).into(),