diff --git a/Cargo.lock b/Cargo.lock index 0ee53ec94b81..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", + "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", - "cln-rpc", + "cln-plugin 0.1.5", + "cln-rpc 0.1.4", "log", "prost", "rcgen", @@ -295,12 +325,30 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "cln-plugin" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd946736b96911cef5a03494368bd1b33977d6f3411440f04311168ad45938be" +dependencies = [ + "anyhow", + "bytes", + "env_logger", + "futures", + "log", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", +] + [[package]] name = "cln-rpc" version = "0.1.4" dependencies = [ "anyhow", - "bitcoin", + "bitcoin 0.29.2", "bytes", "env_logger", "futures-util", @@ -312,6 +360,24 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "cln-rpc" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2a6c99d85386867cab37b5268740e0155710efafa9707a74951586675943ec" +dependencies = [ + "anyhow", + "bitcoin 0.30.1", + "bytes", + "futures-util", + "hex", + "log", + "serde", + "serde_json", + "tokio", + "tokio-util", +] + [[package]] name = "data-encoding" version = "2.4.0" @@ -568,6 +634,26 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "holdinvoice" +version = "0.1.3" +dependencies = [ + "anyhow", + "cln-plugin 0.1.6", + "cln-rpc 0.1.6", + "log", + "parking_lot", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "home" version = "0.5.5" @@ -726,6 +812,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 +952,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", +] + [[package]] name = "pem" version = "1.1.1" @@ -1158,6 +1277,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" @@ -1174,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", ] @@ -1188,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" @@ -1228,6 +1373,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..3b16073a9d61 --- /dev/null +++ b/plugins/holdinvoice/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "holdinvoice" +version = "0.1.3" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +cln-rpc = "0.1.6" +cln-plugin = "0.1.6" + +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..111111754c26 --- /dev/null +++ b/plugins/holdinvoice/src/hold.rs @@ -0,0 +1,574 @@ +use std::{str::FromStr, time::Duration}; + +use anyhow::{anyhow, Error}; +use cln_plugin::Plugin; +use cln_rpc::{ + model::{ + requests::InvoiceRequest, + responses::{ListinvoicesInvoicesStatus, ListpeerchannelsChannelsState}, + }, + 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 = [ + "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(), + })) + } + 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(), + })) + } + 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 => { + 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 + })); + } + } else { + return Ok(payment_hash_missing_error(&pay_hash)); + } + } + 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 { + 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; + } + } + } + } + + 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!(HoldLookupResponse { + state: holdstate.to_string(), + htlc_expiry + })) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +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, + "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 = ["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..7a8e3a165e74 --- /dev/null +++ b/plugins/holdinvoice/src/hooks.rs @@ -0,0 +1,501 @@ +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::responses::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, + ) + .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, +) -> 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 + #[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 + { + 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; + } + } + } + Holdstate::Canceled | Holdstate::Settled => (), + } + } + + 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() + { + 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; + } + }; + warn!( + "payment_hash: `{}` scid: `{}` htlc: `{}`. \ + No longer enough msats for holdinvoice! \ + Canceling htlcs...", + 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> { + 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() { + 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..3138f6e00bf8 --- /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 => !matches!(newstate, Holdstate::Open), + } + } +} +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..62bc7b6a2207 --- /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", "cln_plugin=info,cln_rpc=info,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..ec2d946582c6 --- /dev/null +++ b/plugins/holdinvoice/src/model.rs @@ -0,0 +1,37 @@ +use std::{ + collections::{BTreeMap, HashMap}, + sync::Arc, +}; + +use cln_rpc::model::responses::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..646f4c76ebfb --- /dev/null +++ b/plugins/holdinvoice/src/util.rs @@ -0,0 +1,310 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::anyhow; +use cln_plugin::{Error, Plugin}; +use cln_rpc::{ + model::{ + requests::{ + DatastoreMode, DatastoreRequest, DeldatastoreRequest, ListdatastoreRequest, + ListinvoicesRequest, ListpeerchannelsRequest, + }, + responses::{ + DatastoreResponse, DeldatastoreResponse, ListdatastoreDatastore, ListdatastoreResponse, + ListinvoicesResponse, ListpeerchannelsResponse, + }, + }, + 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, + index: None, + start: None, + limit: 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..a2a44e164e44 --- /dev/null +++ b/plugins/holdinvoice/tests/holdinvoicetest.py @@ -0,0 +1,446 @@ +#!/usr/bin/python + +from pyln.testing.fixtures import * +from pyln.testing.utils import only_one, wait_for +from pyln.client import Millisatoshi +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 + + +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.rpc.connect(l2.info['id'], 'localhost', l2.port) + cl1, _ = l1.fundchannel(l2, 1_000_000) + cl2, _ = l1.fundchannel(l2, 1_000_000) + + 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, 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_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' + ) + } + ) + l1.rpc.connect(l2.info['id'], 'localhost', l2.port) + cl1, _ = l1.fundchannel(l2, 1_000_000) + cl2, _ = l1.fundchannel(l2, 1_000_000) + + 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, 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 new file mode 100755 index 000000000000..59ba8624831b --- /dev/null +++ b/plugins/holdinvoice/tests/stresstest.py @@ -0,0 +1,125 @@ +#!/usr/bin/python + +from pyln.testing.fixtures import * +from pyln.testing.utils import wait_for, mine_funding_to_announce +import time +import threading +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 = 100 +# seconds to hold the invoices with inflight htlcs +delay_seconds = 120 +# amount to be used in msat +amount_msat = 1_000_100_000 + + +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: + invoice_info = rpc.holdinvoicelookup(payment_hash) + state = invoice_info['state'] + state_counts[state] = state_counts.get(state, 0) + 1 + except Exception as e: + 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' + ) + } + ) + l1.fundwallet((amount_msat/1000)*num_iterations*20) + LOGGER.info("holdinvoice: Funding secured") + l1.rpc.connect(l2.info['id'], 'localhost', l2.port) + 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) + 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) + wait_for(lambda: all(channel['state'] == 'CHANNELD_NORMAL' + for channel in + l1.rpc.listpeerchannels(l2.info['id'])['channels'])) + + payment_hashes = [] + + LOGGER.info( + f"holdinvoice: Creating and paying {num_iterations} invoices...") + for _ in range(num_iterations): + label = generate_random_label() + + 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, 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 + wait_for(lambda: lookup_stats(l2.rpc, payment_hashes) + ["accepted"] == num_iterations) + + stats = lookup_stats(l2.rpc, payment_hashes) + LOGGER.info(stats) + assert stats["accepted"] == num_iterations + + LOGGER.info(f"holdinvoice: Holding htlcs for {delay_seconds} seconds...") + + time.sleep(delay_seconds) + + stats = lookup_stats(l2.rpc, payment_hashes) + LOGGER.info(stats) + assert stats["accepted"] == num_iterations + + 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) + + 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 new file mode 100644 index 000000000000..34c22cc3e0c0 --- /dev/null +++ b/plugins/holdinvoice/tests/util.py @@ -0,0 +1,23 @@ +import string +import random +import logging + + +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): + LOGGER = logging.getLogger(__name__) + try: + rpc.dev_pay(bolt11, dev_use_shadow=False) + except Exception as e: + LOGGER.debug(f"holdinvoice: Error paying payment hash:{e}") + pass