From fd6829cd3a6fc3148eecf87da449d331ba46ea19 Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Tue, 22 Aug 2023 11:48:38 +0200 Subject: [PATCH 01/18] holdinvoice --- Cargo.lock | 101 +++- Cargo.toml | 1 + plugins/holdinvoice/Cargo.toml | 20 + plugins/holdinvoice/src/hold.rs | 528 ++++++++++++++++++ plugins/holdinvoice/src/hooks.rs | 552 +++++++++++++++++++ plugins/holdinvoice/src/lib.rs | 55 ++ plugins/holdinvoice/src/main.rs | 71 +++ plugins/holdinvoice/src/model.rs | 37 ++ plugins/holdinvoice/src/tasks.rs | 46 ++ plugins/holdinvoice/src/util.rs | 304 ++++++++++ plugins/holdinvoice/tests/holdinvoicetest.py | 305 ++++++++++ plugins/holdinvoice/tests/stresstest.py | 82 +++ plugins/holdinvoice/tests/util.py | 21 + 13 files changed, 2120 insertions(+), 3 deletions(-) create mode 100644 plugins/holdinvoice/Cargo.toml create mode 100644 plugins/holdinvoice/src/hold.rs create mode 100644 plugins/holdinvoice/src/hooks.rs create mode 100644 plugins/holdinvoice/src/lib.rs create mode 100644 plugins/holdinvoice/src/main.rs create mode 100644 plugins/holdinvoice/src/model.rs create mode 100644 plugins/holdinvoice/src/tasks.rs create mode 100644 plugins/holdinvoice/src/util.rs create mode 100644 plugins/holdinvoice/tests/holdinvoicetest.py create mode 100755 plugins/holdinvoice/tests/stresstest.py create mode 100644 plugins/holdinvoice/tests/util.py diff --git a/Cargo.lock b/Cargo.lock index 0ee53ec94b81..bf41681e6eaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -254,7 +254,7 @@ version = "0.1.4" dependencies = [ "anyhow", "bitcoin", - "cln-rpc", + "cln-rpc 0.1.3", "hex", "log", "prost", @@ -269,8 +269,8 @@ version = "0.1.4" dependencies = [ "anyhow", "cln-grpc", - "cln-plugin", - "cln-rpc", + "cln-plugin 0.1.4", + "cln-rpc 0.1.3", "log", "prost", "rcgen", @@ -295,6 +295,24 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "cln-plugin" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e59ac0c0944b5f43bf8008b8495a3b0d0036e4c208fa34d8575aaff93694e197" +dependencies = [ + "anyhow", + "bytes", + "env_logger", + "futures", + "log", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", +] + [[package]] name = "cln-rpc" version = "0.1.4" @@ -312,6 +330,24 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "cln-rpc" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3b630e345cdfc6f64315414b50815a9eeabbf12438413798bf09e9e79be8b8" +dependencies = [ + "anyhow", + "bitcoin", + "bytes", + "futures-util", + "hex", + "log", + "serde", + "serde_json", + "tokio", + "tokio-util", +] + [[package]] name = "data-encoding" version = "2.4.0" @@ -569,12 +605,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] +<<<<<<< HEAD name = "home" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" dependencies = [ "windows-sys", +======= +name = "holdinvoice" +version = "0.1.0" +dependencies = [ + "anyhow", + "cln-plugin 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", + "cln-rpc 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)", + "log", + "parking_lot", + "serde", + "serde_json", + "tokio", +>>>>>>> ff053ae7a (holdinvoice) ] [[package]] @@ -726,6 +776,16 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a9bad9f94746442c783ca431b22403b519cd7fbeed0533fdd6328b2f2212128" +[[package]] +name = "lock_api" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "log" version = "0.4.20" @@ -856,6 +916,29 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.48.0", +] + [[package]] name = "pem" version = "1.1.1" @@ -1158,6 +1241,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sct" version = "0.7.0" @@ -1228,6 +1317,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "smallvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" + [[package]] name = "socket2" version = "0.4.9" diff --git a/Cargo.toml b/Cargo.toml index ee68e2aaec39..af886ef41e88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,4 +8,5 @@ members = [ "cln-grpc", "plugins", "plugins/grpc-plugin", + "plugins/holdinvoice", ] diff --git a/plugins/holdinvoice/Cargo.toml b/plugins/holdinvoice/Cargo.toml new file mode 100644 index 000000000000..f4b4d5890f28 --- /dev/null +++ b/plugins/holdinvoice/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "holdinvoice" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +cln-rpc = "0.1.3" +cln-plugin = "0.1.4" + +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +tokio = { version = "1", features = ["fs","sync","rt-multi-thread",]} + +parking_lot = "0.12" + +anyhow = "1" +log = "0.4" diff --git a/plugins/holdinvoice/src/hold.rs b/plugins/holdinvoice/src/hold.rs new file mode 100644 index 000000000000..943b863e402e --- /dev/null +++ b/plugins/holdinvoice/src/hold.rs @@ -0,0 +1,528 @@ +use std::{str::FromStr, time::Duration}; + +use anyhow::{anyhow, Error}; +use cln_plugin::Plugin; +use cln_rpc::{ + model::{InvoiceRequest, ListinvoicesInvoicesStatus}, + primitives::{Amount, AmountOrAny}, + ClnRpc, Request, Response, +}; +use log::warn; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::{time, time::Instant}; + +use crate::{ + model::PluginState, + util::{ + datastore_new_state, datastore_update_state_forced, listdatastore_htlc_expiry, + listdatastore_state, listinvoices, listpeerchannels, make_rpc_path, + CANCEL_HOLD_BEFORE_HTLC_EXPIRY_BLOCKS, CANCEL_HOLD_BEFORE_INVOICE_EXPIRY_SECONDS, + }, + Holdstate, +}; + +pub async fn hold_invoice( + plugin: Plugin, + args: serde_json::Value, +) -> Result { + let rpc_path = make_rpc_path(plugin.clone()); + let mut rpc = ClnRpc::new(&rpc_path).await?; + + let valid_arg_keys = vec![ + "amount_msat", + "label", + "description", + "expiry", + "fallbacks", + "preimage", + "cltv", + "deschashonly", + ]; + + let mut new_args = serde_json::Value::Object(Default::default()); + match args { + serde_json::Value::Array(a) => { + for (idx, arg) in a.iter().enumerate() { + if idx < valid_arg_keys.len() { + new_args[valid_arg_keys[idx]] = arg.clone(); + } + } + } + serde_json::Value::Object(o) => { + for (k, v) in o.iter() { + if !valid_arg_keys.contains(&k.as_str()) { + return Ok(invalid_argument_error(k)); + } + new_args[k] = v.clone(); + } + } + _ => return Ok(invalid_input_error(&args.to_string())), + }; + + let inv_req = match build_invoice_request(&new_args) { + Ok(i) => i, + Err(e) => return Ok(e), + }; + + let invoice_request = match rpc.call(Request::Invoice(inv_req)).await { + Ok(resp) => resp, + Err(e) => match e.code { + Some(_) => return Ok(json!(e)), + None => return Err(anyhow!("Unexpected response in invoice: {}", e.to_string())), + }, + }; + let result = match invoice_request { + Response::Invoice(info) => info, + e => return Err(anyhow!("Unexpected result in invoice: {:?}", e)), + }; + datastore_new_state( + &rpc_path, + result.payment_hash.to_string(), + Holdstate::Open.to_string(), + ) + .await?; + Ok(json!(result)) +} + +pub async fn hold_invoice_settle( + plugin: Plugin, + args: serde_json::Value, +) -> Result { + let rpc_path = make_rpc_path(plugin.clone()); + + let pay_hash = match parse_payment_hash(args) { + Ok(ph) => ph, + Err(e) => return Ok(e), + }; + + let data = match listdatastore_state(&rpc_path, pay_hash.clone()).await { + Ok(d) => d, + Err(_) => return Ok(payment_hash_missing_error(&pay_hash)), + }; + + let holdstate = Holdstate::from_str(&data.string.unwrap())?; + + if holdstate.is_valid_transition(&Holdstate::Settled) { + let result = datastore_update_state_forced( + &rpc_path, + pay_hash.clone(), + Holdstate::Settled.to_string(), + ) + .await; + match result { + Ok(_r) => { + let mut holdinvoices = plugin.state().holdinvoices.lock().await; + if let Some(invoice) = holdinvoices.get_mut(&pay_hash.to_string()) { + for (_, htlc) in invoice.htlc_data.iter_mut() { + *htlc.loop_mutex.lock().await = true; + } + } else { + warn!( + "payment_hash: '{}' DROPPED INVOICE from internal state!", + pay_hash + ); + return Err(anyhow!( + "Invoice dropped from internal state unexpectedly: {}", + pay_hash + )); + } + + Ok(json!(HoldstateResponse { + state: Holdstate::Settled.to_string(), + htlc_expiry: None + })) + } + Err(e) => Err(anyhow!( + "Unexpected result {} to method call datastore_update_state_forced", + e.to_string() + )), + } + } else { + Ok(wrong_hold_state(holdstate)) + } +} + +pub async fn hold_invoice_cancel( + plugin: Plugin, + args: serde_json::Value, +) -> Result { + let rpc_path = make_rpc_path(plugin.clone()); + + let pay_hash = match parse_payment_hash(args) { + Ok(ph) => ph, + Err(e) => return Ok(e), + }; + + let data = match listdatastore_state(&rpc_path, pay_hash.clone()).await { + Ok(d) => d, + Err(_) => return Ok(payment_hash_missing_error(&pay_hash)), + }; + + let holdstate = Holdstate::from_str(&data.string.unwrap())?; + + if holdstate.is_valid_transition(&Holdstate::Canceled) { + let result = datastore_update_state_forced( + &rpc_path, + pay_hash.clone(), + Holdstate::Canceled.to_string(), + ) + .await; + match result { + Ok(_r) => { + let mut holdinvoices = plugin.state().holdinvoices.lock().await; + if let Some(invoice) = holdinvoices.get_mut(&pay_hash.to_string()) { + for (_, htlc) in invoice.htlc_data.iter_mut() { + *htlc.loop_mutex.lock().await = true; + } + } + + Ok(json!(HoldstateResponse { + state: Holdstate::Canceled.to_string(), + htlc_expiry: None + })) + } + Err(e) => Err(anyhow!( + "Unexpected result {} to method call datastore_update_state_forced", + e.to_string() + )), + } + } else { + Ok(wrong_hold_state(holdstate)) + } +} + +pub async fn hold_invoice_lookup( + plugin: Plugin, + args: serde_json::Value, +) -> Result { + let rpc_path = make_rpc_path(plugin.clone()); + + let pay_hash = match parse_payment_hash(args) { + Ok(ph) => ph, + Err(e) => return Ok(e), + }; + + let data = match listdatastore_state(&rpc_path, pay_hash.clone()).await { + Ok(d) => d, + Err(_) => return Ok(payment_hash_missing_error(&pay_hash)), + }; + + let holdstate = Holdstate::from_str(&data.string.unwrap())?; + + let mut htlc_expiry = None; + match holdstate { + Holdstate::Open => (), + Holdstate::Accepted => { + htlc_expiry = Some(listdatastore_htlc_expiry(&rpc_path, pay_hash.clone()).await?) + } + Holdstate::Canceled => { + let now = Instant::now(); + loop { + let mut all_cancelled = true; + let channels = match listpeerchannels(&rpc_path).await?.channels { + Some(c) => c, + None => break, + }; + + for chan in channels { + if let Some(htlcs) = chan.htlcs { + for htlc in htlcs { + if let Some(ph) = htlc.payment_hash { + if ph.to_string() == pay_hash { + all_cancelled = false; + } + } + } + } + } + + if all_cancelled { + break; + } + + if now.elapsed().as_secs() > 20 { + return Err(anyhow!( + "holdinvoicelookup: Timed out before cancellation of all \ + related htlcs was finished" + )); + } + + time::sleep(Duration::from_secs(2)).await + } + } + Holdstate::Settled => { + let now = Instant::now(); + loop { + let invoices = listinvoices(&rpc_path, None, Some(pay_hash.clone())) + .await? + .invoices; + + if let Some(inv) = invoices.first() { + match inv.status { + ListinvoicesInvoicesStatus::PAID => { + break; + } + ListinvoicesInvoicesStatus::EXPIRED => { + return Err(anyhow!( + "holdinvoicelookup: Invoice expired while trying to settle!" + )); + } + _ => (), + } + } + + if now.elapsed().as_secs() > 20 { + return Err(anyhow!( + "holdinvoicelookup: Timed out before settlement could be confirmed", + )); + } + + time::sleep(Duration::from_secs(2)).await + } + } + } + Ok(json!(HoldstateResponse { + state: holdstate.to_string(), + htlc_expiry + })) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct HoldstateResponse { + state: String, + #[serde(skip_serializing_if = "Option::is_none")] + htlc_expiry: Option, +} + +fn missing_parameter_error(param: &str) -> serde_json::Value { + json!({ + "code": -32602, + "message": format!("missing required parameter: {}", param) + }) +} + +fn invalid_argument_error(arg: &str) -> serde_json::Value { + json!({ + "code": -1, + "message": format!("Invalid argument: '{}'", arg) + }) +} + +fn invalid_input_error(input: &str) -> serde_json::Value { + json!({ + "code": -1, + "message": format!("Invalid input: '{}'", input) + }) +} + +fn invalid_hash_error(name: &str, token: &str) -> serde_json::Value { + json!({ + "code": -32602, + "message": format!("{}: should be a 32 byte hex value: \ + invalid token '{}'", name, token) + }) +} + +fn payment_hash_missing_error(pay_hash: &str) -> serde_json::Value { + json!({ + "code": -32602, + "message": format!("payment_hash '{}' not found", pay_hash) + }) +} + +fn invalid_integer_error(name: &str, integer: &str) -> serde_json::Value { + json!({ + "code": -32602, + "message": format!("{}: should be an unsigned 64 bit integer: \ + invalid token '{}'", name,integer) + }) +} + +fn too_many_params_error(actual: usize, expected: usize) -> serde_json::Value { + json!({ + "code": -32602, + "message": format!("too many parameters: got {}, expected {}", actual, expected) + }) +} + +fn wrong_hold_state(holdstate: Holdstate) -> serde_json::Value { + json!({ + "code": -32602, + "message": format!("Holdinvoice is in wrong state: '{}'", holdstate) + }) +} + +fn parse_payment_hash(args: serde_json::Value) -> Result { + if let serde_json::Value::Array(i) = args { + if i.is_empty() { + Err(missing_parameter_error("payment_hash")) + } else if i.len() != 1 { + Err(too_many_params_error(i.len(), 1)) + } else if let serde_json::Value::String(s) = i.first().unwrap() { + if s.len() != 64 { + Err(invalid_hash_error("payment_hash", s)) + } else { + Ok(s.clone()) + } + } else { + Err(invalid_hash_error( + "payment_hash", + &i.first().unwrap().to_string(), + )) + } + } else if let serde_json::Value::Object(o) = args { + let valid_arg_keys = vec!["payment_hash"]; + for (k, _v) in o.iter() { + if !valid_arg_keys.contains(&k.as_str()) { + return Err(invalid_argument_error(k)); + } + } + if let Some(pay_hash) = o.get("payment_hash") { + if let serde_json::Value::String(s) = pay_hash { + if s.len() != 64 { + Err(invalid_hash_error("payment_hash", s)) + } else { + Ok(s.clone()) + } + } else { + Err(invalid_hash_error("payment_hash", &pay_hash.to_string())) + } + } else { + Err(missing_parameter_error("payment_hash")) + } + } else { + Err(invalid_input_error(&args.to_string())) + } +} + +fn build_invoice_request(args: &serde_json::Value) -> Result { + let amount_msat = if let Some(amt) = args.get("amount_msat") { + AmountOrAny::Amount(Amount::from_msat(if let Some(amt_u64) = amt.as_u64() { + amt_u64 + } else { + return Err(invalid_integer_error( + "amount_msat|msatoshi", + &amt.to_string(), + )); + })) + } else { + return Err(missing_parameter_error("amount_msat|msatoshi")); + }; + + let label = if let Some(lbl) = args.get("label") { + match lbl { + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::String(s) => s.as_str().to_string(), + e => return Err(invalid_input_error(&e.to_string())), + } + } else { + return Err(missing_parameter_error("label")); + }; + + let description = if let Some(desc) = args.get("description") { + match desc { + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::String(s) => s.as_str().to_string(), + e => return Err(invalid_input_error(&e.to_string())), + } + } else { + return Err(missing_parameter_error("description")); + }; + + let expiry = if let Some(exp) = args.get("expiry") { + Some(if let Some(exp_u64) = exp.as_u64() { + if exp_u64 <= CANCEL_HOLD_BEFORE_INVOICE_EXPIRY_SECONDS { + return Err(json!({ + "code": -32602, + "message": format!("expiry: needs to be greater than '{}' requested: '{}'", + CANCEL_HOLD_BEFORE_INVOICE_EXPIRY_SECONDS, exp_u64) + })); + } else { + exp_u64 + } + } else { + return Err(invalid_integer_error("expiry", &exp.to_string())); + }) + } else { + None + }; + + let fallbacks = if let Some(fbcks) = args.get("fallbacks") { + Some(if let Some(fbcks_arr) = fbcks.as_array() { + fbcks_arr + .iter() + .filter_map(|value| value.as_str().map(|s| s.to_string())) + .collect() + } else { + return Err(json!({ + "code": -32602, + "message": format!("fallbacks: should be an array: \ + invalid token '{}'", fbcks.to_string()) + })); + }) + } else { + None + }; + + let preimage = if let Some(preimg) = args.get("preimage") { + Some(if let Some(preimg_str) = preimg.as_str() { + if preimg_str.len() != 64 { + return Err(invalid_hash_error("preimage", &preimg.to_string())); + } else { + preimg_str.to_string() + } + } else { + return Err(invalid_hash_error("preimage", &preimg.to_string())); + }) + } else { + None + }; + + let cltv = if let Some(c) = args.get("cltv") { + Some(if let Some(c_u64) = c.as_u64() { + if c_u64 as u32 <= CANCEL_HOLD_BEFORE_HTLC_EXPIRY_BLOCKS { + return Err(json!({ + "code": -32602, + "message": format!("cltv: needs to be greater than '{}' requested: '{}'", + CANCEL_HOLD_BEFORE_HTLC_EXPIRY_BLOCKS, c_u64) + })); + } else { + c_u64 as u32 + } + } else { + return Err(json!({ + "code": -32602, + "message": format!("cltv: should be an integer: \ + invalid token '{}'", c.to_string()) + })); + }) + } else { + None + }; + + let deschashonly = if let Some(dhash) = args.get("deschashonly") { + Some(if let Some(dhash_bool) = dhash.as_bool() { + dhash_bool + } else { + return Err(json!({ + "code": -32602, + "message": format!("deschashonly: should be 'true' or 'false': \ + invalid token '{}'", dhash.to_string()) + })); + }) + } else { + None + }; + + Ok(InvoiceRequest { + amount_msat, + label, + description, + expiry, + fallbacks, + preimage, + cltv, + deschashonly, + }) +} diff --git a/plugins/holdinvoice/src/hooks.rs b/plugins/holdinvoice/src/hooks.rs new file mode 100644 index 000000000000..861c728f357b --- /dev/null +++ b/plugins/holdinvoice/src/hooks.rs @@ -0,0 +1,552 @@ +use std::{ + collections::HashMap, + path::PathBuf, + str::FromStr, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{anyhow, Error}; +use cln_plugin::Plugin; +use cln_rpc::{model::ListinvoicesInvoices, primitives::Amount}; +use log::{debug, info, warn}; +use serde_json::json; +use tokio::time::{self}; + +use holdinvoice::util::{ + cleanup_pluginstate_holdinvoices, datastore_htlc_expiry, datastore_update_state, + listdatastore_state, listinvoices, make_rpc_path, CANCEL_HOLD_BEFORE_HTLC_EXPIRY_BLOCKS, +}; +use holdinvoice::Holdstate; +use holdinvoice::{ + model::{HoldHtlc, HoldInvoice, HtlcIdentifier, PluginState}, + util::CANCEL_HOLD_BEFORE_INVOICE_EXPIRY_SECONDS, +}; + +pub async fn htlc_handler( + plugin: Plugin, + v: serde_json::Value, +) -> Result { + if let Some(htlc) = v.get("htlc") { + if let Some(pay_hash) = htlc + .get("payment_hash") + .and_then(|pay_hash| pay_hash.as_str()) + { + debug!("payment_hash: `{}`. htlc_hook started!", pay_hash); + let rpc_path = make_rpc_path(plugin.clone()); + + let is_new_invoice; + let cltv_expiry; + + let amount_msat; + + let invoice; + let scid; + let chan_htlc_id; + let global_htlc_ident; + let hold_state; + + { + let mut holdinvoices = plugin.state().holdinvoices.lock().await; + let generation; + if let Some(holdinvoice) = holdinvoices.get_mut(&pay_hash.to_string()) { + is_new_invoice = false; + debug!( + "payment_hash: `{}`. Htlc is for a known holdinvoice! Processing...", + pay_hash + ); + + hold_state = holdinvoice.hold_state; + invoice = holdinvoice.invoice.clone(); + generation = holdinvoice.generation; + } else { + is_new_invoice = true; + debug!( + "payment_hash: `{}`. New htlc, checking if it's our invoice...", + pay_hash + ); + + match listdatastore_state(&rpc_path, pay_hash.to_string()).await { + Ok(dbstate) => { + debug!( + "payment_hash: `{}`. Htlc is for a holdinvoice! Processing...", + pay_hash + ); + hold_state = Holdstate::from_str(&dbstate.string.unwrap())?; + generation = if let Some(g) = dbstate.generation { + g + } else { + 0 + }; + + invoice = listinvoices(&rpc_path, None, Some(pay_hash.to_string())) + .await? + .invoices + .first() + .ok_or(anyhow!( + "payment_hash: `{}`. holdinvoice not found!", + pay_hash + ))? + .clone(); + } + Err(_e) => { + debug!( + "payment_hash: `{}`. Not a holdinvoice! Continue...", + pay_hash + ); + return Ok(json!({"result": "continue"})); + } + }; + } + + chan_htlc_id = if let Some(chid) = htlc.get("id") { + chid.as_u64().unwrap() + } else { + warn!( + "payment_hash: `{}`. htlc id not found! Rejecting htlc...", + pay_hash + ); + return Ok(json!({"result": "fail"})); + }; + + scid = if let Some(id) = htlc.get("short_channel_id") { + id.as_str().unwrap().to_string() + } else { + warn!( + "payment_hash: `{}`. short_channel_id not found! Rejecting htlc...", + pay_hash + ); + return Ok(json!({"result": "fail"})); + }; + + global_htlc_ident = HtlcIdentifier { + scid: scid.clone(), + htlc_id: chan_htlc_id, + }; + + cltv_expiry = if let Some(ce) = htlc.get("cltv_expiry") { + ce.as_u64().unwrap() as u32 + } else { + warn!( + "payment_hash: `{}`. cltv_expiry not found! Rejecting htlc...", + pay_hash + ); + return Ok(json!({"result": "fail"})); + }; + + amount_msat = if let Some(amt) = htlc.get("amount_msat") { + amt.as_u64().unwrap() + } else { + warn!( + "payment_hash: `{}` scid: `{}` htlc_id: {}: \ + amount_msat not found! Rejecting htlc...", + pay_hash, + global_htlc_ident.scid.to_string(), + global_htlc_ident.htlc_id + ); + return Ok(json!({"result": "fail"})); + }; + + if is_new_invoice { + datastore_htlc_expiry(&rpc_path, pay_hash.to_string(), cltv_expiry.to_string()) + .await?; + + let mut htlc_data = HashMap::new(); + htlc_data.insert( + global_htlc_ident.clone(), + HoldHtlc { + amount_msat, + cltv_expiry, + loop_mutex: Arc::new(tokio::sync::Mutex::new(true)), + }, + ); + holdinvoices.insert( + pay_hash.to_string(), + HoldInvoice { + hold_state, + generation, + htlc_data, + last_htlc_expiry: cltv_expiry, + invoice: invoice.clone(), + }, + ); + } else { + let holdinvoice = holdinvoices.get_mut(&pay_hash.to_string()).unwrap(); + holdinvoice.htlc_data.insert( + global_htlc_ident.clone(), + HoldHtlc { + amount_msat, + cltv_expiry, + loop_mutex: Arc::new(tokio::sync::Mutex::new(true)), + }, + ); + + let earliest_htlc_expiry = holdinvoice + .htlc_data + .values() + .map(|htlc| htlc.cltv_expiry) + .min() + .unwrap(); + + if holdinvoice.last_htlc_expiry != earliest_htlc_expiry { + datastore_htlc_expiry( + &rpc_path, + pay_hash.to_string(), + earliest_htlc_expiry.to_string(), + ) + .await?; + holdinvoice.last_htlc_expiry = earliest_htlc_expiry; + } + } + } + + if let Holdstate::Canceled = hold_state { + info!( + "payment_hash: `{}`. Htlc arrived after \ + hold-cancellation was requested. \ + Rejecting htlc...", + pay_hash + ); + let mut holdinvoices = plugin.state().holdinvoices.lock().await; + cleanup_pluginstate_holdinvoices(&mut holdinvoices, pay_hash, &global_htlc_ident) + .await; + + return Ok(json!({"result": "fail"})); + } + + info!( + "payment_hash: `{}` scid: `{}` htlc_id: `{}`. Holding {}msat", + pay_hash, + global_htlc_ident.scid.to_string(), + global_htlc_ident.htlc_id, + amount_msat + ); + + return loop_htlc_hold( + plugin.clone(), + rpc_path, + pay_hash, + global_htlc_ident, + invoice, + cltv_expiry, + amount_msat, + ) + .await; + } + } + warn!("htlc_accepted hook could not find htlc object"); + Ok(json!({"result": "continue"})) +} + +async fn loop_htlc_hold( + plugin: Plugin, + rpc_path: PathBuf, + pay_hash: &str, + global_htlc_ident: HtlcIdentifier, + invoice: ListinvoicesInvoices, + cltv_expiry: u32, + amount_msat: u64, +) -> Result { + let mut first_iter = true; + loop { + if !first_iter { + time::sleep(Duration::from_secs(2)).await; + } else { + first_iter = false; + } + { + let mut holdinvoices = plugin.state().holdinvoices.lock().await; + if let Some(holdinvoice_data) = holdinvoices.get_mut(&pay_hash.to_string()) { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + #[allow(clippy::clone_on_copy)] + if holdinvoice_data + .htlc_data + .get(&global_htlc_ident) + .unwrap() + .loop_mutex + .lock() + .await + .clone() + || invoice.expires_at <= now + CANCEL_HOLD_BEFORE_INVOICE_EXPIRY_SECONDS + { + match listdatastore_state(&rpc_path, pay_hash.to_string()).await { + Ok(s) => { + holdinvoice_data.hold_state = Holdstate::from_str(&s.string.unwrap())?; + holdinvoice_data.generation = + if let Some(g) = s.generation { g } else { 0 }; + } + Err(e) => { + warn!( + "Error getting state for pay_hash: {} {}", + pay_hash, + e.to_string() + ); + continue; + } + }; + + // cln cannot accept htlcs for expired invoices + if invoice.expires_at <= now + CANCEL_HOLD_BEFORE_INVOICE_EXPIRY_SECONDS { + warn!( + "payment_hash: `{}` scid: `{}` htlc: `{}`. \ + holdinvoice expired! State=CANCELED", + pay_hash, global_htlc_ident.scid, global_htlc_ident.htlc_id + ); + match datastore_update_state( + &rpc_path, + pay_hash.to_string(), + Holdstate::Canceled.to_string(), + holdinvoice_data.generation, + ) + .await + { + Ok(_o) => (), + Err(e) => { + warn!( + "Error updating state for pay_hash: {} {}", + pay_hash, + e.to_string() + ); + continue; + } + }; + + cleanup_pluginstate_holdinvoices( + &mut holdinvoices, + pay_hash, + &global_htlc_ident, + ) + .await; + + return Ok(json!({"result": "fail"})); + } + + #[allow(clippy::clone_on_copy)] + if cltv_expiry + <= plugin.state().blockheight.lock().clone() + + CANCEL_HOLD_BEFORE_HTLC_EXPIRY_BLOCKS + { + warn!( + "payment_hash: `{}` scid: `{}` htlc: `{}`. \ + HTLC timed out. Rejecting htlc...", + pay_hash, global_htlc_ident.scid, global_htlc_ident.htlc_id + ); + let cur_amt: u64 = holdinvoice_data + .htlc_data + .values() + .map(|htlc| htlc.amount_msat) + .sum(); + if Amount::msat(&invoice.amount_msat.unwrap()) > cur_amt - amount_msat + && holdinvoice_data.hold_state == Holdstate::Accepted + { + match datastore_update_state( + &rpc_path, + pay_hash.to_string(), + Holdstate::Open.to_string(), + holdinvoice_data.generation, + ) + .await + { + Ok(_o) => (), + Err(e) => { + warn!( + "Error updating state for pay_hash: {} {}", + pay_hash, + e.to_string() + ); + continue; + } + }; + info!( + "payment_hash: `{}` scid: `{}` htlc: `{}`. \ + No longer enough msats for holdinvoice. \ + State=OPEN", + pay_hash, global_htlc_ident.scid, global_htlc_ident.htlc_id + ); + } + + cleanup_pluginstate_holdinvoices( + &mut holdinvoices, + pay_hash, + &global_htlc_ident, + ) + .await; + + return Ok(json!({"result": "fail"})); + } + + match holdinvoice_data.hold_state { + Holdstate::Open => { + if Amount::msat(&invoice.amount_msat.unwrap()) + <= holdinvoice_data + .htlc_data + .values() + .map(|htlc| htlc.amount_msat) + .sum() + && holdinvoice_data + .hold_state + .is_valid_transition(&Holdstate::Accepted) + { + match datastore_update_state( + &rpc_path, + pay_hash.to_string(), + Holdstate::Accepted.to_string(), + holdinvoice_data.generation, + ) + .await + { + Ok(_o) => (), + Err(e) => { + warn!( + "Error updating state for pay_hash: {} {}", + pay_hash, + e.to_string() + ); + continue; + } + }; + info!( + "payment_hash: `{}` scid: `{}` htlc: `{}`. \ + Got enough msats for holdinvoice. \ + State=ACCEPTED", + pay_hash, global_htlc_ident.scid, global_htlc_ident.htlc_id + ); + *holdinvoice_data + .htlc_data + .get(&global_htlc_ident) + .unwrap() + .loop_mutex + .lock() + .await = false; + } else { + debug!( + "payment_hash: `{}` scid: `{}` htlc: `{}`. \ + Not enough msats for holdinvoice yet.", + pay_hash, global_htlc_ident.scid, global_htlc_ident.htlc_id + ); + } + } + Holdstate::Accepted => { + if Amount::msat(&invoice.amount_msat.unwrap()) + > holdinvoice_data + .htlc_data + .values() + .map(|htlc| htlc.amount_msat) + .sum() + && holdinvoice_data + .hold_state + .is_valid_transition(&Holdstate::Open) + { + match datastore_update_state( + &rpc_path, + pay_hash.to_string(), + Holdstate::Open.to_string(), + holdinvoice_data.generation, + ) + .await + { + Ok(_o) => (), + Err(e) => { + warn!( + "Error updating state for pay_hash: {} {}", + pay_hash, + e.to_string() + ); + continue; + } + }; + info!( + "payment_hash: `{}` scid: `{}` htlc: `{}`. \ + No longer enough msats for holdinvoice. \ + State=OPEN", + pay_hash, global_htlc_ident.scid, global_htlc_ident.htlc_id + ); + } else { + debug!( + "payment_hash: `{}` scid: `{}` htlc: `{}`. \ + Holding accepted holdinvoice.", + pay_hash, global_htlc_ident.scid, global_htlc_ident.htlc_id + ); + *holdinvoice_data + .htlc_data + .get(&global_htlc_ident) + .unwrap() + .loop_mutex + .lock() + .await = false; + } + } + Holdstate::Settled => { + info!( + "payment_hash: `{}` scid: `{}` htlc: `{}`. \ + Settling htlc for holdinvoice. State=SETTLED", + pay_hash, global_htlc_ident.scid, global_htlc_ident.htlc_id + ); + + cleanup_pluginstate_holdinvoices( + &mut holdinvoices, + pay_hash, + &global_htlc_ident, + ) + .await; + + return Ok(json!({"result": "continue"})); + } + Holdstate::Canceled => { + info!( + "payment_hash: `{}` scid: `{}` htlc: `{}`. \ + Rejecting htlc for canceled holdinvoice. \ + State=CANCELED", + pay_hash, global_htlc_ident.scid, global_htlc_ident.htlc_id + ); + + cleanup_pluginstate_holdinvoices( + &mut holdinvoices, + pay_hash, + &global_htlc_ident, + ) + .await; + + return Ok(json!({"result": "fail"})); + } + } + } + } else { + warn!( + "payment_hash: `{}` scid: `{}` htlc: `{}`. \ + DROPPED INVOICE from internal state!", + pay_hash, global_htlc_ident.scid, global_htlc_ident.htlc_id + ); + return Err(anyhow!( + "Invoice dropped from internal state unexpectedly: {}", + pay_hash + )); + } + } + } +} + +pub async fn block_added(plugin: Plugin, v: serde_json::Value) -> Result<(), Error> { + if let Some(block) = v.get("block") { + if let Some(h) = block.get("height") { + *plugin.state().blockheight.lock() = h.as_u64().unwrap() as u32 + } else { + return Err(anyhow!("could not find height for block")); + } + } else { + return Err(anyhow!("could not read block notification")); + }; + + let mut holdinvoices = plugin.state().holdinvoices.lock().await; + for (_, invoice) in holdinvoices.iter_mut() { + for (_, htlc) in invoice.htlc_data.iter_mut() { + *htlc.loop_mutex.lock().await = true; + } + } + + Ok(()) +} diff --git a/plugins/holdinvoice/src/lib.rs b/plugins/holdinvoice/src/lib.rs new file mode 100644 index 000000000000..90acf222711a --- /dev/null +++ b/plugins/holdinvoice/src/lib.rs @@ -0,0 +1,55 @@ +use std::{fmt, str::FromStr}; + +use anyhow::{anyhow, Error}; + +pub mod hold; +pub mod model; +pub mod util; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum Holdstate { + Open, + Settled, + Canceled, + Accepted, +} +impl Holdstate { + pub fn as_i32(&self) -> i32 { + match self { + Holdstate::Open => 0, + Holdstate::Settled => 1, + Holdstate::Canceled => 2, + Holdstate::Accepted => 3, + } + } + pub fn is_valid_transition(&self, newstate: &Holdstate) -> bool { + match self { + Holdstate::Open => !matches!(newstate, Holdstate::Settled), + Holdstate::Settled => matches!(newstate, Holdstate::Settled), + Holdstate::Canceled => matches!(newstate, Holdstate::Canceled), + Holdstate::Accepted => true, + } + } +} +impl fmt::Display for Holdstate { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Holdstate::Open => write!(f, "open"), + Holdstate::Settled => write!(f, "settled"), + Holdstate::Canceled => write!(f, "canceled"), + Holdstate::Accepted => write!(f, "accepted"), + } + } +} +impl FromStr for Holdstate { + type Err = Error; + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "open" => Ok(Holdstate::Open), + "settled" => Ok(Holdstate::Settled), + "canceled" => Ok(Holdstate::Canceled), + "accepted" => Ok(Holdstate::Accepted), + _ => Err(anyhow!("could not parse Holdstate from {}", s)), + } + } +} diff --git a/plugins/holdinvoice/src/main.rs b/plugins/holdinvoice/src/main.rs new file mode 100644 index 000000000000..9b3230d227be --- /dev/null +++ b/plugins/holdinvoice/src/main.rs @@ -0,0 +1,71 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use anyhow::{anyhow, Result}; +use cln_plugin::Builder; +use log::{debug, warn}; +use parking_lot::Mutex; + +mod hooks; +mod tasks; +use holdinvoice::hold::{ + hold_invoice, hold_invoice_cancel, hold_invoice_lookup, hold_invoice_settle, +}; +use holdinvoice::model::PluginState; + +#[tokio::main] +async fn main() -> Result<(), anyhow::Error> { + debug!("Starting holdinvoice plugin"); + std::env::set_var("CLN_PLUGIN_LOG", "debug"); + + let state = PluginState { + blockheight: Arc::new(Mutex::new(u32::default())), + holdinvoices: Arc::new(tokio::sync::Mutex::new(BTreeMap::new())), + }; + + let confplugin = if let Some(p) = Builder::new(tokio::io::stdin(), tokio::io::stdout()) + .rpcmethod( + "holdinvoice", + "create a new invoice and hold it", + hold_invoice, + ) + .rpcmethod( + "holdinvoicesettle", + "settle htlcs to corresponding holdinvoice", + hold_invoice_settle, + ) + .rpcmethod( + "holdinvoicecancel", + "cancel htlcs to corresponding holdinvoice", + hold_invoice_cancel, + ) + .rpcmethod( + "holdinvoicelookup", + "lookup hold status of holdinvoice", + hold_invoice_lookup, + ) + .hook("htlc_accepted", hooks::htlc_handler) + .subscribe("block_added", hooks::block_added) + .configure() + .await? + { + p + } else { + return Ok(()); + }; + + if let Ok(plugin) = confplugin.start(state).await { + let cleanupclone = plugin.clone(); + tokio::spawn(async move { + match tasks::autoclean_holdinvoice_db(cleanupclone).await { + Ok(()) => (), + Err(e) => warn!( + "Error in autoclean_holdinvoice_db thread: {}", + e.to_string() + ), + }; + }); + plugin.join().await + } else { + Err(anyhow!("Error starting the plugin!")) + } +} diff --git a/plugins/holdinvoice/src/model.rs b/plugins/holdinvoice/src/model.rs new file mode 100644 index 000000000000..aab19050e583 --- /dev/null +++ b/plugins/holdinvoice/src/model.rs @@ -0,0 +1,37 @@ +use std::{ + collections::{BTreeMap, HashMap}, + sync::Arc, +}; + +use cln_rpc::model::ListinvoicesInvoices; +use parking_lot::Mutex; + +use crate::Holdstate; + +#[derive(Clone, Debug)] +pub struct HoldHtlc { + pub amount_msat: u64, + pub cltv_expiry: u32, + pub loop_mutex: Arc>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct HtlcIdentifier { + pub scid: String, + pub htlc_id: u64, +} + +#[derive(Clone, Debug)] +pub struct HoldInvoice { + pub hold_state: Holdstate, + pub generation: u64, + pub htlc_data: HashMap, + pub last_htlc_expiry: u32, + pub invoice: ListinvoicesInvoices, +} + +#[derive(Clone, Debug)] +pub struct PluginState { + pub blockheight: Arc>, + pub holdinvoices: Arc>>, +} diff --git a/plugins/holdinvoice/src/tasks.rs b/plugins/holdinvoice/src/tasks.rs new file mode 100644 index 000000000000..f69103abae71 --- /dev/null +++ b/plugins/holdinvoice/src/tasks.rs @@ -0,0 +1,46 @@ +use std::time::Duration; + +use anyhow::Error; + +use cln_plugin::Plugin; +use log::info; +use tokio::time::{self, Instant}; + +use holdinvoice::model::PluginState; +use holdinvoice::util::{ + del_datastore_htlc_expiry, del_datastore_state, listdatastore_all, listinvoices, make_rpc_path, +}; + +pub async fn autoclean_holdinvoice_db(plugin: Plugin) -> Result<(), Error> { + time::sleep(Duration::from_secs(60)).await; + info!("Starting autoclean_holdinvoice_db"); + + let rpc_path = make_rpc_path(plugin.clone()); + loop { + let now = Instant::now(); + let mut count = 0; + { + let node_invoices = listinvoices(&rpc_path, None, None).await?.invoices; + + let payment_hashes: Vec = node_invoices + .iter() + .map(|invoice| invoice.payment_hash.to_string()) + .collect(); + + let datastore = listdatastore_all(&rpc_path).await?.datastore; + for data in datastore { + if !payment_hashes.contains(&data.key[1]) { + let _res = del_datastore_htlc_expiry(&rpc_path, data.key[1].clone()).await; + let _res2 = del_datastore_state(&rpc_path, data.key[1].clone()).await; + count += 1; + } + } + } + info!( + "cleaned up {} holdinvoice database entries in {}ms", + count, + now.elapsed().as_millis() + ); + time::sleep(Duration::from_secs(3_600)).await; + } +} diff --git a/plugins/holdinvoice/src/util.rs b/plugins/holdinvoice/src/util.rs new file mode 100644 index 000000000000..14a69ac55b11 --- /dev/null +++ b/plugins/holdinvoice/src/util.rs @@ -0,0 +1,304 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::anyhow; +use cln_plugin::{Error, Plugin}; +use cln_rpc::model::{ + ListinvoicesRequest, ListinvoicesResponse, ListpeerchannelsRequest, ListpeerchannelsResponse, +}; +use cln_rpc::{ + model::{ + DatastoreMode, DatastoreRequest, DatastoreResponse, DeldatastoreRequest, + DeldatastoreResponse, ListdatastoreDatastore, ListdatastoreRequest, ListdatastoreResponse, + }, + ClnRpc, Request, Response, +}; + +const HOLD_INVOICE_PLUGIN_NAME: &str = "holdinvoice"; +const HOLD_INVOICE_DATASTORE_STATE: &str = "state"; +const HOLD_INVOICE_DATASTORE_HTLC_EXPIRY: &str = "expiry"; +pub const CANCEL_HOLD_BEFORE_INVOICE_EXPIRY_SECONDS: u64 = 1_800; +pub const CANCEL_HOLD_BEFORE_HTLC_EXPIRY_BLOCKS: u32 = 6; + +use log::debug; + +use crate::model::{HoldInvoice, HtlcIdentifier, PluginState}; + +pub async fn listinvoices( + rpc_path: &PathBuf, + label: Option, + payment_hash: Option, +) -> Result { + let mut rpc = ClnRpc::new(&rpc_path).await?; + let invoice_request = rpc + .call(Request::ListInvoices(ListinvoicesRequest { + label, + invstring: None, + payment_hash, + offer_id: None, + })) + .await + .map_err(|e| anyhow!("Error calling listinvoices: {:?}", e))?; + match invoice_request { + Response::ListInvoices(info) => Ok(info), + e => Err(anyhow!("Unexpected result in listinvoices: {:?}", e)), + } +} + +pub async fn listpeerchannels(rpc_path: &PathBuf) -> Result { + let mut rpc = ClnRpc::new(&rpc_path).await?; + let list_peer_channels = rpc + .call(Request::ListPeerChannels(ListpeerchannelsRequest { + id: None, + })) + .await + .map_err(|e| anyhow!("Error calling listpeerchannels: {}", e.to_string()))?; + match list_peer_channels { + Response::ListPeerChannels(info) => Ok(info), + e => Err(anyhow!("Unexpected result in listpeerchannels: {:?}", e)), + } +} + +pub fn make_rpc_path(plugin: Plugin) -> PathBuf { + Path::new(&plugin.configuration().lightning_dir).join(plugin.configuration().rpc_file) +} + +pub async fn cleanup_pluginstate_holdinvoices( + hold_invoices: &mut BTreeMap, + pay_hash: &str, + global_htlc_ident: &HtlcIdentifier, +) { + if let Some(h_inv) = hold_invoices.get_mut(pay_hash) { + h_inv.htlc_data.remove(global_htlc_ident); + if h_inv.htlc_data.is_empty() { + hold_invoices.remove(pay_hash); + } + } +} + +async fn datastore_raw( + rpc_path: &PathBuf, + key: Vec, + string: Option, + hex: Option, + mode: Option, + generation: Option, +) -> Result { + let mut rpc = ClnRpc::new(&rpc_path).await?; + let datastore_request = rpc + .call(Request::Datastore(DatastoreRequest { + key: key.clone(), + string: string.clone(), + hex, + mode, + generation, + })) + .await + .map_err(|e| anyhow!("Error calling datastore: {:?}", e))?; + debug!("datastore_raw: set {:?} to {}", key, string.unwrap()); + match datastore_request { + Response::Datastore(info) => Ok(info), + e => Err(anyhow!("Unexpected result in datastore: {:?}", e)), + } +} + +pub async fn datastore_new_state( + rpc_path: &PathBuf, + pay_hash: String, + string: String, +) -> Result { + datastore_raw( + rpc_path, + vec![ + HOLD_INVOICE_PLUGIN_NAME.to_string(), + pay_hash, + HOLD_INVOICE_DATASTORE_STATE.to_string(), + ], + Some(string), + None, + Some(DatastoreMode::MUST_CREATE), + None, + ) + .await +} + +pub async fn datastore_update_state( + rpc_path: &PathBuf, + pay_hash: String, + string: String, + generation: u64, +) -> Result { + datastore_raw( + rpc_path, + vec![ + HOLD_INVOICE_PLUGIN_NAME.to_string(), + pay_hash, + HOLD_INVOICE_DATASTORE_STATE.to_string(), + ], + Some(string), + None, + Some(DatastoreMode::MUST_REPLACE), + Some(generation), + ) + .await +} + +pub async fn datastore_update_state_forced( + rpc_path: &PathBuf, + pay_hash: String, + string: String, +) -> Result { + datastore_raw( + rpc_path, + vec![ + HOLD_INVOICE_PLUGIN_NAME.to_string(), + pay_hash, + HOLD_INVOICE_DATASTORE_STATE.to_string(), + ], + Some(string), + None, + Some(DatastoreMode::MUST_REPLACE), + None, + ) + .await +} + +pub async fn datastore_htlc_expiry( + rpc_path: &PathBuf, + pay_hash: String, + string: String, +) -> Result { + datastore_raw( + rpc_path, + vec![ + HOLD_INVOICE_PLUGIN_NAME.to_string(), + pay_hash, + HOLD_INVOICE_DATASTORE_HTLC_EXPIRY.to_string(), + ], + Some(string), + None, + Some(DatastoreMode::CREATE_OR_REPLACE), + None, + ) + .await +} + +async fn listdatastore_raw( + rpc_path: &PathBuf, + key: Option>, +) -> Result { + let mut rpc = ClnRpc::new(&rpc_path).await?; + let datastore_request = rpc + .call(Request::ListDatastore(ListdatastoreRequest { key })) + .await + .map_err(|e| anyhow!("Error calling listdatastore: {:?}", e))?; + match datastore_request { + Response::ListDatastore(info) => Ok(info), + e => Err(anyhow!("Unexpected result in listdatastore: {:?}", e)), + } +} + +pub async fn listdatastore_all(rpc_path: &PathBuf) -> Result { + listdatastore_raw(rpc_path, Some(vec![HOLD_INVOICE_PLUGIN_NAME.to_string()])).await +} + +pub async fn listdatastore_state( + rpc_path: &PathBuf, + pay_hash: String, +) -> Result { + let response = listdatastore_raw( + rpc_path, + Some(vec![ + HOLD_INVOICE_PLUGIN_NAME.to_string(), + pay_hash.clone(), + HOLD_INVOICE_DATASTORE_STATE.to_string(), + ]), + ) + .await?; + let data = response.datastore.first().ok_or_else(|| { + anyhow!( + "empty result for listdatastore_state with pay_hash: {}", + pay_hash + ) + })?; + Ok(data.clone()) +} + +pub async fn listdatastore_htlc_expiry(rpc_path: &PathBuf, pay_hash: String) -> Result { + let response = listdatastore_raw( + rpc_path, + Some(vec![ + HOLD_INVOICE_PLUGIN_NAME.to_string(), + pay_hash.clone(), + HOLD_INVOICE_DATASTORE_HTLC_EXPIRY.to_string(), + ]), + ) + .await?; + let data = response + .datastore + .first() + .ok_or_else(|| { + anyhow!( + "empty result for listdatastore_htlc_expiry with pay_hash: {}", + pay_hash + ) + })? + .string + .as_ref() + .ok_or_else(|| { + anyhow!( + "None string for listdatastore_htlc_expiry with pay_hash: {}", + pay_hash + ) + })?; + let cltv = data.parse::()?; + Ok(cltv) +} + +async fn del_datastore_raw( + rpc_path: &PathBuf, + key: Vec, +) -> Result { + let mut rpc = ClnRpc::new(&rpc_path).await?; + let del_datastore_request = rpc + .call(Request::DelDatastore(DeldatastoreRequest { + key, + generation: None, + })) + .await + .map_err(|e| anyhow!("Error calling DelDatastore: {:?}", e))?; + match del_datastore_request { + Response::DelDatastore(info) => Ok(info), + e => Err(anyhow!("Unexpected result in DelDatastore: {:?}", e)), + } +} + +pub async fn del_datastore_state( + rpc_path: &PathBuf, + pay_hash: String, +) -> Result { + del_datastore_raw( + rpc_path, + vec![ + HOLD_INVOICE_PLUGIN_NAME.to_string(), + pay_hash, + HOLD_INVOICE_DATASTORE_STATE.to_string(), + ], + ) + .await +} + +pub async fn del_datastore_htlc_expiry( + rpc_path: &PathBuf, + pay_hash: String, +) -> Result { + del_datastore_raw( + rpc_path, + vec![ + HOLD_INVOICE_PLUGIN_NAME.to_string(), + pay_hash.clone(), + HOLD_INVOICE_DATASTORE_HTLC_EXPIRY.to_string(), + ], + ) + .await +} diff --git a/plugins/holdinvoice/tests/holdinvoicetest.py b/plugins/holdinvoice/tests/holdinvoicetest.py new file mode 100644 index 000000000000..8dabc5df795f --- /dev/null +++ b/plugins/holdinvoice/tests/holdinvoicetest.py @@ -0,0 +1,305 @@ +#!/usr/bin/python + +from pyln.client import LightningRpc +import unittest +import secrets +import threading +import time +from util import generate_random_label +from util import generate_random_number +from util import pay_with_thread + +# need 2 nodes with sufficient liquidity on rpc1 side +# this is the node with holdinvoice +rpc2 = LightningRpc("/tmp/l2-regtest/regtest/lightning-rpc") +# this node pays the invoices +rpc1 = LightningRpc("/tmp/l1-regtest/regtest/lightning-rpc") + + +class TestStringMethods(unittest.TestCase): + + def test_valid_input(self): + result = rpc2.holdinvoice( + amount_msat=1000000, + description="Valid invoice description", + label=generate_random_label() + ) + self.assertIsNotNone(result) + self.assertTrue(isinstance(result, dict)) + self.assertIn("payment_hash", result) + + result = rpc2.holdinvoice( + amount_msat=1000000, + description="", + label=generate_random_label() + ) + self.assertIsNotNone(result) + self.assertTrue(isinstance(result, dict)) + self.assertIn("payment_hash", result) + + result = rpc2.holdinvoice( + amount_msat=1000000, + description="Valid invoice description", + label=generate_random_number() + ) + self.assertIsNotNone(result) + self.assertTrue(isinstance(result, dict)) + self.assertIn("payment_hash", result) + + def test_missing_required_fields(self): + result = rpc2.holdinvoice( + description="Incomplete invoice description", + label=generate_random_label() + ) + self.assertIsNotNone(result) + self.assertTrue(isinstance(result, dict)) + self.assertEqual(result["message"], + "missing required parameter: amount_msat|msatoshi") + + result = rpc2.holdinvoice( + amount_msat=1000000, + description="Incomplete invoice description", + ) + self.assertIsNotNone(result) + self.assertTrue(isinstance(result, dict)) + self.assertEqual(result["message"], + "missing required parameter: label") + + result = rpc2.holdinvoice( + amount_msat=1000000, + label=generate_random_label() + ) + self.assertIsNotNone(result) + self.assertTrue(isinstance(result, dict)) + self.assertEqual(result["message"], + "missing required parameter: description") + + def test_optional_fields(self): + random_hex = secrets.token_hex(32) + result = rpc2.holdinvoice( + amount_msat=2000000, + description="Invoice with optional fields", + label=generate_random_label(), + expiry=3600, + fallbacks=["bcrt1qcpw242j4xsjth7ueq9dgmrqtxjyutuvmraeryr", + "bcrt1qdwydlys0f8khnp87mx688vq4kskjyr68nrx58j"], + preimage=random_hex, + cltv=144, + deschashonly=True + ) + self.assertIsNotNone(result) + self.assertTrue(isinstance(result, dict)) + self.assertIn("payment_hash", result) + + def test_invalid_amount_msat(self): + # Negative amount_msat + result = rpc2.holdinvoice( + amount_msat=-1000, + description="Invalid amount", + label=generate_random_label() + ) + self.assertIsNotNone(result) + self.assertTrue(isinstance(result, dict)) + self.assertEqual( + result["message"], "amount_msat|msatoshi: should be an unsigned " + "64 bit integer: invalid token '-1000'") + + # 0 amount_msat + result = rpc2.holdinvoice( + amount_msat=0, + description="Invalid amount", + label=generate_random_label() + ) + self.assertIsNotNone(result) + self.assertTrue(isinstance(result, dict)) + self.assertEqual( + result["message"], "amount_msat|msatoshi: should be positive msat" + " or 'any': invalid token '\"0msat\"'") + + def test_invalid_expiry(self): + # Negative expiry value + result = rpc2.holdinvoice( + amount_msat=500000, + description="Invalid expiry", + label=generate_random_label(), + expiry=-3600 + ) + self.assertIsNotNone(result) + self.assertTrue(isinstance(result, dict)) + self.assertEqual(result["message"], "expiry: should be an unsigned " + "64 bit integer: invalid token '-3600'") + + def test_invalid_fallbacks(self): + # Fallbacks not as a list of strings + result = rpc2.holdinvoice( + amount_msat=800000, + description="Invalid fallbacks", + label=generate_random_label(), + fallbacks="invalid_fallback" + ) + self.assertIsNotNone(result) + self.assertTrue(isinstance(result, dict)) + self.assertEqual(result["message"], "fallbacks: should be an array: " + "invalid token '\"invalid_fallback\"'") + + def test_invalid_cltv(self): + # Negative cltv value + result = rpc2.holdinvoice( + amount_msat=1200000, + description="Invalid cltv", + label=generate_random_label(), + cltv=-144 + ) + self.assertIsNotNone(result) + self.assertTrue(isinstance(result, dict)) + self.assertEqual(result["message"], "cltv: should be an integer: " + "invalid token '-144'") + + def test_valid_hold_then_settle(self): + result = rpc2.holdinvoice( + amount_msat=1_000_100_000, + description="Valid invoice description", + label=generate_random_label() + ) + self.assertIsNotNone(result) + self.assertTrue(isinstance(result, dict)) + self.assertIn("payment_hash", result) + + result_lookup = rpc2.holdinvoicelookup( + payment_hash=result["payment_hash"]) + self.assertIsNotNone(result_lookup) + self.assertTrue(isinstance(result_lookup, dict)) + self.assertIn("state", result_lookup) + self.assertEqual(result_lookup["state"], "open") + self.assertNotIn("htlc_expiry", result_lookup) + + # test that it won't settle if it's still open + result_settle = rpc2.holdinvoicesettle( + payment_hash=result["payment_hash"]) + self.assertIsNotNone(result_settle) + self.assertTrue(isinstance(result_settle, dict)) + self.assertEqual(result_settle["message"], + "Holdinvoice is in wrong state: 'open'") + + threading.Thread(target=pay_with_thread, args=( + rpc1, result["bolt11"])).start() + + timeout = 10 + start_time = time.time() + + while time.time() - start_time < timeout: + result_lookup = rpc2.holdinvoicelookup( + payment_hash=result["payment_hash"]) + self.assertIsNotNone(result_lookup) + self.assertTrue(isinstance(result_lookup, dict)) + + if result_lookup["state"] == "accepted": + break + else: + time.sleep(1) + + self.assertEqual(result_lookup["state"], "accepted") + self.assertIn("htlc_expiry", result_lookup) + + # test that it's actually holding the htlcs + # and not letting them through + doublecheck = rpc2.listinvoices( + payment_hash=result["payment_hash"])["invoices"] + self.assertEqual(doublecheck[0]["status"], "unpaid") + + result_settle = rpc2.holdinvoicesettle( + payment_hash=result["payment_hash"]) + self.assertIsNotNone(result_settle) + self.assertTrue(isinstance(result_settle, dict)) + self.assertEqual(result_settle["state"], "settled") + self.assertNotIn("htlc_expiry", result_settle) + + result_lookup = rpc2.holdinvoicelookup( + payment_hash=result["payment_hash"]) + self.assertIsNotNone(result_lookup) + self.assertTrue(isinstance(result_lookup, dict)) + self.assertEqual(result_lookup["state"], "settled") + self.assertNotIn("htlc_expiry", result_lookup) + + # ask cln if the invoice is actually paid + # should not be necessary because lookup does this aswell + doublecheck = rpc2.listinvoices( + payment_hash=result["payment_hash"])["invoices"] + self.assertEqual(doublecheck[0]["status"], "paid") + + result_cancel_settled = rpc2.holdinvoicecancel( + payment_hash=result["payment_hash"]) + self.assertIsNotNone(result_cancel_settled) + self.assertTrue(isinstance(result_cancel_settled, dict)) + self.assertEqual( + result_cancel_settled["message"], "Holdinvoice is in wrong " + "state: 'settled'") + + def test_valid_hold_then_cancel(self): + result = rpc2.holdinvoice( + amount_msat=1_000_100_000, + description="Valid invoice description", + label=generate_random_label() + ) + self.assertIsNotNone(result) + self.assertTrue(isinstance(result, dict)) + self.assertIn("payment_hash", result) + + result_lookup = rpc2.holdinvoicelookup( + payment_hash=result["payment_hash"]) + self.assertIsNotNone(result_lookup) + self.assertTrue(isinstance(result_lookup, dict)) + self.assertIn("state", result_lookup) + self.assertEqual(result_lookup["state"], "open") + self.assertNotIn("htlc_expiry", result_lookup) + + threading.Thread(target=pay_with_thread, args=( + rpc1, result["bolt11"])).start() + + timeout = 10 + start_time = time.time() + + while time.time() - start_time < timeout: + result_lookup = rpc2.holdinvoicelookup( + payment_hash=result["payment_hash"]) + self.assertIsNotNone(result_lookup) + self.assertTrue(isinstance(result_lookup, dict)) + + if result_lookup["state"] == "accepted": + break + else: + time.sleep(1) + + self.assertEqual(result_lookup["state"], "accepted") + self.assertIn("htlc_expiry", result_lookup) + + result_cancel = rpc2.holdinvoicecancel( + payment_hash=result["payment_hash"]) + self.assertIsNotNone(result_cancel) + self.assertTrue(isinstance(result_cancel, dict)) + self.assertEqual(result_cancel["state"], "canceled") + self.assertNotIn("htlc_expiry", result_cancel) + + result_lookup = rpc2.holdinvoicelookup( + payment_hash=result["payment_hash"]) + self.assertIsNotNone(result_lookup) + self.assertTrue(isinstance(result_lookup, dict)) + self.assertEqual(result_lookup["state"], "canceled") + self.assertNotIn("htlc_expiry", result_lookup) + + doublecheck = rpc2.listinvoices( + payment_hash=result["payment_hash"])["invoices"] + self.assertEqual(doublecheck[0]["status"], "unpaid") + + # if we cancel we cannot settle after + result_settle_canceled = rpc2.holdinvoicesettle( + payment_hash=result["payment_hash"]) + self.assertIsNotNone(result_settle_canceled) + self.assertTrue(isinstance(result_settle_canceled, dict)) + self.assertEqual( + result_settle_canceled["message"], "Holdinvoice is in wrong " + "state: 'canceled'") + + +if __name__ == '__main__': + unittest.main() diff --git a/plugins/holdinvoice/tests/stresstest.py b/plugins/holdinvoice/tests/stresstest.py new file mode 100755 index 000000000000..ced45eddf130 --- /dev/null +++ b/plugins/holdinvoice/tests/stresstest.py @@ -0,0 +1,82 @@ +#!/usr/bin/python + +from pyln.client import LightningRpc +import time +import threading +import pickle +from util import generate_random_label, pay_with_thread + + +# number of invoices to create, pay, hold and then cancel +num_iterations = 80 +# seconds to hold the invoices with inflight htlcs +delay_seconds = 120 +# amount to be used in msat +amount_msat = 1_000_100_000 + +# need 2 nodes with sufficient liquidity on rpc1 side +# this is the node with holdinvoice +rpc2 = LightningRpc("/tmp/l2-regtest/regtest/lightning-rpc") +# this node pays the invoices +rpc1 = LightningRpc("/tmp/l1-regtest/regtest/lightning-rpc") + + +def lookup_stats(rpc, payment_hashes): + state_counts = {'open': 0, 'settled': 0, 'canceled': 0, 'accepted': 0} + for payment_hash in payment_hashes: + try: + invoice_info = rpc.holdinvoicelookup(payment_hash) + state = invoice_info['state'] + state_counts[state] = state_counts.get(state, 0) + 1 + except Exception as e: + print(f"Error looking up payment hash {payment_hash}:", e) + print(state_counts) + + +payment_hashes = [] + + +for _ in range(num_iterations): + label = generate_random_label() + + try: + result = rpc2.holdinvoice( + amount_msat=amount_msat, + label=label, + description="masstest", + expiry=3600 + ) + payment_hash = result['payment_hash'] + payment_hashes.append(payment_hash) + + # Pay the invoice using a separate thread + threading.Thread(target=pay_with_thread, args=( + rpc1, result["bolt11"])).start() + time.sleep(1) + except Exception as e: + print("Error executing command:", e) + +# Save payment hashes to disk incase something breaks +# and we want to do some manual cleanup +with open('payment_hashes.pkl', 'wb') as f: + pickle.dump(payment_hashes, f) + print("Saved payment hashes to disk.") + +# wait a little more for payments to arrive +time.sleep(5) + +lookup_stats(rpc2, payment_hashes) + +print(f"Waiting for {delay_seconds} seconds...") + +time.sleep(delay_seconds) + +lookup_stats(rpc2, payment_hashes) + +for payment_hash in payment_hashes: + try: + rpc2.holdinvoicecancel(payment_hash) + except Exception as e: + print(f"Error cancelling payment hash {payment_hash}:", e) + +lookup_stats(rpc2, payment_hashes) diff --git a/plugins/holdinvoice/tests/util.py b/plugins/holdinvoice/tests/util.py new file mode 100644 index 000000000000..d84791cb65c4 --- /dev/null +++ b/plugins/holdinvoice/tests/util.py @@ -0,0 +1,21 @@ +import string +import random + + +def generate_random_label(): + label_length = 8 + random_label = ''.join(random.choice(string.ascii_letters) + for _ in range(label_length)) + return random_label + + +def generate_random_number(): + return random.randint(1, 20_000_000_000_000_00_000) + + +def pay_with_thread(rpc, bolt11): + try: + rpc.pay(bolt11) + except Exception: + # print(f"Error paying payment hash {payment_hash}:", e) + pass From 1fe7be893695a30f5480b64ea390836169493c78 Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Sun, 27 Aug 2023 19:23:33 +0200 Subject: [PATCH 02/18] make block_added subscription fn compatible with old and new versions --- plugins/holdinvoice/src/hooks.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/plugins/holdinvoice/src/hooks.rs b/plugins/holdinvoice/src/hooks.rs index 861c728f357b..9fbd7875b18d 100644 --- a/plugins/holdinvoice/src/hooks.rs +++ b/plugins/holdinvoice/src/hooks.rs @@ -531,15 +531,18 @@ async fn loop_htlc_hold( } pub async fn block_added(plugin: Plugin, v: serde_json::Value) -> Result<(), Error> { - if let Some(block) = v.get("block") { - if let Some(h) = block.get("height") { - *plugin.state().blockheight.lock() = h.as_u64().unwrap() as u32 - } else { - return Err(anyhow!("could not find height for block")); - } + let block = if let Some(b) = v.get("block") { + b + } else if let Some(b) = v.get("block_added") { + b } else { return Err(anyhow!("could not read block notification")); }; + if let Some(h) = block.get("height") { + *plugin.state().blockheight.lock() = h.as_u64().unwrap() as u32 + } else { + return Err(anyhow!("could not find height for block")); + } let mut holdinvoices = plugin.state().holdinvoices.lock().await; for (_, invoice) in holdinvoices.iter_mut() { From a938c51620a6733e454ee7ddb243275997e74fd4 Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Sun, 27 Aug 2023 19:24:13 +0200 Subject: [PATCH 03/18] settle and cancel returns should not have htlc_expiry field --- plugins/holdinvoice/src/hold.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/plugins/holdinvoice/src/hold.rs b/plugins/holdinvoice/src/hold.rs index 943b863e402e..6a198f14db5a 100644 --- a/plugins/holdinvoice/src/hold.rs +++ b/plugins/holdinvoice/src/hold.rs @@ -128,9 +128,8 @@ pub async fn hold_invoice_settle( )); } - Ok(json!(HoldstateResponse { + Ok(json!(HoldStateResponse { state: Holdstate::Settled.to_string(), - htlc_expiry: None })) } Err(e) => Err(anyhow!( @@ -177,9 +176,8 @@ pub async fn hold_invoice_cancel( } } - Ok(json!(HoldstateResponse { + Ok(json!(HoldStateResponse { state: Holdstate::Canceled.to_string(), - htlc_expiry: None })) } Err(e) => Err(anyhow!( @@ -282,19 +280,24 @@ pub async fn hold_invoice_lookup( } } } - Ok(json!(HoldstateResponse { + Ok(json!(HoldLookupResponse { state: holdstate.to_string(), htlc_expiry })) } #[derive(Clone, Debug, Serialize, Deserialize)] -struct HoldstateResponse { +struct HoldLookupResponse { state: String, #[serde(skip_serializing_if = "Option::is_none")] htlc_expiry: Option, } +#[derive(Clone, Debug, Serialize, Deserialize)] +struct HoldStateResponse { + state: String, +} + fn missing_parameter_error(param: &str) -> serde_json::Value { json!({ "code": -32602, From a32c607909c0742a790b8aa45372f0cfca132eed Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Sun, 27 Aug 2023 19:26:39 +0200 Subject: [PATCH 04/18] remove htlc_expiry asserts --- plugins/holdinvoice/tests/holdinvoicetest.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugins/holdinvoice/tests/holdinvoicetest.py b/plugins/holdinvoice/tests/holdinvoicetest.py index 8dabc5df795f..79ba02f13a79 100644 --- a/plugins/holdinvoice/tests/holdinvoicetest.py +++ b/plugins/holdinvoice/tests/holdinvoicetest.py @@ -212,7 +212,6 @@ def test_valid_hold_then_settle(self): self.assertIsNotNone(result_settle) self.assertTrue(isinstance(result_settle, dict)) self.assertEqual(result_settle["state"], "settled") - self.assertNotIn("htlc_expiry", result_settle) result_lookup = rpc2.holdinvoicelookup( payment_hash=result["payment_hash"]) @@ -278,7 +277,6 @@ def test_valid_hold_then_cancel(self): self.assertIsNotNone(result_cancel) self.assertTrue(isinstance(result_cancel, dict)) self.assertEqual(result_cancel["state"], "canceled") - self.assertNotIn("htlc_expiry", result_cancel) result_lookup = rpc2.holdinvoicelookup( payment_hash=result["payment_hash"]) From 1830b1c4eaece0659a1f2c939d26f75be1dc0955 Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Sat, 2 Sep 2023 11:48:56 +0200 Subject: [PATCH 05/18] update cln dependencies --- Cargo.lock | 14 +++++++------- plugins/holdinvoice/Cargo.toml | 6 +++--- plugins/holdinvoice/src/hold.rs | 2 +- plugins/holdinvoice/src/hooks.rs | 2 +- plugins/holdinvoice/src/model.rs | 2 +- plugins/holdinvoice/src/util.rs | 16 +++++++++++----- 6 files changed, 24 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bf41681e6eaf..60d0e3c0a140 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -297,9 +297,9 @@ dependencies = [ [[package]] name = "cln-plugin" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e59ac0c0944b5f43bf8008b8495a3b0d0036e4c208fa34d8575aaff93694e197" +checksum = "1098794b7562120ec5caa7b768847655fd5249088676a8d8ba9110a01becf97b" dependencies = [ "anyhow", "bytes", @@ -332,9 +332,9 @@ dependencies = [ [[package]] name = "cln-rpc" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3b630e345cdfc6f64315414b50815a9eeabbf12438413798bf09e9e79be8b8" +checksum = "0fc0228e674bd18614b04e56150f295ebeef4a0cfc1fd8d2a83feb70e9929f3e" dependencies = [ "anyhow", "bitcoin", @@ -614,11 +614,11 @@ dependencies = [ "windows-sys", ======= name = "holdinvoice" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", - "cln-plugin 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", - "cln-rpc 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)", + "cln-plugin 0.1.5", + "cln-rpc 0.1.4", "log", "parking_lot", "serde", diff --git a/plugins/holdinvoice/Cargo.toml b/plugins/holdinvoice/Cargo.toml index f4b4d5890f28..36aa804b6650 100644 --- a/plugins/holdinvoice/Cargo.toml +++ b/plugins/holdinvoice/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "holdinvoice" -version = "0.1.0" +version = "0.1.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -cln-rpc = "0.1.3" -cln-plugin = "0.1.4" +cln-rpc = "0.1.4" +cln-plugin = "0.1.5" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/plugins/holdinvoice/src/hold.rs b/plugins/holdinvoice/src/hold.rs index 6a198f14db5a..995d9f09b84a 100644 --- a/plugins/holdinvoice/src/hold.rs +++ b/plugins/holdinvoice/src/hold.rs @@ -3,7 +3,7 @@ use std::{str::FromStr, time::Duration}; use anyhow::{anyhow, Error}; use cln_plugin::Plugin; use cln_rpc::{ - model::{InvoiceRequest, ListinvoicesInvoicesStatus}, + model::{requests::InvoiceRequest, responses::ListinvoicesInvoicesStatus}, primitives::{Amount, AmountOrAny}, ClnRpc, Request, Response, }; diff --git a/plugins/holdinvoice/src/hooks.rs b/plugins/holdinvoice/src/hooks.rs index 9fbd7875b18d..8cc6391e1e78 100644 --- a/plugins/holdinvoice/src/hooks.rs +++ b/plugins/holdinvoice/src/hooks.rs @@ -8,7 +8,7 @@ use std::{ use anyhow::{anyhow, Error}; use cln_plugin::Plugin; -use cln_rpc::{model::ListinvoicesInvoices, primitives::Amount}; +use cln_rpc::{model::responses::ListinvoicesInvoices, primitives::Amount}; use log::{debug, info, warn}; use serde_json::json; use tokio::time::{self}; diff --git a/plugins/holdinvoice/src/model.rs b/plugins/holdinvoice/src/model.rs index aab19050e583..ec2d946582c6 100644 --- a/plugins/holdinvoice/src/model.rs +++ b/plugins/holdinvoice/src/model.rs @@ -3,7 +3,7 @@ use std::{ sync::Arc, }; -use cln_rpc::model::ListinvoicesInvoices; +use cln_rpc::model::responses::ListinvoicesInvoices; use parking_lot::Mutex; use crate::Holdstate; diff --git a/plugins/holdinvoice/src/util.rs b/plugins/holdinvoice/src/util.rs index 14a69ac55b11..646f4c76ebfb 100644 --- a/plugins/holdinvoice/src/util.rs +++ b/plugins/holdinvoice/src/util.rs @@ -3,13 +3,16 @@ use std::path::{Path, PathBuf}; use anyhow::anyhow; use cln_plugin::{Error, Plugin}; -use cln_rpc::model::{ - ListinvoicesRequest, ListinvoicesResponse, ListpeerchannelsRequest, ListpeerchannelsResponse, -}; use cln_rpc::{ model::{ - DatastoreMode, DatastoreRequest, DatastoreResponse, DeldatastoreRequest, - DeldatastoreResponse, ListdatastoreDatastore, ListdatastoreRequest, ListdatastoreResponse, + requests::{ + DatastoreMode, DatastoreRequest, DeldatastoreRequest, ListdatastoreRequest, + ListinvoicesRequest, ListpeerchannelsRequest, + }, + responses::{ + DatastoreResponse, DeldatastoreResponse, ListdatastoreDatastore, ListdatastoreResponse, + ListinvoicesResponse, ListpeerchannelsResponse, + }, }, ClnRpc, Request, Response, }; @@ -36,6 +39,9 @@ pub async fn listinvoices( invstring: None, payment_hash, offer_id: None, + index: None, + start: None, + limit: None, })) .await .map_err(|e| anyhow!("Error calling listinvoices: {:?}", e))?; From 56b40d511e04ab4ef6d410fd3d7faee1ce3d0919 Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Sat, 2 Sep 2023 11:59:48 +0200 Subject: [PATCH 06/18] make test invoice descriptions identifiable/unique --- plugins/holdinvoice/tests/holdinvoicetest.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/holdinvoice/tests/holdinvoicetest.py b/plugins/holdinvoice/tests/holdinvoicetest.py index 79ba02f13a79..f4a68c9100b9 100644 --- a/plugins/holdinvoice/tests/holdinvoicetest.py +++ b/plugins/holdinvoice/tests/holdinvoicetest.py @@ -39,7 +39,7 @@ def test_valid_input(self): result = rpc2.holdinvoice( amount_msat=1000000, - description="Valid invoice description", + description="Numbers only as label", label=generate_random_number() ) self.assertIsNotNone(result) @@ -48,7 +48,7 @@ def test_valid_input(self): def test_missing_required_fields(self): result = rpc2.holdinvoice( - description="Incomplete invoice description", + description="Missing amount", label=generate_random_label() ) self.assertIsNotNone(result) @@ -58,7 +58,7 @@ def test_missing_required_fields(self): result = rpc2.holdinvoice( amount_msat=1000000, - description="Incomplete invoice description", + description="Missing label", ) self.assertIsNotNone(result) self.assertTrue(isinstance(result, dict)) @@ -95,7 +95,7 @@ def test_invalid_amount_msat(self): # Negative amount_msat result = rpc2.holdinvoice( amount_msat=-1000, - description="Invalid amount", + description="Invalid amount negative", label=generate_random_label() ) self.assertIsNotNone(result) @@ -107,7 +107,7 @@ def test_invalid_amount_msat(self): # 0 amount_msat result = rpc2.holdinvoice( amount_msat=0, - description="Invalid amount", + description="Invalid amount 0", label=generate_random_label() ) self.assertIsNotNone(result) @@ -158,7 +158,7 @@ def test_invalid_cltv(self): def test_valid_hold_then_settle(self): result = rpc2.holdinvoice( amount_msat=1_000_100_000, - description="Valid invoice description", + description="test_valid_hold_then_settle", label=generate_random_label() ) self.assertIsNotNone(result) @@ -237,7 +237,7 @@ def test_valid_hold_then_settle(self): def test_valid_hold_then_cancel(self): result = rpc2.holdinvoice( amount_msat=1_000_100_000, - description="Valid invoice description", + description="test_valid_hold_then_cancel", label=generate_random_label() ) self.assertIsNotNone(result) From 0fa74bd03d9af9a9fd7cb83703f43e915e4ff6f0 Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Tue, 12 Sep 2023 12:49:58 +0200 Subject: [PATCH 07/18] add expiry check for open, never attempted invoices --- Cargo.lock | 2 +- plugins/holdinvoice/Cargo.toml | 2 +- plugins/holdinvoice/src/hold.rs | 20 +++++++++++++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 60d0e3c0a140..de6253a3c0d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -614,7 +614,7 @@ dependencies = [ "windows-sys", ======= name = "holdinvoice" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "cln-plugin 0.1.5", diff --git a/plugins/holdinvoice/Cargo.toml b/plugins/holdinvoice/Cargo.toml index 36aa804b6650..fa5b2a8eeca1 100644 --- a/plugins/holdinvoice/Cargo.toml +++ b/plugins/holdinvoice/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "holdinvoice" -version = "0.1.1" +version = "0.1.2" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/plugins/holdinvoice/src/hold.rs b/plugins/holdinvoice/src/hold.rs index 995d9f09b84a..cf1e75966278 100644 --- a/plugins/holdinvoice/src/hold.rs +++ b/plugins/holdinvoice/src/hold.rs @@ -210,7 +210,25 @@ pub async fn hold_invoice_lookup( let mut htlc_expiry = None; match holdstate { - Holdstate::Open => (), + Holdstate::Open => { + let invoices = listinvoices(&rpc_path, None, Some(pay_hash.clone())) + .await? + .invoices; + if let Some(inv) = invoices.first() { + if inv.status == ListinvoicesInvoicesStatus::EXPIRED { + datastore_update_state_forced( + &rpc_path, + pay_hash.clone(), + Holdstate::Canceled.to_string(), + ) + .await?; + return Ok(json!(HoldLookupResponse { + state: Holdstate::Canceled.to_string(), + htlc_expiry + })); + } + } + } Holdstate::Accepted => { htlc_expiry = Some(listdatastore_htlc_expiry(&rpc_path, pay_hash.clone()).await?) } From c3c74e34c8e56e6e3fa61a0ffd51785e69568d12 Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Tue, 12 Sep 2023 12:54:20 +0200 Subject: [PATCH 08/18] return error if invoice already got deleted --- plugins/holdinvoice/src/hold.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/holdinvoice/src/hold.rs b/plugins/holdinvoice/src/hold.rs index cf1e75966278..b0d6c4fa96ef 100644 --- a/plugins/holdinvoice/src/hold.rs +++ b/plugins/holdinvoice/src/hold.rs @@ -227,6 +227,8 @@ pub async fn hold_invoice_lookup( htlc_expiry })); } + } else { + return Ok(payment_hash_missing_error(&pay_hash)); } } Holdstate::Accepted => { From 551964133412e253a35944aced0f11b48ba3ae86 Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Wed, 20 Sep 2023 15:45:07 +0200 Subject: [PATCH 09/18] CANCEL if invoice or htlc is about to expire, disallow ACCEPTED->OPEN --- Cargo.lock | 2 +- plugins/holdinvoice/Cargo.toml | 2 +- plugins/holdinvoice/src/hooks.rs | 124 +++++++++---------------------- plugins/holdinvoice/src/lib.rs | 2 +- 4 files changed, 38 insertions(+), 92 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index de6253a3c0d9..5010aa90f03a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -614,7 +614,7 @@ dependencies = [ "windows-sys", ======= name = "holdinvoice" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "cln-plugin 0.1.5", diff --git a/plugins/holdinvoice/Cargo.toml b/plugins/holdinvoice/Cargo.toml index fa5b2a8eeca1..b43f49d6ae45 100644 --- a/plugins/holdinvoice/Cargo.toml +++ b/plugins/holdinvoice/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "holdinvoice" -version = "0.1.2" +version = "0.1.3" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/plugins/holdinvoice/src/hooks.rs b/plugins/holdinvoice/src/hooks.rs index 8cc6391e1e78..7a8e3a165e74 100644 --- a/plugins/holdinvoice/src/hooks.rs +++ b/plugins/holdinvoice/src/hooks.rs @@ -229,7 +229,6 @@ pub async fn htlc_handler( global_htlc_ident, invoice, cltv_expiry, - amount_msat, ) .await; } @@ -245,7 +244,6 @@ async fn loop_htlc_hold( global_htlc_ident: HtlcIdentifier, invoice: ListinvoicesInvoices, cltv_expiry: u32, - amount_msat: u64, ) -> Result { let mut first_iter = true; loop { @@ -290,93 +288,44 @@ async fn loop_htlc_hold( }; // cln cannot accept htlcs for expired invoices - if invoice.expires_at <= now + CANCEL_HOLD_BEFORE_INVOICE_EXPIRY_SECONDS { - warn!( - "payment_hash: `{}` scid: `{}` htlc: `{}`. \ - holdinvoice expired! State=CANCELED", - pay_hash, global_htlc_ident.scid, global_htlc_ident.htlc_id - ); - match datastore_update_state( - &rpc_path, - pay_hash.to_string(), - Holdstate::Canceled.to_string(), - holdinvoice_data.generation, - ) - .await - { - Ok(_o) => (), - Err(e) => { - warn!( - "Error updating state for pay_hash: {} {}", - pay_hash, - e.to_string() - ); - continue; - } - }; - - cleanup_pluginstate_holdinvoices( - &mut holdinvoices, - pay_hash, - &global_htlc_ident, - ) - .await; - - return Ok(json!({"result": "fail"})); - } - #[allow(clippy::clone_on_copy)] if cltv_expiry <= plugin.state().blockheight.lock().clone() + CANCEL_HOLD_BEFORE_HTLC_EXPIRY_BLOCKS + || invoice.expires_at <= now + CANCEL_HOLD_BEFORE_INVOICE_EXPIRY_SECONDS { - warn!( - "payment_hash: `{}` scid: `{}` htlc: `{}`. \ - HTLC timed out. Rejecting htlc...", - pay_hash, global_htlc_ident.scid, global_htlc_ident.htlc_id - ); - let cur_amt: u64 = holdinvoice_data - .htlc_data - .values() - .map(|htlc| htlc.amount_msat) - .sum(); - if Amount::msat(&invoice.amount_msat.unwrap()) > cur_amt - amount_msat - && holdinvoice_data.hold_state == Holdstate::Accepted - { - match datastore_update_state( - &rpc_path, - pay_hash.to_string(), - Holdstate::Open.to_string(), - holdinvoice_data.generation, - ) - .await - { - Ok(_o) => (), - Err(e) => { - warn!( - "Error updating state for pay_hash: {} {}", - pay_hash, - e.to_string() - ); - continue; + match holdinvoice_data.hold_state { + Holdstate::Open | Holdstate::Accepted => { + match datastore_update_state( + &rpc_path, + pay_hash.to_string(), + Holdstate::Canceled.to_string(), + holdinvoice_data.generation, + ) + .await + { + Ok(_o) => { + warn!( + "payment_hash: `{}` scid: `{}` htlc: `{}`. \ + holdinvoice/htlc expired! Canceling htlc...", + pay_hash, + global_htlc_ident.scid, + global_htlc_ident.htlc_id + ); + holdinvoice_data.hold_state = Holdstate::Canceled + } + Err(e) => { + warn!( + "Error updating state for pay_hash: {} {}", + pay_hash, + e.to_string() + ); + continue; + } } - }; - info!( - "payment_hash: `{}` scid: `{}` htlc: `{}`. \ - No longer enough msats for holdinvoice. \ - State=OPEN", - pay_hash, global_htlc_ident.scid, global_htlc_ident.htlc_id - ); + } + Holdstate::Canceled | Holdstate::Settled => (), } - - cleanup_pluginstate_holdinvoices( - &mut holdinvoices, - pay_hash, - &global_htlc_ident, - ) - .await; - - return Ok(json!({"result": "fail"})); } match holdinvoice_data.hold_state { @@ -437,14 +386,11 @@ async fn loop_htlc_hold( .values() .map(|htlc| htlc.amount_msat) .sum() - && holdinvoice_data - .hold_state - .is_valid_transition(&Holdstate::Open) { match datastore_update_state( &rpc_path, pay_hash.to_string(), - Holdstate::Open.to_string(), + Holdstate::Canceled.to_string(), holdinvoice_data.generation, ) .await @@ -459,10 +405,10 @@ async fn loop_htlc_hold( continue; } }; - info!( + warn!( "payment_hash: `{}` scid: `{}` htlc: `{}`. \ - No longer enough msats for holdinvoice. \ - State=OPEN", + No longer enough msats for holdinvoice! \ + Canceling htlcs...", pay_hash, global_htlc_ident.scid, global_htlc_ident.htlc_id ); } else { diff --git a/plugins/holdinvoice/src/lib.rs b/plugins/holdinvoice/src/lib.rs index 90acf222711a..3138f6e00bf8 100644 --- a/plugins/holdinvoice/src/lib.rs +++ b/plugins/holdinvoice/src/lib.rs @@ -27,7 +27,7 @@ impl Holdstate { Holdstate::Open => !matches!(newstate, Holdstate::Settled), Holdstate::Settled => matches!(newstate, Holdstate::Settled), Holdstate::Canceled => matches!(newstate, Holdstate::Canceled), - Holdstate::Accepted => true, + Holdstate::Accepted => !matches!(newstate, Holdstate::Open), } } } From eb9928f53b1067881df05275804f54a0dc282200 Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Tue, 3 Oct 2023 14:01:15 +0200 Subject: [PATCH 10/18] fix Cargo.lock --- Cargo.lock | 106 ++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 81 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5010aa90f03a..5d135ca3f049 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -195,11 +195,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0694ea59225b0c5f3cb405ff3f670e4828358ed26aec49dc352f730f0cb1a8a3" dependencies = [ "bech32", - "bitcoin_hashes", - "secp256k1", + "bitcoin_hashes 0.11.0", + "secp256k1 0.24.3", "serde", ] +[[package]] +name = "bitcoin" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e99ff7289b20a7385f66a0feda78af2fc119d28fb56aea8886a9cd0a4abdd75" +dependencies = [ + "bech32", + "bitcoin-private", + "bitcoin_hashes 0.12.0", + "hex_lit", + "secp256k1 0.27.0", + "serde", +] + +[[package]] +name = "bitcoin-private" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73290177011694f38ec25e165d0387ab7ea749a4b81cd4c80dae5988229f7a57" + [[package]] name = "bitcoin_hashes" version = "0.11.0" @@ -209,6 +229,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin_hashes" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d7066118b13d4b20b23645932dfb3a81ce7e29f95726c2036fa33cd7b092501" +dependencies = [ + "bitcoin-private", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -253,8 +283,8 @@ name = "cln-grpc" version = "0.1.4" dependencies = [ "anyhow", - "bitcoin", - "cln-rpc 0.1.3", + "bitcoin 0.29.2", + "cln-rpc 0.1.4", "hex", "log", "prost", @@ -269,8 +299,8 @@ version = "0.1.4" dependencies = [ "anyhow", "cln-grpc", - "cln-plugin 0.1.4", - "cln-rpc 0.1.3", + "cln-plugin 0.1.5", + "cln-rpc 0.1.4", "log", "prost", "rcgen", @@ -297,9 +327,9 @@ dependencies = [ [[package]] name = "cln-plugin" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1098794b7562120ec5caa7b768847655fd5249088676a8d8ba9110a01becf97b" +checksum = "bd946736b96911cef5a03494368bd1b33977d6f3411440f04311168ad45938be" dependencies = [ "anyhow", "bytes", @@ -318,7 +348,7 @@ name = "cln-rpc" version = "0.1.4" dependencies = [ "anyhow", - "bitcoin", + "bitcoin 0.29.2", "bytes", "env_logger", "futures-util", @@ -332,12 +362,12 @@ dependencies = [ [[package]] name = "cln-rpc" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc0228e674bd18614b04e56150f295ebeef4a0cfc1fd8d2a83feb70e9929f3e" +checksum = "ef2a6c99d85386867cab37b5268740e0155710efafa9707a74951586675943ec" dependencies = [ "anyhow", - "bitcoin", + "bitcoin 0.30.1", "bytes", "futures-util", "hex", @@ -605,26 +635,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -<<<<<<< HEAD -name = "home" -version = "0.5.5" +name = "hex_lit" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" -dependencies = [ - "windows-sys", -======= +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] name = "holdinvoice" version = "0.1.3" dependencies = [ "anyhow", - "cln-plugin 0.1.5", - "cln-rpc 0.1.4", + "cln-plugin 0.1.6", + "cln-rpc 0.1.6", "log", "parking_lot", "serde", "serde_json", "tokio", ->>>>>>> ff053ae7a (holdinvoice) +] + +[[package]] +name = "home" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" +dependencies = [ + "windows-sys", ] [[package]] @@ -936,7 +972,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets 0.48.0", + "windows-targets", ] [[package]] @@ -1263,8 +1299,19 @@ version = "0.24.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1629c9c557ef9b293568b338dddfc8208c98a18c59d722a9d53f859d9c9b62" dependencies = [ - "bitcoin_hashes", - "secp256k1-sys", + "bitcoin_hashes 0.11.0", + "secp256k1-sys 0.6.1", + "serde", +] + +[[package]] +name = "secp256k1" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25996b82292a7a57ed3508f052cfff8640d38d32018784acd714758b43da9c8f" +dependencies = [ + "bitcoin_hashes 0.12.0", + "secp256k1-sys 0.8.1", "serde", ] @@ -1277,6 +1324,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a129b9e9efbfb223753b9163c4ab3b13cff7fd9c7f010fbac25ab4099fa07e" +dependencies = [ + "cc", +] + [[package]] name = "serde" version = "1.0.188" From b670104692ec72133fc92283628193be80123b29 Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Tue, 3 Oct 2023 14:01:34 +0200 Subject: [PATCH 11/18] update cln dependencies --- plugins/holdinvoice/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/holdinvoice/Cargo.toml b/plugins/holdinvoice/Cargo.toml index b43f49d6ae45..3b16073a9d61 100644 --- a/plugins/holdinvoice/Cargo.toml +++ b/plugins/holdinvoice/Cargo.toml @@ -6,8 +6,8 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -cln-rpc = "0.1.4" -cln-plugin = "0.1.5" +cln-rpc = "0.1.6" +cln-plugin = "0.1.6" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" From bfe5d23f4a1ede95ec50e23df7343df783930414 Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Tue, 3 Oct 2023 14:01:49 +0200 Subject: [PATCH 12/18] fix minor clippy nags --- plugins/holdinvoice/src/hold.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/holdinvoice/src/hold.rs b/plugins/holdinvoice/src/hold.rs index b0d6c4fa96ef..ca2faed4b9b3 100644 --- a/plugins/holdinvoice/src/hold.rs +++ b/plugins/holdinvoice/src/hold.rs @@ -29,7 +29,7 @@ pub async fn hold_invoice( let rpc_path = make_rpc_path(plugin.clone()); let mut rpc = ClnRpc::new(&rpc_path).await?; - let valid_arg_keys = vec![ + let valid_arg_keys = [ "amount_msat", "label", "description", @@ -395,7 +395,7 @@ fn parse_payment_hash(args: serde_json::Value) -> Result Date: Tue, 3 Oct 2023 14:30:28 +0200 Subject: [PATCH 13/18] only wait for connected, normal channels when looking for canceled invoices --- plugins/holdinvoice/src/hold.rs | 37 ++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/plugins/holdinvoice/src/hold.rs b/plugins/holdinvoice/src/hold.rs index ca2faed4b9b3..111111754c26 100644 --- a/plugins/holdinvoice/src/hold.rs +++ b/plugins/holdinvoice/src/hold.rs @@ -3,7 +3,10 @@ use std::{str::FromStr, time::Duration}; use anyhow::{anyhow, Error}; use cln_plugin::Plugin; use cln_rpc::{ - model::{requests::InvoiceRequest, responses::ListinvoicesInvoicesStatus}, + model::{ + requests::InvoiceRequest, + responses::{ListinvoicesInvoicesStatus, ListpeerchannelsChannelsState}, + }, primitives::{Amount, AmountOrAny}, ClnRpc, Request, Response, }; @@ -244,12 +247,32 @@ pub async fn hold_invoice_lookup( }; for chan in channels { - if let Some(htlcs) = chan.htlcs { - for htlc in htlcs { - if let Some(ph) = htlc.payment_hash { - if ph.to_string() == pay_hash { - all_cancelled = false; - } + let connected = if let Some(c) = chan.peer_connected { + c + } else { + continue; + }; + let state = if let Some(s) = chan.state { + s + } else { + continue; + }; + if !connected + || state != ListpeerchannelsChannelsState::CHANNELD_NORMAL + && state != ListpeerchannelsChannelsState::CHANNELD_AWAITING_SPLICE + { + continue; + } + + let htlcs = if let Some(h) = chan.htlcs { + h + } else { + continue; + }; + for htlc in htlcs { + if let Some(ph) = htlc.payment_hash { + if ph.to_string() == pay_hash { + all_cancelled = false; } } } From fe381964264af710fb23b48de2b2b57217b3e6a4 Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Thu, 5 Oct 2023 20:28:20 +0200 Subject: [PATCH 14/18] rewrite tests to use pyln-testing --- plugins/holdinvoice/tests/holdinvoicetest.py | 619 ++++++++++--------- plugins/holdinvoice/tests/stresstest.py | 143 +++-- plugins/holdinvoice/tests/util.py | 4 +- 3 files changed, 417 insertions(+), 349 deletions(-) diff --git a/plugins/holdinvoice/tests/holdinvoicetest.py b/plugins/holdinvoice/tests/holdinvoicetest.py index f4a68c9100b9..0acf36012ed6 100644 --- a/plugins/holdinvoice/tests/holdinvoicetest.py +++ b/plugins/holdinvoice/tests/holdinvoicetest.py @@ -1,303 +1,334 @@ #!/usr/bin/python -from pyln.client import LightningRpc -import unittest +from pyln.testing.fixtures import * +from pyln.testing.utils import only_one, mine_funding_to_announce import secrets import threading import time +import os from util import generate_random_label from util import generate_random_number from util import pay_with_thread -# need 2 nodes with sufficient liquidity on rpc1 side -# this is the node with holdinvoice -rpc2 = LightningRpc("/tmp/l2-regtest/regtest/lightning-rpc") -# this node pays the invoices -rpc1 = LightningRpc("/tmp/l1-regtest/regtest/lightning-rpc") - - -class TestStringMethods(unittest.TestCase): - - def test_valid_input(self): - result = rpc2.holdinvoice( - amount_msat=1000000, - description="Valid invoice description", - label=generate_random_label() - ) - self.assertIsNotNone(result) - self.assertTrue(isinstance(result, dict)) - self.assertIn("payment_hash", result) - - result = rpc2.holdinvoice( - amount_msat=1000000, - description="", - label=generate_random_label() - ) - self.assertIsNotNone(result) - self.assertTrue(isinstance(result, dict)) - self.assertIn("payment_hash", result) - - result = rpc2.holdinvoice( - amount_msat=1000000, - description="Numbers only as label", - label=generate_random_number() - ) - self.assertIsNotNone(result) - self.assertTrue(isinstance(result, dict)) - self.assertIn("payment_hash", result) - - def test_missing_required_fields(self): - result = rpc2.holdinvoice( - description="Missing amount", - label=generate_random_label() - ) - self.assertIsNotNone(result) - self.assertTrue(isinstance(result, dict)) - self.assertEqual(result["message"], - "missing required parameter: amount_msat|msatoshi") - - result = rpc2.holdinvoice( - amount_msat=1000000, - description="Missing label", - ) - self.assertIsNotNone(result) - self.assertTrue(isinstance(result, dict)) - self.assertEqual(result["message"], - "missing required parameter: label") - - result = rpc2.holdinvoice( - amount_msat=1000000, - label=generate_random_label() - ) - self.assertIsNotNone(result) - self.assertTrue(isinstance(result, dict)) - self.assertEqual(result["message"], - "missing required parameter: description") - - def test_optional_fields(self): - random_hex = secrets.token_hex(32) - result = rpc2.holdinvoice( - amount_msat=2000000, - description="Invoice with optional fields", - label=generate_random_label(), - expiry=3600, - fallbacks=["bcrt1qcpw242j4xsjth7ueq9dgmrqtxjyutuvmraeryr", - "bcrt1qdwydlys0f8khnp87mx688vq4kskjyr68nrx58j"], - preimage=random_hex, - cltv=144, - deschashonly=True - ) - self.assertIsNotNone(result) - self.assertTrue(isinstance(result, dict)) - self.assertIn("payment_hash", result) - - def test_invalid_amount_msat(self): - # Negative amount_msat - result = rpc2.holdinvoice( - amount_msat=-1000, - description="Invalid amount negative", - label=generate_random_label() - ) - self.assertIsNotNone(result) - self.assertTrue(isinstance(result, dict)) - self.assertEqual( - result["message"], "amount_msat|msatoshi: should be an unsigned " - "64 bit integer: invalid token '-1000'") - - # 0 amount_msat - result = rpc2.holdinvoice( - amount_msat=0, - description="Invalid amount 0", - label=generate_random_label() - ) - self.assertIsNotNone(result) - self.assertTrue(isinstance(result, dict)) - self.assertEqual( - result["message"], "amount_msat|msatoshi: should be positive msat" - " or 'any': invalid token '\"0msat\"'") - - def test_invalid_expiry(self): - # Negative expiry value - result = rpc2.holdinvoice( - amount_msat=500000, - description="Invalid expiry", - label=generate_random_label(), - expiry=-3600 - ) - self.assertIsNotNone(result) - self.assertTrue(isinstance(result, dict)) - self.assertEqual(result["message"], "expiry: should be an unsigned " - "64 bit integer: invalid token '-3600'") - - def test_invalid_fallbacks(self): - # Fallbacks not as a list of strings - result = rpc2.holdinvoice( - amount_msat=800000, - description="Invalid fallbacks", - label=generate_random_label(), - fallbacks="invalid_fallback" - ) - self.assertIsNotNone(result) - self.assertTrue(isinstance(result, dict)) - self.assertEqual(result["message"], "fallbacks: should be an array: " - "invalid token '\"invalid_fallback\"'") - - def test_invalid_cltv(self): - # Negative cltv value - result = rpc2.holdinvoice( - amount_msat=1200000, - description="Invalid cltv", - label=generate_random_label(), - cltv=-144 - ) - self.assertIsNotNone(result) - self.assertTrue(isinstance(result, dict)) - self.assertEqual(result["message"], "cltv: should be an integer: " - "invalid token '-144'") - - def test_valid_hold_then_settle(self): - result = rpc2.holdinvoice( - amount_msat=1_000_100_000, - description="test_valid_hold_then_settle", - label=generate_random_label() - ) - self.assertIsNotNone(result) - self.assertTrue(isinstance(result, dict)) - self.assertIn("payment_hash", result) - - result_lookup = rpc2.holdinvoicelookup( - payment_hash=result["payment_hash"]) - self.assertIsNotNone(result_lookup) - self.assertTrue(isinstance(result_lookup, dict)) - self.assertIn("state", result_lookup) - self.assertEqual(result_lookup["state"], "open") - self.assertNotIn("htlc_expiry", result_lookup) - - # test that it won't settle if it's still open - result_settle = rpc2.holdinvoicesettle( - payment_hash=result["payment_hash"]) - self.assertIsNotNone(result_settle) - self.assertTrue(isinstance(result_settle, dict)) - self.assertEqual(result_settle["message"], - "Holdinvoice is in wrong state: 'open'") - - threading.Thread(target=pay_with_thread, args=( - rpc1, result["bolt11"])).start() - - timeout = 10 - start_time = time.time() - - while time.time() - start_time < timeout: - result_lookup = rpc2.holdinvoicelookup( - payment_hash=result["payment_hash"]) - self.assertIsNotNone(result_lookup) - self.assertTrue(isinstance(result_lookup, dict)) - - if result_lookup["state"] == "accepted": - break - else: - time.sleep(1) - - self.assertEqual(result_lookup["state"], "accepted") - self.assertIn("htlc_expiry", result_lookup) - - # test that it's actually holding the htlcs - # and not letting them through - doublecheck = rpc2.listinvoices( - payment_hash=result["payment_hash"])["invoices"] - self.assertEqual(doublecheck[0]["status"], "unpaid") - - result_settle = rpc2.holdinvoicesettle( - payment_hash=result["payment_hash"]) - self.assertIsNotNone(result_settle) - self.assertTrue(isinstance(result_settle, dict)) - self.assertEqual(result_settle["state"], "settled") - - result_lookup = rpc2.holdinvoicelookup( - payment_hash=result["payment_hash"]) - self.assertIsNotNone(result_lookup) - self.assertTrue(isinstance(result_lookup, dict)) - self.assertEqual(result_lookup["state"], "settled") - self.assertNotIn("htlc_expiry", result_lookup) - - # ask cln if the invoice is actually paid - # should not be necessary because lookup does this aswell - doublecheck = rpc2.listinvoices( - payment_hash=result["payment_hash"])["invoices"] - self.assertEqual(doublecheck[0]["status"], "paid") - - result_cancel_settled = rpc2.holdinvoicecancel( - payment_hash=result["payment_hash"]) - self.assertIsNotNone(result_cancel_settled) - self.assertTrue(isinstance(result_cancel_settled, dict)) - self.assertEqual( - result_cancel_settled["message"], "Holdinvoice is in wrong " - "state: 'settled'") - - def test_valid_hold_then_cancel(self): - result = rpc2.holdinvoice( - amount_msat=1_000_100_000, - description="test_valid_hold_then_cancel", - label=generate_random_label() - ) - self.assertIsNotNone(result) - self.assertTrue(isinstance(result, dict)) - self.assertIn("payment_hash", result) - - result_lookup = rpc2.holdinvoicelookup( - payment_hash=result["payment_hash"]) - self.assertIsNotNone(result_lookup) - self.assertTrue(isinstance(result_lookup, dict)) - self.assertIn("state", result_lookup) - self.assertEqual(result_lookup["state"], "open") - self.assertNotIn("htlc_expiry", result_lookup) - - threading.Thread(target=pay_with_thread, args=( - rpc1, result["bolt11"])).start() - - timeout = 10 - start_time = time.time() - - while time.time() - start_time < timeout: - result_lookup = rpc2.holdinvoicelookup( - payment_hash=result["payment_hash"]) - self.assertIsNotNone(result_lookup) - self.assertTrue(isinstance(result_lookup, dict)) - - if result_lookup["state"] == "accepted": - break - else: - time.sleep(1) - - self.assertEqual(result_lookup["state"], "accepted") - self.assertIn("htlc_expiry", result_lookup) - - result_cancel = rpc2.holdinvoicecancel( - payment_hash=result["payment_hash"]) - self.assertIsNotNone(result_cancel) - self.assertTrue(isinstance(result_cancel, dict)) - self.assertEqual(result_cancel["state"], "canceled") - - result_lookup = rpc2.holdinvoicelookup( - payment_hash=result["payment_hash"]) - self.assertIsNotNone(result_lookup) - self.assertTrue(isinstance(result_lookup, dict)) - self.assertEqual(result_lookup["state"], "canceled") - self.assertNotIn("htlc_expiry", result_lookup) - - doublecheck = rpc2.listinvoices( - payment_hash=result["payment_hash"])["invoices"] - self.assertEqual(doublecheck[0]["status"], "unpaid") - - # if we cancel we cannot settle after - result_settle_canceled = rpc2.holdinvoicesettle( - payment_hash=result["payment_hash"]) - self.assertIsNotNone(result_settle_canceled) - self.assertTrue(isinstance(result_settle_canceled, dict)) - self.assertEqual( - result_settle_canceled["message"], "Holdinvoice is in wrong " - "state: 'canceled'") - - -if __name__ == '__main__': - unittest.main() + +def test_inputs(node_factory): + node = node_factory.get_node( + options={ + 'important-plugin': os.path.join( + os.getcwd(), '../../target/release/holdinvoice' + ) + } + ) + result = node.rpc.call("holdinvoice", { + "amount_msat": 1000000, + "description": "Valid invoice description", + "label": generate_random_label()} + ) + assert result is not None + assert isinstance(result, dict) is True + assert "payment_hash" in result + + result = node.rpc.call("holdinvoice", { + "amount_msat": 1000000, + "description": "", + "label": generate_random_label()} + ) + assert result is not None + assert isinstance(result, dict) is True + assert "payment_hash" in result + + result = node.rpc.call("holdinvoice", { + "amount_msat": 1000000, + "description": "Numbers only as label", + "label": generate_random_number()} + ) + assert result is not None + assert isinstance(result, dict) is True + assert "payment_hash" in result + + result = node.rpc.call("holdinvoice", { + "description": "Missing amount", + "label": generate_random_label()} + ) + assert result is not None + assert isinstance(result, dict) is True + expected_message = ("missing required parameter: amount_msat|msatoshi") + assert result["message"] == expected_message + + result = node.rpc.call("holdinvoice", { + "amount_msat": 1000000, + "description": "Missing label", } + ) + assert result is not None + assert isinstance(result, dict) is True + assert result["message"] == "missing required parameter: label" + + result = node.rpc.call("holdinvoice", { + "amount_msat": 1000000, + "label": generate_random_label()} + ) + assert result is not None + assert isinstance(result, dict) is True + assert result["message"] == "missing required parameter: description" + + random_hex = secrets.token_hex(32) + result = node.rpc.call("holdinvoice", { + "amount_msat": 2000000, + "description": "Invoice with optional fields", + "label": generate_random_label(), + "expiry": 3600, + "fallbacks": ["bcrt1qcpw242j4xsjth7ueq9dgmrqtxjyutuvmraeryr", + "bcrt1qdwydlys0f8khnp87mx688vq4kskjyr68nrx58j"], + "preimage": random_hex, + "cltv": 144, + "deschashonly": True} + ) + assert result is not None + assert isinstance(result, dict) is True + assert "payment_hash" in result + + # Negative amount_msat + result = node.rpc.call("holdinvoice", { + "amount_msat": -1000, + "description": "Invalid amount negative", + "label": generate_random_label()} + ) + assert result is not None + assert isinstance(result, dict) is True + expected_message = ("amount_msat|msatoshi: should be an unsigned " + "64 bit integer: invalid token '-1000'") + assert result["message"] == expected_message + + # 0 amount_msat + result = node.rpc.call("holdinvoice", { + "amount_msat": 0, + "description": "Invalid amount 0", + "label": generate_random_label()} + ) + assert result is not None + assert isinstance(result, dict) is True + expected_message = ("amount_msat|msatoshi: should be positive msat" + " or 'any': invalid token '\"0msat\"'") + assert result["message"] == expected_message + + # Negative expiry value + result = node.rpc.call("holdinvoice", { + "amount_msat": 500000, + "description": "Invalid expiry", + "label": generate_random_label(), + "expiry": -3600} + ) + assert result is not None + assert isinstance(result, dict) is True + expected_message = ("expiry: should be an unsigned " + "64 bit integer: invalid token '-3600'") + assert result["message"] == expected_message + + # Fallbacks not as a list of strings + result = node.rpc.call("holdinvoice", { + "amount_msat": 800000, + "description": "Invalid fallbacks", + "label": generate_random_label(), + "fallbacks": "invalid_fallback"} + ) + assert result is not None + assert isinstance(result, dict) is True + expected_message = ("fallbacks: should be an array: " + "invalid token '\"invalid_fallback\"'") + assert result["message"] == expected_message + + # Negative cltv value + result = node.rpc.call("holdinvoice", { + "amount_msat": 1200000, + "description": "Invalid cltv", + "label": generate_random_label(), + "cltv": -144} + ) + assert result is not None + assert isinstance(result, dict) is True + expected_message = ("cltv: should be an integer: " + "invalid token '-144'") + assert result["message"] == expected_message + + +def test_valid_hold_then_settle(node_factory, bitcoind): + l1, l2 = node_factory.get_nodes(2, + opts={ + 'important-plugin': os.path.join( + os.getcwd(), + '../../target/release/holdinvoice' + ) + } + ) + l1.fundwallet(10**7) + l1.rpc.connect(l2.info['id'], 'localhost', l2.port) + cl1, _ = l1.fundchannel(l2, 1_000_000) + mine_funding_to_announce(bitcoind, [l1]) + cl2, _ = l1.fundchannel(l2, 1_000_000) + mine_funding_to_announce(bitcoind, [l1]) + + l1.wait_channel_active(cl1) + l1.wait_channel_active(cl2) + + invoice = l2.rpc.call("holdinvoice", { + "amount_msat": 1_000_100_000, + "description": "test_valid_hold_then_settle", + "label": generate_random_label(), + "cltv": 144} + ) + assert invoice is not None + assert isinstance(invoice, dict) is True + assert "payment_hash" in invoice + + result_lookup = l2.rpc.call("holdinvoicelookup", { + "payment_hash": invoice["payment_hash"]}) + assert result_lookup is not None + assert isinstance(result_lookup, dict) is True + assert "state" in result_lookup + assert result_lookup["state"] == "open" + assert "htlc_expiry" not in result_lookup + + # test that it won't settle if it's still open + result_settle = l2.rpc.call("holdinvoicesettle", { + "payment_hash": invoice["payment_hash"]}) + assert result_settle is not None + assert isinstance(result_settle, dict) is True + expected_message = ("Holdinvoice is in wrong state: 'open'") + assert result_settle["message"] == expected_message + + threading.Thread(target=pay_with_thread, args=( + l1.rpc, invoice["bolt11"])).start() + + timeout = 10 + start_time = time.time() + + while time.time() - start_time < timeout: + result_lookup = l2.rpc.call("holdinvoicelookup", { + "payment_hash": invoice["payment_hash"]}) + assert result_lookup is not None + assert isinstance(result_lookup, dict) is True + + if result_lookup["state"] == "accepted": + break + else: + time.sleep(1) + + assert result_lookup["state"] == "accepted" + assert "htlc_expiry" in result_lookup + + # test that it's actually holding the htlcs + # and not letting them through + doublecheck = only_one(l2.rpc.call("listinvoices", { + "payment_hash": invoice["payment_hash"]})["invoices"]) + assert doublecheck["status"] == "unpaid" + + result_settle = l2.rpc.call("holdinvoicesettle", { + "payment_hash": invoice["payment_hash"]}) + assert result_settle is not None + assert isinstance(result_settle, dict) is True + assert result_settle["state"] == "settled" + + result_lookup = l2.rpc.call("holdinvoicelookup", { + "payment_hash": invoice["payment_hash"]}) + assert result_lookup is not None + assert isinstance(result_lookup, dict) is True + assert result_lookup["state"] == "settled" + assert "htlc_expiry" not in result_lookup + + # ask cln if the invoice is actually paid + # should not be necessary because lookup does this aswell + doublecheck = only_one(l2.rpc.call("listinvoices", { + "payment_hash": invoice["payment_hash"]})["invoices"]) + assert doublecheck["status"] == "paid" + + result_cancel_settled = l2.rpc.call("holdinvoicecancel", { + "payment_hash": invoice["payment_hash"]}) + assert result_cancel_settled is not None + assert isinstance(result_cancel_settled, dict) is True + expected_message = ("Holdinvoice is in wrong " + "state: 'settled'") + assert result_cancel_settled["message"] == expected_message + + +def test_valid_hold_then_cancel(node_factory, bitcoind): + l1, l2 = node_factory.get_nodes(2, + opts={ + 'important-plugin': os.path.join( + os.getcwd(), + '../../target/release/holdinvoice' + ) + } + ) + l1.fundwallet(10**7) + l1.rpc.connect(l2.info['id'], 'localhost', l2.port) + cl1, _ = l1.fundchannel(l2, 1_000_000) + mine_funding_to_announce(bitcoind, [l1]) + cl2, _ = l1.fundchannel(l2, 1_000_000) + mine_funding_to_announce(bitcoind, [l1]) + + l1.wait_channel_active(cl1) + l1.wait_channel_active(cl2) + + invoice = l2.rpc.call("holdinvoice", { + "amount_msat": 1_000_100_000, + "description": "test_valid_hold_then_cancel", + "label": generate_random_label(), + "cltv": 144} + ) + assert invoice is not None + assert isinstance(invoice, dict) is True + assert "payment_hash" in invoice + + result_lookup = l2.rpc.call("holdinvoicelookup", { + "payment_hash": invoice["payment_hash"]}) + assert result_lookup is not None + assert isinstance(result_lookup, dict) is True + assert "state" in result_lookup + assert result_lookup["state"] == "open" + assert "htlc_expiry" not in result_lookup + + threading.Thread(target=pay_with_thread, args=( + l1.rpc, invoice["bolt11"])).start() + + timeout = 10 + start_time = time.time() + + while time.time() - start_time < timeout: + result_lookup = l2.rpc.call("holdinvoicelookup", { + "payment_hash": invoice["payment_hash"]}) + assert result_lookup is not None + assert isinstance(result_lookup, dict) is True + + if result_lookup["state"] == "accepted": + break + else: + time.sleep(1) + + assert result_lookup["state"] == "accepted" + assert "htlc_expiry" in result_lookup + + result_cancel = l2.rpc.call("holdinvoicecancel", { + "payment_hash": invoice["payment_hash"]}) + assert result_cancel is not None + assert isinstance(result_cancel, dict) is True + assert result_cancel["state"] == "canceled" + + result_lookup = l2.rpc.call("holdinvoicelookup", { + "payment_hash": invoice["payment_hash"]}) + assert result_lookup is not None + assert isinstance(result_lookup, dict) is True + assert result_lookup["state"] == "canceled" + assert "htlc_expiry" not in result_lookup + + doublecheck = only_one(l2.rpc.call("listinvoices", { + "payment_hash": invoice["payment_hash"]})["invoices"]) + assert doublecheck["status"] == "unpaid" + + # if we cancel we cannot settle after + result_settle_canceled = l2.rpc.call("holdinvoicesettle", { + "payment_hash": invoice["payment_hash"]}) + assert result_settle_canceled is not None + assert isinstance(result_settle_canceled, dict) is True + expected_message = ("Holdinvoice is in wrong " + "state: 'canceled'") + result_settle_canceled["message"] == expected_message diff --git a/plugins/holdinvoice/tests/stresstest.py b/plugins/holdinvoice/tests/stresstest.py index ced45eddf130..ff2790833a04 100755 --- a/plugins/holdinvoice/tests/stresstest.py +++ b/plugins/holdinvoice/tests/stresstest.py @@ -1,27 +1,24 @@ #!/usr/bin/python -from pyln.client import LightningRpc +from pyln.testing.fixtures import * +from pyln.testing.utils import only_one, mine_funding_to_announce import time import threading -import pickle +import os +import logging from util import generate_random_label, pay_with_thread # number of invoices to create, pay, hold and then cancel -num_iterations = 80 +num_iterations = 5 # seconds to hold the invoices with inflight htlcs -delay_seconds = 120 +delay_seconds = 12 # amount to be used in msat amount_msat = 1_000_100_000 -# need 2 nodes with sufficient liquidity on rpc1 side -# this is the node with holdinvoice -rpc2 = LightningRpc("/tmp/l2-regtest/regtest/lightning-rpc") -# this node pays the invoices -rpc1 = LightningRpc("/tmp/l1-regtest/regtest/lightning-rpc") - def lookup_stats(rpc, payment_hashes): + LOGGER = logging.getLogger(__name__) state_counts = {'open': 0, 'settled': 0, 'canceled': 0, 'accepted': 0} for payment_hash in payment_hashes: try: @@ -29,54 +26,94 @@ def lookup_stats(rpc, payment_hashes): state = invoice_info['state'] state_counts[state] = state_counts.get(state, 0) + 1 except Exception as e: - print(f"Error looking up payment hash {payment_hash}:", e) - print(state_counts) - - -payment_hashes = [] - - -for _ in range(num_iterations): - label = generate_random_label() - - try: - result = rpc2.holdinvoice( - amount_msat=amount_msat, - label=label, - description="masstest", - expiry=3600 - ) - payment_hash = result['payment_hash'] - payment_hashes.append(payment_hash) + LOGGER.error( + f"holdinvoice: Error looking up payment hash {payment_hash}:", + e) + return state_counts + + +def test_stress(node_factory, bitcoind): + LOGGER = logging.getLogger(__name__) + l1, l2 = node_factory.get_nodes(2, + opts={ + 'important-plugin': os.path.join( + os.getcwd(), + '../../target/release/holdinvoice' + ) + } + ) + LOGGER.info("holdinvoice: Nodes created") + l1.fundwallet((amount_msat/1000)*num_iterations*20) + LOGGER.info("holdinvoice: Funding secured") + l1.rpc.connect(l2.info['id'], 'localhost', l2.port) + LOGGER.info("holdinvoice: Nodes connected") + for _ in range(int(num_iterations/10)+1): + for _ in range(10): + res = l1.rpc.fundchannel(l2.info['id'], int( + (amount_msat*0.95)/1000), minconf=0) + blockid = bitcoind.generate_block(1, wait_for_mempool=res['txid'])[0] + + for i, txid in enumerate(bitcoind.rpc.getblock(blockid)['tx']): + if txid == res['txid']: + txnum = i + + scid = '{}x{}x{}'.format( + bitcoind.rpc.getblockcount(), txnum, res['outnum']) + mine_funding_to_announce(bitcoind, [l1]) + LOGGER.info("holdinvoice: Funded 10 channels") + + l1.wait_channel_active(scid) + payment_hashes = [] + + LOGGER.info( + f"holdinvoice: Creating and paying {num_iterations} invoices...") + for _ in range(num_iterations): + label = generate_random_label() - # Pay the invoice using a separate thread - threading.Thread(target=pay_with_thread, args=( - rpc1, result["bolt11"])).start() - time.sleep(1) - except Exception as e: - print("Error executing command:", e) - -# Save payment hashes to disk incase something breaks -# and we want to do some manual cleanup -with open('payment_hashes.pkl', 'wb') as f: - pickle.dump(payment_hashes, f) - print("Saved payment hashes to disk.") + try: + invoice = l2.rpc.call("holdinvoice", { + "amount_msat": amount_msat, + "label": label, + "description": "masstest", + "cltv": 144, + "expiry": 3600} + ) + payment_hash = invoice['payment_hash'] + payment_hashes.append(payment_hash) + + # Pay the invoice using a separate thread + threading.Thread(target=pay_with_thread, args=( + l1.rpc, invoice["bolt11"])).start() + time.sleep(1) + except Exception as e: + LOGGER.error("holdinvoice: Error executing command:", e) -# wait a little more for payments to arrive -time.sleep(5) + LOGGER.info(f"holdinvoice: Done paying {num_iterations} invoices!") + # wait a little more for payments to arrive + time.sleep(10) -lookup_stats(rpc2, payment_hashes) + stats = lookup_stats(l2.rpc, payment_hashes) + LOGGER.info(stats) + assert stats["accepted"] == num_iterations -print(f"Waiting for {delay_seconds} seconds...") + LOGGER.info(f"holdinvoice: Holding htlcs for {delay_seconds} seconds...") -time.sleep(delay_seconds) + time.sleep(delay_seconds) -lookup_stats(rpc2, payment_hashes) + stats = lookup_stats(l2.rpc, payment_hashes) + LOGGER.info(stats) + assert stats["accepted"] == num_iterations -for payment_hash in payment_hashes: - try: - rpc2.holdinvoicecancel(payment_hash) - except Exception as e: - print(f"Error cancelling payment hash {payment_hash}:", e) + LOGGER.info(f"holdinvoice: Cancelling all {num_iterations} invoices...") + for payment_hash in payment_hashes: + try: + l2.rpc.call("holdinvoicecancel", { + "payment_hash": payment_hash}) + except Exception as e: + LOGGER.error( + f"holdinvoice: holdinvoice:Error cancelling " + f"payment hash {payment_hash}:", e) -lookup_stats(rpc2, payment_hashes) + stats = lookup_stats(l2.rpc, payment_hashes) + LOGGER.info(stats) + assert stats["canceled"] == num_iterations diff --git a/plugins/holdinvoice/tests/util.py b/plugins/holdinvoice/tests/util.py index d84791cb65c4..5fa414db1f8d 100644 --- a/plugins/holdinvoice/tests/util.py +++ b/plugins/holdinvoice/tests/util.py @@ -16,6 +16,6 @@ def generate_random_number(): def pay_with_thread(rpc, bolt11): try: rpc.pay(bolt11) - except Exception: - # print(f"Error paying payment hash {payment_hash}:", e) + except Exception as e: + print(f"holdinvoice: Error paying payment hash:{e}") pass From 72c22f060b2b966b58a0fdc8e7608fbbc2c9eb02 Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Thu, 5 Oct 2023 21:18:53 +0200 Subject: [PATCH 15/18] try to speed up tests --- plugins/holdinvoice/tests/holdinvoicetest.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/plugins/holdinvoice/tests/holdinvoicetest.py b/plugins/holdinvoice/tests/holdinvoicetest.py index 0acf36012ed6..ce64c45d55ed 100644 --- a/plugins/holdinvoice/tests/holdinvoicetest.py +++ b/plugins/holdinvoice/tests/holdinvoicetest.py @@ -160,15 +160,12 @@ def test_valid_hold_then_settle(node_factory, bitcoind): ) } ) - l1.fundwallet(10**7) l1.rpc.connect(l2.info['id'], 'localhost', l2.port) cl1, _ = l1.fundchannel(l2, 1_000_000) - mine_funding_to_announce(bitcoind, [l1]) cl2, _ = l1.fundchannel(l2, 1_000_000) - mine_funding_to_announce(bitcoind, [l1]) - l1.wait_channel_active(cl1) - l1.wait_channel_active(cl2) + l1.wait_local_channel_active(cl1) + l1.wait_local_channel_active(cl2) invoice = l2.rpc.call("holdinvoice", { "amount_msat": 1_000_100_000, @@ -259,15 +256,12 @@ def test_valid_hold_then_cancel(node_factory, bitcoind): ) } ) - l1.fundwallet(10**7) l1.rpc.connect(l2.info['id'], 'localhost', l2.port) cl1, _ = l1.fundchannel(l2, 1_000_000) - mine_funding_to_announce(bitcoind, [l1]) cl2, _ = l1.fundchannel(l2, 1_000_000) - mine_funding_to_announce(bitcoind, [l1]) - l1.wait_channel_active(cl1) - l1.wait_channel_active(cl2) + l1.wait_local_channel_active(cl1) + l1.wait_local_channel_active(cl2) invoice = l2.rpc.call("holdinvoice", { "amount_msat": 1_000_100_000, From 14d3bab609371296eeb1f6c9096fa5402b433933 Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Thu, 5 Oct 2023 21:48:20 +0200 Subject: [PATCH 16/18] small fix for prev commit --- plugins/holdinvoice/tests/holdinvoicetest.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/holdinvoice/tests/holdinvoicetest.py b/plugins/holdinvoice/tests/holdinvoicetest.py index ce64c45d55ed..a047e4015ba8 100644 --- a/plugins/holdinvoice/tests/holdinvoicetest.py +++ b/plugins/holdinvoice/tests/holdinvoicetest.py @@ -164,8 +164,8 @@ def test_valid_hold_then_settle(node_factory, bitcoind): cl1, _ = l1.fundchannel(l2, 1_000_000) cl2, _ = l1.fundchannel(l2, 1_000_000) - l1.wait_local_channel_active(cl1) - l1.wait_local_channel_active(cl2) + l1.wait_channel_active(cl1) + l1.wait_channel_active(cl2) invoice = l2.rpc.call("holdinvoice", { "amount_msat": 1_000_100_000, @@ -260,8 +260,8 @@ def test_valid_hold_then_cancel(node_factory, bitcoind): cl1, _ = l1.fundchannel(l2, 1_000_000) cl2, _ = l1.fundchannel(l2, 1_000_000) - l1.wait_local_channel_active(cl1) - l1.wait_local_channel_active(cl2) + l1.wait_channel_active(cl1) + l1.wait_channel_active(cl2) invoice = l2.rpc.call("holdinvoice", { "amount_msat": 1_000_100_000, From 0afded2a62c12dc944945a55c8c989d997b3d5df Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Fri, 6 Oct 2023 01:26:23 +0200 Subject: [PATCH 17/18] reduce dep log spam --- plugins/holdinvoice/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/holdinvoice/src/main.rs b/plugins/holdinvoice/src/main.rs index 9b3230d227be..62bc7b6a2207 100644 --- a/plugins/holdinvoice/src/main.rs +++ b/plugins/holdinvoice/src/main.rs @@ -15,7 +15,7 @@ use holdinvoice::model::PluginState; #[tokio::main] async fn main() -> Result<(), anyhow::Error> { debug!("Starting holdinvoice plugin"); - std::env::set_var("CLN_PLUGIN_LOG", "debug"); + std::env::set_var("CLN_PLUGIN_LOG", "cln_plugin=info,cln_rpc=info,debug"); let state = PluginState { blockheight: Arc::new(Mutex::new(u32::default())), From fe8fdf545cb20d9634b20ae769f083c2ba0686e5 Mon Sep 17 00:00:00 2001 From: daywalker90 Date: Fri, 6 Oct 2023 18:04:37 +0200 Subject: [PATCH 18/18] small test fixes and new test for force close --- plugins/holdinvoice/tests/holdinvoicetest.py | 130 ++++++++++++++++++- plugins/holdinvoice/tests/stresstest.py | 24 ++-- plugins/holdinvoice/tests/util.py | 6 +- 3 files changed, 143 insertions(+), 17 deletions(-) diff --git a/plugins/holdinvoice/tests/holdinvoicetest.py b/plugins/holdinvoice/tests/holdinvoicetest.py index a047e4015ba8..a2a44e164e44 100644 --- a/plugins/holdinvoice/tests/holdinvoicetest.py +++ b/plugins/holdinvoice/tests/holdinvoicetest.py @@ -1,7 +1,8 @@ #!/usr/bin/python from pyln.testing.fixtures import * -from pyln.testing.utils import only_one, mine_funding_to_announce +from pyln.testing.utils import only_one, wait_for +from pyln.client import Millisatoshi import secrets import threading import time @@ -15,7 +16,7 @@ def test_inputs(node_factory): node = node_factory.get_node( options={ 'important-plugin': os.path.join( - os.getcwd(), '../../target/release/holdinvoice' + os.getcwd(), 'target/release/holdinvoice' ) } ) @@ -156,7 +157,7 @@ def test_valid_hold_then_settle(node_factory, bitcoind): opts={ 'important-plugin': os.path.join( os.getcwd(), - '../../target/release/holdinvoice' + 'target/release/holdinvoice' ) } ) @@ -194,7 +195,7 @@ def test_valid_hold_then_settle(node_factory, bitcoind): assert result_settle["message"] == expected_message threading.Thread(target=pay_with_thread, args=( - l1.rpc, invoice["bolt11"])).start() + l1, invoice["bolt11"])).start() timeout = 10 start_time = time.time() @@ -247,12 +248,129 @@ def test_valid_hold_then_settle(node_factory, bitcoind): assert result_cancel_settled["message"] == expected_message +def test_fc_hold_then_settle(node_factory, bitcoind): + l1, l2 = node_factory.get_nodes(2, + opts={ + 'important-plugin': os.path.join( + os.getcwd(), + 'target/release/holdinvoice' + ) + } + ) + l1.rpc.connect(l2.info['id'], 'localhost', l2.port) + cl1, _ = l1.fundchannel(l2, 1_000_000) + + l1.wait_channel_active(cl1) + + fundsres = l2.rpc.call("listfunds")["outputs"] + total_funds = 0 + for utxo in fundsres: + total_funds += utxo["amount_msat"] + assert total_funds == Millisatoshi(0) + + invoice_amt = 10_000_000 + + invoice = l2.rpc.call("holdinvoice", { + "amount_msat": invoice_amt, + "description": "test_valid_hold_then_settle", + "label": generate_random_label(), + "cltv": 50} + ) + assert invoice is not None + assert isinstance(invoice, dict) is True + assert "payment_hash" in invoice + + threading.Thread(target=pay_with_thread, args=( + l1, invoice["bolt11"])).start() + + timeout = 10 + start_time = time.time() + + while time.time() - start_time < timeout: + result_lookup = l2.rpc.call("holdinvoicelookup", { + "payment_hash": invoice["payment_hash"]}) + assert result_lookup is not None + assert isinstance(result_lookup, dict) is True + + if result_lookup["state"] == "accepted": + break + else: + time.sleep(1) + + assert result_lookup["state"] == "accepted" + assert "htlc_expiry" in result_lookup + + # test that it's actually holding the htlcs + # and not letting them through + doublecheck = only_one(l2.rpc.call("listinvoices", { + "payment_hash": invoice["payment_hash"]})["invoices"]) + assert doublecheck["status"] == "unpaid" + + fc = l1.rpc.close(cl1, 1) + bitcoind.generate_block(1, wait_for_mempool=fc['txid']) + wait_for(lambda: l1.rpc.listpeerchannels(l2.info['id'])[ + 'channels'][0]["state"] == 'ONCHAIN') + wait_for(lambda: l2.rpc.listpeerchannels(l1.info['id'])[ + 'channels'][0]["state"] == 'ONCHAIN') + assert l2.channel_state(l1) == 'ONCHAIN' + + result_settle = l2.rpc.call("holdinvoicesettle", { + "payment_hash": invoice["payment_hash"]}) + assert result_settle is not None + assert isinstance(result_settle, dict) is True + assert result_settle["state"] == "settled" + + result_lookup = l2.rpc.call("holdinvoicelookup", { + "payment_hash": invoice["payment_hash"]}) + assert result_lookup is not None + assert isinstance(result_lookup, dict) is True + assert result_lookup["state"] == "settled" + assert "htlc_expiry" not in result_lookup + + # ask cln if the invoice is actually paid + # should not be necessary because lookup does this aswell + doublecheck = only_one(l2.rpc.call("listinvoices", { + "payment_hash": invoice["payment_hash"]})["invoices"]) + assert doublecheck["status"] == "paid" + + # payres = only_one(l1.rpc.call( + # "listpays", {"payment_hash": invoice["payment_hash"]})["pays"]) + # assert payres["status"] == "complete" + + fundsres = l2.rpc.call("listfunds")["outputs"] + total_funds = 0 + for utxo in fundsres: + total_funds += utxo["amount_msat"] + assert total_funds == Millisatoshi(0) + + for _ in range(15): + bitcoind.generate_block(5) + time.sleep(0.2) + + wait_for(lambda: any('ONCHAIN:All outputs resolved' in status_str + for status_str in l1.rpc.listpeerchannels( + l2.info['id'])['channels'][0]["status"])) + wait_for(lambda: any('ONCHAIN:All outputs resolved' in status_str + for status_str in l2.rpc.listpeerchannels( + l1.info['id'])['channels'][0]["status"])) + + payres = only_one(l1.rpc.call( + "listpays", {"payment_hash": invoice["payment_hash"]})["pays"]) + assert payres["status"] == "complete" + + fundsres = l2.rpc.call("listfunds")["outputs"] + total_funds = 0 + for utxo in fundsres: + total_funds += utxo["amount_msat"] + assert total_funds > Millisatoshi(0) + + def test_valid_hold_then_cancel(node_factory, bitcoind): l1, l2 = node_factory.get_nodes(2, opts={ 'important-plugin': os.path.join( os.getcwd(), - '../../target/release/holdinvoice' + 'target/release/holdinvoice' ) } ) @@ -282,7 +400,7 @@ def test_valid_hold_then_cancel(node_factory, bitcoind): assert "htlc_expiry" not in result_lookup threading.Thread(target=pay_with_thread, args=( - l1.rpc, invoice["bolt11"])).start() + l1, invoice["bolt11"])).start() timeout = 10 start_time = time.time() diff --git a/plugins/holdinvoice/tests/stresstest.py b/plugins/holdinvoice/tests/stresstest.py index ff2790833a04..59ba8624831b 100755 --- a/plugins/holdinvoice/tests/stresstest.py +++ b/plugins/holdinvoice/tests/stresstest.py @@ -1,7 +1,7 @@ #!/usr/bin/python from pyln.testing.fixtures import * -from pyln.testing.utils import only_one, mine_funding_to_announce +from pyln.testing.utils import wait_for, mine_funding_to_announce import time import threading import os @@ -10,9 +10,9 @@ # number of invoices to create, pay, hold and then cancel -num_iterations = 5 +num_iterations = 100 # seconds to hold the invoices with inflight htlcs -delay_seconds = 12 +delay_seconds = 120 # amount to be used in msat amount_msat = 1_000_100_000 @@ -38,16 +38,14 @@ def test_stress(node_factory, bitcoind): opts={ 'important-plugin': os.path.join( os.getcwd(), - '../../target/release/holdinvoice' + 'target/release/holdinvoice' ) } ) - LOGGER.info("holdinvoice: Nodes created") l1.fundwallet((amount_msat/1000)*num_iterations*20) LOGGER.info("holdinvoice: Funding secured") l1.rpc.connect(l2.info['id'], 'localhost', l2.port) - LOGGER.info("holdinvoice: Nodes connected") - for _ in range(int(num_iterations/10)+1): + for _ in range(int(num_iterations/7)+1): for _ in range(10): res = l1.rpc.fundchannel(l2.info['id'], int( (amount_msat*0.95)/1000), minconf=0) @@ -63,6 +61,10 @@ def test_stress(node_factory, bitcoind): LOGGER.info("holdinvoice: Funded 10 channels") l1.wait_channel_active(scid) + wait_for(lambda: all(channel['state'] == 'CHANNELD_NORMAL' + for channel in + l1.rpc.listpeerchannels(l2.info['id'])['channels'])) + payment_hashes = [] LOGGER.info( @@ -83,14 +85,15 @@ def test_stress(node_factory, bitcoind): # Pay the invoice using a separate thread threading.Thread(target=pay_with_thread, args=( - l1.rpc, invoice["bolt11"])).start() + l1, invoice["bolt11"])).start() time.sleep(1) except Exception as e: LOGGER.error("holdinvoice: Error executing command:", e) LOGGER.info(f"holdinvoice: Done paying {num_iterations} invoices!") # wait a little more for payments to arrive - time.sleep(10) + wait_for(lambda: lookup_stats(l2.rpc, payment_hashes) + ["accepted"] == num_iterations) stats = lookup_stats(l2.rpc, payment_hashes) LOGGER.info(stats) @@ -114,6 +117,9 @@ def test_stress(node_factory, bitcoind): f"holdinvoice: holdinvoice:Error cancelling " f"payment hash {payment_hash}:", e) + wait_for(lambda: lookup_stats(l2.rpc, payment_hashes) + ["canceled"] == num_iterations) + stats = lookup_stats(l2.rpc, payment_hashes) LOGGER.info(stats) assert stats["canceled"] == num_iterations diff --git a/plugins/holdinvoice/tests/util.py b/plugins/holdinvoice/tests/util.py index 5fa414db1f8d..34c22cc3e0c0 100644 --- a/plugins/holdinvoice/tests/util.py +++ b/plugins/holdinvoice/tests/util.py @@ -1,5 +1,6 @@ import string import random +import logging def generate_random_label(): @@ -14,8 +15,9 @@ def generate_random_number(): def pay_with_thread(rpc, bolt11): + LOGGER = logging.getLogger(__name__) try: - rpc.pay(bolt11) + rpc.dev_pay(bolt11, dev_use_shadow=False) except Exception as e: - print(f"holdinvoice: Error paying payment hash:{e}") + LOGGER.debug(f"holdinvoice: Error paying payment hash:{e}") pass