diff --git a/Cargo.lock b/Cargo.lock index 23b8aa5f070..a6bd481a906 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -429,6 +429,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "base64-url" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb9fb9fb058cc3063b5fc88d9a21eefa2735871498a04e1650da76ed511c8569" +dependencies = [ + "base64 0.21.5", +] + [[package]] name = "base64ct" version = "1.6.0" @@ -772,8 +781,10 @@ checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-targets 0.48.5", ] @@ -1771,6 +1782,7 @@ dependencies = [ "tower-http", "tracing", "url", + "zebedee-rust", ] [[package]] @@ -4634,6 +4646,17 @@ dependencies = [ "opaque-debug", ] +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + [[package]] name = "sha3" version = "0.10.8" @@ -5837,6 +5860,24 @@ dependencies = [ "time", ] +[[package]] +name = "zebedee-rust" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3049eb96aeb02d4c3ca506b7c0988ac0cb464bf3b189a1a7c3f3d6f3a64873" +dependencies = [ + "anyhow", + "base64-url", + "chrono", + "rand", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror", + "validator", +] + [[package]] name = "zerocopy" version = "0.7.31" diff --git a/fedimint-testing/src/gateway.rs b/fedimint-testing/src/gateway.rs index 7486af3e89f..71864a3f6a2 100644 --- a/fedimint-testing/src/gateway.rs +++ b/fedimint-testing/src/gateway.rs @@ -17,7 +17,7 @@ use fedimint_logging::LOG_TEST; use futures::executor::block_on; use lightning_invoice::RoutingFees; use ln_gateway::client::GatewayClientBuilder; -use ln_gateway::lnrpc_client::{ILnRpcClient, LightningBuilder}; +use ln_gateway::lightning::{ILnRpcClient, LightningBuilder}; use ln_gateway::rpc::rpc_client::GatewayRpcClient; use ln_gateway::rpc::{ConnectFedPayload, FederationConnectionInfo}; use ln_gateway::{Gateway, GatewayState}; diff --git a/fedimint-testing/src/ln/mock.rs b/fedimint-testing/src/ln/mock.rs index 7c18aa19a44..63d973cdbae 100644 --- a/fedimint-testing/src/ln/mock.rs +++ b/fedimint-testing/src/ln/mock.rs @@ -18,7 +18,8 @@ use ln_gateway::gateway_lnrpc::{ self, EmptyResponse, GetNodeInfoResponse, GetRouteHintsResponse, InterceptHtlcResponse, PayInvoiceRequest, PayInvoiceResponse, }; -use ln_gateway::lnrpc_client::{HtlcResult, ILnRpcClient, LightningRpcError, RouteHtlcStream}; +use ln_gateway::lightning::cln::{HtlcResult, RouteHtlcStream}; +use ln_gateway::lightning::{ILnRpcClient, LightningRpcError}; use rand::rngs::OsRng; use tokio::sync::mpsc; use tracing::info; diff --git a/fedimint-testing/src/ln/mod.rs b/fedimint-testing/src/ln/mod.rs index 26ec5c7595b..2576a1cce0a 100644 --- a/fedimint-testing/src/ln/mod.rs +++ b/fedimint-testing/src/ln/mod.rs @@ -7,7 +7,7 @@ use fedimint_core::{Amount, BitcoinHash}; use lightning_invoice::{ Bolt11Invoice, Currency, InvoiceBuilder, PaymentSecret, DEFAULT_EXPIRY_TIME, }; -use ln_gateway::lnrpc_client::ILnRpcClient; +use ln_gateway::lightning::ILnRpcClient; use rand::rngs::OsRng; use secp256k1_zkp::SecretKey; diff --git a/fedimint-testing/src/ln/real.rs b/fedimint-testing/src/ln/real.rs index efcdebe13cc..b04a88c4203 100644 --- a/fedimint-testing/src/ln/real.rs +++ b/fedimint-testing/src/ln/real.rs @@ -19,10 +19,9 @@ use ln_gateway::gateway_lnrpc::{ EmptyResponse, GetNodeInfoResponse, GetRouteHintsResponse, InterceptHtlcResponse, PayInvoiceRequest, PayInvoiceResponse, }; -use ln_gateway::lnd::GatewayLndClient; -use ln_gateway::lnrpc_client::{ - ILnRpcClient, LightningRpcError, NetworkLnRpcClient, RouteHtlcStream, -}; +use ln_gateway::lightning::cln::{NetworkLnRpcClient, RouteHtlcStream}; +use ln_gateway::lightning::lnd::GatewayLndClient; +use ln_gateway::lightning::{ILnRpcClient, LightningRpcError}; use secp256k1::PublicKey; use tokio::sync::Mutex; use tonic_lnd::lnrpc::{GetInfoRequest, Invoice as LndInvoice, ListChannelsRequest}; diff --git a/gateway/ln-gateway/Cargo.toml b/gateway/ln-gateway/Cargo.toml index f358b5318cc..f7af54b8f9e 100644 --- a/gateway/ln-gateway/Cargo.toml +++ b/gateway/ln-gateway/Cargo.toml @@ -66,6 +66,7 @@ tonic_lnd = { workspace = true } tower-http = { version = "0.4.3", features = ["cors", "auth"] } tracing = { version = "0.1.37", default-features = false, features= ["log", "attributes", "std"] } url = { version = "2.3.1", features = ["serde"] } +zebedee-rust = "0.6.0" [dev-dependencies] fedimint-dummy-server = { path = "../../modules/fedimint-dummy-server" } diff --git a/gateway/ln-gateway/src/client.rs b/gateway/ln-gateway/src/client.rs index 07c2e321044..fd8ec3d82c6 100644 --- a/gateway/ln-gateway/src/client.rs +++ b/gateway/ln-gateway/src/client.rs @@ -16,7 +16,7 @@ use rand::thread_rng; use tracing::info; use crate::db::{FederationConfig, FederationIdKey, FederationIdKeyPrefix}; -use crate::lnrpc_client::ILnRpcClient; +use crate::lightning::ILnRpcClient; use crate::state_machine::GatewayClientInit; use crate::{FederationToClientMap, GatewayError, Result, ScidToFederationMap}; diff --git a/gateway/ln-gateway/src/lib.rs b/gateway/ln-gateway/src/lib.rs index c7c838d64b6..8eb4e10b4ec 100644 --- a/gateway/ln-gateway/src/lib.rs +++ b/gateway/ln-gateway/src/lib.rs @@ -1,7 +1,6 @@ pub mod client; pub mod db; -pub mod lnd; -pub mod lnrpc_client; +pub mod lightning; pub mod rpc; pub mod state_machine; pub mod types; @@ -26,7 +25,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use bitcoin::{Address, Network, Txid}; use bitcoin_hashes::hex::ToHex; -use clap::{Parser, Subcommand}; +use clap::Parser; use client::GatewayClientBuilder; use db::{ DbKeyPrefix, FederationIdKey, GatewayConfiguration, GatewayConfigurationKey, GatewayPublicKey, @@ -58,15 +57,14 @@ use fedimint_wallet_client::{ use futures::stream::StreamExt; use gateway_lnrpc::intercept_htlc_response::Action; use gateway_lnrpc::{GetNodeInfoResponse, InterceptHtlcResponse}; +use lightning::{ILnRpcClient, LightningBuilder, LightningMode, LightningRpcError}; use lightning_invoice::RoutingFees; -use lnrpc_client::{ILnRpcClient, LightningBuilder, LightningRpcError, RouteHtlcStream}; use rand::rngs::OsRng; use rpc::{ FederationConnectionInfo, FederationInfo, GatewayFedConfig, GatewayInfo, LeaveFedPayload, SetConfigurationPayload, }; use secp256k1::PublicKey; -use serde::{Deserialize, Serialize}; use state_machine::pay::OutgoingPaymentError; use state_machine::GatewayClientModule; use strum::IntoEnumIterator; @@ -76,7 +74,8 @@ use tracing::{debug, error, info, warn}; use crate::db::{FederationConfig, FederationIdKeyPrefix}; use crate::gateway_lnrpc::intercept_htlc_response::Forward; -use crate::lnrpc_client::GatewayLightningBuilder; +use crate::lightning::cln::RouteHtlcStream; +use crate::lightning::GatewayLightningBuilder; use crate::rpc::rpc_server::run_webserver; use crate::rpc::{ BackupPayload, BalancePayload, ConnectFedPayload, DepositAddressPayload, RestorePayload, @@ -1388,29 +1387,6 @@ async fn wait_for_new_password( .await; } -#[derive(Debug, Clone, Subcommand, Serialize, Deserialize)] -pub enum LightningMode { - #[clap(name = "lnd")] - Lnd { - /// LND RPC address - #[arg(long = "lnd-rpc-host", env = "FM_LND_RPC_ADDR")] - lnd_rpc_addr: String, - - /// LND TLS cert file path - #[arg(long = "lnd-tls-cert", env = "FM_LND_TLS_CERT")] - lnd_tls_cert: String, - - /// LND macaroon file path - #[arg(long = "lnd-macaroon", env = "FM_LND_MACAROON")] - lnd_macaroon: String, - }, - #[clap(name = "cln")] - Cln { - #[arg(long = "cln-extension-addr", env = "FM_GATEWAY_LIGHTNING_ADDR")] - cln_extension_addr: SafeUrl, - }, -} - #[derive(Debug, Error)] pub enum GatewayError { #[error("Federation error: {}", OptStacktrace(.0))] diff --git a/gateway/ln-gateway/src/lightning/alby.rs b/gateway/ln-gateway/src/lightning/alby.rs new file mode 100644 index 00000000000..0c04aa02515 --- /dev/null +++ b/gateway/ln-gateway/src/lightning/alby.rs @@ -0,0 +1,179 @@ +use std::collections::BTreeMap; +use std::fmt; +use std::net::SocketAddr; +use std::str::FromStr; +use std::sync::Arc; + +use async_trait::async_trait; +use fedimint_core::task::TaskGroup; +use fedimint_core::Amount; +use fedimint_ln_common::PrunedInvoice; +use secp256k1::PublicKey; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::sync::oneshot::Sender; +use tokio::sync::{mpsc, Mutex}; +use tokio_stream::wrappers::ReceiverStream; +use tracing::info; + +use super::{ + send_htlc_to_webhook, ILnRpcClient, LightningRpcError, RouteHtlcStream, WebhookClient, +}; +use crate::gateway_lnrpc::{ + EmptyResponse, GetNodeInfoResponse, GetRouteHintsResponse, InterceptHtlcRequest, + InterceptHtlcResponse, PayInvoiceRequest, PayInvoiceResponse, +}; +use crate::rpc::rpc_webhook_server::run_webhook_server; + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct AlbyPayResponse { + amount: u64, + description: String, + destination: String, + fee: u64, + payment_hash: Vec, + payment_preimage: Vec, + payment_request: String, +} + +#[derive(Clone)] +pub struct GatewayAlbyClient { + bind_addr: SocketAddr, + api_key: String, + pub outcomes: Arc>>>, +} + +impl GatewayAlbyClient { + pub async fn new( + bind_addr: SocketAddr, + api_key: String, + outcomes: Arc>>>, + ) -> Self { + info!("Gateway configured to connect to Alby at \n address: {bind_addr:?}"); + Self { + api_key, + bind_addr, + outcomes, + } + } +} + +impl fmt::Debug for GatewayAlbyClient { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "AlbyClient") + } +} + +#[async_trait] +impl ILnRpcClient for GatewayAlbyClient { + /// Returns the public key of the lightning node to use in route hint + /// + /// What should we do here with Alby? + /// - Is the pubkey always the same? + /// - Could change to optional: If Custodial API, hardcode the pubkey for + /// the node + async fn info(&self) -> Result { + let mainnet = "mainnet"; + let alias = "getalby.com"; + let pub_key = PublicKey::from_str( + "030a58b8653d32b99200a2334cfe913e51dc7d155aa0116c176657a4f1722677a3", + ) + .unwrap(); + let pub_key = pub_key.serialize().to_vec(); + + return Ok(GetNodeInfoResponse { + pub_key, + alias: alias.to_string(), + network: mainnet.to_string(), + }); + } + + /// We can probably just use the Alby node pubkey here? + /// SCID is the short channel ID mapping to the federation + async fn routehints( + &self, + _num_route_hints: usize, + ) -> Result { + todo!() + } + + /// Pay an invoice using the alby api + /// Pay needs to be idempotent, this is why we need lookup payment, + /// would need to do something similar with Alby + async fn pay( + &self, + request: PayInvoiceRequest, + ) -> Result { + let client = reqwest::Client::new(); + let endpoint = "https://api.getalby.com/payments/bolt11"; + + let req = json!({ + "invoice": request.invoice, + }); + + let response = client + .post(endpoint) + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&req) + .send() + .await + .unwrap(); + + let response = response.json::().await.unwrap(); + + Ok(PayInvoiceResponse { + preimage: response.payment_preimage, + }) + } + + // FIXME: deduplicate implementation with pay + async fn pay_private( + &self, + _invoice: PrunedInvoice, + _max_delay: u64, + _max_fee: Amount, + ) -> Result { + todo!() + + // Ok(PayInvoiceResponse { preimage }) + } + + /// Returns true if the lightning backend supports payments without full + /// invoices + fn supports_private_payments(&self) -> bool { + false + } + + async fn route_htlcs<'a>( + self: Box, + task_group: &mut TaskGroup, + ) -> Result<(RouteHtlcStream<'a>, Arc), LightningRpcError> { + const CHANNEL_SIZE: usize = 100; + let (gateway_sender, gateway_receiver) = + mpsc::channel::>(CHANNEL_SIZE); + + let new_client = + Arc::new(Self::new(self.bind_addr, self.api_key.clone(), self.outcomes.clone()).await); + + run_webhook_server( + self.bind_addr, + task_group, + gateway_sender.clone(), + WebhookClient::Alby(*self), + ) + .await + .map_err(|_| LightningRpcError::FailedToRouteHtlcs { + failure_reason: "Failed to start webhook server".to_string(), + })?; + + Ok((Box::pin(ReceiverStream::new(gateway_receiver)), new_client)) + } + + async fn complete_htlc( + &self, + htlc: InterceptHtlcResponse, + ) -> Result { + send_htlc_to_webhook(&self.outcomes, htlc).await?; + Ok(EmptyResponse {}) + } +} diff --git a/gateway/ln-gateway/src/lnrpc_client.rs b/gateway/ln-gateway/src/lightning/cln.rs similarity index 54% rename from gateway/ln-gateway/src/lnrpc_client.rs rename to gateway/ln-gateway/src/lightning/cln.rs index 06dbd15bffc..f96a5ac5a38 100644 --- a/gateway/ln-gateway/src/lnrpc_client.rs +++ b/gateway/ln-gateway/src/lightning/cln.rs @@ -3,104 +3,23 @@ use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; -use fedimint_core::encoding::{Decodable, Encodable}; use fedimint_core::task::{sleep, TaskGroup}; use fedimint_core::util::SafeUrl; -use fedimint_core::Amount; -use fedimint_ln_common::PrunedInvoice; use futures::stream::BoxStream; -use serde::{Deserialize, Serialize}; -use thiserror::Error; use tonic::transport::{Channel, Endpoint}; use tonic::Request; use tracing::info; +use super::{ILnRpcClient, LightningRpcError}; use crate::gateway_lnrpc::gateway_lightning_client::GatewayLightningClient; use crate::gateway_lnrpc::{ EmptyRequest, EmptyResponse, GetNodeInfoResponse, GetRouteHintsRequest, GetRouteHintsResponse, InterceptHtlcRequest, InterceptHtlcResponse, PayInvoiceRequest, PayInvoiceResponse, }; -use crate::lnd::GatewayLndClient; -use crate::LightningMode; +use crate::lightning::MAX_LIGHTNING_RETRIES; pub type HtlcResult = std::result::Result; pub type RouteHtlcStream<'a> = BoxStream<'a, HtlcResult>; -pub const MAX_LIGHTNING_RETRIES: u32 = 10; - -#[derive(Error, Debug, Serialize, Deserialize, Encodable, Decodable, Clone, Eq, PartialEq)] -pub enum LightningRpcError { - #[error("Failed to connect to Lightning node")] - FailedToConnect, - #[error("Failed to retrieve node info: {failure_reason}")] - FailedToGetNodeInfo { failure_reason: String }, - #[error("Failed to retrieve route hints: {failure_reason}")] - FailedToGetRouteHints { failure_reason: String }, - #[error("Payment failed: {failure_reason}")] - FailedPayment { failure_reason: String }, - #[error("Failed to route HTLCs: {failure_reason}")] - FailedToRouteHtlcs { failure_reason: String }, - #[error("Failed to complete HTLC: {failure_reason}")] - FailedToCompleteHtlc { failure_reason: String }, - #[error("Failed to open channel: {failure_reason}")] - FailedToOpenChannel { failure_reason: String }, - #[error("Failed to get Invoice: {failure_reason}")] - FailedToGetInvoice { failure_reason: String }, -} - -#[async_trait] -pub trait ILnRpcClient: Debug + Send + Sync { - /// Get the public key and alias of the lightning node - async fn info(&self) -> Result; - - /// Get route hints to the lightning node - async fn routehints( - &self, - num_route_hints: usize, - ) -> Result; - - /// Attempt to pay an invoice using the lightning node - async fn pay( - &self, - invoice: PayInvoiceRequest, - ) -> Result; - - /// Attempt to pay an invoice using the lightning node using a - /// [`PrunedInvoice`], increasing the user's privacy by not sending the - /// invoice description to the gateway. - async fn pay_private( - &self, - _invoice: PrunedInvoice, - _max_delay: u64, - _max_fee: Amount, - ) -> Result { - Err(LightningRpcError::FailedPayment { - failure_reason: "Private payments not supported".to_string(), - }) - } - - /// Returns true if the lightning backend supports payments without full - /// invoices. If this returns true, then [`ILnRpcClient::pay_private`] has - /// to be implemented. - fn supports_private_payments(&self) -> bool { - false - } - - // Consumes the current lightning client because `route_htlcs` should only be - // called once per client. A stream of intercepted HTLCs and a `Arc are returned to the caller. The caller can use this new - // client to interact with the lightning node, but since it is an `Arc` is - // cannot call `route_htlcs` again. - async fn route_htlcs<'a>( - self: Box, - task_group: &mut TaskGroup, - ) -> Result<(RouteHtlcStream<'a>, Arc), LightningRpcError>; - - async fn complete_htlc( - &self, - htlc: InterceptHtlcResponse, - ) -> Result; -} - /// An `ILnRpcClient` that wraps around `GatewayLightningClient` for /// convenience, and makes real RPC requests over the wire to a remote lightning /// node. The lightning node is exposed via a corresponding @@ -221,31 +140,3 @@ impl ILnRpcClient for NetworkLnRpcClient { Ok(res.into_inner()) } } - -#[async_trait] -pub trait LightningBuilder { - async fn build(&self) -> Box; -} - -#[derive(Clone)] -pub struct GatewayLightningBuilder { - pub lightning_mode: LightningMode, -} - -#[async_trait] -impl LightningBuilder for GatewayLightningBuilder { - async fn build(&self) -> Box { - match self.lightning_mode.clone() { - LightningMode::Cln { cln_extension_addr } => { - Box::new(NetworkLnRpcClient::new(cln_extension_addr).await) - } - LightningMode::Lnd { - lnd_rpc_addr, - lnd_tls_cert, - lnd_macaroon, - } => Box::new( - GatewayLndClient::new(lnd_rpc_addr, lnd_tls_cert, lnd_macaroon, None).await, - ), - } - } -} diff --git a/gateway/ln-gateway/src/lightning/coinos.rs b/gateway/ln-gateway/src/lightning/coinos.rs new file mode 100644 index 00000000000..516e8ecdf24 --- /dev/null +++ b/gateway/ln-gateway/src/lightning/coinos.rs @@ -0,0 +1,221 @@ +use std::collections::BTreeMap; +use std::fmt; +use std::net::SocketAddr; +use std::sync::Arc; + +use async_trait::async_trait; +use fedimint_core::task::TaskGroup; +use fedimint_core::Amount; +use fedimint_ln_common::PrunedInvoice; +use lightning_invoice::Bolt11Invoice; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::sync::oneshot::Sender; +use tokio::sync::{mpsc, Mutex}; +use tokio_stream::wrappers::ReceiverStream; +use tracing::info; + +use super::{ + send_htlc_to_webhook, ILnRpcClient, LightningRpcError, RouteHtlcStream, WebhookClient, +}; +use crate::gateway_lnrpc::{ + EmptyResponse, GetNodeInfoResponse, GetRouteHintsResponse, InterceptHtlcRequest, + InterceptHtlcResponse, PayInvoiceRequest, PayInvoiceResponse, +}; +use crate::rpc::rpc_webhook_server::run_webhook_server; + +#[derive(Debug, Serialize, Deserialize)] +pub struct CoinosInvoiceResponse { + pub amount: u64, + pub created: u64, + pub currency: String, + pub hash: Bolt11Invoice, + pub id: String, + pub rate: f64, + pub pending: u64, + pub received: u64, + pub text: String, + pub tip: Option, + pub r#type: String, + pub uid: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CoinosPayResponse { + pub id: String, + pub amount: i64, + pub fee: u64, + pub hash: String, + pub ourfee: u64, + pub iid: Option, + pub uid: String, + pub confirmed: bool, + pub rate: f64, + pub currency: String, + pub r#type: String, + pub r#ref: String, + pub tip: Option, + pub created: u64, +} + +#[derive(Clone)] +pub struct GatewayCoinosClient { + bind_addr: SocketAddr, + api_key: String, + pub outcomes: Arc>>>, +} + +impl GatewayCoinosClient { + pub async fn new( + bind_addr: SocketAddr, + api_key: String, + outcomes: Arc>>>, + ) -> Self { + info!("Gateway configured to connect to Coinos at \n address: {bind_addr:?}"); + Self { + api_key, + bind_addr, + outcomes, + } + } +} + +impl fmt::Debug for GatewayCoinosClient { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "CoinosClient") + } +} + +#[async_trait] +impl ILnRpcClient for GatewayCoinosClient { + /// Returns the public key of the lightning node to use in route hint + /// Coinos always uses the same pubkey, so we can get it by querying + /// for an invoice and then parsing the pubkey and network + async fn info(&self) -> Result { + let endpoint = "https://coinos.io/api/invoice"; + let alias = "Coinos"; + + let client = reqwest::Client::new(); + let req = json!({ + "invoice": { + "amount": 1000, + "type": "lightning" + } + }); + let response = client + .post(endpoint) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&req) + .send() + .await + .map_err(|e| LightningRpcError::FailedToGetInvoice { + failure_reason: format!("Failed to get invoice: {:?}", e), + })?; + + let invoice_response = response.json::().await.unwrap(); + let pub_key = invoice_response.hash.payee_pub_key().ok_or_else(|| { + LightningRpcError::FailedToGetInvoice { + failure_reason: "Failed to get pubkey from invoice".to_string(), + } + })?; + + return Ok(GetNodeInfoResponse { + pub_key: pub_key.serialize().to_vec(), + alias: alias.to_string(), + network: invoice_response.hash.network().to_string(), + }); + } + + /// We can probably just use the Coinos node pubkey here? + /// SCID is the short channel ID mapping to the federation + async fn routehints( + &self, + _num_route_hints: usize, + ) -> Result { + todo!() + } + + /// Pay an invoice using the Coinos Api + async fn pay( + &self, + request: PayInvoiceRequest, + ) -> Result { + let endpoint = "https://coinos.io/api/payments"; + let client = reqwest::Client::new(); + let req = json!({ + "payreq": request.invoice, + }); + let response = client + .post(endpoint) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&req) + .send() + .await + .map_err(|e| LightningRpcError::FailedPayment { + failure_reason: format!("Failed to pay invoice: {:?}", e), + })?; + + let _pay_response = response.json::().await.map_err(|e| { + LightningRpcError::FailedPayment { + failure_reason: format!("Failed to parse invoice: {:?}", e), + } + })?; + // TODO: We need the preimage back from a successful payment + // let preimage = pay_response.preimage; + + Ok(PayInvoiceResponse { preimage: vec![] }) + } + + // FIXME: deduplicate implementation with pay + async fn pay_private( + &self, + _invoice: PrunedInvoice, + _max_delay: u64, + _max_fee: Amount, + ) -> Result { + todo!() + + // Ok(PayInvoiceResponse { preimage }) + } + + /// Returns true if the lightning backend supports payments without full + /// invoices + fn supports_private_payments(&self) -> bool { + false + } + + async fn route_htlcs<'a>( + self: Box, + task_group: &mut TaskGroup, + ) -> Result<(RouteHtlcStream<'a>, Arc), LightningRpcError> { + const CHANNEL_SIZE: usize = 100; + let (gateway_sender, gateway_receiver) = + mpsc::channel::>(CHANNEL_SIZE); + + let new_client = + Arc::new(Self::new(self.bind_addr, self.api_key.clone(), self.outcomes.clone()).await); + + run_webhook_server( + self.bind_addr, + task_group, + gateway_sender.clone(), + WebhookClient::Coinos(*self), + ) + .await + .map_err(|_| LightningRpcError::FailedToRouteHtlcs { + failure_reason: "Failed to start webhook server".to_string(), + })?; + + Ok((Box::pin(ReceiverStream::new(gateway_receiver)), new_client)) + } + + async fn complete_htlc( + &self, + htlc: InterceptHtlcResponse, + ) -> Result { + send_htlc_to_webhook(&self.outcomes, htlc).await?; + Ok(EmptyResponse {}) + } +} diff --git a/gateway/ln-gateway/src/lnd.rs b/gateway/ln-gateway/src/lightning/lnd.rs similarity index 99% rename from gateway/ln-gateway/src/lnd.rs rename to gateway/ln-gateway/src/lightning/lnd.rs index 564fae01763..25fc2dbbd0e 100644 --- a/gateway/ln-gateway/src/lnd.rs +++ b/gateway/ln-gateway/src/lightning/lnd.rs @@ -24,15 +24,14 @@ use tonic_lnd::tonic::Code; use tonic_lnd::{connect, Client as LndClient}; use tracing::{debug, error, info, trace, warn}; +use super::cln::RouteHtlcStream; +use super::{ILnRpcClient, LightningRpcError, MAX_LIGHTNING_RETRIES}; use crate::gateway_lnrpc::get_route_hints_response::{RouteHint, RouteHintHop}; use crate::gateway_lnrpc::intercept_htlc_response::{Action, Cancel, Forward, Settle}; use crate::gateway_lnrpc::{ EmptyResponse, GetNodeInfoResponse, GetRouteHintsResponse, InterceptHtlcRequest, InterceptHtlcResponse, PayInvoiceRequest, PayInvoiceResponse, }; -use crate::lnrpc_client::{ - ILnRpcClient, LightningRpcError, RouteHtlcStream, MAX_LIGHTNING_RETRIES, -}; type HtlcSubscriptionSender = mpsc::Sender>; diff --git a/gateway/ln-gateway/src/lightning/mod.rs b/gateway/ln-gateway/src/lightning/mod.rs new file mode 100644 index 00000000000..413081867df --- /dev/null +++ b/gateway/ln-gateway/src/lightning/mod.rs @@ -0,0 +1,251 @@ +pub mod alby; +pub mod cln; +pub mod coinos; +pub mod lnd; +pub mod strike; +pub mod zbd; + +use std::collections::BTreeMap; +use std::fmt::Debug; +use std::net::SocketAddr; +use std::sync::Arc; + +use async_trait::async_trait; +use clap::Subcommand; +use fedimint_core::encoding::{Decodable, Encodable}; +use fedimint_core::task::TaskGroup; +use fedimint_core::util::SafeUrl; +use fedimint_core::Amount; +use fedimint_ln_common::PrunedInvoice; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tokio::sync::oneshot::Sender; +use tokio::sync::Mutex; + +use self::alby::GatewayAlbyClient; +use self::cln::{NetworkLnRpcClient, RouteHtlcStream}; +use self::coinos::GatewayCoinosClient; +use self::lnd::GatewayLndClient; +use self::strike::GatewayStrikeClient; +use self::zbd::GatewayZbdClient; +use crate::gateway_lnrpc::{ + EmptyResponse, GetNodeInfoResponse, GetRouteHintsResponse, InterceptHtlcResponse, + PayInvoiceRequest, PayInvoiceResponse, +}; + +pub const MAX_LIGHTNING_RETRIES: u32 = 10; + +#[derive(Error, Debug, Serialize, Deserialize, Encodable, Decodable, Clone, Eq, PartialEq)] +pub enum LightningRpcError { + #[error("Failed to connect to Lightning node")] + FailedToConnect, + #[error("Failed to retrieve node info: {failure_reason}")] + FailedToGetNodeInfo { failure_reason: String }, + #[error("Failed to retrieve route hints: {failure_reason}")] + FailedToGetRouteHints { failure_reason: String }, + #[error("Payment failed: {failure_reason}")] + FailedPayment { failure_reason: String }, + #[error("Failed to route HTLCs: {failure_reason}")] + FailedToRouteHtlcs { failure_reason: String }, + #[error("Failed to complete HTLC: {failure_reason}")] + FailedToCompleteHtlc { failure_reason: String }, + #[error("Failed to open channel: {failure_reason}")] + FailedToOpenChannel { failure_reason: String }, + #[error("Failed to get Invoice: {failure_reason}")] + FailedToGetInvoice { failure_reason: String }, +} + +#[async_trait] +pub trait ILnRpcClient: Debug + Send + Sync { + /// Get the public key and alias of the lightning node + async fn info(&self) -> Result; + + /// Get route hints to the lightning node + async fn routehints( + &self, + num_route_hints: usize, + ) -> Result; + + /// Attempt to pay an invoice using the lightning node + async fn pay( + &self, + invoice: PayInvoiceRequest, + ) -> Result; + + /// Attempt to pay an invoice using the lightning node using a + /// [`PrunedInvoice`], increasing the user's privacy by not sending the + /// invoice description to the gateway. + async fn pay_private( + &self, + _invoice: PrunedInvoice, + _max_delay: u64, + _max_fee: Amount, + ) -> Result { + Err(LightningRpcError::FailedPayment { + failure_reason: "Private payments not supported".to_string(), + }) + } + + /// Returns true if the lightning backend supports payments without full + /// invoices. If this returns true, then [`ILnRpcClient::pay_private`] has + /// to be implemented. + fn supports_private_payments(&self) -> bool { + false + } + + // Consumes the current lightning client because `route_htlcs` should only be + // called once per client. A stream of intercepted HTLCs and a `Arc are returned to the caller. The caller can use this new + // client to interact with the lightning node, but since it is an `Arc` is + // cannot call `route_htlcs` again. + async fn route_htlcs<'a>( + self: Box, + task_group: &mut TaskGroup, + ) -> Result<(RouteHtlcStream<'a>, Arc), LightningRpcError>; + + async fn complete_htlc( + &self, + htlc: InterceptHtlcResponse, + ) -> Result; +} + +#[derive(Debug, Clone, Subcommand, Serialize, Deserialize)] +pub enum LightningMode { + #[clap(name = "lnd")] + Lnd { + /// LND RPC address + #[arg(long = "lnd-rpc-host", env = "FM_LND_RPC_ADDR")] + lnd_rpc_addr: String, + + /// LND TLS cert file path + #[arg(long = "lnd-tls-cert", env = "FM_LND_TLS_CERT")] + lnd_tls_cert: String, + + /// LND macaroon file path + #[arg(long = "lnd-macaroon", env = "FM_LND_MACAROON")] + lnd_macaroon: String, + }, + #[clap(name = "cln")] + Cln { + #[arg(long = "cln-extension-addr", env = "FM_GATEWAY_LIGHTNING_ADDR")] + cln_extension_addr: SafeUrl, + }, + #[clap(name = "alby")] + Alby { + #[arg(long = "bind-addr", env = "FM_GATEWAY_WEBSERVER_BIND_ADDR")] + bind_addr: SocketAddr, + #[arg(long = "api-key", env = "FM_GATEWAY_LIGHTNING_API_KEY")] + api_key: String, + }, + #[clap(name = "coinos")] + Coinos { + #[arg(long = "bind-addr", env = "FM_GATEWAY_WEBSERVER_BIND_ADDR")] + bind_addr: SocketAddr, + #[arg(long = "api-key", env = "FM_GATEWAY_LIGHTNING_API_KEY")] + api_key: String, + }, + #[clap(name = "zbd")] + Zbd { + #[arg(long = "bind-addr", env = "FM_GATEWAY_WEBSERVER_BIND_ADDR")] + bind_addr: SocketAddr, + #[arg(long = "api-key", env = "FM_GATEWAY_LIGHTNING_API_KEY")] + api_key: String, + }, + #[clap(name = "strike")] + Strike { + #[arg(long = "bind-addr", env = "FM_GATEWAY_WEBSERVER_BIND_ADDR")] + bind_addr: SocketAddr, + #[arg(long = "api-key", env = "FM_GATEWAY_LIGHTNING_API_KEY")] + api_key: String, + }, +} + +#[async_trait] +pub trait LightningBuilder { + async fn build(&self) -> Box; +} + +#[derive(Clone)] +pub struct GatewayLightningBuilder { + pub lightning_mode: LightningMode, +} + +#[async_trait] +impl LightningBuilder for GatewayLightningBuilder { + async fn build(&self) -> Box { + match self.lightning_mode.clone() { + LightningMode::Cln { cln_extension_addr } => { + Box::new(NetworkLnRpcClient::new(cln_extension_addr).await) + } + LightningMode::Lnd { + lnd_rpc_addr, + lnd_tls_cert, + lnd_macaroon, + } => Box::new( + GatewayLndClient::new(lnd_rpc_addr, lnd_tls_cert, lnd_macaroon, None).await, + ), + LightningMode::Alby { bind_addr, api_key } => { + let outcomes = Arc::new(Mutex::new(BTreeMap::new())); + Box::new(GatewayAlbyClient::new(bind_addr, api_key, outcomes).await) + } + LightningMode::Coinos { bind_addr, api_key } => { + let outcomes = Arc::new(Mutex::new(BTreeMap::new())); + Box::new(GatewayCoinosClient::new(bind_addr, api_key, outcomes).await) + } + LightningMode::Zbd { bind_addr, api_key } => { + let outcomes = Arc::new(Mutex::new(BTreeMap::new())); + Box::new(GatewayZbdClient::new(bind_addr, api_key, outcomes).await) + } + LightningMode::Strike { bind_addr, api_key } => { + let outcomes = Arc::new(Mutex::new(BTreeMap::new())); + Box::new(GatewayStrikeClient::new(bind_addr, api_key, outcomes).await) + } + } + } +} + +#[derive(Clone)] +pub enum WebhookClient { + Alby(GatewayAlbyClient), + Coinos(GatewayCoinosClient), + Zbd(GatewayZbdClient), + Strike(GatewayStrikeClient), +} + +impl WebhookClient { + pub async fn set_outcome_sender(&self, htlc_id: u64, sender: Sender) { + match self { + WebhookClient::Alby(client) => { + client.outcomes.lock().await.insert(htlc_id, sender); + } + WebhookClient::Coinos(client) => { + client.outcomes.lock().await.insert(htlc_id, sender); + } + WebhookClient::Zbd(client) => { + client.outcomes.lock().await.insert(htlc_id, sender); + } + WebhookClient::Strike(client) => { + client.outcomes.lock().await.insert(htlc_id, sender); + } + } + } +} + +pub async fn send_htlc_to_webhook( + outcomes: &Arc>>>, + htlc: InterceptHtlcResponse, +) -> Result<(), LightningRpcError> { + let htlc_id = htlc.htlc_id; + if let Some(sender) = outcomes.lock().await.remove(&htlc_id) { + sender + .send(htlc) + .map_err(|_| LightningRpcError::FailedToCompleteHtlc { + failure_reason: "Failed to send back to webhook".to_string(), + })?; + Ok(()) + } else { + Err(LightningRpcError::FailedToCompleteHtlc { + failure_reason: format!("Could not find sender for HTLC {}", htlc_id), + }) + } +} diff --git a/gateway/ln-gateway/src/lightning/strike.rs b/gateway/ln-gateway/src/lightning/strike.rs new file mode 100644 index 00000000000..aa3e3e5d0fe --- /dev/null +++ b/gateway/ln-gateway/src/lightning/strike.rs @@ -0,0 +1,267 @@ +use std::collections::BTreeMap; +use std::fmt; +use std::net::SocketAddr; +use std::sync::Arc; + +use async_trait::async_trait; +use fedimint_core::task::TaskGroup; +use fedimint_core::Amount; +use fedimint_ln_common::PrunedInvoice; +use lightning_invoice::Bolt11Invoice; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::sync::oneshot::Sender; +use tokio::sync::{mpsc, Mutex}; +use tokio_stream::wrappers::ReceiverStream; +use tracing::info; + +use super::{ + send_htlc_to_webhook, ILnRpcClient, LightningRpcError, RouteHtlcStream, WebhookClient, +}; +use crate::gateway_lnrpc::{ + EmptyResponse, GetNodeInfoResponse, GetRouteHintsResponse, InterceptHtlcRequest, + InterceptHtlcResponse, PayInvoiceRequest, PayInvoiceResponse, +}; +use crate::rpc::rpc_webhook_server::run_webhook_server; + +#[derive(Debug, Serialize, Deserialize)] +pub struct StrikeAmount { + amount: String, + currency: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StrikeInvoiceResponse { + invoice_id: String, + amount: StrikeAmount, + state: String, + created: String, + correlation_id: String, + description: String, + issuer_id: String, + receiver_id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConversionRate { + amount: String, + source_currency: String, + target_currency: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StrikeQuoteResponse { + quote_id: String, + description: String, + ln_invoice: Bolt11Invoice, + onchain_address: String, + expiration: String, + expiration_in_sec: u32, + source_amount: StrikeAmount, + target_amount: StrikeAmount, + conversion_rate: ConversionRate, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StrikePaymentQuote { + payment_quote_id: String, + description: String, + valid_until: String, + conversion_rate: ConversionRate, + amount: StrikeAmount, + lighning_network_fee: StrikeAmount, + total_amount: StrikeAmount, +} + +#[derive(Clone)] +pub struct GatewayStrikeClient { + bind_addr: SocketAddr, + api_key: String, + pub outcomes: Arc>>>, +} + +impl GatewayStrikeClient { + pub async fn new( + bind_addr: SocketAddr, + api_key: String, + outcomes: Arc>>>, + ) -> Self { + info!("Gateway configured to connect to Strike at \n address: {bind_addr:?}"); + Self { + api_key, + bind_addr, + outcomes, + } + } +} + +impl fmt::Debug for GatewayStrikeClient { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "StrikeClient") + } +} + +#[async_trait] +impl ILnRpcClient for GatewayStrikeClient { + /// Returns the public key of the lightning node to use in route hint + /// Strike always uses the same pubkey, so we can get it by querying + /// for an invoice and then parsing the pubkey and network + async fn info(&self) -> Result { + let endpoint = "https://api.strike.me/v1/invoices"; + let alias = "StrikeZapHq"; + + let client = reqwest::Client::new(); + let req = json!({ + "invoice": { + "amount": 0.0000001, + "currency": "BTC" + }, + "description": "Gateway GetInfo", + }); + let response = client + .post(endpoint) + .header("Content-Type", "application/json") + .bearer_auth(&self.api_key) + .json(&req) + .send() + .await + .map_err(|e| LightningRpcError::FailedToGetInvoice { + failure_reason: format!("Failed to get invoice: {:?}", e), + })?; + + let invoice_response = response.json::().await.unwrap(); + + let response = client + .get(format!( + "https://api.strike.me/v1/invoices/{}/quote", + invoice_response.invoice_id + )) + .header("Content-Type", "application/json") + .bearer_auth(&self.api_key) + .send() + .await + .map_err(|e| LightningRpcError::FailedToGetInvoice { + failure_reason: format!("Failed to get invoice: {:?}", e), + })?; + + let quote_response = response.json::().await.unwrap(); + + let pub_key = quote_response.ln_invoice.payee_pub_key().ok_or( + LightningRpcError::FailedToGetInvoice { + failure_reason: format!("Failed to get invoice: {:?}", "No payee pub key"), + }, + )?; + + return Ok(GetNodeInfoResponse { + pub_key: pub_key.serialize().to_vec(), + alias: alias.to_string(), + network: quote_response.ln_invoice.network().to_string(), + }); + } + + /// We can probably just use the Coinos node pubkey here? + /// SCID is the short channel ID mapping to the federation + async fn routehints( + &self, + _num_route_hints: usize, + ) -> Result { + todo!() + } + + /// Pay an invoice using the Coinos Api + async fn pay( + &self, + request: PayInvoiceRequest, + ) -> Result { + let endpoint = "https://api.strike.me/v1/payments"; + let client = reqwest::Client::new(); + let req = json!({ + "lnInvoice": request.invoice, + "sourceCurrency": "BTC", + }); + let response = client + .post(endpoint) + .header("Content-Type", "application/json") + .bearer_auth(&self.api_key) + .json(&req) + .send() + .await + .map_err(|e| LightningRpcError::FailedPayment { + failure_reason: format!("Failed to pay invoice: {:?}", e), + })?; + + let payment_quote_response = response.json::().await.unwrap(); + + let _response = client + .post(format!( + "https://api.strike.me/v1/payments/{}/execute", + payment_quote_response.payment_quote_id + )) + .header("Content-Type", "application/json") + .bearer_auth(&self.api_key) + .send() + .await + .map_err(|e| LightningRpcError::FailedPayment { + failure_reason: format!("Failed to pay invoice: {:?}", e), + })?; + + // TODO: Strike payment quote execution doesn't return a preimage ?????? + + Ok(PayInvoiceResponse { preimage: vec![] }) + } + + // FIXME: deduplicate implementation with pay + async fn pay_private( + &self, + _invoice: PrunedInvoice, + _max_delay: u64, + _max_fee: Amount, + ) -> Result { + todo!() + + // Ok(PayInvoiceResponse { preimage }) + } + + /// Returns true if the lightning backend supports payments without full + /// invoices + fn supports_private_payments(&self) -> bool { + false + } + + async fn route_htlcs<'a>( + self: Box, + task_group: &mut TaskGroup, + ) -> Result<(RouteHtlcStream<'a>, Arc), LightningRpcError> { + const CHANNEL_SIZE: usize = 100; + let (gateway_sender, gateway_receiver) = + mpsc::channel::>(CHANNEL_SIZE); + + let new_client = + Arc::new(Self::new(self.bind_addr, self.api_key.clone(), self.outcomes.clone()).await); + + run_webhook_server( + self.bind_addr, + task_group, + gateway_sender.clone(), + WebhookClient::Strike(*self), + ) + .await + .map_err(|_| LightningRpcError::FailedToRouteHtlcs { + failure_reason: "Failed to start webhook server".to_string(), + })?; + + Ok((Box::pin(ReceiverStream::new(gateway_receiver)), new_client)) + } + + async fn complete_htlc( + &self, + htlc: InterceptHtlcResponse, + ) -> Result { + send_htlc_to_webhook(&self.outcomes, htlc).await?; + Ok(EmptyResponse {}) + } +} diff --git a/gateway/ln-gateway/src/lightning/zbd.rs b/gateway/ln-gateway/src/lightning/zbd.rs new file mode 100644 index 00000000000..bec39adbadb --- /dev/null +++ b/gateway/ln-gateway/src/lightning/zbd.rs @@ -0,0 +1,179 @@ +use std::collections::BTreeMap; +use std::fmt; +use std::net::SocketAddr; +use std::str::FromStr; +use std::sync::Arc; + +use async_trait::async_trait; +use fedimint_core::task::TaskGroup; +use fedimint_core::Amount; +use fedimint_ln_common::PrunedInvoice; +use secp256k1::PublicKey; +use serde::{Deserialize, Serialize}; +use tokio::sync::oneshot::Sender; +use tokio::sync::{mpsc, Mutex}; +use tokio_stream::wrappers::ReceiverStream; +use tracing::info; +use zebedee_rust::payments::{Payment, PaymentInvoiceResponse}; +use zebedee_rust::ZebedeeClient; + +use super::{ + send_htlc_to_webhook, ILnRpcClient, LightningRpcError, RouteHtlcStream, WebhookClient, +}; +use crate::gateway_lnrpc::{ + EmptyResponse, GetNodeInfoResponse, GetRouteHintsResponse, InterceptHtlcRequest, + InterceptHtlcResponse, PayInvoiceRequest, PayInvoiceResponse, +}; +use crate::rpc::rpc_webhook_server::run_webhook_server; + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct AlbyPayResponse { + amount: u64, + description: String, + destination: String, + fee: u64, + payment_hash: Vec, + payment_preimage: Vec, + payment_request: String, +} + +#[derive(Clone)] +pub struct GatewayZbdClient { + client: ZebedeeClient, + bind_addr: SocketAddr, + api_key: String, + pub outcomes: Arc>>>, +} + +impl GatewayZbdClient { + pub async fn new( + bind_addr: SocketAddr, + api_key: String, + outcomes: Arc>>>, + ) -> Self { + info!("Gateway configured to connect to Zebedee at \n address: {bind_addr:?}"); + let client = ZebedeeClient::new().apikey(api_key.clone()).build(); + Self { + client, + bind_addr, + api_key, + outcomes, + } + } +} + +impl fmt::Debug for GatewayZbdClient { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "AlbyClient") + } +} + +#[async_trait] +impl ILnRpcClient for GatewayZbdClient { + /// Returns the public key of the lightning node to use in route hint + async fn info(&self) -> Result { + let mainnet = "mainnet"; + let alias = "zlnd1"; + let pub_key = PublicKey::from_str( + "03d6b14390cd178d670aa2d57c93d9519feaae7d1e34264d8bbb7932d47b75a50d", + ) + .unwrap(); + let pub_key = pub_key.serialize().to_vec(); + + return Ok(GetNodeInfoResponse { + pub_key, + alias: alias.to_string(), + network: mainnet.to_string(), + }); + } + + /// We can probably just use the Alby node pubkey here? + /// SCID is the short channel ID mapping to the federation + async fn routehints( + &self, + _num_route_hints: usize, + ) -> Result { + todo!() + } + + /// Pay an invoice using the alby api + /// Pay needs to be idempotent, this is why we need lookup payment, + /// would need to do something similar with Alby + async fn pay( + &self, + request: PayInvoiceRequest, + ) -> Result { + let payment = Payment { + invoice: request.invoice, + ..Default::default() + }; + + let response: PaymentInvoiceResponse = + self.client.pay_invoice(&payment).await.map_err(|e| { + LightningRpcError::FailedPayment { + failure_reason: e.to_string(), + } + })?; + + if response.success && response.data.is_some() { + let data = response.data.unwrap(); + let preimage = data.preimage.unwrap().into_bytes(); + return Ok(PayInvoiceResponse { preimage }); + } + + Err(LightningRpcError::FailedPayment { + failure_reason: "Failed to pay invoice".to_string(), + }) + } + + // FIXME: deduplicate implementation with pay + async fn pay_private( + &self, + _invoice: PrunedInvoice, + _max_delay: u64, + _max_fee: Amount, + ) -> Result { + todo!() + + // Ok(PayInvoiceResponse { preimage }) + } + + /// Returns true if the lightning backend supports payments without full + /// invoices + fn supports_private_payments(&self) -> bool { + false + } + + async fn route_htlcs<'a>( + self: Box, + task_group: &mut TaskGroup, + ) -> Result<(RouteHtlcStream<'a>, Arc), LightningRpcError> { + const CHANNEL_SIZE: usize = 100; + let (gateway_sender, gateway_receiver) = + mpsc::channel::>(CHANNEL_SIZE); + + let new_client = + Arc::new(Self::new(self.bind_addr, self.api_key.clone(), self.outcomes.clone()).await); + + run_webhook_server( + self.bind_addr, + task_group, + gateway_sender.clone(), + WebhookClient::Zbd(*self), + ) + .await + .map_err(|_| LightningRpcError::FailedToRouteHtlcs { + failure_reason: "Failed to start webhook server".to_string(), + })?; + + Ok((Box::pin(ReceiverStream::new(gateway_receiver)), new_client)) + } + + async fn complete_htlc( + &self, + htlc: InterceptHtlcResponse, + ) -> Result { + send_htlc_to_webhook(&self.outcomes, htlc).await?; + Ok(EmptyResponse {}) + } +} diff --git a/gateway/ln-gateway/src/rpc/mod.rs b/gateway/ln-gateway/src/rpc/mod.rs index 6cfed327af8..d5878123901 100644 --- a/gateway/ln-gateway/src/rpc/mod.rs +++ b/gateway/ln-gateway/src/rpc/mod.rs @@ -1,5 +1,6 @@ pub mod rpc_client; pub mod rpc_server; +pub mod rpc_webhook_server; use std::borrow::Cow; use std::collections::BTreeMap; diff --git a/gateway/ln-gateway/src/rpc/rpc_webhook_server.rs b/gateway/ln-gateway/src/rpc/rpc_webhook_server.rs new file mode 100644 index 00000000000..af32df10d8a --- /dev/null +++ b/gateway/ln-gateway/src/rpc/rpc_webhook_server.rs @@ -0,0 +1,163 @@ +use std::net::SocketAddr; + +use axum::routing::post; +use axum::{Extension, Json, Router}; +use axum_macros::debug_handler; +use fedimint_core::task::TaskGroup; +use serde::{Deserialize, Deserializer, Serialize}; +use tracing::{error, instrument}; + +use crate::gateway_lnrpc::intercept_htlc_response::Action; +use crate::gateway_lnrpc::{InterceptHtlcRequest, InterceptHtlcResponse}; +use crate::lightning::{LightningRpcError, WebhookClient}; +use crate::GatewayError; + +pub async fn run_webhook_server( + bind_addr: SocketAddr, + task_group: &mut TaskGroup, + htlc_stream_sender: tokio::sync::mpsc::Sender>, + client: WebhookClient, +) -> axum::response::Result<()> { + let app = Router::new() + .route("/handle_htlc", post(handle_htlc)) + .layer(Extension(htlc_stream_sender.clone())) + .layer(Extension(client)); + + let handle = task_group.make_handle(); + let shutdown_rx = handle.make_shutdown_rx().await; + let server = axum::Server::bind(&bind_addr).serve(app.into_make_service()); + task_group + .spawn("Gateway Webhook Server", move |_| async move { + let graceful = server.with_graceful_shutdown(async { + shutdown_rx.await; + }); + + if let Err(e) = graceful.await { + error!("Error shutting down gatewayd webhook server: {:?}", e); + } + }) + .await; + + Ok(()) +} + +/// `WebhookHandleHtlcParams` is a structure that holds an intercepted HTLC +/// request. +/// +/// Example JSON representation: +/// ```json +/// { +/// "htlc": { +/// "payment_hash": "a3f1e3b56a...", +/// "incoming_amount_msat": 1000, +/// "outgoing_amount_msat": 900, +/// "incoming_expiry": 300, +/// "short_channel_id": 2, // This is the short channel id of the federation mapping +/// "incoming_chan_id": 987654321, +/// "htlc_id": 12345 +/// } +/// } +/// ``` +struct WebhookHandleHtlcParams { + htlc: InterceptHtlcRequest, +} + +use std::fmt; + +use serde::de::{MapAccess, Visitor}; + +impl<'de> Deserialize<'de> for WebhookHandleHtlcParams { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct WebhookHandleHtlcParamsVisitor; + + impl<'de> Visitor<'de> for WebhookHandleHtlcParamsVisitor { + type Value = WebhookHandleHtlcParams; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct WebhookHandleHtlcParams") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut htlc = InterceptHtlcRequest::default(); + + while let Some(key) = map.next_key()? { + match key { + "payment_hash" => htlc.payment_hash = map.next_value()?, + "incoming_amount_msat" => htlc.incoming_amount_msat = map.next_value()?, + "outgoing_amount_msat" => htlc.outgoing_amount_msat = map.next_value()?, + "incoming_expiry" => htlc.incoming_expiry = map.next_value()?, + "short_channel_id" => htlc.short_channel_id = map.next_value()?, + "incoming_chan_id" => htlc.incoming_chan_id = map.next_value()?, + "htlc_id" => htlc.htlc_id = map.next_value()?, + _ => (), + } + } + + Ok(WebhookHandleHtlcParams { htlc }) + } + } + + deserializer.deserialize_struct( + "WebhookHandleHtlcParams", + &[ + "payment_hash", + "incoming_amount_msat", + "outgoing_amount_msat", + "incoming_expiry", + "short_channel_id", + "incoming_chan_id", + "htlc_id", + ], + WebhookHandleHtlcParamsVisitor, + ) + } +} + +#[derive(Serialize)] +struct WebhookHandleHtlcResponse { + preimage: Vec, +} + +#[debug_handler] +#[instrument(skip_all, err)] +async fn handle_htlc( + Extension(htlc_stream_sender): Extension< + tokio::sync::mpsc::Sender>, + >, + Extension(client): Extension, + params: Json, +) -> Result, GatewayError> { + let htlc = params.htlc.clone(); + let (sender, receiver) = tokio::sync::oneshot::channel::(); + + client.set_outcome_sender(htlc.htlc_id, sender).await; + + htlc_stream_sender.send(Ok(htlc)).await.map_err(|e| { + error!("Error sending htlc to stream: {:?}", e); + anyhow::anyhow!("Error sending htlc to stream: {:?}", e) + })?; + + let response = receiver.await.map_err(|_| GatewayError::Disconnected)?; + + match response.action { + Some(Action::Settle(preimage)) => Ok(Json(WebhookHandleHtlcResponse { + preimage: preimage.preimage, + })), + Some(Action::Cancel(cancel)) => Err(GatewayError::LightningRpcError( + LightningRpcError::FailedToCompleteHtlc { + failure_reason: cancel.reason, + }, + )), + _ => Err(GatewayError::LightningRpcError( + LightningRpcError::FailedToCompleteHtlc { + failure_reason: "Invalid action specified for htlc {htlc_id}".to_string(), + }, + )), + } +} diff --git a/gateway/ln-gateway/src/state_machine/mod.rs b/gateway/ln-gateway/src/state_machine/mod.rs index 87b06e2b339..10d7916f4b2 100644 --- a/gateway/ln-gateway/src/state_machine/mod.rs +++ b/gateway/ln-gateway/src/state_machine/mod.rs @@ -50,7 +50,7 @@ use self::pay::{ OutgoingPaymentError, }; use crate::gateway_lnrpc::InterceptHtlcRequest; -use crate::lnrpc_client::ILnRpcClient; +use crate::lightning::ILnRpcClient; use crate::state_machine::complete::{ GatewayCompleteCommon, GatewayCompleteStates, WaitForPreimageState, }; diff --git a/gateway/ln-gateway/src/state_machine/pay.rs b/gateway/ln-gateway/src/state_machine/pay.rs index 3fe72d25158..ced994f1e4a 100644 --- a/gateway/ln-gateway/src/state_machine/pay.rs +++ b/gateway/ln-gateway/src/state_machine/pay.rs @@ -25,7 +25,7 @@ use super::{GatewayClientContext, GatewayClientStateMachines, GatewayExtReceiveS use crate::db::PreimageAuthentication; use crate::fetch_lightning_node_info; use crate::gateway_lnrpc::{PayInvoiceRequest, PayInvoiceResponse}; -use crate::lnrpc_client::LightningRpcError; +use crate::lightning::LightningRpcError; use crate::state_machine::GatewayClientModule; #[cfg_attr(doc, aquamarine::aquamarine)]