diff --git a/Cargo.lock b/Cargo.lock index 75f85c53a514..b7a62211d873 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.98" @@ -325,9 +334,9 @@ dependencies = [ [[package]] name = "bitcoin" -version = "0.32.6" +version = "0.32.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8929a18b8e33ea6b3c09297b687baaa71fb1b97353243a3f1029fad5c59c5b" +checksum = "0fda569d741b895131a88ee5589a467e73e9c4718e958ac9308e4f7dc44b6945" dependencies = [ "base58ck", "bech32 0.11.0", @@ -367,7 +376,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f00d509810205bfef492f1d6cefe1e2ac35b5e66675d51642315ddc5cee0e78" dependencies = [ - "bitcoin 0.32.6", + "bitcoin 0.32.7", "dnssec-prover", "getrandom 0.3.3", "lightning", @@ -464,12 +473,26 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cln-bip353" version = "0.1.0" dependencies = [ "anyhow", - "bitcoin 0.32.6", + "bitcoin 0.32.7", "bitcoin-payment-instructions", "bytes", "cln-plugin", @@ -524,6 +547,8 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "bitcoin 0.31.2", + "chrono", "cln-plugin", "cln-rpc", "hex", @@ -1209,6 +1234,30 @@ dependencies = [ "tower-service", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "1.5.0" @@ -1466,7 +1515,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e540fcb289a76826c9c0b078d3dd1f05691972c5a53fb4d3120540862040a147" dependencies = [ "bech32 0.11.0", - "bitcoin 0.32.6", + "bitcoin 0.32.7", "dnssec-prover", "hashbrown 0.13.2", "libm", @@ -1482,7 +1531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11209f386879b97198b2bfc9e9c1e5d42870825c6bd4376f17f95357244d6600" dependencies = [ "bech32 0.11.0", - "bitcoin 0.32.6", + "bitcoin 0.32.7", "lightning-types", ] @@ -1492,7 +1541,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2cd84d4e71472035903e43caded8ecc123066ce466329ccd5ae537a8d5488c7" dependencies = [ - "bitcoin 0.32.6", + "bitcoin 0.32.7", ] [[package]] @@ -3336,6 +3385,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/doc/lightningd-config.5.md b/doc/lightningd-config.5.md index cc5fa08ba5a0..0a9d5826137d 100644 --- a/doc/lightningd-config.5.md +++ b/doc/lightningd-config.5.md @@ -575,7 +575,7 @@ the address is announced. IPv4 or IPv6 address of the Tor control port (default port 9051), and this will be used to configure a Tor hidden service for port 9735 in case of mainnet (bitcoin) network whereas other networks (testnet, -testnet4, signet, regtest) will set the same default ports they use for +testnet4, signet, regtest) will set the same default ports they use for non-Tor addresses (see above). The Tor hidden service will be configured to point to the first IPv4 or IPv6 address we bind to and is by default unique to @@ -804,6 +804,24 @@ The operations will be bundled into a single transaction. The channel will remai active while awaiting splice confirmation, however you can only spend the smaller of the prior channel balance and the new one. +* **experimental-lsps-client** + + Specifying this enables client side support for the lsps protocol +([blip][blip] #50). Core-Lightning only supports the lsps2 ([blip][blip] #52) +subprotocol describing the creation of just-in-time-channel (JIT-channels) +between a LSP and this client. + +* **experimental-lsps2-service** + + Specifying this enables a LSP JIT-Channel service according to the lsps +protocol ([blip][blip] #52). It requires a LSP-Policy plugin to be available and +a *experimental-lsps2-promise-secret* to be set. + +* **experimental-lsps2-promise-secret**=*promise_secret* + + Sets a `promise_secret` for the LSP JIT-Channel service. Is a 64-character hex + string that acts as the secret for promises according to ([blip][blip] #52). + Is required if *experimental-lsps2-service* is set. BUGS ---- @@ -838,3 +856,4 @@ the rest of the code is covered by the BSD-style MIT license. [bolt]: https://github.com/lightning/bolts [bolt12]: https://github.com/rustyrussell/lightning-rfc/blob/guilt/offers/12-offer-encoding.md [pr4421]: https://github.com/ElementsProject/lightning/pull/4421 +[blip]: https://github.com/lightning/blips diff --git a/plugins/lsps-plugin/Cargo.toml b/plugins/lsps-plugin/Cargo.toml index 13279da12c17..60b3b57eda20 100644 --- a/plugins/lsps-plugin/Cargo.toml +++ b/plugins/lsps-plugin/Cargo.toml @@ -14,6 +14,8 @@ path = "src/service.rs" [dependencies] anyhow = "1.0" async-trait = "0.1" +bitcoin = "0.31" +chrono = { version= "0.4.42", features = ["serde"] } cln-plugin = { version = "0.5", path = "../" } cln-rpc = { version = "0.5", path = "../../cln-rpc" } hex = "0.4" diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index c54699c67e2b..dbc00926917e 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -1,10 +1,38 @@ +use anyhow::{anyhow, Context}; +use chrono::{Duration, Utc}; use cln_lsps::jsonrpc::client::JsonRpcClient; +use cln_lsps::lsps0::primitives::Msat; use cln_lsps::lsps0::{ self, transport::{Bolt8Transport, CustomMessageHookManager, WithCustomMessageHookManager}, }; -use serde::Deserialize; +use cln_lsps::lsps2::cln::tlv::encode_tu64; +use cln_lsps::lsps2::cln::{ + HtlcAcceptedRequest, HtlcAcceptedResponse, TLV_FORWARD_AMT, TLV_PAYMENT_SECRET, +}; +use cln_lsps::lsps2::model::{ + compute_opening_fee, Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest, + Lsps2GetInfoResponse, OpeningFeeParams, +}; +use cln_lsps::util; +use cln_lsps::LSP_FEATURE_BIT; +use cln_plugin::options; +use cln_rpc::model::requests::{ + DatastoreMode, DatastoreRequest, DeldatastoreRequest, ListdatastoreRequest, ListpeersRequest, +}; +use cln_rpc::model::responses::InvoiceResponse; +use cln_rpc::primitives::{AmountOrAny, PublicKey, ShortChannelId}; +use cln_rpc::ClnRpc; +use log::{debug, info, warn}; +use serde::{Deserialize, Serialize}; use std::path::Path; +use std::str::FromStr as _; + +/// An option to enable this service. +const OPTION_ENABLED: options::FlagConfigOption = options::ConfigOption::new_flag( + "experimental-lsps-client", + "Enables an LSPS client on the node.", +); #[derive(Clone)] struct State { @@ -24,41 +52,621 @@ async fn main() -> Result<(), anyhow::Error> { if let Some(plugin) = cln_plugin::Builder::new(tokio::io::stdin(), tokio::io::stdout()) .hook("custommsg", CustomMessageHookManager::on_custommsg::) + .option(OPTION_ENABLED) .rpcmethod( "lsps-listprotocols", "list protocols supported by lsp", on_lsps_listprotocols, ) - .start(state) + .rpcmethod( + "lsps-lsps2-getinfo", + "Low-level command to request the opening fee menu of an LSP", + on_lsps_lsps2_getinfo, + ) + .rpcmethod( + "lsps-lsps2-buy", + "Low-level command to return the lsps2.buy result from an ", + on_lsps_lsps2_buy, + ) + .rpcmethod( + "lsps-lsps2-approve", + "Low-level command to approve a jit channel opening for the given scid", + on_lsps_lsps2_approve, + ) + .rpcmethod( + "lsps-lsps2-invoice", + "Requests a new jit channel from LSP and returns the matching invoice", + on_lsps_jitchannel, + ) + .hook("htlc_accepted", on_htlc_accepted) + .hook("openchannel", on_openchannel) + .configure() .await? { + if !plugin.option(&OPTION_ENABLED)? { + return plugin + .disable(&format!("`{}` not enabled", OPTION_ENABLED.name)) + .await; + } + + let plugin = plugin.start(state).await?; plugin.join().await } else { Ok(()) } } +/// Rpc Method handler for `lsps-lsps2-getinfo`. +async fn on_lsps_lsps2_getinfo( + p: cln_plugin::Plugin, + v: serde_json::Value, +) -> Result { + let req: ClnRpcLsps2GetinfoRequest = + serde_json::from_value(v).context("Failed to parse request JSON")?; + debug!( + "Requesting opening fee menu from lsp {} with token {:?}", + req.lsp_id, req.token + ); + + let dir = p.configuration().lightning_dir; + let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file); + let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?; + + // Fail early: Check that we are connected to the peer and that it has the + // LSP feature bit set. + ensure_lsp_connected(&mut cln_client, &req.lsp_id).await?; + + // Create Transport and Client + let transport = Bolt8Transport::new( + &req.lsp_id, + rpc_path.clone(), // Clone path for potential reuse + p.state().hook_manager.clone(), + None, // Use default timeout + ) + .context("Failed to create Bolt8Transport")?; + let client = JsonRpcClient::new(transport); + + // 1. Call lsps2.get_info. + let info_req = Lsps2GetInfoRequest { token: req.token }; + let info_res: Lsps2GetInfoResponse = client + .call_typed(info_req) + .await + .context("lsps2.get_info call failed")?; + debug!("received lsps2.get_info response: {:?}", info_res); + + Ok(serde_json::to_value(info_res)?) +} + +/// Rpc Method handler for `lsps-lsps2-buy`. +async fn on_lsps_lsps2_buy( + p: cln_plugin::Plugin, + v: serde_json::Value, +) -> Result { + let req: ClnRpcLsps2BuyRequest = + serde_json::from_value(v).context("Failed to parse request JSON")?; + debug!( + "Asking for a channel from lsp {} with opening fee params {:?} and payment size {:?}", + req.lsp_id, req.opening_fee_params, req.payment_size_msat + ); + + let dir = p.configuration().lightning_dir; + let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file); + let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?; + + // Fail early: Check that we are connected to the peer and that it has the + // LSP feature bit set. + ensure_lsp_connected(&mut cln_client, &req.lsp_id).await?; + + // Create Transport and Client + let transport = Bolt8Transport::new( + &req.lsp_id, + rpc_path.clone(), // Clone path for potential reuse + p.state().hook_manager.clone(), + None, // Use default timeout + ) + .context("Failed to create Bolt8Transport")?; + let client = JsonRpcClient::new(transport); + + let selected_params = req.opening_fee_params; + if let Some(payment_size) = req.payment_size_msat { + if payment_size < selected_params.min_payment_size_msat { + return Err(anyhow!( + "Requested payment size {}msat is below minimum {}msat required by LSP", + payment_size, + selected_params.min_payment_size_msat + )); + } + if payment_size > selected_params.max_payment_size_msat { + return Err(anyhow!( + "Requested payment size {}msat is above maximum {}msat allowed by LSP", + payment_size, + selected_params.max_payment_size_msat + )); + } + + let opening_fee = compute_opening_fee( + payment_size.msat(), + selected_params.min_fee_msat.msat(), + selected_params.proportional.ppm() as u64, + ) + .ok_or_else(|| { + warn!( + "Opening fee calculation overflowed for payment size {}", + payment_size + ); + anyhow!("failed to calculate opening fee") + })?; + + info!( + "Calculated opening fee: {}msat for payment size {}msat", + opening_fee, payment_size + ); + } else { + info!("No payment size specified, requesting JIT channel for a variable-amount invoice."); + // Check if the selected params allow for variable amount (implicitly they do if max > min) + if selected_params.min_payment_size_msat >= selected_params.max_payment_size_msat { + // This shouldn't happen if LSP follows spec, but good to check. + warn!("Selected fee params seem unsuitable for variable amount: min >= max"); + } + } + + debug!("Calling lsps2.buy for peer {}", req.lsp_id); + let buy_req = Lsps2BuyRequest { + opening_fee_params: selected_params, // Pass the chosen params back + payment_size_msat: req.payment_size_msat, + }; + let buy_res: Lsps2BuyResponse = client + .call_typed(buy_req) + .await + .context("lsps2.buy call failed")?; + + Ok(serde_json::to_value(buy_res)?) +} + +async fn on_lsps_lsps2_approve( + p: cln_plugin::Plugin, + v: serde_json::Value, +) -> Result { + let req: ClnRpcLsps2Approve = serde_json::from_value(v)?; + let ds_rec = DatastoreRecord { + jit_channel_scid: req.jit_channel_scid, + client_trusts_lsp: req.client_trusts_lsp.unwrap_or_default(), + }; + let ds_rec_json = serde_json::to_string(&ds_rec)?; + + let dir = p.configuration().lightning_dir; + let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file); + let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?; + + let ds_req = DatastoreRequest { + generation: None, + hex: None, + mode: Some(DatastoreMode::CREATE_OR_REPLACE), + string: Some(ds_rec_json), + key: vec!["lsps".to_string(), "client".to_string(), req.lsp_id], + }; + let _ds_res = cln_client.call_typed(&ds_req).await?; + Ok(serde_json::Value::default()) +} + +/// RPC Method handler for `lsps-jitchannel`. +/// Calls lsps2.get_info, selects parameters, calculates fee, calls lsps2.buy, +/// creates invoice. +async fn on_lsps_jitchannel( + p: cln_plugin::Plugin, + v: serde_json::Value, +) -> Result { + #[derive(Deserialize)] + struct Request { + lsp_id: String, + // Optional: for discounts/API keys + token: Option, + // Pass-through of cln invoice rpc params + pub amount_msat: cln_rpc::primitives::AmountOrAny, + pub description: String, + pub label: String, + } + + let req: Request = serde_json::from_value(v).context("Failed to parse request JSON")?; + debug!( + "Handling lsps-buy-jit-channel request for peer {} with payment_size {:?} and token {:?}", + req.lsp_id, req.amount_msat, req.token + ); + + let dir = p.configuration().lightning_dir; + let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file); + let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?; + + // 1. Get LSP's opening fee menu. + let info_res: Lsps2GetInfoResponse = cln_client + .call_raw( + "lsps-lsps2-getinfo", + &ClnRpcLsps2GetinfoRequest { + lsp_id: req.lsp_id.clone(), + token: req.token, + }, + ) + .await?; + + // 2. Select Fee Parameters. + // Simple strategy for now: choose the first valid option as LSPS2 requires + // this to be the cheapest. Could be more sophisticated (e.g., user choice). + let selected_params = info_res + .opening_fee_params_menu + .iter() + .find(|params| { + // Basic validation on client side: check expiry and promise length + let fut_now = Utc::now() + Duration::minutes(1); // Add some extra time for network delay + let expiry_valid = params.valid_until > fut_now; + if !expiry_valid { + warn!("Ignoring expired fee params from LSP {:?}", params); + } + expiry_valid + }) + .cloned() // Clone the selected params + .ok_or_else(|| { + anyhow!( + "No valid/unexpired fee parameters offered by LSP {}", + req.lsp_id + ) + })?; + + info!("Selected fee parameters: {:?}", selected_params); + + let payment_size_msat = match req.amount_msat { + AmountOrAny::Amount(amount) => Some(Msat::from_msat(amount.msat())), + AmountOrAny::Any => None, + }; + + // 3. Request channel from LSP. + let buy_res: Lsps2BuyResponse = cln_client + .call_raw( + "lsps-lsps2-buy", + &ClnRpcLsps2BuyRequest { + lsp_id: req.lsp_id.clone(), + payment_size_msat, + opening_fee_params: selected_params.clone(), + }, + ) + .await?; + + debug!("Received lsps2.buy response: {:?}", buy_res); + + // We define the invoice expiry here to avoid cloning `selected_params` + // as they are about to be moved to the `Lsps2BuyRequest`. + let expiry = (selected_params.valid_until - Utc::now()).num_seconds(); + if expiry <= 10 { + return Err(anyhow!( + "Invoice lifetime is too short, options are valid until: {}", + selected_params.valid_until, + )); + } + + // 4. Create and invoice with a route hint pointing to the LSP, using + // the scid we got from the LSP. + let hint = RoutehintHopDev { + id: req.lsp_id.clone(), + short_channel_id: buy_res.jit_channel_scid.to_string(), + fee_base_msat: Some(0), + fee_proportional_millionths: 0, + cltv_expiry_delta: u16::try_from(buy_res.lsp_cltv_expiry_delta)?, + }; + + let inv: cln_rpc::model::responses::InvoiceResponse = cln_client + .call_raw( + "invoice", + &InvoiceRequest { + amount_msat: req.amount_msat, + dev_routes: Some(vec![vec![hint]]), + description: req.description, + label: req.label, + expiry: Some(expiry as u64), + cltv: Some(u32::try_from(6 + 2)?), // TODO: FETCH REAL VALUE! + deschashonly: None, + preimage: None, + exposeprivatechannels: None, + fallbacks: None, + }, + ) + .await?; + + // 5. Approve jit_channel_scid for a jit channel opening. + let appr_req = ClnRpcLsps2Approve { + lsp_id: req.lsp_id, + jit_channel_scid: buy_res.jit_channel_scid, + client_trusts_lsp: Some(buy_res.client_trusts_lsp), + }; + let _: serde_json::Value = cln_client.call_raw("lsps-lsps2-approve", &appr_req).await?; + + // 6. Return invoice. + let out = InvoiceResponse { + bolt11: inv.bolt11, + created_index: inv.created_index, + warning_capacity: inv.warning_capacity, + warning_deadends: inv.warning_deadends, + warning_mpp: inv.warning_mpp, + warning_offline: inv.warning_offline, + warning_private_unused: inv.warning_private_unused, + expires_at: inv.expires_at, + payment_hash: inv.payment_hash, + payment_secret: inv.payment_secret, + }; + Ok(serde_json::to_value(out)?) +} + +async fn on_htlc_accepted( + _p: cln_plugin::Plugin, + v: serde_json::Value, +) -> Result { + let req: HtlcAcceptedRequest = serde_json::from_value(v)?; + + let htlc_amt = req.htlc.amount_msat; + let onion_amt = match req.onion.forward_msat { + Some(a) => a, + None => { + debug!("onion is missing forward_msat"); + let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?; + return Ok(value); + } + }; + + let is_lsp_payment = req + .htlc + .extra_tlvs + .as_ref() + .map_or(false, |tlv| tlv.contains(65537)); + + if !is_lsp_payment || htlc_amt.msat() >= onion_amt.msat() { + // Not an Lsp payment. + let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?; + return Ok(value); + } + debug!("incoming jit-channel htlc"); + + // Safe unwrap(): we already checked that `extra_tlvs` exists. + let extra_tlvs = req.htlc.extra_tlvs.unwrap(); + let deducted_amt = match extra_tlvs.get_tu64(65537)? { + Some(amt) => amt, + None => { + warn!("htlc is missing the extra_fee amount"); + let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?; + return Ok(value); + } + }; + debug!("lsp htlc is deducted by an extra_fee={}", deducted_amt); + + // Fixme: Check that it is not a forward (has payment_secret) before rpc_calls. + + // Fixme: Check that we did not already pay for this channel. + // - via datastore or invoice label. + + // Fixme: Check the if MPP or No-MPP, assuming No-MPP for now. + // - check that extra_fee + htlc is the total_amount_msat of the onion. + + let mut payload = req.onion.payload.clone(); + payload.set_tu64(TLV_FORWARD_AMT, htlc_amt.msat()); + let payment_secret = match payload.get(TLV_PAYMENT_SECRET) { + Some(s) => s, + None => { + debug!("can't decode tlv payment_secret {:?}", payload); + let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?; + return Ok(value); + } + }; + + let total_amt = htlc_amt.msat(); + let mut ps = Vec::new(); + ps.extend_from_slice(&payment_secret[0..32]); + ps.extend(encode_tu64(total_amt)); + payload.insert(TLV_PAYMENT_SECRET, ps); + let payload_bytes = match payload.to_bytes() { + Ok(b) => b, + Err(e) => { + warn!("can't encode payload to bytes {}", e); + let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?; + return Ok(value); + } + }; + + info!( + "Amended onion payload with forward_amt={} and total_msat={}", + htlc_amt.msat(), + total_amt + ); + let value = serde_json::to_value(HtlcAcceptedResponse::continue_( + Some(payload_bytes), + None, + None, + ))?; + Ok(value) +} + +/// Allows `zero_conf` channels to the client if the LSP is on the allowlist. +async fn on_openchannel( + p: cln_plugin::Plugin, + v: serde_json::Value, +) -> Result { + #[derive(Deserialize)] + struct Request { + id: String, + } + + let req: Request = serde_json::from_value(v.get("openchannel").unwrap().clone()) + .context("Failed to parse request JSON")?; + let dir = p.configuration().lightning_dir; + let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file); + let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?; + + let ds_req = ListdatastoreRequest { + key: Some(vec![ + "lsps".to_string(), + "client".to_string(), + req.id.clone(), + ]), + }; + let ds_res = cln_client.call_typed(&ds_req).await?; + if let Some(_rec) = ds_res.datastore.iter().next() { + info!("Allowing zero-conf channel from LSP {}", &req.id); + let ds_req = DeldatastoreRequest { + generation: None, + key: vec!["lsps".to_string(), "client".to_string(), req.id.clone()], + }; + if let Some(err) = cln_client.call_typed(&ds_req).await.err() { + // We can do nothing but report that there was an issue deleting the + // datastore record. + warn!("Failed to delete LSP record from datastore: {}", err); + } + // Fixme: Check that we actually use client-trusts-LSP mode - can be + // found in the ds record. + return Ok(serde_json::json!({ + "result": "continue", + "reserve": "0msat", + "mindepth": 0, + })); + } else { + // Not a requested JIT-channel opening, continue. + Ok(serde_json::json!({"result": "continue"})) + } +} + async fn on_lsps_listprotocols( p: cln_plugin::Plugin, v: serde_json::Value, ) -> Result { #[derive(Deserialize)] struct Request { - peer: String, + lsp_id: String, } let dir = p.configuration().lightning_dir; let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file); + let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?; + + let req: Request = serde_json::from_value(v).context("Failed to parse request JSON")?; - let req: Request = serde_json::from_value(v).unwrap(); + // Fail early: Check that we are connected to the peer and that it has the + // LSP feature bit set. + ensure_lsp_connected(&mut cln_client, &req.lsp_id).await?; - let client = JsonRpcClient::new(Bolt8Transport::new( - &req.peer, + // Create the transport first and handle potential errors + let transport = Bolt8Transport::new( + &req.lsp_id, rpc_path, p.state().hook_manager.clone(), - None, - )?); + None, // Use default timeout + ) + .context("Failed to create Bolt8Transport")?; + + // Now create the client using the transport + let client = JsonRpcClient::new(transport); + + let request = lsps0::model::Lsps0listProtocolsRequest {}; let res: lsps0::model::Lsps0listProtocolsResponse = client - .call_typed(lsps0::model::Lsps0listProtocolsRequest {}) - .await?; + .call_typed(request) + .await + .context("lsps0.list_protocols call failed")?; + + debug!("Received lsps0.list_protocols response: {:?}", res); Ok(serde_json::to_value(res)?) } + +/// Checks that the node is connected to the peer and that it has the LSP +/// feature bit set. +async fn ensure_lsp_connected(cln_client: &mut ClnRpc, lsp_id: &str) -> Result<(), anyhow::Error> { + let res = cln_client + .call_typed(&ListpeersRequest { + id: Some(PublicKey::from_str(lsp_id)?), + level: None, + }) + .await?; + + // unwrap in next line is safe as we checked that an item exists before. + if res.peers.is_empty() || !res.peers.first().unwrap().connected { + debug!("Node isn't connected to lsp {lsp_id}"); + return Err(anyhow!("not connected to lsp")); + } + + res.peers + .first() + .filter(|peer| { + // Check that feature bit is set + peer.features.as_deref().map_or(false, |f_str| { + if let Some(feature_bits) = hex::decode(f_str).ok() { + util::is_feature_bit_set_reversed(&feature_bits, LSP_FEATURE_BIT) + } else { + false + } + }) + }) + .ok_or_else(|| { + anyhow!( + "peer is not an lsp, feature bit {} is missing", + LSP_FEATURE_BIT, + ) + })?; + + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct LspsBuyJitChannelResponse { + bolt11: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InvoiceRequest { + pub amount_msat: cln_rpc::primitives::AmountOrAny, + pub description: String, + pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub expiry: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fallbacks: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub preimage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cltv: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deschashonly: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exposeprivatechannels: Option>, + #[serde(rename = "dev-routes", skip_serializing_if = "Option::is_none")] + pub dev_routes: Option>>, +} + +// This variant is used by dev-routes, using slightly different key names. +// TODO Remove once we have consolidated the routehint format. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RoutehintHopDev { + pub id: String, + pub short_channel_id: String, + pub fee_base_msat: Option, + pub fee_proportional_millionths: u32, + pub cltv_expiry_delta: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ClnRpcLsps2BuyRequest { + lsp_id: String, + payment_size_msat: Option, + opening_fee_params: OpeningFeeParams, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ClnRpcLsps2GetinfoRequest { + lsp_id: String, + token: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ClnRpcLsps2Approve { + lsp_id: String, + jit_channel_scid: ShortChannelId, + #[serde(default)] + client_trusts_lsp: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct DatastoreRecord { + jit_channel_scid: ShortChannelId, + client_trusts_lsp: bool, +} diff --git a/plugins/lsps-plugin/src/jsonrpc/mod.rs b/plugins/lsps-plugin/src/jsonrpc/mod.rs index 78a4fa1a8514..b7c871ee0932 100644 --- a/plugins/lsps-plugin/src/jsonrpc/mod.rs +++ b/plugins/lsps-plugin/src/jsonrpc/mod.rs @@ -56,7 +56,7 @@ pub type Result = std::result::Result; /// request format. pub trait JsonRpcRequest: Serialize { const METHOD: &'static str; - fn into_request(self, id: impl Into>) -> RequestObject + fn into_request(self, id: Option) -> RequestObject where Self: Sized, { @@ -64,7 +64,7 @@ pub trait JsonRpcRequest: Serialize { jsonrpc: "2.0".into(), method: Self::METHOD.into(), params: Some(self), - id: id.into(), + id, } } } diff --git a/plugins/lsps-plugin/src/lib.rs b/plugins/lsps-plugin/src/lib.rs index 8d4044c193e0..f14b96c7de90 100644 --- a/plugins/lsps-plugin/src/lib.rs +++ b/plugins/lsps-plugin/src/lib.rs @@ -1,2 +1,6 @@ pub mod jsonrpc; pub mod lsps0; +pub mod lsps2; +pub mod util; + +pub const LSP_FEATURE_BIT: usize = 729; diff --git a/plugins/lsps-plugin/src/lsps0/handler.rs b/plugins/lsps-plugin/src/lsps0/handler.rs new file mode 100644 index 000000000000..6b552f477cd9 --- /dev/null +++ b/plugins/lsps-plugin/src/lsps0/handler.rs @@ -0,0 +1,90 @@ +use crate::{ + jsonrpc::{server::RequestHandler, JsonRpcResponse, RequestObject, RpcError}, + lsps0::model::{Lsps0listProtocolsRequest, Lsps0listProtocolsResponse}, + util::unwrap_payload_with_peer_id, +}; +use async_trait::async_trait; + +pub struct Lsps0ListProtocolsHandler { + pub lsps2_enabled: bool, +} + +#[async_trait] +impl RequestHandler for Lsps0ListProtocolsHandler { + async fn handle(&self, payload: &[u8]) -> core::result::Result, RpcError> { + let (payload, _) = unwrap_payload_with_peer_id(payload); + + let req: RequestObject = + serde_json::from_slice(&payload).unwrap(); + if let Some(id) = req.id { + let mut protocols = vec![]; + if self.lsps2_enabled { + protocols.push(2); + } + let res = Lsps0listProtocolsResponse { protocols }.into_response(id); + let res_vec = serde_json::to_vec(&res).unwrap(); + return Ok(res_vec); + } + // If request has no ID (notification), return empty Ok result. + Ok(vec![]) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::{ + jsonrpc::{JsonRpcRequest, ResponseObject}, + util::wrap_payload_with_peer_id, + }; + use cln_rpc::primitives::PublicKey; + + const PUBKEY: [u8; 33] = [ + 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, + 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, + 0xf8, 0x17, 0x98, + ]; + + fn create_peer_id() -> PublicKey { + PublicKey::from_slice(&PUBKEY).expect("Valid pubkey") + } + + fn create_wrapped_request(request: &RequestObject) -> Vec { + let payload = serde_json::to_vec(request).expect("Failed to serialize request"); + wrap_payload_with_peer_id(&payload, create_peer_id()) + } + + #[tokio::test] + async fn test_lsps2_disabled_returns_empty_protocols() { + let handler = Lsps0ListProtocolsHandler { + lsps2_enabled: false, + }; + + let request = Lsps0listProtocolsRequest {}.into_request(Some("test-id".to_string())); + let payload = create_wrapped_request(&request); + + let result = handler.handle(&payload).await.unwrap(); + let response: ResponseObject = + serde_json::from_slice(&result).unwrap(); + + let data = response.into_inner().expect("Should have result data"); + assert!(data.protocols.is_empty()); + } + + #[tokio::test] + async fn test_lsps2_enabled_returns_protocol_2() { + let handler = Lsps0ListProtocolsHandler { + lsps2_enabled: true, + }; + + let request = Lsps0listProtocolsRequest {}.into_request(Some("test-id".to_string())); + let payload = create_wrapped_request(&request); + + let result = handler.handle(&payload).await.unwrap(); + let response: ResponseObject = + serde_json::from_slice(&result).unwrap(); + + let data = response.into_inner().expect("Should have result data"); + assert_eq!(data.protocols, vec![2]); + } +} diff --git a/plugins/lsps-plugin/src/lsps0/mod.rs b/plugins/lsps-plugin/src/lsps0/mod.rs index e7716c921b42..f32b0a55819b 100644 --- a/plugins/lsps-plugin/src/lsps0/mod.rs +++ b/plugins/lsps-plugin/src/lsps0/mod.rs @@ -1,2 +1,4 @@ +pub mod handler; pub mod model; +pub mod primitives; pub mod transport; diff --git a/plugins/lsps-plugin/src/lsps0/primitives.rs b/plugins/lsps-plugin/src/lsps0/primitives.rs new file mode 100644 index 000000000000..3b2ff52d1e8c --- /dev/null +++ b/plugins/lsps-plugin/src/lsps0/primitives.rs @@ -0,0 +1,199 @@ +use core::fmt; +use serde::{ + de::{self}, + Deserialize, Deserializer, Serialize, Serializer, +}; + +const MSAT_PER_SAT: u64 = 1_000; + +/// Represents a monetary amount as defined in LSPS0.msat. Is converted to a +/// `String` in json messages with a suffix `_msat` or `_sat` and internally +/// represented as Millisatoshi `u64`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Msat(pub u64); + +impl Msat { + /// Constructs a new `Msat` struct from a `u64` msat value. + pub fn from_msat(msat: u64) -> Self { + Msat(msat) + } + + /// Construct a new `Msat` struct from a `u64` sat value. + pub fn from_sat(sat: u64) -> Self { + Msat(sat * MSAT_PER_SAT) + } + + /// Returns the sat amount of the field. Is a floored integer division e.g + /// 100678 becomes 100. + pub fn to_sats_floor(&self) -> u64 { + self.0 / 1000 + } + + /// Returns the msat value as `u64`. Is the inner value of `Msat`. + pub fn msat(&self) -> u64 { + self.0 + } +} + +impl core::fmt::Display for Msat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}_msat", self.0) + } +} + +impl Serialize for Msat { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0.to_string()) + } +} + +impl<'de> Deserialize<'de> for Msat { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct MsatVisitor; + + impl<'de> de::Visitor<'de> for MsatVisitor { + type Value = Msat; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a string representing a number") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + value + .parse::() + .map(Msat::from_msat) + .map_err(|_| E::custom(format!("Invalid number string: {}", value))) + } + + // Also handle if JSON mistakenly has a number instead of string + fn visit_u64(self, value: u64) -> Result + where + E: de::Error, + { + Ok(Msat::from_msat(value)) + } + + fn visit_i64(self, value: i64) -> Result + where + E: de::Error, + { + if value < 0 { + Err(E::custom("Msat cannot be negative")) + } else { + Ok(Msat::from_msat(value as u64)) + } + } + } + + deserializer.deserialize_any(MsatVisitor) + } +} + +/// Represents parts-per-million as defined in LSPS0.ppm. Gets it's own type +/// from the rationals: "This is its own type so that fractions can be expressed +/// using this type, instead of as a floating-point type which might lose +/// accuracy when serialized into text.". Having it as a separate type also +/// provides more clarity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] // Key attribute! Serialize/Deserialize as the inner u32 +pub struct Ppm(pub u32); // u32 is sufficient as 1,000,000 fits easily + +impl Ppm { + /// Constructs a new `Ppm` from a u32. + pub const fn from_ppm(value: u32) -> Self { + Ppm(value) + } + + /// Applies the proportion to a base amount (e.g., in msats). + pub fn apply_to(&self, base_msat: u64) -> u64 { + // Careful about integer division order and potential overflow + (base_msat as u128 * self.0 as u128 / 1_000_000) as u64 + } + + /// Returns the ppm. + pub fn ppm(&self) -> u32 { + self.0 + } +} + +impl core::fmt::Display for Ppm { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}ppm", self.0) + } +} + +/// Represents a short channel id as defined in LSPS0.scid. Matches with the +/// implementation in cln_rpc. +pub type ShortChannelId = cln_rpc::primitives::ShortChannelId; + +/// Represents a datetime as defined in LSPS0.datetime. Uses ISO8601 in UTC +/// timezone. +pub type DateTime = chrono::DateTime; + +#[cfg(test)] +mod tests { + use super::*; + use serde_json; + + #[derive(Debug, Serialize, Deserialize)] + struct TestMessage { + amount: Msat, + } + + /// Test serialization of a struct containing Msat. + #[test] + fn test_msat_serialization() { + let msg = TestMessage { + amount: Msat(12345000), + }; + + let expected_amount_json = r#""amount":"12345000""#; + + // Assert that the field gets serialized as string. + let json_string = serde_json::to_string(&msg).expect("Serialization failed"); + assert!( + json_string.contains(expected_amount_json), + "Serialized JSON should contain '{}'", + expected_amount_json + ); + + // Parse back to generic json value and check field. + let json_value: serde_json::Value = + serde_json::from_str(&json_string).expect("Failed to parse JSON back"); + assert_eq!( + json_value + .get("amount") + .expect("JSON should have 'amount' field"), + &serde_json::Value::String("12345000".to_string()), + "JSON 'amount' field should have the correct string value" + ); + } + + /// Test deserialization into a struct containing Msat. + #[test] + fn test_msat_deserialization_and_errors() { + // Case 1: Input string uses "_msat" suffix + let json_ok = r#"{"amount":"987654321"}"#; + let expected_value_msat = Msat(987654321); + let message1: TestMessage = + serde_json::from_str(json_ok).expect("Deserialization from string failed"); + assert_eq!(message1.amount, expected_value_msat); + + // Case 2: Non-numeric Value before suffix + let json_non_numeric = r#"{"amount":"abc"}"#; + let result_non_numeric = serde_json::from_str::(json_non_numeric); + assert!( + result_non_numeric.is_err(), + "Deserialization should fail for non-numeric value" + ); + } +} diff --git a/plugins/lsps-plugin/src/lsps2/cln.rs b/plugins/lsps-plugin/src/lsps2/cln.rs new file mode 100644 index 000000000000..6e3d6d232c2a --- /dev/null +++ b/plugins/lsps-plugin/src/lsps2/cln.rs @@ -0,0 +1,727 @@ +//! Backfill structs for missing or incomplete Core Lightning types. +//! +//! This module provides struct implementations that are not available or +//! fully accessible in the core-lightning crate, enabling better compatibility +//! and interoperability with Core Lightning's RPC interface. +use cln_rpc::primitives::{Amount, ShortChannelId}; +use hex::FromHex; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::lsps2::cln::tlv::TlvStream; + +pub const TLV_FORWARD_AMT: u64 = 2; +pub const TLV_OUTGOING_CLTV: u64 = 4; +pub const TLV_SHORT_CHANNEL_ID: u64 = 6; +pub const TLV_PAYMENT_SECRET: u64 = 8; + +#[derive(Debug, Deserialize)] +#[allow(unused)] +pub struct Onion { + pub forward_msat: Option, + #[serde(deserialize_with = "from_hex")] + pub next_onion: Vec, + pub outgoing_cltv_value: Option, + pub payload: TlvStream, + // pub payload: TlvStream, + #[serde(deserialize_with = "from_hex")] + pub shared_secret: Vec, + pub short_channel_id: Option, + pub total_msat: Option, + #[serde(rename = "type")] + pub type_: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(unused)] +pub struct Htlc { + pub amount_msat: Amount, + pub cltv_expiry: u32, + pub cltv_expiry_relative: u16, + pub id: u64, + #[serde(deserialize_with = "from_hex")] + pub payment_hash: Vec, + pub short_channel_id: ShortChannelId, + pub extra_tlvs: Option, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HtlcAcceptedResult { + Continue, + Fail, + Resolve, +} + +impl std::fmt::Display for HtlcAcceptedResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + HtlcAcceptedResult::Continue => "continue", + HtlcAcceptedResult::Fail => "fail", + HtlcAcceptedResult::Resolve => "resolve", + }; + write!(f, "{s}") + } +} + +#[derive(Debug, Deserialize)] +pub struct HtlcAcceptedRequest { + pub htlc: Htlc, + pub onion: Onion, + pub forward_to: Option, +} + +#[derive(Debug, Serialize)] +pub struct HtlcAcceptedResponse { + pub result: HtlcAcceptedResult, + #[serde(skip_serializing_if = "Option::is_none")] + pub payment_key: Option, + #[serde(skip_serializing_if = "Option::is_none", serialize_with = "to_hex")] + pub payload: Option>, + #[serde(skip_serializing_if = "Option::is_none", serialize_with = "to_hex")] + pub forward_to: Option>, + #[serde(skip_serializing_if = "Option::is_none", serialize_with = "to_hex")] + pub extra_tlvs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub failure_message: Option, + #[serde(skip_serializing_if = "Option::is_none", serialize_with = "to_hex")] + pub failure_onion: Option>, +} + +impl HtlcAcceptedResponse { + pub fn continue_( + payload: Option>, + forward_to: Option>, + extra_tlvs: Option>, + ) -> Self { + Self { + result: HtlcAcceptedResult::Continue, + payment_key: None, + payload, + forward_to, + extra_tlvs, + failure_message: None, + failure_onion: None, + } + } + + pub fn fail(failure_message: Option, failure_onion: Option>) -> Self { + Self { + result: HtlcAcceptedResult::Fail, + payment_key: None, + payload: None, + forward_to: None, + extra_tlvs: None, + failure_message, + failure_onion, + } + } +} + +/// Deserializes a lowercase hex string to a `Vec`. +pub fn from_hex<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::Error; + String::deserialize(deserializer) + .and_then(|string| Vec::from_hex(string).map_err(|err| Error::custom(err.to_string()))) +} + +pub fn to_hex(bytes: &Option>, serializer: S) -> Result +where + S: Serializer, +{ + match bytes { + Some(data) => serializer.serialize_str(&hex::encode(data)), + None => serializer.serialize_none(), + } +} + +pub mod tlv { + use anyhow::Result; + use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize, Serializer}; + use std::{convert::TryFrom, fmt}; + + #[derive(Clone, Debug)] + pub struct TlvRecord { + pub type_: u64, + pub value: Vec, + } + + #[derive(Clone, Debug, Default)] + pub struct TlvStream(pub Vec); + + #[derive(Debug)] + pub enum TlvError { + DuplicateType(u64), + NotSorted, + LengthMismatch(u64, usize, usize), + Truncated, + NonCanonicalBigSize, + TrailingBytes, + Hex(hex::FromHexError), + Other(String), + } + + impl fmt::Display for TlvError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TlvError::DuplicateType(t) => write!(f, "duplicate tlv type {}", t), + TlvError::NotSorted => write!(f, "tlv types must be strictly increasing"), + TlvError::LengthMismatch(t, e, g) => { + write!(f, "length mismatch type {}: expected {}, got {}", t, e, g) + } + TlvError::Truncated => write!(f, "truncated input"), + TlvError::NonCanonicalBigSize => write!(f, "non-canonical bigsize encoding"), + TlvError::TrailingBytes => write!(f, "leftover bytes after parsing"), + TlvError::Hex(e) => write!(f, "hex error: {}", e), + TlvError::Other(s) => write!(f, "{}", s), + } + } + } + + impl std::error::Error for TlvError {} + impl From for TlvError { + fn from(e: hex::FromHexError) -> Self { + TlvError::Hex(e) + } + } + + impl TlvStream { + pub fn to_bytes(&mut self) -> Result> { + self.0.sort_by_key(|r| r.type_); + for w in self.0.windows(2) { + if w[0].type_ == w[1].type_ { + return Err(TlvError::DuplicateType(w[0].type_).into()); + } + if w[0].type_ > w[1].type_ { + return Err(TlvError::NotSorted.into()); + } + } + let mut out = Vec::new(); + for rec in &self.0 { + out.extend(encode_bigsize(rec.type_)); + out.extend(encode_bigsize(rec.value.len() as u64)); + out.extend(&rec.value); + } + Ok(out) + } + + pub fn from_bytes(mut bytes: &[u8]) -> Result { + let mut recs = Vec::new(); + let mut last_type: Option = None; + + while !bytes.is_empty() { + let (t, n1) = decode_bigsize(bytes)?; + bytes = &bytes[n1..]; + let (len, n2) = decode_bigsize(bytes)?; + bytes = &bytes[n2..]; + + let l = + usize::try_from(len).map_err(|_| TlvError::Other("length too large".into()))?; + if bytes.len() < l { + return Err(TlvError::Truncated.into()); + } + let v = bytes[..l].to_vec(); + bytes = &bytes[l..]; + + if let Some(prev) = last_type { + if t == prev { + return Err(TlvError::DuplicateType(t).into()); + } + if t < prev { + return Err(TlvError::NotSorted.into()); + } + } + last_type = Some(t); + recs.push(TlvRecord { type_: t, value: v }); + } + Ok(TlvStream(recs)) + } + + pub fn from_bytes_with_length_prefix(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(TlvError::Truncated.into()); + } + + let (length, length_bytes) = decode_bigsize(bytes)?; + let remaining = &bytes[length_bytes..]; + + let length_usize = usize::try_from(length) + .map_err(|_| TlvError::Other("length prefix too large".into()))?; + + if remaining.len() != length_usize { + return Err(TlvError::LengthMismatch(0, length_usize, remaining.len()).into()); + } + + Self::from_bytes(remaining) + } + + /// Attempt to auto-detect whether the input has a length prefix or not + /// First tries to parse as length-prefixed, then falls back to raw TLV + /// parsing. + pub fn from_bytes_auto(bytes: &[u8]) -> Result { + // Try length-prefixed first + if let Ok(stream) = Self::from_bytes_with_length_prefix(bytes) { + return Ok(stream); + } + + // Fall back to raw TLV parsing + Self::from_bytes(bytes) + } + + /// Get a reference to the value of a TLV record by type. + pub fn get(&self, type_: u64) -> Option<&[u8]> { + self.0 + .iter() + .find(|rec| rec.type_ == type_) + .map(|rec| rec.value.as_slice()) + } + + /// Insert a TLV record (replaces if type already exists). + pub fn insert(&mut self, type_: u64, value: Vec) { + // If the type already exists, replace its value. + if let Some(rec) = self.0.iter_mut().find(|rec| rec.type_ == type_) { + rec.value = value; + return; + } + // Otherwise push and re-sort to maintain canonical order. + self.0.push(TlvRecord { type_, value }); + self.0.sort_by_key(|r| r.type_); + } + + /// Remove a record by type. + pub fn remove(&mut self, type_: u64) -> Option> { + if let Some(pos) = self.0.iter().position(|rec| rec.type_ == type_) { + Some(self.0.remove(pos).value) + } else { + None + } + } + + /// Check if a type exists. + pub fn contains(&self, type_: u64) -> bool { + self.0.iter().any(|rec| rec.type_ == type_) + } + + /// Insert or override a `tu64` value for `type_` (keeps canonical TLV order). + pub fn set_tu64(&mut self, type_: u64, value: u64) { + let enc = encode_tu64(value); + if let Some(rec) = self.0.iter_mut().find(|r| r.type_ == type_) { + rec.value = enc; + } else { + self.0.push(TlvRecord { type_, value: enc }); + self.0.sort_by_key(|r| r.type_); + } + } + + /// Read a `tu64` if present, validating minimal encoding. + /// Returns Ok(None) if the type isn't present. + pub fn get_tu64(&self, type_: u64) -> Result, TlvError> { + if let Some(rec) = self.0.iter().find(|r| r.type_ == type_) { + Ok(Some(decode_tu64(&rec.value)?)) + } else { + Ok(None) + } + } + } + + impl Serialize for TlvStream { + fn serialize(&self, serializer: S) -> Result { + let mut tmp = self.clone(); + let bytes = tmp.to_bytes().map_err(serde::ser::Error::custom)?; + serializer.serialize_str(&hex::encode(bytes)) + } + } + + impl<'de> Deserialize<'de> for TlvStream { + fn deserialize>(deserializer: D) -> Result { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = TlvStream; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "a hex string representing a Lightning TLV stream") + } + fn visit_str(self, s: &str) -> Result { + let bytes = hex::decode(s).map_err(E::custom)?; + TlvStream::from_bytes_auto(&bytes).map_err(E::custom) + } + } + deserializer.deserialize_str(V) + } + } + + impl TryFrom<&[u8]> for TlvStream { + type Error = anyhow::Error; + fn try_from(value: &[u8]) -> Result { + TlvStream::from_bytes(value) + } + } + + impl From> for TlvStream { + fn from(v: Vec) -> Self { + TlvStream(v) + } + } + + /// BOLT #1 BigSize encoding + fn encode_bigsize(x: u64) -> Vec { + let mut out = Vec::new(); + if x < 0xfd { + out.push(x as u8); + } else if x <= 0xffff { + out.push(0xfd); + out.extend_from_slice(&(x as u16).to_be_bytes()); + } else if x <= 0xffff_ffff { + out.push(0xfe); + out.extend_from_slice(&(x as u32).to_be_bytes()); + } else { + out.push(0xff); + out.extend_from_slice(&x.to_be_bytes()); + } + out + } + + fn decode_bigsize(input: &[u8]) -> Result<(u64, usize)> { + if input.is_empty() { + return Err(TlvError::Truncated.into()); + } + match input[0] { + n @ 0x00..=0xfc => Ok((n as u64, 1)), + 0xfd => { + if input.len() < 3 { + return Err(TlvError::Truncated.into()); + } + let v = u16::from_be_bytes([input[1], input[2]]) as u64; + if v < 0xfd { + return Err(TlvError::NonCanonicalBigSize.into()); + } + Ok((v, 3)) + } + 0xfe => { + if input.len() < 5 { + return Err(TlvError::Truncated.into()); + } + let v = u32::from_be_bytes([input[1], input[2], input[3], input[4]]) as u64; + if v <= 0xffff { + return Err(TlvError::NonCanonicalBigSize.into()); + } + Ok((v, 5)) + } + 0xff => { + if input.len() < 9 { + return Err(TlvError::Truncated.into()); + } + let v = u64::from_be_bytes([ + input[1], input[2], input[3], input[4], input[5], input[6], input[7], input[8], + ]); + if v <= 0xffff_ffff { + return Err(TlvError::NonCanonicalBigSize.into()); + } + Ok((v, 9)) + } + } + } + + /// Encode a BOLT #1 `tu64`: big-endian, minimal length (no leading 0x00). + /// Value 0 is encoded as zero-length. + pub fn encode_tu64(v: u64) -> Vec { + if v == 0 { + return Vec::new(); + } + let bytes = v.to_be_bytes(); + let first = bytes.iter().position(|&b| b != 0).unwrap(); // safe: v != 0 + bytes[first..].to_vec() + } + + /// Decode a BOLT #1 `tu64`, enforcing minimal form. + /// Empty slice -> 0. Leading 0x00 or >8 bytes is invalid. + fn decode_tu64(raw: &[u8]) -> Result { + if raw.is_empty() { + return Ok(0); + } + if raw.len() > 8 { + return Err(TlvError::Other("tu64 too long".into())); + } + if raw[0] == 0 { + return Err(TlvError::Other("non-minimal tu64 (leading zero)".into())); + } + let mut buf = [0u8; 8]; + buf[8 - raw.len()..].copy_from_slice(raw); + Ok(u64::from_be_bytes(buf)) + } + + #[cfg(test)] + mod tests { + use super::*; + use anyhow::Result; + + // Small helpers to keep tests readable + fn rec(type_: u64, value: &[u8]) -> TlvRecord { + TlvRecord { + type_, + value: value.to_vec(), + } + } + + fn build_bytes(type_: u64, value: &[u8]) -> Vec { + let mut v = Vec::new(); + v.extend(super::encode_bigsize(type_)); + v.extend(super::encode_bigsize(value.len() as u64)); + v.extend(value); + v + } + + #[test] + fn encode_then_decode_roundtrip() -> Result<()> { + let mut stream = TlvStream(vec![rec(1, &[0x01, 0x02]), rec(5, &[0xaa])]); + + // Encode + let bytes = stream.to_bytes()?; + // Expect exact TLV sequence: + // type=1 -> 0x01, len=2 -> 0x02, value=0x01 0x02 + // type=5 -> 0x05, len=1 -> 0x01, value=0xaa + assert_eq!(hex::encode(&bytes), "010201020501aa"); + + // Decode back + let decoded = TlvStream::from_bytes(&bytes)?; + assert_eq!(decoded.0.len(), 2); + assert_eq!(decoded.0[0].type_, 1); + assert_eq!(decoded.0[0].value, vec![0x01, 0x02]); + assert_eq!(decoded.0[1].type_, 5); + assert_eq!(decoded.0[1].value, vec![0xaa]); + + Ok(()) + } + + #[test] + fn json_hex_roundtrip() -> Result<()> { + let stream = TlvStream(vec![rec(1, &[0x01, 0x02]), rec(5, &[0xaa])]); + + // Serialize to hex string in JSON + let json = serde_json::to_string(&stream)?; + // It's a quoted hex string; check inner value + let s: String = serde_json::from_str(&json)?; + assert_eq!(s, "010201020501aa"); + + // And back from JSON hex + let back: TlvStream = serde_json::from_str(&json)?; + assert_eq!(back.0.len(), 2); + assert_eq!(back.0[0].type_, 1); + assert_eq!(back.0[0].value, vec![0x01, 0x02]); + assert_eq!(back.0[1].type_, 5); + assert_eq!(back.0[1].value, vec![0xaa]); + + Ok(()) + } + + #[test] + fn decode_with_len_prefix() -> Result<()> { + let payload = "1202039896800401760608000073000f2c0007"; + let stream = TlvStream::from_bytes_with_length_prefix(&hex::decode(payload).unwrap())?; + // let stream: TlvStream = serde_json::from_str(payload)?; + println!("TLV {:?}", stream.0); + + Ok(()) + } + + #[test] + fn bigsize_boundary_minimal_encodings() -> Result<()> { + // Types at 0xfc, 0xfd, 0x10000 to exercise size switches + let mut stream = TlvStream(vec![ + rec(0x00fc, &[0x11]), // single-byte bigsize + rec(0x00fd, &[0x22]), // 0xfd prefix + u16 + rec(0x0001_0000, &[0x33]), // 0xfe prefix + u32 + ]); + + let bytes = stream.to_bytes()?; // just ensure it encodes + // Decode back to confirm roundtrip/canonical encodings accepted + let back = TlvStream::from_bytes(&bytes)?; + assert_eq!(back.0[0].type_, 0x00fc); + assert_eq!(back.0[1].type_, 0x00fd); + assert_eq!(back.0[2].type_, 0x0001_0000); + Ok(()) + } + + #[test] + fn decode_rejects_non_canonical_bigsize() { + // (1) Non-canonical: 0xfd 00 fc encodes 0xfc but should be a single byte + let mut bytes = Vec::new(); + bytes.extend([0xfd, 0x00, 0xfc]); // non-canonical type + bytes.extend([0x01]); // len = 1 + bytes.extend([0x00]); // value + let err = TlvStream::from_bytes(&bytes).unwrap_err(); + assert!(format!("{}", err).contains("non-canonical")); + + // (2) Non-canonical: 0xfe 00 00 00 ff encodes 0xff but should be 0xfd-form + let mut bytes = Vec::new(); + bytes.extend([0xfe, 0x00, 0x00, 0x00, 0xff]); + bytes.extend([0x01]); + bytes.extend([0x00]); + let err = TlvStream::from_bytes(&bytes).unwrap_err(); + assert!(format!("{}", err).contains("non-canonical")); + + // (3) Non-canonical: 0xff 00..01 encodes 1, which should be single byte + let mut bytes = Vec::new(); + bytes.extend([0xff, 0, 0, 0, 0, 0, 0, 0, 1]); + bytes.extend([0x01]); + bytes.extend([0x00]); + let err = TlvStream::from_bytes(&bytes).unwrap_err(); + assert!(format!("{}", err).contains("non-canonical")); + } + + #[test] + fn decode_rejects_out_of_order_types() { + // Build two TLVs but put type 5 before type 1 + let mut bad = Vec::new(); + bad.extend(build_bytes(5, &[0xaa])); + bad.extend(build_bytes(1, &[0x00])); + + let err = TlvStream::from_bytes(&bad).unwrap_err(); + assert!( + format!("{}", err).contains("increasing") || format!("{}", err).contains("sorted"), + "expected ordering error, got: {err}" + ); + } + + #[test] + fn decode_rejects_duplicate_types() { + // Two records with same type=1 + let mut bad = Vec::new(); + bad.extend(build_bytes(1, &[0x01])); + bad.extend(build_bytes(1, &[0x02])); + let err = TlvStream::from_bytes(&bad).unwrap_err(); + assert!( + format!("{}", err).contains("duplicate"), + "expected duplicate error, got: {err}" + ); + } + + #[test] + fn encode_rejects_duplicate_types() { + // insert duplicate types and expect encode to fail + let mut s = TlvStream(vec![rec(1, &[0x01]), rec(1, &[0x02])]); + let err = s.to_bytes().unwrap_err(); + assert!( + format!("{}", err).contains("duplicate"), + "expected duplicate error, got: {err}" + ); + } + + #[test] + fn decode_truncated_value() { + // type=1, len=2 but only 1 byte of value provided + let mut bytes = Vec::new(); + bytes.extend(encode_bigsize(1)); + bytes.extend(encode_bigsize(2)); + bytes.push(0x00); // missing one more byte + let err = TlvStream::from_bytes(&bytes).unwrap_err(); + assert!( + format!("{}", err).contains("truncated"), + "expected truncated error, got: {err}" + ); + } + + #[test] + fn set_and_get_tu64_basic() -> Result<()> { + let mut s = TlvStream::default(); + s.set_tu64(42, 123456789); + assert_eq!(s.get_tu64(42)?, Some(123456789)); + Ok(()) + } + + #[test] + fn set_tu64_overwrite_keeps_order() -> Result<()> { + let mut s = TlvStream(vec![ + TlvRecord { + type_: 1, + value: vec![0xaa], + }, + TlvRecord { + type_: 10, + value: vec![0xbb], + }, + ]); + + // insert between 1 and 10 + s.set_tu64(5, 7); + assert_eq!( + s.0.iter().map(|r| r.type_).collect::>(), + vec![1, 5, 10] + ); + assert_eq!(s.get_tu64(5)?, Some(7)); + + // overwrite existing 5 (no duplicate, order preserved) + s.set_tu64(5, 9); + let types: Vec = s.0.iter().map(|r| r.type_).collect(); + assert_eq!(types, vec![1, 5, 10]); + assert_eq!(s.0.iter().filter(|r| r.type_ == 5).count(), 1); + assert_eq!(s.get_tu64(5)?, Some(9)); + Ok(()) + } + + #[test] + fn tu64_zero_encodes_empty_and_roundtrips() -> Result<()> { + let mut s = TlvStream::default(); + s.set_tu64(3, 0); + + // stored value is zero-length + let rec = s.0.iter().find(|r| r.type_ == 3).unwrap(); + assert!(rec.value.is_empty()); + + // wire round-trip + let mut sc = s.clone(); + let bytes = sc.to_bytes()?; + let s2 = TlvStream::from_bytes(&bytes)?; + assert_eq!(s2.get_tu64(3)?, Some(0)); + Ok(()) + } + + #[test] + fn get_tu64_missing_returns_none() -> Result<()> { + let s = TlvStream::default(); + assert_eq!(s.get_tu64(999)?, None); + Ok(()) + } + + #[test] + fn get_tu64_rejects_non_minimal_and_too_long() { + // non-minimal: leading zero + let mut s = TlvStream::default(); + s.0.push(TlvRecord { + type_: 9, + value: vec![0x00, 0x01], + }); + assert!(s.get_tu64(9).is_err()); + + // too long: 9 bytes + let mut s2 = TlvStream::default(); + s2.0.push(TlvRecord { + type_: 9, + value: vec![0; 9], + }); + assert!(s2.get_tu64(9).is_err()); + } + + #[test] + fn tu64_multi_roundtrip_bytes_and_json() -> Result<()> { + let mut s = TlvStream::default(); + s.set_tu64(42, 0); + s.set_tu64(7, 256); + + // wire roundtrip + let mut sc = s.clone(); + let bytes = sc.to_bytes()?; + let s2 = TlvStream::from_bytes(&bytes)?; + assert_eq!(s2.get_tu64(42)?, Some(0)); + assert_eq!(s2.get_tu64(7)?, Some(256)); + + // json hex roundtrip (custom Serialize/Deserialize) + let json = serde_json::to_string(&s)?; + let s3: TlvStream = serde_json::from_str(&json)?; + assert_eq!(s3.get_tu64(42)?, Some(0)); + assert_eq!(s3.get_tu64(7)?, Some(256)); + Ok(()) + } + } +} diff --git a/plugins/lsps-plugin/src/lsps2/handler.rs b/plugins/lsps-plugin/src/lsps2/handler.rs new file mode 100644 index 000000000000..1bac89b9d1bd --- /dev/null +++ b/plugins/lsps-plugin/src/lsps2/handler.rs @@ -0,0 +1,1590 @@ +use crate::{ + jsonrpc::{server::RequestHandler, JsonRpcResponse as _, RequestObject, RpcError}, + lsps0::primitives::{Msat, ShortChannelId}, + lsps2::{ + cln::{HtlcAcceptedRequest, HtlcAcceptedResponse, TLV_FORWARD_AMT}, + model::{ + compute_opening_fee, + failure_codes::{TEMPORARY_CHANNEL_FAILURE, UNKNOWN_NEXT_PEER}, + DatastoreEntry, Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest, + Lsps2GetInfoResponse, Lsps2PolicyGetChannelCapacityRequest, + Lsps2PolicyGetChannelCapacityResponse, Lsps2PolicyGetInfoRequest, + Lsps2PolicyGetInfoResponse, OpeningFeeParams, Promise, + }, + DS_MAIN_KEY, DS_SUB_KEY, + }, + util::unwrap_payload_with_peer_id, +}; +use anyhow::{Context, Result as AnyResult}; +use async_trait::async_trait; +use bitcoin::hashes::Hash as _; +use chrono::Utc; +use cln_rpc::{ + model::{ + requests::{ + DatastoreMode, DatastoreRequest, DeldatastoreRequest, FundchannelRequest, + GetinfoRequest, ListdatastoreRequest, ListpeerchannelsRequest, + }, + responses::{ + DatastoreResponse, DeldatastoreResponse, FundchannelResponse, GetinfoResponse, + ListdatastoreResponse, ListpeerchannelsResponse, + }, + }, + primitives::{Amount, AmountOrAll, ChannelState}, + ClnRpc, +}; +use log::{debug, warn}; +use rand::{rng, Rng as _}; +use std::{fmt, path::PathBuf, time::Duration}; + +#[async_trait] +pub trait ClnApi: Send + Sync { + async fn lsps2_getpolicy( + &self, + params: &Lsps2PolicyGetInfoRequest, + ) -> AnyResult; + + async fn lsps2_getchannelcapacity( + &self, + params: &Lsps2PolicyGetChannelCapacityRequest, + ) -> AnyResult; + + async fn cln_getinfo(&self, params: &GetinfoRequest) -> AnyResult; + + async fn cln_datastore(&self, params: &DatastoreRequest) -> AnyResult; + + async fn cln_listdatastore( + &self, + params: &ListdatastoreRequest, + ) -> AnyResult; + + async fn cln_deldatastore( + &self, + params: &DeldatastoreRequest, + ) -> AnyResult; + + async fn cln_fundchannel(&self, params: &FundchannelRequest) -> AnyResult; + + async fn cln_listpeerchannels( + &self, + params: &ListpeerchannelsRequest, + ) -> AnyResult; +} + +const DEFAULT_CLTV_EXPIRY_DELTA: u32 = 144; + +#[derive(Clone)] +pub struct ClnApiRpc { + rpc_path: PathBuf, +} + +impl ClnApiRpc { + pub fn new(rpc_path: PathBuf) -> Self { + Self { rpc_path } + } + + async fn create_rpc(&self) -> AnyResult { + ClnRpc::new(&self.rpc_path).await + } +} + +#[async_trait] +impl ClnApi for ClnApiRpc { + async fn lsps2_getpolicy( + &self, + params: &Lsps2PolicyGetInfoRequest, + ) -> AnyResult { + let mut rpc = self.create_rpc().await?; + rpc.call_raw("lsps2-policy-getpolicy", params) + .await + .map_err(anyhow::Error::new) + .with_context(|| "calling lsps2-policy-getpolicy") + } + + async fn lsps2_getchannelcapacity( + &self, + params: &Lsps2PolicyGetChannelCapacityRequest, + ) -> AnyResult { + let mut rpc = self.create_rpc().await?; + rpc.call_raw("lsps2-policy-getchannelcapacity", params) + .await + .map_err(anyhow::Error::new) + .with_context(|| "calling lsps2-policy-getchannelcapacity") + } + + async fn cln_getinfo(&self, params: &GetinfoRequest) -> AnyResult { + let mut rpc = self.create_rpc().await?; + rpc.call_typed(params) + .await + .map_err(anyhow::Error::new) + .with_context(|| "calling getinfo") + } + + async fn cln_datastore(&self, params: &DatastoreRequest) -> AnyResult { + let mut rpc = self.create_rpc().await?; + rpc.call_typed(params) + .await + .map_err(anyhow::Error::new) + .with_context(|| "calling datastore") + } + + async fn cln_listdatastore( + &self, + params: &ListdatastoreRequest, + ) -> AnyResult { + let mut rpc = self.create_rpc().await?; + rpc.call_typed(params) + .await + .map_err(anyhow::Error::new) + .with_context(|| "calling listdatastore") + } + + async fn cln_deldatastore( + &self, + params: &DeldatastoreRequest, + ) -> AnyResult { + let mut rpc = self.create_rpc().await?; + rpc.call_typed(params) + .await + .map_err(anyhow::Error::new) + .with_context(|| "calling deldatastore") + } + + async fn cln_fundchannel(&self, params: &FundchannelRequest) -> AnyResult { + let mut rpc = self.create_rpc().await?; + rpc.call_typed(params) + .await + .map_err(anyhow::Error::new) + .with_context(|| "calling fundchannel") + } + + async fn cln_listpeerchannels( + &self, + params: &ListpeerchannelsRequest, + ) -> AnyResult { + let mut rpc = self.create_rpc().await?; + rpc.call_typed(params) + .await + .map_err(anyhow::Error::new) + .with_context(|| "calling listpeerchannels") + } +} + +/// Handler for the `lsps2.get_info` method. +pub struct Lsps2GetInfoHandler { + pub api: A, + pub promise_secret: [u8; 32], +} + +impl Lsps2GetInfoHandler { + pub fn new(api: A, promise_secret: [u8; 32]) -> Self { + Self { + api, + promise_secret, + } + } +} + +/// The RequestHandler calls the internal rpc command `lsps2-policy-getinfo`. It +/// expects a plugin has registered this command and manages policies for the +/// LSPS2 service. +#[async_trait] +impl RequestHandler for Lsps2GetInfoHandler { + async fn handle(&self, payload: &[u8]) -> core::result::Result, RpcError> { + let (payload, _) = unwrap_payload_with_peer_id(payload); + + let req: RequestObject = serde_json::from_slice(&payload) + .map_err(|e| RpcError::parse_error(format!("failed to parse request: {e}")))?; + + if req.id.is_none() { + // Is a notification we can not reply so we just return + return Ok(vec![]); + } + let params = req + .params + .ok_or(RpcError::invalid_params("expected params but was missing"))?; + + let policy_params: Lsps2PolicyGetInfoRequest = params.into(); + let res_data: Lsps2PolicyGetInfoResponse = self + .api + .lsps2_getpolicy(&policy_params) + .await + .map_err(|e| RpcError { + code: 200, + message: format!("failed to fetch policy {e:#}"), + data: None, + })?; + + let opening_fee_params_menu = res_data + .policy_opening_fee_params_menu + .iter() + .map(|v| { + let promise: Promise = v + .get_hmac_hex(&self.promise_secret) + .try_into() + .map_err(|e| RpcError::internal_error(format!("invalid promise: {e}")))?; + Ok(OpeningFeeParams { + min_fee_msat: v.min_fee_msat, + proportional: v.proportional, + valid_until: v.valid_until, + min_lifetime: v.min_lifetime, + max_client_to_self_delay: v.max_client_to_self_delay, + min_payment_size_msat: v.min_payment_size_msat, + max_payment_size_msat: v.max_payment_size_msat, + promise, + }) + }) + .collect::, RpcError>>()?; + + let res = Lsps2GetInfoResponse { + opening_fee_params_menu, + } + .into_response(req.id.unwrap()); // We checked that we got an id before. + + serde_json::to_vec(&res) + .map_err(|e| RpcError::internal_error(format!("Failed to serialize response: {}", e))) + } +} + +pub struct Lsps2BuyHandler { + pub api: A, + pub promise_secret: [u8; 32], +} + +impl Lsps2BuyHandler { + pub fn new(api: A, promise_secret: [u8; 32]) -> Self { + Self { + api, + promise_secret, + } + } +} + +#[async_trait] +impl RequestHandler for Lsps2BuyHandler { + async fn handle(&self, payload: &[u8]) -> core::result::Result, RpcError> { + let (payload, peer_id) = unwrap_payload_with_peer_id(payload); + + let req: RequestObject = serde_json::from_slice(&payload) + .map_err(|e| RpcError::parse_error(format!("Failed to parse request: {}", e)))?; + + if req.id.is_none() { + // Is a notification we can not reply so we just return + return Ok(vec![]); + } + + let req_params = req + .params + .ok_or_else(|| RpcError::invalid_request("Missing params field"))?; + + let fee_params = req_params.opening_fee_params; + + // FIXME: In the future we should replace the \`None\` with a meaningful + // value that reflects the inbound capacity for this node from the + // public network for a better pre-condition check on the payment_size. + fee_params.validate(&self.promise_secret, req_params.payment_size_msat, None)?; + + // Generate a tmp scid to identify jit channel request in htlc. + let get_info_req = GetinfoRequest {}; + let info = self.api.cln_getinfo(&get_info_req).await.map_err(|e| { + warn!("Failed to call getinfo via rpc {}", e); + RpcError::internal_error("Internal error") + })?; + + // FIXME: Future task: Check that we don't conflict with any jit scid we + // already handed out -> Check datastore entries. + let jit_scid_u64 = generate_jit_scid(info.blockheight); + let jit_scid = ShortChannelId::from(jit_scid_u64); + let ds_data = DatastoreEntry { + peer_id, + opening_fee_params: fee_params, + expected_payment_size: req_params.payment_size_msat, + }; + let ds_json = serde_json::to_string(&ds_data).map_err(|e| { + warn!("Failed to serialize opening fee params to string {}", e); + RpcError::internal_error("Internal error") + })?; + + let ds_req = DatastoreRequest { + generation: None, + hex: None, + mode: Some(DatastoreMode::MUST_CREATE), + string: Some(ds_json), + key: vec![ + DS_MAIN_KEY.to_string(), + DS_SUB_KEY.to_string(), + jit_scid.to_string(), + ], + }; + + let _ds_res = self.api.cln_datastore(&ds_req).await.map_err(|e| { + warn!("Failed to store jit request in ds via rpc {}", e); + RpcError::internal_error("Internal error") + })?; + + let res = Lsps2BuyResponse { + jit_channel_scid: jit_scid, + // We can make this configurable if necessary. + lsp_cltv_expiry_delta: DEFAULT_CLTV_EXPIRY_DELTA, + // We can implement the other mode later on as we might have to do + // some additional work on core-lightning to enable this. + client_trusts_lsp: false, + } + .into_response(req.id.unwrap()); // We checked that we got an id before. + + serde_json::to_vec(&res) + .map_err(|e| RpcError::internal_error(format!("Failed to serialize response: {}", e))) + } +} + +fn generate_jit_scid(best_blockheigt: u32) -> u64 { + let mut rng = rng(); + let block = best_blockheigt + 6; // Approx 1 hour in the future and should avoid collision with confirmed channels + let tx_idx: u32 = rng.random_range(0..5000); + let output_idx: u16 = rng.random_range(0..10); + + ((block as u64) << 40) | ((tx_idx as u64) << 16) | (output_idx as u64) +} + +pub struct HtlcAcceptedHookHandler { + api: A, + htlc_minimum_msat: u64, + backoff_listpeerchannels: Duration, +} + +impl HtlcAcceptedHookHandler { + pub fn new(api: A, htlc_minimum_msat: u64) -> Self { + Self { + api, + htlc_minimum_msat, + backoff_listpeerchannels: Duration::from_secs(10), + } + } + + pub async fn handle(&self, req: HtlcAcceptedRequest) -> AnyResult { + let scid = match req.onion.short_channel_id { + Some(scid) => scid, + None => { + // We are the final destination of this htlc. + return Ok(HtlcAcceptedResponse::continue_(None, None, None)); + } + }; + + // A) Is this SCID one that we care about? + let ds_req = ListdatastoreRequest { + key: Some(scid_ds_key(scid)), + }; + let ds_res = self.api.cln_listdatastore(&ds_req).await.map_err(|e| { + warn!("Failed to listpeerchannels via rpc {}", e); + RpcError::internal_error("Internal error") + })?; + + let (ds_rec, ds_gen) = match deserialize_by_key(&ds_res, scid_ds_key(scid)) { + Ok(r) => r, + Err(DsError::NotFound { .. }) => { + // We don't know the scid, continue. + return Ok(HtlcAcceptedResponse::continue_(None, None, None)); + } + Err(e @ DsError::MissingValue { .. }) + | Err(e @ DsError::HexDecode { .. }) + | Err(e @ DsError::JsonParse { .. }) => { + // We have a data issue, log and continue. + // Note: We may want to actually reject the htlc here or throw + // an error alltogether but we will try to fulfill this htlc for + // now. + warn!("datastore issue: {}", e); + return Ok(HtlcAcceptedResponse::continue_(None, None, None)); + } + }; + + // Fixme: Check that we don't have a channel yet with the peer that we await to + // become READY to use. + // --- + + // Fixme: We only accept no-mpp for now, mpp and other flows will be added later on + if ds_rec.expected_payment_size.is_some() { + warn!("mpp payments are not implemented yet"); + return Ok(HtlcAcceptedResponse::fail( + Some(UNKNOWN_NEXT_PEER.to_string()), + None, + )); + } + + // B) Is the fee option menu still valid? + let now = Utc::now(); + if now >= ds_rec.opening_fee_params.valid_until { + // Not valid anymore, remove from DS and fail HTLC. + let ds_req = DeldatastoreRequest { + generation: ds_gen, + key: scid_ds_key(scid), + }; + match self.api.cln_deldatastore(&ds_req).await { + Ok(_) => debug!("removed datastore for scid: {}, wasn't valid anymore", scid), + Err(e) => warn!("could not remove datastore for scid: {}: {}", scid, e), + }; + return Ok(HtlcAcceptedResponse::fail( + Some(TEMPORARY_CHANNEL_FAILURE.to_string()), + None, + )); + } + + // C) Is the amount in the boundaries of the fee menu? + if req.htlc.amount_msat.msat() < ds_rec.opening_fee_params.min_fee_msat.msat() + || req.htlc.amount_msat.msat() > ds_rec.opening_fee_params.max_payment_size_msat.msat() + { + // No! reject the HTLC. + debug!("amount_msat for scid: {}, was too low or to high", scid); + return Ok(HtlcAcceptedResponse::fail( + Some(UNKNOWN_NEXT_PEER.to_string()), + None, + )); + } + + // D) Check that the amount_msat covers the opening fee (only for non-mpp right now) + let opening_fee = if let Some(opening_fee) = compute_opening_fee( + req.htlc.amount_msat.msat(), + ds_rec.opening_fee_params.min_fee_msat.msat(), + ds_rec.opening_fee_params.proportional.ppm() as u64, + ) { + if opening_fee + self.htlc_minimum_msat >= req.htlc.amount_msat.msat() { + debug!("amount_msat for scid: {}, does not cover opening fee", scid); + return Ok(HtlcAcceptedResponse::fail( + Some(UNKNOWN_NEXT_PEER.to_string()), + None, + )); + } + opening_fee + } else { + // The computation overflowed. + debug!("amount_msat for scid: {}, was too low or to high", scid); + return Ok(HtlcAcceptedResponse::fail( + Some(UNKNOWN_NEXT_PEER.to_string()), + None, + )); + }; + + // E) We made it, open a channel to the peer. + let ch_cap_req = Lsps2PolicyGetChannelCapacityRequest { + opening_fee_params: ds_rec.opening_fee_params, + init_payment_size: Msat::from_msat(req.htlc.amount_msat.msat()), + scid, + }; + let ch_cap_res = match self.api.lsps2_getchannelcapacity(&ch_cap_req).await { + Ok(r) => r, + Err(e) => { + warn!("failed to get channel capacity for scid {}: {}", scid, e); + return Ok(HtlcAcceptedResponse::fail( + Some(UNKNOWN_NEXT_PEER.to_string()), + None, + )); + } + }; + + let cap = match ch_cap_res.channel_capacity_msat { + Some(c) => c, + None => { + debug!("policy giver does not allow channel for scid {}", scid); + return Ok(HtlcAcceptedResponse::fail( + Some(UNKNOWN_NEXT_PEER.to_string()), + None, + )); + } + }; + + // We take the policy-giver seriously, if the capacity is too low, we + // still try to open the channel. + // Fixme: We may check that the capacity is ge than the + // (amount_msat - opening fee) in the future. + // Fixme: Make this configurable, maybe return the whole request from + // the policy giver? + let fund_ch_req = FundchannelRequest { + announce: Some(false), + close_to: None, + compact_lease: None, + feerate: None, + minconf: None, + mindepth: Some(0), + push_msat: None, + request_amt: None, + reserve: None, + channel_type: Some(vec![12, 22, 50]), + utxos: None, + amount: AmountOrAll::Amount(Amount::from_msat(cap)), + id: ds_rec.peer_id, + }; + + let fund_ch_res = match self.api.cln_fundchannel(&fund_ch_req).await { + Ok(r) => r, + Err(e) => { + // Fixme: Retry to fund the channel. + warn!("could not fund jit channel for scid {}: {}", scid, e); + return Ok(HtlcAcceptedResponse::fail( + Some(UNKNOWN_NEXT_PEER.to_string()), + None, + )); + } + }; + + // F) Wait for the peer to send `channel_ready`. + // Fixme: Use event to check for channel ready, + // Fixme: Check for htlc timeout if peer refuses to send "ready". + // Fixme: handle unexpected channel states. + let mut is_active = false; + while !is_active { + let ls_ch_req = ListpeerchannelsRequest { + id: Some(ds_rec.peer_id), + short_channel_id: None, + }; + let ls_ch_res = match self.api.cln_listpeerchannels(&ls_ch_req).await { + Ok(r) => r, + Err(e) => { + warn!("failed to fetch peer channels for scid {}: {}", scid, e); + tokio::time::sleep(self.backoff_listpeerchannels).await; + continue; + } + }; + let chs = ls_ch_res + .channels + .iter() + .find(|&ch| ch.channel_id.is_some_and(|id| id == fund_ch_res.channel_id)); + if let Some(ch) = chs { + debug!("jit channel for scid {} has state {:?}", scid, ch.state); + if ch.state == ChannelState::CHANNELD_NORMAL { + is_active = true; + } + } + tokio::time::sleep(self.backoff_listpeerchannels).await; + } + + // G) We got a working channel, deduct fee and forward htlc. + let deducted_amt_msat = req.htlc.amount_msat.msat() - opening_fee; + let mut payload = req.onion.payload.clone(); + payload.set_tu64(TLV_FORWARD_AMT, deducted_amt_msat); + + // It is okay to unwrap the next line as we do not have duplicate entries. + let payload_bytes = payload.to_bytes().unwrap(); + debug!("ABOUT TO SEND PAYLOAD: {:0x?}", &payload_bytes); + eprintln!("ABOUT TO SEND PAYLOAD: {:0x?}", &payload_bytes); + + let mut extra_tlvs = req.htlc.extra_tlvs.unwrap_or_default().clone(); + extra_tlvs.set_tu64(65537, opening_fee); + let extra_tlvs_bytes = extra_tlvs.to_bytes().unwrap(); + + Ok(HtlcAcceptedResponse::continue_( + Some(payload_bytes), + Some(fund_ch_res.channel_id.as_byte_array().to_vec()), + Some(extra_tlvs_bytes), + )) + } +} + +#[derive(Debug)] +pub enum DsError { + /// No datastore entry with this exact key. + NotFound { key: Vec }, + /// Entry existed but had neither `string` nor `hex`. + MissingValue { key: Vec }, + /// JSON parse failed (from `string` or decoded `hex`). + JsonParse { + key: Vec, + source: serde_json::Error, + }, + /// Hex decode failed. + HexDecode { + key: Vec, + source: hex::FromHexError, + }, +} + +impl fmt::Display for DsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DsError::NotFound { key } => write!(f, "no datastore entry for key {:?}", key), + DsError::MissingValue { key } => write!( + f, + "datastore entry had neither `string` nor `hex` for key {:?}", + key + ), + DsError::JsonParse { key, source } => { + write!(f, "failed to parse JSON at key {:?}: {}", key, source) + } + DsError::HexDecode { key, source } => { + write!(f, "failed to decode hex at key {:?}: {}", key, source) + } + } + } +} + +impl std::error::Error for DsError {} + +fn scid_ds_key(scid: ShortChannelId) -> Vec { + vec![ + DS_MAIN_KEY.to_string(), + DS_SUB_KEY.to_string(), + scid.to_string(), + ] +} + +pub fn deserialize_by_key( + resp: &ListdatastoreResponse, + key: K, +) -> std::result::Result<(DatastoreEntry, Option), DsError> +where + K: AsRef<[String]>, +{ + let wanted: &[String] = key.as_ref(); + + let ds = resp + .datastore + .iter() + .find(|d| d.key.as_slice() == wanted) + .ok_or_else(|| DsError::NotFound { + key: wanted.to_vec(), + })?; + + // Prefer `string`, fall back to `hex` + if let Some(s) = &ds.string { + let value = serde_json::from_str::(s).map_err(|e| DsError::JsonParse { + key: ds.key.clone(), + source: e, + })?; + return Ok((value, ds.generation)); + } + + if let Some(hx) = &ds.hex { + let bytes = hex::decode(hx).map_err(|e| DsError::HexDecode { + key: ds.key.clone(), + source: e, + })?; + let value = + serde_json::from_slice::(&bytes).map_err(|e| DsError::JsonParse { + key: ds.key.clone(), + source: e, + })?; + return Ok((value, ds.generation)); + } + + Err(DsError::MissingValue { + key: ds.key.clone(), + }) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + use crate::{ + jsonrpc::{JsonRpcRequest, ResponseObject}, + lsps0::primitives::{Msat, Ppm}, + lsps2::{ + cln::{tlv::TlvStream, HtlcAcceptedResult}, + model::PolicyOpeningFeeParams, + }, + util::wrap_payload_with_peer_id, + }; + use chrono::{TimeZone, Utc}; + use cln_rpc::{model::responses::ListdatastoreDatastore, RpcError as ClnRpcError}; + use cln_rpc::{ + model::responses::ListpeerchannelsChannels, + primitives::{Amount, PublicKey, Sha256}, + }; + use serde::Serialize; + + const PUBKEY: [u8; 33] = [ + 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, + 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, + 0xf8, 0x17, 0x98, + ]; + + fn create_peer_id() -> PublicKey { + PublicKey::from_slice(&PUBKEY).expect("Valid pubkey") + } + + fn create_wrapped_request(request: &RequestObject) -> Vec { + let payload = serde_json::to_vec(request).expect("Failed to serialize request"); + wrap_payload_with_peer_id(&payload, create_peer_id()) + } + + /// Build a pair: policy params + buy params with a Promise derived from `secret` + fn params_with_promise(secret: &[u8; 32]) -> (PolicyOpeningFeeParams, OpeningFeeParams) { + let policy = PolicyOpeningFeeParams { + min_fee_msat: Msat(2_000), + proportional: Ppm(10_000), + valid_until: Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(), + min_lifetime: 1000, + max_client_to_self_delay: 42, + min_payment_size_msat: Msat(1_000_000), + max_payment_size_msat: Msat(100_000_000), + }; + let hex = policy.get_hmac_hex(secret); + let promise: Promise = hex.try_into().expect("hex->Promise"); + let buy = OpeningFeeParams { + min_fee_msat: policy.min_fee_msat, + proportional: policy.proportional, + valid_until: policy.valid_until, + min_lifetime: policy.min_lifetime, + max_client_to_self_delay: policy.max_client_to_self_delay, + min_payment_size_msat: policy.min_payment_size_msat, + max_payment_size_msat: policy.max_payment_size_msat, + promise, + }; + (policy, buy) + } + + #[derive(Clone, Default)] + struct FakeCln { + lsps2_getpolicy_response: Arc>>, + lsps2_getpolicy_error: Arc>>, + cln_getinfo_response: Arc>>, + cln_getinfo_error: Arc>>, + cln_datastore_response: Arc>>, + cln_datastore_error: Arc>>, + cln_listdatastore_response: Arc>>, + cln_listdatastore_error: Arc>>, + cln_deldatastore_response: Arc>>, + cln_deldatastore_error: Arc>>, + cln_fundchannel_response: Arc>>, + cln_fundchannel_error: Arc>>, + cln_listpeerchannels_response: Arc>>, + cln_listpeerchannels_error: Arc>>, + lsps2_getchannelcapacity_response: + Arc>>, + lsps2_getchannelcapacity_error: Arc>>, + } + + #[async_trait] + impl ClnApi for FakeCln { + async fn lsps2_getpolicy( + &self, + _params: &Lsps2PolicyGetInfoRequest, + ) -> Result { + if let Some(err) = self.lsps2_getpolicy_error.lock().unwrap().take() { + return Err(anyhow::Error::new(err).context("from fake api")); + }; + if let Some(res) = self.lsps2_getpolicy_response.lock().unwrap().take() { + return Ok(res); + }; + panic!("No lsps2 response defined"); + } + + async fn lsps2_getchannelcapacity( + &self, + _params: &Lsps2PolicyGetChannelCapacityRequest, + ) -> AnyResult { + if let Some(err) = self.lsps2_getchannelcapacity_error.lock().unwrap().take() { + return Err(anyhow::Error::new(err).context("from fake api")); + } + if let Some(res) = self + .lsps2_getchannelcapacity_response + .lock() + .unwrap() + .take() + { + return Ok(res); + } + panic!("No lsps2 getchannelcapacity response defined"); + } + + async fn cln_getinfo( + &self, + _params: &GetinfoRequest, + ) -> Result { + if let Some(err) = self.cln_getinfo_error.lock().unwrap().take() { + return Err(anyhow::Error::new(err).context("from fake api")); + }; + if let Some(res) = self.cln_getinfo_response.lock().unwrap().take() { + return Ok(res); + }; + panic!("No cln getinfo response defined"); + } + + async fn cln_datastore( + &self, + _params: &DatastoreRequest, + ) -> Result { + if let Some(err) = self.cln_datastore_error.lock().unwrap().take() { + return Err(anyhow::Error::new(err).context("from fake api")); + }; + if let Some(res) = self.cln_datastore_response.lock().unwrap().take() { + return Ok(res); + }; + panic!("No cln datastore response defined"); + } + + async fn cln_listdatastore( + &self, + _params: &ListdatastoreRequest, + ) -> AnyResult { + if let Some(err) = self.cln_listdatastore_error.lock().unwrap().take() { + return Err(anyhow::Error::new(err).context("from fake api")); + } + if let Some(res) = self.cln_listdatastore_response.lock().unwrap().take() { + return Ok(res); + } + panic!("No cln listdatastore response defined"); + } + + async fn cln_deldatastore( + &self, + _params: &DeldatastoreRequest, + ) -> AnyResult { + if let Some(err) = self.cln_deldatastore_error.lock().unwrap().take() { + return Err(anyhow::Error::new(err).context("from fake api")); + } + if let Some(res) = self.cln_deldatastore_response.lock().unwrap().take() { + return Ok(res); + } + panic!("No cln deldatastore response defined"); + } + + async fn cln_fundchannel( + &self, + _params: &FundchannelRequest, + ) -> AnyResult { + if let Some(err) = self.cln_fundchannel_error.lock().unwrap().take() { + return Err(anyhow::Error::new(err).context("from fake api")); + } + if let Some(res) = self.cln_fundchannel_response.lock().unwrap().take() { + return Ok(res); + } + panic!("No cln fundchannel response defined"); + } + + async fn cln_listpeerchannels( + &self, + _params: &ListpeerchannelsRequest, + ) -> AnyResult { + if let Some(err) = self.cln_listpeerchannels_error.lock().unwrap().take() { + return Err(anyhow::Error::new(err).context("from fake api")); + } + + if let Some(res) = self.cln_listpeerchannels_response.lock().unwrap().take() { + return Ok(res); + } + + // Default: return a ready channel + let channel = ListpeerchannelsChannels { + channel_id: Some(*Sha256::from_bytes_ref(&[1u8; 32])), + state: ChannelState::CHANNELD_NORMAL, + peer_id: create_peer_id(), + peer_connected: true, + alias: None, + closer: None, + funding: None, + funding_outnum: None, + funding_txid: None, + htlcs: None, + in_offered_msat: None, + initial_feerate: None, + last_feerate: None, + last_stable_connection: None, + last_tx_fee_msat: None, + lost_state: None, + max_accepted_htlcs: None, + minimum_htlc_in_msat: None, + next_feerate: None, + next_fee_step: None, + out_fulfilled_msat: None, + out_offered_msat: None, + owner: None, + private: None, + receivable_msat: None, + reestablished: None, + scratch_txid: None, + short_channel_id: None, + spendable_msat: None, + status: None, + their_reserve_msat: None, + to_us_msat: None, + total_msat: None, + close_to: None, + close_to_addr: None, + direction: None, + dust_limit_msat: None, + fee_base_msat: None, + fee_proportional_millionths: None, + feerate: None, + ignore_fee_limits: None, + in_fulfilled_msat: None, + in_payments_fulfilled: None, + in_payments_offered: None, + max_to_us_msat: None, + maximum_htlc_out_msat: None, + min_to_us_msat: None, + minimum_htlc_out_msat: None, + our_max_htlc_value_in_flight_msat: None, + our_reserve_msat: None, + our_to_self_delay: None, + out_payments_fulfilled: None, + out_payments_offered: None, + their_max_htlc_value_in_flight_msat: None, + their_to_self_delay: None, + updates: None, + inflight: None, + #[allow(deprecated)] + max_total_htlc_in_msat: None, + opener: cln_rpc::primitives::ChannelSide::LOCAL, + }; + + Ok(ListpeerchannelsResponse { + channels: vec![channel], + }) + } + } + + fn create_test_htlc_request( + scid: Option, + amount_msat: u64, + ) -> HtlcAcceptedRequest { + let payload = TlvStream::default(); + + HtlcAcceptedRequest { + onion: crate::lsps2::cln::Onion { + short_channel_id: scid, + payload, + next_onion: vec![], + forward_msat: None, + outgoing_cltv_value: None, + shared_secret: vec![], + total_msat: None, + type_: None, + }, + htlc: crate::lsps2::cln::Htlc { + amount_msat: Amount::from_msat(amount_msat), + cltv_expiry: 100, + cltv_expiry_relative: 10, + payment_hash: vec![], + extra_tlvs: None, + short_channel_id: ShortChannelId::from(123456789u64), + id: 0, + }, + forward_to: None, + } + } + + fn create_test_datastore_entry( + peer_id: PublicKey, + expected_payment_size: Option, + ) -> DatastoreEntry { + let (_, policy) = params_with_promise(&[0u8; 32]); + DatastoreEntry { + peer_id, + opening_fee_params: policy, + expected_payment_size, + } + } + + fn minimal_getinfo(height: u32) -> GetinfoResponse { + GetinfoResponse { + lightning_dir: String::default(), + alias: None, + our_features: None, + warning_bitcoind_sync: None, + warning_lightningd_sync: None, + address: None, + binding: None, + blockheight: height, + color: String::default(), + fees_collected_msat: Amount::from_msat(0), + id: PublicKey::from_slice(&PUBKEY).expect("pubkey from slice"), + network: String::default(), + num_active_channels: u32::default(), + num_inactive_channels: u32::default(), + num_peers: u32::default(), + num_pending_channels: u32::default(), + version: String::default(), + } + } + + #[tokio::test] + async fn test_successful_get_info() { + let promise_secret = [0u8; 32]; + let params = Lsps2PolicyGetInfoResponse { + policy_opening_fee_params_menu: vec![PolicyOpeningFeeParams { + min_fee_msat: Msat(2000), + proportional: Ppm(10000), + valid_until: Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(), + min_lifetime: 1000, + max_client_to_self_delay: 42, + min_payment_size_msat: Msat(1000000), + max_payment_size_msat: Msat(100000000), + }], + }; + let promise = params.policy_opening_fee_params_menu[0].get_hmac_hex(&promise_secret); + let fake = FakeCln::default(); + *fake.lsps2_getpolicy_response.lock().unwrap() = Some(params); + let handler = Lsps2GetInfoHandler::new(fake, promise_secret); + + let request = Lsps2GetInfoRequest { token: None }.into_request(Some("test-id".to_string())); + let payload = create_wrapped_request(&request); + + let result = handler.handle(&payload).await.unwrap(); + let response: ResponseObject = + serde_json::from_slice(&result).unwrap(); + let response = response.into_inner().unwrap(); + + assert_eq!( + response.opening_fee_params_menu[0].min_payment_size_msat, + Msat(1000000) + ); + assert_eq!( + response.opening_fee_params_menu[0].max_payment_size_msat, + Msat(100000000) + ); + assert_eq!( + response.opening_fee_params_menu[0].promise, + promise.try_into().unwrap() + ); + } + + #[tokio::test] + async fn test_get_info_rpc_error_handling() { + let fake = FakeCln::default(); + *fake.lsps2_getpolicy_error.lock().unwrap() = Some(ClnRpcError { + code: Some(-1), + message: "not found".to_string(), + data: None, + }); + let handler = Lsps2GetInfoHandler::new(fake, [0; 32]); + let request = Lsps2GetInfoRequest { token: None }.into_request(Some("test-id".to_string())); + let payload = create_wrapped_request(&request); + + let result = handler.handle(&payload).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.code, 200); + assert!(error.message.contains("failed to fetch policy")); + } + + #[tokio::test] + async fn buy_ok_fixed_amount() { + let secret = [0u8; 32]; + let fake = FakeCln::default(); + *fake.cln_getinfo_response.lock().unwrap() = Some(minimal_getinfo(900_000)); + *fake.cln_datastore_response.lock().unwrap() = Some(DatastoreResponse { + generation: Some(0), + hex: None, + string: None, + key: vec![], + }); + + let handler = Lsps2BuyHandler::new(fake, secret); + let (_policy, buy) = params_with_promise(&secret); + + // Set payment_size_msat => "MPP+fixed-invoice" mode. + let req = Lsps2BuyRequest { + opening_fee_params: buy, + payment_size_msat: Some(Msat(2_000_000)), + } + .into_request(Some("ok-fixed".into())); + let payload = create_wrapped_request(&req); + + let out = handler.handle(&payload).await.unwrap(); + let resp: ResponseObject = serde_json::from_slice(&out).unwrap(); + let resp = resp.into_inner().unwrap(); + + assert_eq!(resp.lsp_cltv_expiry_delta, DEFAULT_CLTV_EXPIRY_DELTA); + assert!(!resp.client_trusts_lsp); + assert!(resp.jit_channel_scid.to_u64() > 0); + } + + #[tokio::test] + async fn buy_ok_variable_amount_no_payment_size() { + let secret = [2u8; 32]; + let fake = FakeCln::default(); + *fake.cln_getinfo_response.lock().unwrap() = Some(minimal_getinfo(900_100)); + *fake.cln_datastore_response.lock().unwrap() = Some(DatastoreResponse { + generation: Some(0), + hex: None, + string: None, + key: vec![], + }); + + let handler = Lsps2BuyHandler::new(fake, secret); + let (_policy, buy) = params_with_promise(&secret); + + // No payment_size_msat => "no-MPP+var-invoice" mode. + let req = Lsps2BuyRequest { + opening_fee_params: buy, + payment_size_msat: None, + } + .into_request(Some("ok-var".into())); + let payload = create_wrapped_request(&req); + + let out = handler.handle(&payload).await.unwrap(); + let resp: ResponseObject = serde_json::from_slice(&out).unwrap(); + assert!(resp.into_inner().is_ok()); + } + + #[tokio::test] + async fn buy_rejects_invalid_promise_or_past_valid_until_with_201() { + let secret = [3u8; 32]; + let handler = Lsps2BuyHandler::new(FakeCln::default(), secret); + + // Case A: wrong promise (derive with different secret) + let (_policy_wrong, mut buy_wrong) = params_with_promise(&[9u8; 32]); + buy_wrong.valid_until = Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(); // future, so only promise is wrong + let req_wrong = Lsps2BuyRequest { + opening_fee_params: buy_wrong, + payment_size_msat: Some(Msat(2_000_000)), + } + .into_request(Some("bad-promise".into())); + let err1 = handler + .handle(&create_wrapped_request(&req_wrong)) + .await + .unwrap_err(); + assert_eq!(err1.code, 201); + + // Case B: past valid_until + let (_policy, mut buy_past) = params_with_promise(&secret); + buy_past.valid_until = Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(); // past + let req_past = Lsps2BuyRequest { + opening_fee_params: buy_past, + payment_size_msat: Some(Msat(2_000_000)), + } + .into_request(Some("past-valid".into())); + let err2 = handler + .handle(&create_wrapped_request(&req_past)) + .await + .unwrap_err(); + assert_eq!(err2.code, 201); + } + + #[tokio::test] + async fn buy_rejects_when_opening_fee_ge_payment_size_with_202() { + let secret = [4u8; 32]; + let handler = Lsps2BuyHandler::new(FakeCln::default(), secret); + + // Make min_fee already >= payment_size to trigger 202 + let policy = PolicyOpeningFeeParams { + min_fee_msat: Msat(10_000), + proportional: Ppm(0), // no extra percentage + valid_until: Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(), + min_lifetime: 1000, + max_client_to_self_delay: 42, + min_payment_size_msat: Msat(1), + max_payment_size_msat: Msat(u64::MAX / 2), + }; + let hex = policy.get_hmac_hex(&secret); + let promise: Promise = hex.try_into().unwrap(); + let buy = OpeningFeeParams { + min_fee_msat: policy.min_fee_msat, + proportional: policy.proportional, + valid_until: policy.valid_until, + min_lifetime: policy.min_lifetime, + max_client_to_self_delay: policy.max_client_to_self_delay, + min_payment_size_msat: policy.min_payment_size_msat, + max_payment_size_msat: policy.max_payment_size_msat, + promise, + }; + + let req = Lsps2BuyRequest { + opening_fee_params: buy, + payment_size_msat: Some(Msat(9_999)), // strictly less than min_fee => opening_fee >= payment_size + } + .into_request(Some("too-small".into())); + + let err = handler + .handle(&create_wrapped_request(&req)) + .await + .unwrap_err(); + assert_eq!(err.code, 202); + } + + #[tokio::test] + async fn buy_rejects_on_fee_overflow_with_203() { + let secret = [5u8; 32]; + let handler = Lsps2BuyHandler::new(FakeCln::default(), secret); + + // Choose values likely to overflow if multiplication isn't checked: + // opening_fee = min_fee + payment_size * proportional / 1_000_000 + let policy = PolicyOpeningFeeParams { + min_fee_msat: Msat(u64::MAX / 2), + proportional: Ppm(u32::MAX), // 4_294_967_295 ppm + valid_until: Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(), + min_lifetime: 1000, + max_client_to_self_delay: 42, + min_payment_size_msat: Msat(1), + max_payment_size_msat: Msat(u64::MAX), + }; + let hex = policy.get_hmac_hex(&secret); + let promise: Promise = hex.try_into().unwrap(); + let buy = OpeningFeeParams { + min_fee_msat: policy.min_fee_msat, + proportional: policy.proportional, + valid_until: policy.valid_until, + min_lifetime: policy.min_lifetime, + max_client_to_self_delay: policy.max_client_to_self_delay, + min_payment_size_msat: policy.min_payment_size_msat, + max_payment_size_msat: policy.max_payment_size_msat, + promise, + }; + + let req = Lsps2BuyRequest { + opening_fee_params: buy, + payment_size_msat: Some(Msat(u64::MAX / 2)), + } + .into_request(Some("overflow".into())); + + let err = handler + .handle(&create_wrapped_request(&req)) + .await + .unwrap_err(); + assert_eq!(err.code, 203); + } + #[tokio::test] + async fn test_htlc_no_scid_continues() { + let fake = FakeCln::default(); + let handler = HtlcAcceptedHookHandler::new(fake, 1000); + + // HTLC with no short_channel_id (final destination) + let req = create_test_htlc_request(None, 1000000); + + let result = handler.handle(req).await.unwrap(); + assert_eq!(result.result, HtlcAcceptedResult::Continue); + } + + #[tokio::test] + async fn test_htlc_unknown_scid_continues() { + let fake = FakeCln::default(); + let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000); + let scid = ShortChannelId::from(123456789u64); + + // Return empty datastore response (SCID not found) + *fake.cln_listdatastore_response.lock().unwrap() = + Some(ListdatastoreResponse { datastore: vec![] }); + + let req = create_test_htlc_request(Some(scid), 1000000); + + let result = handler.handle(req).await.unwrap(); + assert_eq!(result.result, HtlcAcceptedResult::Continue); + } + + #[tokio::test] + async fn test_htlc_expired_fee_menu_fails() { + let fake = FakeCln::default(); + let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000); + let peer_id = create_peer_id(); + let scid = ShortChannelId::from(123456789u64); + + // Create datastore entry with expired fee menu + let mut ds_entry = create_test_datastore_entry(peer_id, None); + ds_entry.opening_fee_params.valid_until = + Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(); // expired + + let ds_entry_json = serde_json::to_string(&ds_entry).unwrap(); + *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse { + datastore: vec![ListdatastoreDatastore { + key: scid_ds_key(scid), + generation: Some(1), + hex: None, + string: Some(ds_entry_json), + }], + }); + + // Mock successful deletion + *fake.cln_deldatastore_response.lock().unwrap() = Some(DeldatastoreResponse { + generation: Some(1), + hex: None, + string: None, + key: scid_ds_key(scid), + }); + + let req = create_test_htlc_request(Some(scid), 1000000); + + let result = handler.handle(req).await.unwrap(); + assert_eq!(result.result, HtlcAcceptedResult::Fail); + assert_eq!( + result.failure_message.unwrap(), + TEMPORARY_CHANNEL_FAILURE.to_string() + ); + } + + #[tokio::test] + async fn test_htlc_amount_too_low_fails() { + let fake = FakeCln::default(); + let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000); + let peer_id = create_peer_id(); + let scid = ShortChannelId::from(123456789u64); + + let ds_entry = create_test_datastore_entry(peer_id, None); + let ds_entry_json = serde_json::to_string(&ds_entry).unwrap(); + + *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse { + datastore: vec![ListdatastoreDatastore { + key: scid_ds_key(scid), + generation: Some(1), + hex: None, + string: Some(ds_entry_json), + }], + }); + + // HTLC amount below minimum + let req = create_test_htlc_request(Some(scid), 100); + + let result = handler.handle(req).await.unwrap(); + assert_eq!(result.result, HtlcAcceptedResult::Fail); + assert_eq!( + result.failure_message.unwrap(), + UNKNOWN_NEXT_PEER.to_string() + ); + } + + #[tokio::test] + async fn test_htlc_amount_too_high_fails() { + let fake = FakeCln::default(); + let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000); + let peer_id = create_peer_id(); + let scid = ShortChannelId::from(123456789u64); + + let ds_entry = create_test_datastore_entry(peer_id, None); + let ds_entry_json = serde_json::to_string(&ds_entry).unwrap(); + + *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse { + datastore: vec![ListdatastoreDatastore { + key: scid_ds_key(scid), + generation: Some(1), + hex: None, + string: Some(ds_entry_json), + }], + }); + + // HTLC amount above maximum + let req = create_test_htlc_request(Some(scid), 200_000_000); + + let result = handler.handle(req).await.unwrap(); + assert_eq!(result.result, HtlcAcceptedResult::Fail); + assert_eq!( + result.failure_message.unwrap(), + UNKNOWN_NEXT_PEER.to_string() + ); + } + + #[tokio::test] + async fn test_htlc_amount_doesnt_cover_fee_fails() { + let fake = FakeCln::default(); + let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000); + let peer_id = create_peer_id(); + let scid = ShortChannelId::from(123456789u64); + + let ds_entry = create_test_datastore_entry(peer_id, None); + let ds_entry_json = serde_json::to_string(&ds_entry).unwrap(); + + *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse { + datastore: vec![ListdatastoreDatastore { + key: scid_ds_key(scid), + generation: Some(1), + hex: None, + string: Some(ds_entry_json), + }], + }); + + // HTLC amount just barely covers minimum fee but not minimum HTLC + let req = create_test_htlc_request(Some(scid), 2500); // min_fee is 2000, htlc_minimum is 1000 + + let result = handler.handle(req).await.unwrap(); + assert_eq!(result.result, HtlcAcceptedResult::Fail); + assert_eq!( + result.failure_message.unwrap(), + UNKNOWN_NEXT_PEER.to_string() + ); + } + + #[tokio::test] + async fn test_htlc_channel_capacity_request_fails() { + let fake = FakeCln::default(); + let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000); + let peer_id = create_peer_id(); + let scid = ShortChannelId::from(123456789u64); + + let ds_entry = create_test_datastore_entry(peer_id, None); + let ds_entry_json = serde_json::to_string(&ds_entry).unwrap(); + + *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse { + datastore: vec![ListdatastoreDatastore { + key: scid_ds_key(scid), + generation: Some(1), + hex: None, + string: Some(ds_entry_json), + }], + }); + + *fake.lsps2_getchannelcapacity_error.lock().unwrap() = Some(ClnRpcError { + code: Some(-1), + message: "capacity check failed".to_string(), + data: None, + }); + + let req = create_test_htlc_request(Some(scid), 10_000_000); + + let result = handler.handle(req).await.unwrap(); + assert_eq!(result.result, HtlcAcceptedResult::Fail); + assert_eq!( + result.failure_message.unwrap(), + UNKNOWN_NEXT_PEER.to_string() + ); + } + + #[tokio::test] + async fn test_htlc_policy_denies_channel() { + let fake = FakeCln::default(); + let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000); + let peer_id = create_peer_id(); + let scid = ShortChannelId::from(123456789u64); + + let ds_entry = create_test_datastore_entry(peer_id, None); + let ds_entry_json = serde_json::to_string(&ds_entry).unwrap(); + + *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse { + datastore: vec![ListdatastoreDatastore { + key: scid_ds_key(scid), + generation: Some(1), + hex: None, + string: Some(ds_entry_json), + }], + }); + + // Policy response with no channel capacity (denied) + *fake.lsps2_getchannelcapacity_response.lock().unwrap() = + Some(Lsps2PolicyGetChannelCapacityResponse { + channel_capacity_msat: None, + }); + + let req = create_test_htlc_request(Some(scid), 10_000_000); + + let result = handler.handle(req).await.unwrap(); + assert_eq!(result.result, HtlcAcceptedResult::Fail); + assert_eq!( + result.failure_message.unwrap(), + UNKNOWN_NEXT_PEER.to_string() + ); + } + + #[tokio::test] + async fn test_htlc_fund_channel_fails() { + let fake = FakeCln::default(); + let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000); + let peer_id = create_peer_id(); + let scid = ShortChannelId::from(123456789u64); + + let ds_entry = create_test_datastore_entry(peer_id, None); + let ds_entry_json = serde_json::to_string(&ds_entry).unwrap(); + + *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse { + datastore: vec![ListdatastoreDatastore { + key: scid_ds_key(scid), + generation: Some(1), + hex: None, + string: Some(ds_entry_json), + }], + }); + + *fake.lsps2_getchannelcapacity_response.lock().unwrap() = + Some(Lsps2PolicyGetChannelCapacityResponse { + channel_capacity_msat: Some(50_000_000), + }); + + *fake.cln_fundchannel_error.lock().unwrap() = Some(ClnRpcError { + code: Some(-1), + message: "insufficient funds".to_string(), + data: None, + }); + + let req = create_test_htlc_request(Some(scid), 10_000_000); + + let result = handler.handle(req).await.unwrap(); + assert_eq!(result.result, HtlcAcceptedResult::Fail); + assert_eq!( + result.failure_message.unwrap(), + UNKNOWN_NEXT_PEER.to_string() + ); + } + + #[tokio::test] + async fn test_htlc_successful_flow() { + let fake = FakeCln::default(); + let handler = HtlcAcceptedHookHandler { + api: fake.clone(), + htlc_minimum_msat: 1000, + backoff_listpeerchannels: Duration::from_millis(10), + }; + let peer_id = create_peer_id(); + let scid = ShortChannelId::from(123456789u64); + + let ds_entry = create_test_datastore_entry(peer_id, None); + let ds_entry_json = serde_json::to_string(&ds_entry).unwrap(); + + *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse { + datastore: vec![ListdatastoreDatastore { + key: scid_ds_key(scid), + generation: Some(1), + hex: None, + string: Some(ds_entry_json), + }], + }); + + *fake.lsps2_getchannelcapacity_response.lock().unwrap() = + Some(Lsps2PolicyGetChannelCapacityResponse { + channel_capacity_msat: Some(50_000_000), + }); + + *fake.cln_fundchannel_response.lock().unwrap() = Some(FundchannelResponse { + channel_id: *Sha256::from_bytes_ref(&[1u8; 32]), + outnum: 0, + txid: String::default(), + channel_type: None, + close_to: None, + mindepth: None, + tx: String::default(), + }); + + let req = create_test_htlc_request(Some(scid), 10_000_000); + + let result = handler.handle(req).await.unwrap(); + assert_eq!(result.result, HtlcAcceptedResult::Continue); + + assert!(result.payload.is_some()); + assert!(result.extra_tlvs.is_some()); + assert!(result.forward_to.is_some()); + + // The payload should have the deducted amount + let payload_bytes = result.payload.unwrap(); + let payload_tlv = TlvStream::from_bytes(&payload_bytes).unwrap(); + + // Should contain forward amount. + assert!(payload_tlv.get(TLV_FORWARD_AMT).is_some()); + } + + #[tokio::test] + async fn test_htlc_mpp_not_implemented() { + let fake = FakeCln::default(); + let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000); + let peer_id = create_peer_id(); + let scid = ShortChannelId::from(123456789u64); + + // Create entry with expected_payment_size (MPP mode) + let mut ds_entry = create_test_datastore_entry(peer_id, None); + ds_entry.expected_payment_size = Some(Msat::from_msat(1000000)); + let ds_entry_json = serde_json::to_string(&ds_entry).unwrap(); + + *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse { + datastore: vec![ListdatastoreDatastore { + key: scid_ds_key(scid), + generation: Some(1), + hex: None, + string: Some(ds_entry_json), + }], + }); + + let req = create_test_htlc_request(Some(scid), 10_000_000); + + let result = handler.handle(req).await.unwrap(); + assert_eq!(result.result, HtlcAcceptedResult::Fail); + assert_eq!( + result.failure_message.unwrap(), + UNKNOWN_NEXT_PEER.to_string() + ); + } +} diff --git a/plugins/lsps-plugin/src/lsps2/mod.rs b/plugins/lsps-plugin/src/lsps2/mod.rs new file mode 100644 index 000000000000..60d8ebf5f101 --- /dev/null +++ b/plugins/lsps-plugin/src/lsps2/mod.rs @@ -0,0 +1,19 @@ +use cln_plugin::options; + +pub mod cln; +pub mod handler; +pub mod model; + +pub const OPTION_ENABLED: options::FlagConfigOption = options::ConfigOption::new_flag( + "experimental-lsps2-service", + "Enables lsps2 for the LSP service", +); + +pub const OPTION_PROMISE_SECRET: options::StringConfigOption = + options::ConfigOption::new_str_no_default( + "experimental-lsps2-promise-secret", + "A 64-character hex string that is the secret for promises", + ); + +pub const DS_MAIN_KEY: &'static str = "lsps"; +pub const DS_SUB_KEY: &'static str = "lsps2"; diff --git a/plugins/lsps-plugin/src/lsps2/model.rs b/plugins/lsps-plugin/src/lsps2/model.rs new file mode 100644 index 000000000000..bcf8de079d05 --- /dev/null +++ b/plugins/lsps-plugin/src/lsps2/model.rs @@ -0,0 +1,657 @@ +use crate::{ + jsonrpc::{JsonRpcRequest, RpcError}, + lsps0::primitives::{DateTime, Msat, Ppm, ShortChannelId}, +}; +use bitcoin::hashes::{sha256, Hash, HashEngine, Hmac, HmacEngine}; +use chrono::Utc; +use log::debug; +use serde::{Deserialize, Serialize}; + +pub mod failure_codes { + pub const TEMPORARY_CHANNEL_FAILURE: &'static str = "1007"; + pub const UNKNOWN_NEXT_PEER: &'static str = "4010"; +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Error { + InvalidOpeningFeeParams, + PaymentSizeTooSmall, + PaymentSizeTooLarge, + ClientRejected, +} + +impl core::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let err_str = match self { + Error::InvalidOpeningFeeParams => "invalid opening fee params", + Error::PaymentSizeTooSmall => "payment size too small", + Error::PaymentSizeTooLarge => "payment size too large", + Error::ClientRejected => "client rejected", + }; + write!(f, "{}", &err_str) + } +} + +impl From for RpcError { + fn from(value: Error) -> Self { + match value { + Error::InvalidOpeningFeeParams => RpcError { + code: 201, + message: "invalid opening fee params".to_string(), + data: None, + }, + Error::PaymentSizeTooSmall => RpcError { + code: 202, + message: "payment size too small".to_string(), + data: None, + }, + Error::PaymentSizeTooLarge => RpcError { + code: 203, + message: "payment size too large".to_string(), + data: None, + }, + Error::ClientRejected => RpcError { + code: 001, + message: "client rejected".to_string(), + data: None, + }, + } + } +} + +impl core::error::Error for Error {} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Lsps2GetInfoRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, +} + +impl JsonRpcRequest for Lsps2GetInfoRequest { + const METHOD: &'static str = "lsps2.get_info"; +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Lsps2GetInfoResponse { + pub opening_fee_params_menu: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PromiseError { + TooLong { length: usize, max: usize }, +} + +impl core::fmt::Display for PromiseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PromiseError::TooLong { length, max } => { + write!( + f, + "promise string is too long: {} bytes (max allowed {})", + length, max + ) + } + } + } +} + +impl core::error::Error for PromiseError {} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "String")] +pub struct Promise(String); + +impl Promise { + pub const MAX_BYTES: usize = 512; +} + +impl TryFrom for Promise { + type Error = PromiseError; + + fn try_from(s: String) -> Result { + let len = s.len(); + if len <= Promise::MAX_BYTES { + Ok(Promise(s)) + } else { + Err(PromiseError::TooLong { + length: len, + max: Promise::MAX_BYTES, + }) + } + } +} + +impl TryFrom<&str> for Promise { + type Error = PromiseError; + + fn try_from(s: &str) -> Result { + let len = s.len(); + if len <= Promise::MAX_BYTES { + Ok(Promise(s.to_owned())) + } else { + Err(PromiseError::TooLong { + length: len, + max: Promise::MAX_BYTES, + }) + } + } +} + +impl core::fmt::Display for Promise { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Represents a set of parameters for calculating the opening fee for a JIT +/// channel. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] // LSPS2 requires the client to fail if a field is unrecognized. +pub struct OpeningFeeParams { + pub min_fee_msat: Msat, + pub proportional: Ppm, + pub valid_until: DateTime, + pub min_lifetime: u32, + pub max_client_to_self_delay: u32, + pub min_payment_size_msat: Msat, + pub max_payment_size_msat: Msat, + pub promise: Promise, // Max 512 bytes +} + +impl OpeningFeeParams { + pub fn validate( + &self, + secret: &[u8], + payment_size_msat: Option, + receivable: Option, + ) -> Result<(), Error> { + // LSPs MUST check that the opening_fee_params.promise does in fact + // prove that it previously promised the specified opening_fee_params. + let mut hmac = HmacEngine::::new(&secret); + hmac.input(&self.min_fee_msat.msat().to_be_bytes()); + hmac.input(&self.proportional.ppm().to_be_bytes()); + hmac.input(self.valid_until.to_rfc3339().as_bytes()); + hmac.input(&self.min_lifetime.to_be_bytes()); + hmac.input(&self.max_client_to_self_delay.to_be_bytes()); + hmac.input(&self.min_payment_size_msat.msat().to_be_bytes()); + hmac.input(&self.max_payment_size_msat.msat().to_be_bytes()); + let promise: String = Hmac::from_engine(hmac) + .to_byte_array() + .iter() + .map(|b| format!("{:02x}", b)) + .collect(); + if self.promise != Promise(promise) { + return Err(Error::InvalidOpeningFeeParams); + } + + // LSPs MUST check that the opening_fee_params.valid_until is not a past + // datetime. + let now = Utc::now(); + if now > self.valid_until { + debug!("Got invalid opening fee params: timeout, {:?}", self); + return Err(Error::InvalidOpeningFeeParams); + } + + // If the payment_size_msat is specified in the request, the LSP: + // - MUST compute the opening_fee and check that the computation did + // not hit an overflow failure. + // - MUST check that the resulting opening_fee is strictly less than + // the payment_size_msat. + // - SHOULD check that it has sufficient incoming liquidity from the + // public network to be able to receive at least + // payment_size_msat. + if let Some(payment_size_msat) = payment_size_msat { + let opening_fee = compute_opening_fee( + payment_size_msat.msat(), + self.min_fee_msat.msat(), + self.proportional.ppm() as u64, + ) + .ok_or(Error::PaymentSizeTooLarge)?; + if opening_fee >= payment_size_msat.msat() { + return Err(Error::PaymentSizeTooSmall); + } + + if let Some(rec) = receivable { + if opening_fee >= rec.msat() { + return Err(Error::PaymentSizeTooLarge); + } + } + } + + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Lsps2BuyRequest { + pub opening_fee_params: OpeningFeeParams, + #[serde(skip_serializing_if = "Option::is_none")] + pub payment_size_msat: Option, +} + +impl JsonRpcRequest for Lsps2BuyRequest { + const METHOD: &'static str = "lsps2.buy"; +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Lsps2BuyResponse { + pub jit_channel_scid: ShortChannelId, + pub lsp_cltv_expiry_delta: u32, + // is an optional Boolean. If not specified, it defaults to false. If + // specified and true, the client MUST trust the LSP to actually create and + // confirm a valid channel funding transaction. + #[serde(default)] + pub client_trusts_lsp: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Lsps2PolicyGetInfoRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, +} + +impl From for Lsps2PolicyGetInfoRequest { + fn from(value: Lsps2GetInfoRequest) -> Self { + Self { token: value.token } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Lsps2PolicyGetInfoResponse { + pub policy_opening_fee_params_menu: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Lsps2PolicyGetChannelCapacityRequest { + pub opening_fee_params: OpeningFeeParams, + pub init_payment_size: Msat, + pub scid: ShortChannelId, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Lsps2PolicyGetChannelCapacityResponse { + pub channel_capacity_msat: Option, +} + +/// An internal representation of a policy of parameters for calculating the +/// opening fee for a JIT channel. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PolicyOpeningFeeParams { + pub min_fee_msat: Msat, + pub proportional: Ppm, + pub valid_until: DateTime, + pub min_lifetime: u32, + pub max_client_to_self_delay: u32, + pub min_payment_size_msat: Msat, + pub max_payment_size_msat: Msat, +} + +impl PolicyOpeningFeeParams { + pub fn get_hmac_hex(&self, secret: &[u8]) -> String { + let mut hmac = HmacEngine::::new(&secret); + hmac.input(&self.min_fee_msat.msat().to_be_bytes()); + hmac.input(&self.proportional.ppm().to_be_bytes()); + hmac.input(self.valid_until.to_rfc3339().as_bytes()); + hmac.input(&self.min_lifetime.to_be_bytes()); + hmac.input(&self.max_client_to_self_delay.to_be_bytes()); + hmac.input(&self.min_payment_size_msat.msat().to_be_bytes()); + hmac.input(&self.max_payment_size_msat.msat().to_be_bytes()); + let promise = Hmac::from_engine(hmac) + .to_byte_array() + .iter() + .map(|b| format!("{:02x}", b)) + .collect(); + promise + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DatastoreEntry { + pub peer_id: cln_rpc::primitives::PublicKey, + pub opening_fee_params: OpeningFeeParams, + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_payment_size: Option, +} + +/// Computes the opening fee in millisatoshis as described in LSPS2. +/// Returns None if an arithmetic overflow occurs during calculation. +/// +/// # Arguments +/// * `payment_size_msat` - The size of the payment for which the channel is +/// being opened. +/// * `opening_fee_min_fee_msat` - The minimum fee to be paid by the client to +/// the LSP +/// * `opening_fee_proportional` - The proportional fee charged by the LSP +pub fn compute_opening_fee( + payment_size_msat: u64, + opening_fee_min_fee_msat: u64, + opening_fee_proportional: u64, +) -> Option { + payment_size_msat + .checked_mul(opening_fee_proportional) + .and_then(|f| f.checked_add(999999)) + .and_then(|f| f.checked_div(1000000)) + .map(|f| std::cmp::max(f, opening_fee_min_fee_msat)) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + + use super::*; + + // Helper struct for testing Serde + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestData { + label: String, + value: Promise, + } + + // Helper function to create valid opening fee params + fn create_valid_opening_fee_params(secret: &[u8]) -> OpeningFeeParams { + let params = OpeningFeeParams { + min_fee_msat: Msat::from_msat(1000), + proportional: Ppm::from_ppm(1000), // 0.1% + valid_until: Utc::now() + Duration::hours(1), // Valid for 1 hour + min_lifetime: 144, // blocks + max_client_to_self_delay: 2016, // blocks + min_payment_size_msat: Msat::from_msat(1000), // 1 Sat + max_payment_size_msat: Msat::from_msat(100_000_000_000), // 1 BTC + promise: Promise("placeholder".to_string()), // Will be replaced + }; + + // Compute the correct promise + let mut hmac = HmacEngine::::new(secret); + hmac.input(¶ms.min_fee_msat.msat().to_be_bytes()); + hmac.input(¶ms.proportional.ppm().to_be_bytes()); + hmac.input(params.valid_until.to_rfc3339().as_bytes()); + hmac.input(¶ms.min_lifetime.to_be_bytes()); + hmac.input(¶ms.max_client_to_self_delay.to_be_bytes()); + hmac.input(¶ms.min_payment_size_msat.msat().to_be_bytes()); + hmac.input(¶ms.max_payment_size_msat.msat().to_be_bytes()); + let promise: String = Hmac::from_engine(hmac) + .to_byte_array() + .iter() + .map(|b| format!("{:02x}", b)) + .collect(); + + OpeningFeeParams { + promise: Promise(promise), + ..params + } + } + + #[test] + fn test_serde_promise_ok() { + let json = r#"{"label": "short", "value": "This is valid"}"#; + let result = serde_json::from_str::(json); + assert!(result.is_ok()); + let data = result.unwrap(); + assert_eq!(data.value.0, "This is valid"); + } + + #[test] + fn test_serde_promise_too_long() { + let long_value = "a".repeat(513); // Exceeds 512 bytes + let json = format!(r#"{{"label": "long", "value": "{}"}}"#, long_value); + let result = serde_json::from_str::(&json); + assert!(result.is_err()); + // Check the error message relates to our PromiseError + assert!(result + .unwrap_err() + .to_string() + .contains("promise string is too long")); + } + + #[test] + fn test_serde_promise_wrong_type() { + // Input JSON has a number where a string is expected for 'value' + let json = r#"{"label": "wrong_type", "value": 123}"#; + let result = serde_json::from_str::(json); + assert!(result.is_err()); + // This error occurs when Serde tries to deserialize 123 as the String + // required by `try_from = "String"`. + assert!(result + .unwrap_err() + .to_string() + .contains("invalid type: integer")); + } + + #[test] + fn test_validate_success_minimal() { + let secret = b"test_secret_key_32_bytes_long___"; + let params = create_valid_opening_fee_params(secret); + + let result = params.validate(secret, None, None); + assert!( + result.is_ok(), + "Valid params with no payment_size should succeed" + ); + } + + #[test] + fn test_validate_success_with_payment_size() { + let secret = b"test_secret_key_32_bytes_long___"; + let params = create_valid_opening_fee_params(secret); + let payment_size = Msat::from_msat(10_000_000); // 10M msat + + let result = params.validate(secret, Some(payment_size), None); + assert!( + result.is_ok(), + "Valid params with valid payment_size should succeed" + ); + } + + #[test] + fn test_validate_success_with_payment_size_and_receivable() { + let secret = b"test_secret_key_32_bytes_long___"; + let params = create_valid_opening_fee_params(secret); + let payment_size = Msat::from_msat(10_000_000); // 10M msat + let receivable = Msat::from_msat(50_000_000); // 50M msat + + let result = params.validate(secret, Some(payment_size), Some(receivable)); + assert!( + result.is_ok(), + "Valid params with payment_size and receivable should succeed" + ); + } + + #[test] + fn test_validate_invalid_promise() { + let secret = b"test_secret_key_32_bytes_long___"; + let mut params = create_valid_opening_fee_params(secret); + params.min_fee_msat = Msat(10); + + let result = params.validate(secret, None, None); + assert!( + matches!(result, Err(Error::InvalidOpeningFeeParams)), + "Invalid promise should fail validation" + ); + } + + #[test] + fn test_validate_wrong_secret() { + let secret1 = b"test_secret_key_32_bytes_long___"; + let secret2 = b"different_secret_key_32_bytes___"; + let params = create_valid_opening_fee_params(secret1); + + let result = params.validate(secret2, None, None); + assert!( + matches!(result, Err(Error::InvalidOpeningFeeParams)), + "Wrong secret should fail validation" + ); + } + + #[test] + fn test_validate_expired_timestamp() { + let secret = b"test_secret_key_32_bytes_long___"; + let mut params = create_valid_opening_fee_params(secret); + params.valid_until = Utc::now() - Duration::hours(1); // Expired 1 hour ago + + // Recompute promise with expired timestamp + let mut hmac = HmacEngine::::new(secret); + hmac.input(¶ms.min_fee_msat.msat().to_be_bytes()); + hmac.input(¶ms.proportional.ppm().to_be_bytes()); + hmac.input(params.valid_until.to_rfc3339().as_bytes()); + hmac.input(¶ms.min_lifetime.to_be_bytes()); + hmac.input(¶ms.max_client_to_self_delay.to_be_bytes()); + hmac.input(¶ms.min_payment_size_msat.msat().to_be_bytes()); + hmac.input(¶ms.max_payment_size_msat.msat().to_be_bytes()); + let promise: String = Hmac::from_engine(hmac) + .to_byte_array() + .iter() + .map(|b| format!("{:02x}", b)) + .collect(); + params.promise = Promise(promise); + + let result = params.validate(secret, None, None); + assert!( + matches!(result, Err(Error::InvalidOpeningFeeParams)), + "Expired timestamp should fail validation" + ); + } + + #[test] + fn test_validate_payment_size_overflow() { + let secret = b"test_secret_key_32_bytes_long___"; + let mut params = create_valid_opening_fee_params(secret); + // Set proportional fee high enough to cause overflow + params.proportional = Ppm::from_ppm(u32::MAX); + + // Recompute promise + let mut hmac = HmacEngine::::new(secret); + hmac.input(¶ms.min_fee_msat.msat().to_be_bytes()); + hmac.input(¶ms.proportional.ppm().to_be_bytes()); + hmac.input(params.valid_until.to_rfc3339().as_bytes()); + hmac.input(¶ms.min_lifetime.to_be_bytes()); + hmac.input(¶ms.max_client_to_self_delay.to_be_bytes()); + hmac.input(¶ms.min_payment_size_msat.msat().to_be_bytes()); + hmac.input(¶ms.max_payment_size_msat.msat().to_be_bytes()); + let promise: String = Hmac::from_engine(hmac) + .to_byte_array() + .iter() + .map(|b| format!("{:02x}", b)) + .collect(); + params.promise = Promise(promise); + + let payment_size = Msat::from_msat(u64::MAX); + let result = params.validate(secret, Some(payment_size), None); + assert!( + matches!(result, Err(Error::PaymentSizeTooLarge)), + "Overflow in fee calculation should return PaymentSizeTooLarge" + ); + } + + #[test] + fn test_validate_opening_fee_equals_payment_size() { + let secret = b"test_secret_key_32_bytes_long___"; + let params = create_valid_opening_fee_params(secret); + + // Find a payment size where opening fee equals payment size + // With min_fee_msat = 1000 and proportional = 1000 (0.1%) + // The opening fee will be max(1000, payment * 1000 / 1_000_000) + // So for small payments, fee = 1000 + let payment_size = Msat::from_msat(1000); // Same as min_fee_msat + + let result = params.validate(secret, Some(payment_size), None); + assert!( + matches!(result, Err(Error::PaymentSizeTooSmall)), + "Opening fee equal to payment size should fail" + ); + } + + #[test] + fn test_validate_opening_fee_greater_than_payment_size() { + let secret = b"test_secret_key_32_bytes_long___"; + let params = create_valid_opening_fee_params(secret); + + // Payment size smaller than minimum fee + let payment_size = Msat::from_msat(500); // Less than min_fee_msat (1000) + + let result = params.validate(secret, Some(payment_size), None); + assert!( + matches!(result, Err(Error::PaymentSizeTooSmall)), + "Opening fee greater than payment size should fail" + ); + } + + #[test] + fn test_validate_opening_fee_equals_receivable() { + let secret = b"test_secret_key_32_bytes_long___"; + let params = create_valid_opening_fee_params(secret); + + let payment_size = Msat::from_msat(10_000_000); // 10M msat + let receivable = Msat::from_msat(1000); // Same as min_fee_msat + + let result = params.validate(secret, Some(payment_size), Some(receivable)); + assert!( + matches!(result, Err(Error::PaymentSizeTooLarge)), + "Opening fee equal to receivable should fail" + ); + } + + #[test] + fn test_validate_opening_fee_greater_than_receivable() { + let secret = b"test_secret_key_32_bytes_long___"; + let params = create_valid_opening_fee_params(secret); + + let payment_size = Msat::from_msat(10_000_000); // 10M msat + let receivable = Msat::from_msat(500); // Less than min_fee_msat (1000) + + let result = params.validate(secret, Some(payment_size), Some(receivable)); + assert!( + matches!(result, Err(Error::PaymentSizeTooLarge)), + "Opening fee greater than receivable should fail" + ); + } + + #[test] + fn test_validate_large_payment_proportional_fee() { + let secret = b"test_secret_key_32_bytes_long___"; + let params = create_valid_opening_fee_params(secret); + + // Large payment where proportional fee dominates + // Opening fee = max(1000, 1_000_000_000 * 1000 / 1_000_000) = max(1000, 1_000_000) = 1_000_000 + let payment_size = Msat::from_msat(1_000_000_000); + + let result = params.validate(secret, Some(payment_size), None); + assert!( + result.is_ok(), + "Large payment with proportional fee should succeed" + ); + } + + #[test] + fn test_validate_max_values() { + let secret = b"test_secret_key_32_bytes_long___"; + let mut params = OpeningFeeParams { + min_fee_msat: Msat::from_msat(u64::MAX / 1000), // Avoid overflow + proportional: Ppm::from_ppm(100), // Small proportional to avoid overflow + valid_until: Utc::now() + Duration::hours(1), + min_lifetime: u32::MAX, + max_client_to_self_delay: u32::MAX, + min_payment_size_msat: Msat::from_msat(1), + max_payment_size_msat: Msat::from_msat(u64::MAX), + promise: Promise("placeholder".to_string()), + }; + + // Compute promise + let mut hmac = HmacEngine::::new(secret); + hmac.input(¶ms.min_fee_msat.msat().to_be_bytes()); + hmac.input(¶ms.proportional.ppm().to_be_bytes()); + hmac.input(params.valid_until.to_rfc3339().as_bytes()); + hmac.input(¶ms.min_lifetime.to_be_bytes()); + hmac.input(¶ms.max_client_to_self_delay.to_be_bytes()); + hmac.input(¶ms.min_payment_size_msat.msat().to_be_bytes()); + hmac.input(¶ms.max_payment_size_msat.msat().to_be_bytes()); + let promise: String = Hmac::from_engine(hmac) + .to_byte_array() + .iter() + .map(|b| format!("{:02x}", b)) + .collect(); + params.promise = Promise(promise); + + let result = params.validate(secret, None, None); + assert!(result.is_ok(), "Maximum safe values should be valid"); + } +} diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs index 8078f00430ab..60607754b36e 100644 --- a/plugins/lsps-plugin/src/service.rs +++ b/plugins/lsps-plugin/src/service.rs @@ -1,13 +1,17 @@ -use anyhow::anyhow; +use anyhow::{anyhow, bail}; use async_trait::async_trait; -use cln_lsps::jsonrpc::server::{JsonRpcResponseWriter, RequestHandler}; +use cln_lsps::jsonrpc::server::JsonRpcResponseWriter; +use cln_lsps::jsonrpc::TransportError; use cln_lsps::jsonrpc::{server::JsonRpcServer, JsonRpcRequest}; -use cln_lsps::jsonrpc::{JsonRpcResponse, RequestObject, RpcError, TransportError}; -use cln_lsps::lsps0; -use cln_lsps::lsps0::model::{Lsps0listProtocolsRequest, Lsps0listProtocolsResponse}; +use cln_lsps::lsps0::handler::Lsps0ListProtocolsHandler; +use cln_lsps::lsps0::model::Lsps0listProtocolsRequest; use cln_lsps::lsps0::transport::{self, CustomMsg}; -use cln_plugin::options::ConfigOption; -use cln_plugin::{options, Plugin}; +use cln_lsps::lsps2::cln::{HtlcAcceptedRequest, HtlcAcceptedResponse}; +use cln_lsps::lsps2::handler::{ClnApiRpc, HtlcAcceptedHookHandler}; +use cln_lsps::lsps2::model::{Lsps2BuyRequest, Lsps2GetInfoRequest}; +use cln_lsps::util::wrap_payload_with_peer_id; +use cln_lsps::{lsps0, lsps2, util, LSP_FEATURE_BIT}; +use cln_plugin::Plugin; use cln_rpc::notifications::CustomMsgNotification; use cln_rpc::primitives::PublicKey; use log::debug; @@ -15,51 +19,122 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; -/// An option to enable this service. It defaults to `false` as we don't want a -/// node to be an LSP per default. -/// If a user want's to run an LSP service on their node this has to explicitly -/// set to true. We keep this as a dev option for now until it actually does -/// something. -const OPTION_ENABLED: options::DefaultBooleanConfigOption = ConfigOption::new_bool_with_default( - "dev-lsps-service", - false, - "Enables an LSPS service on the node.", -); - #[derive(Clone)] struct State { lsps_service: JsonRpcServer, + lsps2_enabled: bool, } #[tokio::main] async fn main() -> Result<(), anyhow::Error> { - let lsps_service = JsonRpcServer::builder() - .with_handler( - Lsps0listProtocolsRequest::METHOD.to_string(), - Arc::new(Lsps0ListProtocolsHandler), - ) - .build(); - let state = State { lsps_service }; - if let Some(plugin) = cln_plugin::Builder::new(tokio::io::stdin(), tokio::io::stdout()) - .option(OPTION_ENABLED) + .option(lsps2::OPTION_ENABLED) + .option(lsps2::OPTION_PROMISE_SECRET) + .featurebits( + cln_plugin::FeatureBitsKind::Node, + util::feature_bit_to_hex(LSP_FEATURE_BIT), + ) + .featurebits( + cln_plugin::FeatureBitsKind::Init, + util::feature_bit_to_hex(LSP_FEATURE_BIT), + ) .hook("custommsg", on_custommsg) + .hook("htlc_accepted", on_htlc_accepted) .configure() .await? { - if !plugin.option(&OPTION_ENABLED)? { + let rpc_path = + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file); + + if plugin.option(&lsps2::OPTION_ENABLED)? { + log::debug!("lsps2-service enabled"); + if let Some(secret_hex) = plugin.option(&lsps2::OPTION_PROMISE_SECRET)? { + let secret_hex = secret_hex.trim().to_lowercase(); + + let decoded_bytes = match hex::decode(&secret_hex) { + Ok(bytes) => bytes, + Err(_) => { + return plugin + .disable(&format!( + "Invalid hex string for promise secret: {}", + secret_hex + )) + .await; + } + }; + + let secret: [u8; 32] = match decoded_bytes.try_into() { + Ok(array) => array, + Err(vec) => { + return plugin + .disable(&format!( + "Promise secret must be exactly 32 bytes, got {}", + vec.len() + )) + .await; + } + }; + + let mut lsps_builder = JsonRpcServer::builder().with_handler( + Lsps0listProtocolsRequest::METHOD.to_string(), + Arc::new(Lsps0ListProtocolsHandler { + lsps2_enabled: plugin.option(&lsps2::OPTION_ENABLED)?, + }), + ); + + let cln_api_rpc = lsps2::handler::ClnApiRpc::new(rpc_path); + let getinfo_handler = + lsps2::handler::Lsps2GetInfoHandler::new(cln_api_rpc.clone(), secret); + let buy_handler = lsps2::handler::Lsps2BuyHandler::new(cln_api_rpc, secret); + lsps_builder = lsps_builder + .with_handler( + Lsps2GetInfoRequest::METHOD.to_string(), + Arc::new(getinfo_handler), + ) + .with_handler(Lsps2BuyRequest::METHOD.to_string(), Arc::new(buy_handler)); + + let lsps_service = lsps_builder.build(); + + let state = State { + lsps_service, + lsps2_enabled: true, + }; + let plugin = plugin.start(state).await?; + plugin.join().await + } else { + bail!("lsps2 enabled but no promise-secret set."); + } + } else { return plugin - .disable(&format!("`{}` not enabled", OPTION_ENABLED.name)) + .disable(&format!("`{}` not enabled", &lsps2::OPTION_ENABLED.name)) .await; } - - let plugin = plugin.start(state).await?; - plugin.join().await } else { Ok(()) } } +async fn on_htlc_accepted( + p: Plugin, + v: serde_json::Value, +) -> Result { + if !p.state().lsps2_enabled { + // just continue. + // Fixme: Add forward and extra tlvs from incoming. + let res = serde_json::to_value(&HtlcAcceptedResponse::continue_(None, None, None))?; + return Ok(res); + } + + let req: HtlcAcceptedRequest = serde_json::from_value(v)?; + let rpc_path = Path::new(&p.configuration().lightning_dir).join(&p.configuration().rpc_file); + let api = ClnApiRpc::new(rpc_path); + // Fixme: Use real htlc_minimum_amount. + let handler = HtlcAcceptedHookHandler::new(api, 1000); + let res = handler.handle(req).await?; + let res_val = serde_json::to_value(&res)?; + Ok(res_val) +} + async fn on_custommsg( p: Plugin, v: serde_json::Value, @@ -84,8 +159,12 @@ async fn on_custommsg( rpc_path: rpc_path.try_into()?, }; + // The payload inside CustomMsg is the actual JSON-RPC + // request/notification, we wrap it to attach the peer_id as well. + let payload = wrap_payload_with_peer_id(&req.payload, msg.peer_id); + let service = p.state().lsps_service.clone(); - match service.handle_message(&req.payload, &mut writer).await { + match service.handle_message(&payload, &mut writer).await { Ok(_) => continue_response, Err(e) => { debug!("failed to handle lsps message: {}", e); @@ -108,19 +187,3 @@ impl JsonRpcResponseWriter for LspsResponseWriter { transport::send_custommsg(&mut client, payload.to_vec(), self.peer_id).await } } - -pub struct Lsps0ListProtocolsHandler; - -#[async_trait] -impl RequestHandler for Lsps0ListProtocolsHandler { - async fn handle(&self, payload: &[u8]) -> core::result::Result, RpcError> { - let req: RequestObject = - serde_json::from_slice(payload).unwrap(); - if let Some(id) = req.id { - let res = Lsps0listProtocolsResponse { protocols: vec![] }.into_response(id); - let res_vec = serde_json::to_vec(&res).unwrap(); - return Ok(res_vec); - } - Ok(vec![]) - } -} diff --git a/plugins/lsps-plugin/src/util.rs b/plugins/lsps-plugin/src/util.rs new file mode 100644 index 000000000000..06784911dfc2 --- /dev/null +++ b/plugins/lsps-plugin/src/util.rs @@ -0,0 +1,273 @@ +use anyhow::anyhow; +use anyhow::Result; +use cln_rpc::primitives::PublicKey; +use core::fmt; +use serde_json::Value; +use std::str::FromStr; + +/// Checks whether a feature bit is set in a bitmap interpreted as +/// **big-endian across bytes**, while keeping **LSB-first within each byte**. +/// +/// This function creates a reversed copy of `bitmap` (so the least-significant +/// byte becomes last), then calls the simple LSB-first `is_feature_bit_set` on it. +/// No mutation of the caller’s slice occurs. +/// +/// In other words: +/// - byte order: **reversed** (big-endian across the slice) +/// - bit order within a byte: **LSB-first** (unchanged) +/// +/// If you need *full* MSB-first (also within a byte), don’t use this helper— +/// rewrite the mask as `1u8 << (7 - bit_index)` instead. +/// +/// # Arguments +/// * `bitmap` – byte slice containing the bitfield (original order, not modified) +/// * `feature_bit` – zero-based bit index across the entire bitmap +/// +/// # Returns +/// `true` if the bit is set; `false` if the bit is unset or out of bounds +pub fn is_feature_bit_set_reversed(bitmap: &[u8], feature_bit: usize) -> bool { + let mut reversed = bitmap.to_vec(); + reversed.reverse(); + is_feature_bit_set(&reversed, feature_bit) +} + +/// Checks if the feature bit is set in the provided bitmap. +/// Returns true if the `feature_bit` is set in the `bitmap`. Returns false if +/// the `feature_bit` is unset or our ouf bounds. +/// +/// # Arguments +/// +/// * `bitmap`: A slice of bytes representing the feature bitmap. +/// * `feature_bit`: The 0-based index of the bit to check across the bitmap. +/// +pub fn is_feature_bit_set(bitmap: &[u8], feature_bit: usize) -> bool { + let byte_index = feature_bit >> 3; // Equivalent to feature_bit / 8 + let bit_index = feature_bit & 7; // Equivalent to feature_bit % 8 + + if let Some(&target_byte) = bitmap.get(byte_index) { + let mask = 1 << bit_index; + (target_byte & mask) != 0 + } else { + false + } +} + +/// Returns a single feature_bit in hex representation, least-significant bit +/// first. +/// +/// # Arguments +/// +/// * `feature_bit`: The 0-based index of the bit to check across the bitmap. +/// +pub fn feature_bit_to_hex(feature_bit: usize) -> String { + let byte_index = feature_bit >> 3; // Equivalent to feature_bit / 8 + let mask = 1 << (feature_bit & 7); // Equivalent to feature_bit % 8 + let mut map = vec![0u8; byte_index + 1]; + map[0] |= mask; // least-significant bit first ordering. + hex::encode(&map) +} + +/// Errors that can occur when unwrapping payload data +#[derive(Debug, Clone, PartialEq)] +pub enum UnwrapError { + /// The public key bytes are invalid + InvalidPublicKey(String), + /// Failed to deserialize json value, + SerdeFailure(String), +} + +impl fmt::Display for UnwrapError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UnwrapError::InvalidPublicKey(e) => { + write!(f, "Invalid public key: {}", e) + } + UnwrapError::SerdeFailure(e) => { + write!(f, "Failed to serialize or deserialize json value: {}", e) + } + } + } +} + +impl std::error::Error for UnwrapError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + _ => None, + } + } +} + +/// Wraps a payload with a peer ID for internal LSPS message transmission. +pub fn try_wrap_payload_with_peer_id(payload: &[u8], peer_id: PublicKey) -> Result> { + // We expect the payload to be valid json, so no empty payload allowed, also + // checks that we have curly braces at start and end. + if payload.is_empty() || payload[0] != b'{' || payload[payload.len() - 1] != b'}' { + return Err(anyhow!("payload no valid json")); + } + + let pubkey_hex = peer_id.to_string(); + let mut result = Vec::with_capacity(pubkey_hex.len() + payload.len() + 13); + + result.extend_from_slice(&payload[..payload.len() - 1]); + result.extend_from_slice(b",\"peer_id\":\""); + result.extend_from_slice(pubkey_hex.as_bytes()); + result.extend_from_slice(b"\"}"); + Ok(result) +} + +/// Safely unwraps payload data and a peer ID +pub fn try_unwrap_payload_with_peer_id(data: &[u8]) -> Result<(Vec, PublicKey)> { + let mut json: Value = + serde_json::from_slice(data).map_err(|e| UnwrapError::SerdeFailure(e.to_string()))?; + + if let Value::Object(ref mut map) = json { + if let Some(Value::String(peer_id)) = map.remove("peer_id") { + let modified_json = serde_json::to_string(&json) + .map_err(|e| UnwrapError::SerdeFailure(e.to_string()))?; + return Ok(( + modified_json.into_bytes(), + PublicKey::from_str(&peer_id) + .map_err(|e| UnwrapError::InvalidPublicKey(e.to_string()))?, + )); + } + } + Err(UnwrapError::InvalidPublicKey(String::from( + "public key missing", + )))? +} + +/// Unwraps payload data and peer ID, panicking on error +/// +/// This is a convenience function for cases where one knows the data is valid. +pub fn unwrap_payload_with_peer_id(data: &[u8]) -> (Vec, PublicKey) { + try_unwrap_payload_with_peer_id(data).expect("Failed to unwrap payload with peer_id") +} + +/// Wraps payload data and peer ID, panicking on error +/// +/// This is a convenience function for cases where one knows that the payload is +/// valid. +pub fn wrap_payload_with_peer_id(payload: &[u8], peer_id: PublicKey) -> Vec { + try_wrap_payload_with_peer_id(payload, peer_id).expect("Failed to wrap payload with peer_id") +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + // Valid test public key + const PUBKEY: [u8; 33] = [ + 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, + 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, + 0xf8, 0x17, 0x98, + ]; + + #[test] + fn test_wrap_and_unwrap_roundtrip() { + let peer_id = PublicKey::from_slice(&PUBKEY).unwrap(); + let payload = + json!({"jsonrpc": "2.0","method": "some-method","params": {},"id": "some-id"}); + let wrapped = wrap_payload_with_peer_id(payload.to_string().as_bytes(), peer_id); + + let (unwrapped_payload, unwrapped_peer_id) = unwrap_payload_with_peer_id(&wrapped); + let value: serde_json::Value = serde_json::from_slice(&unwrapped_payload).unwrap(); + + assert_eq!(value, payload); + assert_eq!(unwrapped_peer_id, peer_id); + } + + #[test] + fn test_invalid_pubkey() { + let mut invalid_data = vec![0u8; 40]; + // Set an invalid public key (all zeros) + invalid_data[0] = 0x02; // Valid prefix + // But rest remains zeros which is invalid + let payload = json!({"jsonrpc": "2.0","method": "some-method","params": {},"id": "some-id","peer_id": hex::encode(&invalid_data)}); + + let result = try_unwrap_payload_with_peer_id(payload.to_string().as_bytes()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err().downcast_ref::(), + Some(UnwrapError::InvalidPublicKey(_)) + )); + } + + #[test] + fn test_basic_bit_checks() { + // Example bitmap: + // Byte 0: 0b10100101 (165) -> Bits 0, 2, 5, 7 set + // Byte 1: 0b01101010 (106) -> Bits 1, 3, 5, 6 set (indices 9, 11, 13, 14) + let bitmap: &[u8] = &[0b10100101, 0b01101010]; + + // Check bits in byte 0 (indices 0-7) + assert_eq!(is_feature_bit_set(bitmap, 0), true); // Bit 0 + assert_eq!(is_feature_bit_set(bitmap, 1), false); // Bit 1 + assert_eq!(is_feature_bit_set(bitmap, 2), true); // Bit 2 + assert_eq!(is_feature_bit_set(bitmap, 3), false); // Bit 3 + assert_eq!(is_feature_bit_set(bitmap, 4), false); // Bit 4 + assert_eq!(is_feature_bit_set(bitmap, 5), true); // Bit 5 + assert_eq!(is_feature_bit_set(bitmap, 6), false); // Bit 6 + assert_eq!(is_feature_bit_set(bitmap, 7), true); // Bit 7 + + // Check bits in byte 1 (indices 8-15) + assert_eq!(is_feature_bit_set(bitmap, 8), false); // Bit 8 (Byte 1, bit 0) + assert_eq!(is_feature_bit_set(bitmap, 9), true); // Bit 9 (Byte 1, bit 1) + assert_eq!(is_feature_bit_set(bitmap, 10), false); // Bit 10 (Byte 1, bit 2) + assert_eq!(is_feature_bit_set(bitmap, 11), true); // Bit 11 (Byte 1, bit 3) + assert_eq!(is_feature_bit_set(bitmap, 12), false); // Bit 12 (Byte 1, bit 4) + assert_eq!(is_feature_bit_set(bitmap, 13), true); // Bit 13 (Byte 1, bit 5) + assert_eq!(is_feature_bit_set(bitmap, 14), true); // Bit 14 (Byte 1, bit 6) + assert_eq!(is_feature_bit_set(bitmap, 15), false); // Bit 15 (Byte 1, bit 7) + } + + #[test] + fn test_out_of_bounds() { + let bitmap: &[u8] = &[0b11111111, 0b00000000]; // 16 bits total + + assert_eq!(is_feature_bit_set(bitmap, 15), false); // Last valid bit (is 0) + assert_eq!(is_feature_bit_set(bitmap, 16), false); // Out of bounds + assert_eq!(is_feature_bit_set(bitmap, 100), false); // Way out of bounds + } + + #[test] + fn test_empty_bitmap() { + let bitmap: &[u8] = &[]; + assert_eq!(is_feature_bit_set(bitmap, 0), false); + assert_eq!(is_feature_bit_set(bitmap, 8), false); + } + + #[test] + fn test_feature_to_hex_bit_0_be() { + // Bit 0 is in Byte 0 (LE index). num_bytes=1. BE index = 1-1-0=0. + // Expected map: [0x01] + let feature_hex = feature_bit_to_hex(0); + assert_eq!(feature_hex, "01"); + assert!(is_feature_bit_set(&hex::decode(feature_hex).unwrap(), 0)); + } + + #[test] + fn test_feature_to_hex_bit_8_be() { + // Bit 8 is in Byte 1 (LE index). num_bytes=2. BE index = 2-1-1=0. + // Mask is 0x01 for bit 0 within its byte. + // Expected map: [0x01, 0x00] (Byte for 8-15 first, then 0-7) + let feature_hex = feature_bit_to_hex(8); + let mut decoded = hex::decode(&feature_hex).unwrap(); + decoded.reverse(); + assert_eq!(feature_hex, "0100"); + assert!(is_feature_bit_set(&decoded, 8)); + } + + #[test] + fn test_feature_to_hex_bit_27_be() { + // Bit 27 is in Byte 3 (LE index). num_bytes=4. BE index = 4-1-3=0. + // Mask is 0x08 for bit 3 within its byte. + // Expected map: [0x08, 0x00, 0x00, 0x00] (Byte for 24-31 first) + let feature_hex = feature_bit_to_hex(27); + let mut decoded = hex::decode(&feature_hex).unwrap(); + decoded.reverse(); + assert_eq!(feature_hex, "08000000"); + assert!(is_feature_bit_set(&decoded, 27)); + } +} diff --git a/tests/plugins/lsps2_policy.py b/tests/plugins/lsps2_policy.py new file mode 100755 index 000000000000..d71fc67035d9 --- /dev/null +++ b/tests/plugins/lsps2_policy.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""A simple implementation of a LSPS2 compatible policy plugin. It is the job +of this plugin to deliver a fee options menu to the LSPS2 service plugin. +""" + +from pyln.client import Plugin +from datetime import datetime, timedelta, timezone + + +plugin = Plugin() + + +@plugin.method("lsps2-policy-getpolicy") +def lsps2_policy_getpolicy(request): + """Returns an opening fee menu for the LSPS2 plugin.""" + now = datetime.now(timezone.utc) + + # Is ISO 8601 format "YYYY-MM-DDThh:mm:ss.uuuZ" + valid_until = (now + timedelta(hours=1)).isoformat().replace("+00:00", "Z") + + return { + "policy_opening_fee_params_menu": [ + { + "min_fee_msat": "1000", + "proportional": 1000, + "valid_until": valid_until, + "min_lifetime": 2000, + "max_client_to_self_delay": 2016, + "min_payment_size_msat": "1000", + "max_payment_size_msat": "100000000", + }, + { + "min_fee_msat": "1092000", + "proportional": 2400, + "valid_until": valid_until, + "min_lifetime": 1008, + "max_client_to_self_delay": 2016, + "min_payment_size_msat": "1000", + "max_payment_size_msat": "1000000", + }, + ] + } + + +@plugin.method("lsps2-policy-getchannelcapacity") +def lsps2_policy_getchannelcapacity(request, init_payment_size, scid, opening_fee_params): + """Returns an opening fee menu for the LSPS2 plugin.""" + return {"channel_capacity_msat": 100000000} + + +plugin.run() diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py index 28f09d64d750..c45e4739a0d0 100644 --- a/tests/test_cln_lsps.py +++ b/tests/test_cln_lsps.py @@ -1,7 +1,8 @@ from fixtures import * # noqa: F401,F403 from pyln.testing.utils import RUST - +from utils import only_one import os +import pytest import unittest RUST_PROFILE = os.environ.get("RUST_PROFILE", "debug") @@ -20,12 +21,234 @@ def test_lsps_service_disabled(node_factory): @unittest.skipUnless(RUST, 'RUST is not enabled') def test_lsps0_listprotocols(node_factory): - l1, l2 = node_factory.get_nodes(2, opts=[ - {}, {"dev-lsps-service": True} - ]) + l1, l2 = node_factory.get_nodes( + 2, + opts=[ + {"experimental-lsps-client": None}, + { + "experimental-lsps2-service": None, + "experimental-lsps2-promise-secret": "0" * 64, + }, + ], + ) + + # We don't need a channel to query for lsps services + node_factory.join_nodes([l1, l2], fundchannel=False) + + res = l1.rpc.lsps_listprotocols(lsp_id=l2.info["id"]) + assert res + + +def test_lsps2_enabled(node_factory): + l1, l2 = node_factory.get_nodes( + 2, + opts=[ + {"experimental-lsps-client": None}, + { + "experimental-lsps2-service": None, + "experimental-lsps2-promise-secret": "0" * 64, + }, + ], + ) + + node_factory.join_nodes([l1, l2], fundchannel=False) + + res = l1.rpc.lsps_listprotocols(lsp_id=l2.info["id"]) + assert res["protocols"] == [2] + + +def test_lsps2_getinfo(node_factory): + plugin = os.path.join(os.path.dirname(__file__), "plugins/lsps2_policy.py") + + l1, l2 = node_factory.get_nodes( + 2, + opts=[ + {"experimental-lsps-client": None}, + { + "experimental-lsps2-service": None, + "experimental-lsps2-promise-secret": "0" * 64, + "plugin": plugin, + }, + ] + ) + + node_factory.join_nodes([l1, l2], fundchannel=False) + + res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info["id"]) + assert res["opening_fee_params_menu"] + + +def test_lsps2_buy(node_factory): + # We need a policy service to fetch from. + plugin = os.path.join(os.path.dirname(__file__), "plugins/lsps2_policy.py") + + l1, l2 = node_factory.get_nodes( + 2, + opts=[ + {"experimental-lsps-client": None}, + { + "experimental-lsps2-service": None, + "experimental-lsps2-promise-secret": "0" * 64, + "plugin": plugin, + }, + ] + ) # We don't need a channel to query for lsps services node_factory.join_nodes([l1, l2], fundchannel=False) - res = l1.rpc.lsps_listprotocols(peer=l2.info['id']) + res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info["id"]) + params = res["opening_fee_params_menu"][0] + + res = l1.rpc.lsps_lsps2_buy(lsp_id=l2.info["id"], opening_fee_params=params) assert res + + +def test_lsps2_buyjitchannel_no_mpp_var_invoice(node_factory, bitcoind): + """Tests the creation of a "Just-In-Time-Channel" (jit-channel). + + At the beginning we have the following situation where l2 acts as the LSP + (LSP) + l1 l2----l3 + + l1 now wants to get a channel from l2 via the lsps2 jit-channel protocol: + - l1 requests a new jit channel form l2 + - l1 creates an invoice based on the opening fee parameters it got from l2 + - l3 pays the invoice + - l2 opens a channel to l1 and forwards the payment (deducted by a fee) + + eventualy this will result in the following situation + (LSP) + l1----l2----l3 + """ + # We need a policy service to fetch from. + plugin = os.path.join(os.path.dirname(__file__), "plugins/lsps2_policy.py") + + l1, l2, l3 = node_factory.get_nodes( + 3, + opts=[ + {"experimental-lsps-client": None}, + { + "experimental-lsps2-service": None, + "experimental-lsps2-promise-secret": "0" * 64, + "plugin": plugin, + "fee-base": 0, # We are going to deduct our fee anyways, + "fee-per-satoshi": 0, # We are going to deduct our fee anyways, + }, + {}, + ], + ) + + # Give the LSP some funds to open jit-channels + addr = l2.rpc.newaddr()["bech32"] + bitcoind.rpc.sendtoaddress(addr, 1) + bitcoind.generate_block(1) + + node_factory.join_nodes([l3, l2], fundchannel=True, wait_for_announce=True) + node_factory.join_nodes([l1, l2], fundchannel=False) + + chanid = only_one(l3.rpc.listpeerchannels(l2.info["id"])["channels"])[ + "short_channel_id" + ] + + inv = l1.rpc.lsps_lsps2_invoice( + lsp_id=l2.info["id"], + amount_msat="any", + description="lsp-jit-channel-0", + label="lsp-jit-channel-0", + ) + assert inv + + dec = l3.rpc.decode(inv["bolt11"]) + assert dec + + routehint = only_one(only_one(dec["routes"])) + + amt = 10000000 + + route = [ + {"amount_msat": amt, "id": l2.info["id"], "delay": 14, "channel": chanid}, + { + "amount_msat": amt, + "id": l1.info["id"], + "delay": 8, + "channel": routehint["short_channel_id"], + }, + ] + + l3.rpc.sendpay( + route, + dec["payment_hash"], + payment_secret=inv["payment_secret"], + bolt11=inv["bolt11"], + partid=0, + ) + + res = l3.rpc.waitsendpay(dec["payment_hash"]) + assert res["payment_preimage"] + + # l1 should have gotten a jit-channel. + chs = l1.rpc.listpeerchannels()["channels"] + assert len(chs) == 1 + + +def test_lsps2_non_approved_zero_conf(node_factory, bitcoind): + """Checks that we don't allow zerof_conf channels from an LSP if we did + not approve it first. + """ + # We need a policy service to fetch from. + plugin = os.path.join(os.path.dirname(__file__), "plugins/lsps2_policy.py") + + l1, l2, l3 = node_factory.get_nodes( + 3, + opts=[ + {"experimental-lsps-client": None}, + { + "experimental-lsps2-service": None, + "experimental-lsps2-promise-secret": "0" * 64, + "plugin": plugin, + "fee-base": 0, # We are going to deduct our fee anyways, + "fee-per-satoshi": 0, # We are going to deduct our fee anyways, + }, + {"disable-mpp": None}, + ], + ) + + # Give the LSP some funds to open jit-channels + addr = l2.rpc.newaddr()["bech32"] + bitcoind.rpc.sendtoaddress(addr, 1) + bitcoind.generate_block(1) + + node_factory.join_nodes([l3, l2], fundchannel=True, wait_for_announce=True) + node_factory.join_nodes([l1, l2], fundchannel=False) + + fee_opt = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info["id"])[ + "opening_fee_params_menu" + ][0] + buy_res = l1.rpc.lsps_lsps2_buy(lsp_id=l2.info["id"], opening_fee_params=fee_opt) + + hint = [ + [ + { + "id": l2.info["id"], + "short_channel_id": buy_res["jit_channel_scid"], + "fee_base_msat": 0, + "fee_proportional_millionths": 0, + "cltv_expiry_delta": buy_res["lsp_cltv_expiry_delta"], + } + ] + ] + + bolt11 = l1.dev_invoice( + amount_msat="any", + description="lsp-invoice-1", + label="lsp-invoice-1", + dev_routes=hint, + )["bolt11"] + + with pytest.raises(ValueError): + l3.rpc.pay(bolt11, amount_msat=10000000) + + # l1 shouldn't have a new channel. + chs = l1.rpc.listpeerchannels()["channels"] + assert len(chs) == 0