diff --git a/CHANGELOG.md b/CHANGELOG.md index 11253d2..d461163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Added +- holdinvoice methods: ``make_hold_invoice``, ``cancel_hold_invoice``, ``settle_hold_invoice`` +- holdinvoice notification: ``hold_invoice_accepted`` + ## [0.1.7] 2025-11-27 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index d842055..b13eb2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,17 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-utility" version = "0.3.1" @@ -76,10 +87,10 @@ dependencies = [ ] [[package]] -name = "atomic-destructor" -version = "0.3.0" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "base64" @@ -89,9 +100,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" [[package]] name = "bech32" @@ -101,17 +112,17 @@ checksum = "98f7eed2b2781a6f0b5c903471d48e15f56fb4e1165df8a9a2337fd1a59d45ea" [[package]] name = "bech32" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" [[package]] name = "bip39" -version = "2.2.0" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d193de1f7487df1914d3a568b772458861d33f9c54249612cc2893d6915054" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes 0.13.0", + "bitcoin_hashes 0.14.1", "serde", "unicode-normalization", ] @@ -142,9 +153,9 @@ dependencies = [ [[package]] name = "bitcoin-io" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" [[package]] name = "bitcoin_hashes" @@ -159,9 +170,9 @@ dependencies = [ [[package]] name = "bitcoin_hashes" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ "bitcoin-io", "hex-conservative 0.2.2", @@ -192,6 +203,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "btreecap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6160c957d8aa33d0a8ba1dbab98e3cb57023ad9374c501441e88559f99e6c4c9" + [[package]] name = "bumpalo" version = "3.19.0" @@ -215,9 +232,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.47" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "shlex", @@ -271,15 +288,21 @@ dependencies = [ "anyhow", "cln-plugin", "cln-rpc", + "futures", "hex", "log", "log-panics", + "nostr", "nostr-sdk", "parking_lot", + "prost", "regex", "serde", "serde_json", "tokio", + "tonic", + "tonic-prost", + "tonic-prost-build", "uuid", ] @@ -336,7 +359,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -374,12 +396,46 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -495,10 +551,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -525,6 +579,37 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -571,12 +656,98 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -625,9 +796,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -639,9 +810,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -679,6 +850,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "inout" version = "0.1.4" @@ -696,9 +877,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", ] [[package]] @@ -709,9 +896,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -725,9 +912,15 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" @@ -746,9 +939,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "log-panics" @@ -782,15 +975,21 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "negentropy" version = "0.5.0" @@ -800,20 +999,20 @@ checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" [[package]] name = "nostr" version = "0.44.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3595fecf0e0aaacb69a0dc0101a4453f3c76eda333d6bbc49f68f64390b3d85" +source = "git+https://github.com/rust-nostr/nostr.git?rev=bf3028eb11b4e324c0c660b4e4c83348cdcc00c2#bf3028eb11b4e324c0c660b4e4c83348cdcc00c2" dependencies = [ "aes", "base64", - "bech32 0.11.0", + "bech32 0.11.1", "bip39", - "bitcoin_hashes 0.14.0", + "bitcoin_hashes 0.14.1", "cbc", "chacha20", "chacha20poly1305", - "getrandom 0.2.16", "hex", "instant", + "once_cell", + "rand", "scrypt", "secp256k1 0.29.1", "serde", @@ -825,9 +1024,9 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +source = "git+https://github.com/rust-nostr/nostr.git?rev=bf3028eb11b4e324c0c660b4e4c83348cdcc00c2#bf3028eb11b4e324c0c660b4e4c83348cdcc00c2" dependencies = [ + "btreecap", "lru", "nostr", "tokio", @@ -836,42 +1035,27 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +source = "git+https://github.com/rust-nostr/nostr.git?rev=bf3028eb11b4e324c0c660b4e4c83348cdcc00c2#bf3028eb11b4e324c0c660b4e4c83348cdcc00c2" dependencies = [ "nostr", ] [[package]] -name = "nostr-relay-pool" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b" +name = "nostr-sdk" +version = "0.44.1" +source = "git+https://github.com/rust-nostr/nostr.git?rev=bf3028eb11b4e324c0c660b4e4c83348cdcc00c2#bf3028eb11b4e324c0c660b4e4c83348cdcc00c2" dependencies = [ "async-utility", "async-wsocket", - "atomic-destructor", + "futures", "hex", "lru", "negentropy", "nostr", "nostr-database", - "tokio", - "tracing", -] - -[[package]] -name = "nostr-sdk" -version = "0.44.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" -dependencies = [ - "async-utility", - "nostr", - "nostr-database", "nostr-gossip", - "nostr-relay-pool", "tokio", + "tokio-stream", "tracing", ] @@ -946,6 +1130,36 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -987,6 +1201,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -997,49 +1221,102 @@ dependencies = [ ] [[package]] -name = "quote" -version = "1.0.42" +name = "prost" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" +dependencies = [ + "anyhow", + "itertools", "proc-macro2", + "quote", + "syn", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "prost-types" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" +dependencies = [ + "prost", +] [[package]] -name = "rand" -version = "0.8.5" +name = "pulldown-cmark" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", + "bitflags", + "memchr", + "unicase", ] [[package]] -name = "rand" -version = "0.9.2" +name = "pulldown-cmark-to-cmark" +version = "21.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "8246feae3db61428fd0bb94285c690b460e4517d83152377543ca802357785f1" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.3", + "pulldown-cmark", ] [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "quote" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.3", ] [[package]] @@ -1122,12 +1399,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -1138,9 +1429,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" dependencies = [ "zeroize", ] @@ -1212,7 +1503,6 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "rand 0.8.5", "secp256k1-sys 0.10.1", "serde", ] @@ -1360,6 +1650,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -1371,6 +1667,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1502,6 +1811,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -1533,11 +1843,110 @@ dependencies = [ "tokio", ] +[[package]] +name = "tonic" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" +dependencies = [ + "async-trait", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c40aaccc9f9eccf2cd82ebc111adc13030d23e887244bc9cfa5d1d636049de3" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tonic-prost" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a16cba4043dc3ff43fcb3f96b4c5c154c64cbd18ca8dce2ab2c6a451d058a2" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "log", "pin-project-lite", @@ -1579,9 +1988,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -1595,6 +2004,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tungstenite" version = "0.26.2" @@ -1606,7 +2021,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.2", + "rand", "rustls", "rustls-pki-types", "sha1", @@ -1620,6 +2035,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -1677,9 +2098,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom 0.3.4", "js-sys", @@ -1698,6 +2119,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1715,9 +2145,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -1728,9 +2158,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" dependencies = [ "cfg-if", "js-sys", @@ -1741,9 +2171,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1751,9 +2181,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", @@ -1764,18 +2194,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", @@ -1998,18 +2428,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.30" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea879c944afe8a2b25fef16bb4ba234f47c694565e97383b36f3a878219065c" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.30" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 8e9ad32..1fe834e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,8 +18,15 @@ cln-plugin = "0.5" # cln-plugin = { path = "../lightning/plugins/", version = "^0.4" } parking_lot = "0.12" -# nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "f7122f5", features = ["nip47", "nip04", "nip44"]} -nostr-sdk = { version = "0.44", features = ["nip47", "nip04", "nip44"] } +nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "bf3028eb11b4e324c0c660b4e4c83348cdcc00c2" } +nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "bf3028eb11b4e324c0c660b4e4c83348cdcc00c2", features = [ + "nip47", + "nip04", + "nip44", +] } +# nostr-sdk = { version = "0.44", features = ["nip47", "nip04", "nip44"] } + +futures = "0.3" uuid = { version = "1", features = ["v4"] } @@ -27,6 +34,17 @@ hex = "0.4" regex = "1" +tonic = { version = "0.14", default-features = false, features = [ + "codegen", + "transport", + "tls-ring", +] } +prost = "0.14" +tonic-prost = "0.14" + +[build-dependencies] +tonic-prost-build = "0.14" + [profile.optimized] inherits = "release" diff --git a/README.md b/README.md index 8d8c56b..d13ed13 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,9 @@ For a private relay you can for example use [nostr-rs-relay](https://github.com/ * list all NWC configurations or just the one with ``label`` * ***label***: optional. The label the NWC was created with +## Holdinvoice support +For methods or notifications related to holdinvoices you need v0.3.2+ of [hold](https://github.com/BoltzExchange/hold) with enabled grpc (which is the default just make sure the port is free) + ## Supported NWC methods * ``pay_invoice`` * ``multi_pay_invoice`` @@ -137,10 +140,14 @@ For a private relay you can for example use [nostr-rs-relay](https://github.com/ * ``list_transactions`` * ``get_balance`` * ``get_info`` (no ``block_hash``) +* ``make_hold_invoice`` (requires [Holdinvoice support](#holdinvoice_support)) +* ``cancel_hold_invoice`` (requires [Holdinvoice support](#holdinvoice_support)) +* ``settle_hold_invoice`` (requires [Holdinvoice support](#holdinvoice_support)) ## Supported NWC notifications * ``payment_received`` * ``payment_sent`` +* ``hold_invoice_accepted`` (requires [Holdinvoice support](#holdinvoice_support)) ## Supported content encryption: * [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..3e87891 --- /dev/null +++ b/build.rs @@ -0,0 +1,6 @@ +fn main() { + tonic_prost_build::configure() + .protoc_arg("--experimental_allow_proto3_optional") + .compile_protos(&["protos/hold.proto"], &["protos"]) + .unwrap_or_else(|e| panic!("Could not build protos: {e}")); +} diff --git a/protos/hold.proto b/protos/hold.proto new file mode 100644 index 0000000..e7ad240 --- /dev/null +++ b/protos/hold.proto @@ -0,0 +1,185 @@ +syntax = "proto3"; + +package hold; + +service Hold { + rpc GetInfo (GetInfoRequest) returns (GetInfoResponse); + + rpc Invoice (InvoiceRequest) returns (InvoiceResponse) {} + rpc Inject (InjectRequest) returns (InjectResponse) {} + + rpc List (ListRequest) returns (ListResponse) {} + + rpc Settle (SettleRequest) returns (SettleResponse) {} + rpc Cancel (CancelRequest) returns (CancelResponse) {} + + // Cleans cancelled invoices + rpc Clean (CleanRequest) returns (CleanResponse) {} + + rpc Track (TrackRequest) returns (stream TrackResponse) {} + rpc TrackAll (TrackAllRequest) returns (stream TrackAllResponse) {} + + rpc OnionMessages (stream OnionMessageResponse) returns (stream OnionMessage) {} +} + +enum HookAction { + Continue = 0; + Resolve = 1; +} + +message GetInfoRequest {} +message GetInfoResponse { + string version = 1; +} + +message Hop { + bytes public_key = 1; + uint64 short_channel_id = 2; + uint64 base_fee = 3; + uint64 ppm_fee = 4; + uint64 cltv_expiry_delta = 5; +} + +message RoutingHint { + repeated Hop hops = 1; +} + +message InvoiceRequest { + bytes payment_hash = 1; + uint64 amount_msat = 2; + + oneof description { + string memo = 3; + bytes hash = 4; + } + + optional uint64 expiry = 5; + optional uint64 min_final_cltv_expiry = 6; + repeated RoutingHint routing_hints = 7; +} +message InvoiceResponse { + string bolt11 = 1; +} + +message InjectRequest { + string invoice = 1; + optional uint64 min_cltv_expiry = 2; +} +message InjectResponse {} + +message ListRequest { + message Pagination { + // Inclusive + int64 index_start = 1; + uint64 limit = 2; + } + + oneof constraint { + bytes payment_hash = 1; + Pagination pagination = 2; + } +} + +enum InvoiceState { + UNPAID = 0; + ACCEPTED = 1; + PAID = 2; + CANCELLED = 3; +} + +message Htlc { + int64 id = 1; + InvoiceState state = 2; + string scid = 3; + uint64 channel_id = 4; + uint64 msat = 5; + uint64 created_at = 6; + optional uint64 cltv_expiry = 7; +} + +message Invoice { + int64 id = 1; + bytes payment_hash = 2; + optional bytes preimage = 3; + string invoice = 4; + InvoiceState state = 5; + uint64 created_at = 6; + optional uint64 settled_at = 8; + + repeated Htlc htlcs = 7; + + optional uint64 min_cltv_expiry = 9; +} + +message ListResponse { + repeated Invoice invoices = 1; +} + +message SettleRequest { + bytes payment_preimage = 1; +} +message SettleResponse {} + +message CancelRequest { + bytes payment_hash = 1; +} +message CancelResponse {} + +message CleanRequest { + // Clean everything older than age seconds + optional uint64 age = 1; +} +message CleanResponse { + uint64 cleaned = 1; +} + +message TrackRequest { + bytes payment_hash = 1; +} + +message TrackResponse { + InvoiceState state = 1; +} + +message TrackAllRequest { + repeated bytes payment_hashes = 1; +} + +message TrackAllResponse { + bytes payment_hash = 1; + string bolt11 = 2; + InvoiceState state = 3; +} + +message OnionMessage { + message ReplyBlindedPath { + message Hop { + optional bytes blinded_node_id = 1; + optional bytes encrypted_recipient_data = 2; + } + + optional bytes first_node_id = 1; + optional string first_scid = 2; + optional uint64 first_scid_dir = 3; + optional bytes first_path_key = 4; + repeated Hop hops = 5; + } + + message UnknownField { + uint64 number = 1; + bytes value = 2; + } + + uint64 id = 1; + optional bytes pathsecret = 2; + optional ReplyBlindedPath reply_blindedpath = 3; + optional bytes invoice_request = 4; + optional bytes invoice = 5; + optional bytes invoice_error = 6; + repeated UnknownField unknown_fields = 7; +} + +message OnionMessageResponse { + uint64 id = 1; + HookAction action = 2; +} diff --git a/src/main.rs b/src/main.rs index 1605f77..b404935 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,8 @@ -use std::{path::Path, time::Duration}; +use std::{ + path::{Path, PathBuf}, + str::FromStr, + time::Duration, +}; use anyhow::anyhow; use cln_plugin::{ @@ -7,15 +11,22 @@ use cln_plugin::{ Plugin, }; use cln_rpc::{model::requests::ListdatastoreRequest, ClnRpc}; -use nostr_sdk::nips::nip47; +use nostr::nips::nip47; use nwc::run_nwc; use nwc_notifications::{payment_received_handler, payment_sent_handler}; use parse::read_startup_options; use rpc::{nwc_budget, nwc_create, nwc_list, nwc_revoke}; +use serde_json::json; use structs::PluginState; use tokio::time; +use tonic::transport::{Certificate, ClientTlsConfig, Endpoint, Identity}; use util::{load_nwc_store, update_nwc_store}; +use crate::{ + hold::{hold_client::HoldClient, InvoiceState, ListRequest}, + nwc_notifications::holdinvoice_accepted_handler, +}; + mod nwc; mod nwc_balance; mod nwc_hold; @@ -32,6 +43,9 @@ mod tasks; mod util; pub const STARTUP_DELAY: u64 = 1; +pub mod hold { + tonic::include_proto!("hold"); +} const OPT_RELAYS: StringArrayConfigOption = ConfigOption::new_str_arr_no_default( "nip47-relays", @@ -56,10 +70,17 @@ pub const WALLET_PAY_METHODS: [nip47::Method; 4] = [ nip47::Method::PayKeysend, nip47::Method::MultiPayKeysend, ]; +pub const WALLET_HOLD_METHODS: [nip47::Method; 3] = [ + nip47::Method::MakeHoldInvoice, + nip47::Method::CancelHoldInvoice, + nip47::Method::SettleHoldInvoice, +]; pub const WALLET_NOTIFICATIONS: [nip47::NotificationType; 2] = [ nip47::NotificationType::PaymentReceived, nip47::NotificationType::PaymentSent, ]; +pub const WALLET_HOLD_NOTIFICATIONS: [nip47::NotificationType; 1] = + [nip47::NotificationType::HoldInvoiceAccepted]; #[tokio::main] async fn main() -> Result<(), anyhow::Error> { @@ -107,6 +128,16 @@ async fn main() -> Result<(), anyhow::Error> { }; let plugin = confplugin.start(state).await?; + match check_hold_support(plugin.clone()).await { + Ok(()) => { + log::info!("Hold support activated, loading pending invoices..."); + if let Err(e) = load_pending_hold_invoices(plugin.clone()).await { + log::error!("Error loading pending hold invoices: {e}"); + } + } + Err(e) => log::info!("Hold support not activated: {e}"), + } + { let mut rpc = plugin.state().rpc_lock.lock().await; @@ -165,3 +196,111 @@ async fn load_nwcs(plugin: Plugin, rpc: &mut ClnRpc) -> Result<(), } Ok(()) } + +async fn load_pending_hold_invoices(plugin: Plugin) -> Result<(), anyhow::Error> { + let mut hold_client = plugin.state().hold_client.lock().clone().unwrap(); + let invoices_request = ListRequest { constraint: None }; + let invoices = hold_client + .list(invoices_request) + .await? + .into_inner() + .invoices; + + for invoice in invoices { + if invoice.state() == InvoiceState::Accepted || invoice.state() == InvoiceState::Unpaid { + log::debug!( + "Starting holdinvoice accepted handler for {}", + hex::encode(&invoice.payment_hash) + ); + tokio::spawn(holdinvoice_accepted_handler( + plugin.clone(), + invoice.payment_hash, + )); + } + } + + Ok(()) +} + +async fn check_hold_support(plugin: Plugin) -> Result<(), anyhow::Error> { + let mut rpc = plugin.state().rpc_lock.lock().await; + let hold_grpc_host_response: serde_json::Value = rpc + .call_raw("listconfigs", &json!({"config": "hold-grpc-host"})) + .await?; + + let Some(hold_grpc_host_configs) = hold_grpc_host_response.get("configs") else { + return Err(anyhow!("Unsopprted listconfigs response!")); + }; + let Some(hold_grpc_host_config) = hold_grpc_host_configs.get("hold-grpc-host") else { + return Err(anyhow!("hold-grpc-host config not found")); + }; + let Some(hold_grpc_host_value) = hold_grpc_host_config.get("value_str") else { + return Err(anyhow!("hold-grpc-host config not a string")); + }; + let Some(hold_grpc_host) = hold_grpc_host_value.as_str() else { + return Err(anyhow!("hold-grpc-host config not convertable to string")); + }; + + let hold_grpc_port_response: serde_json::Value = rpc + .call_raw("listconfigs", &json!({"config": "hold-grpc-port"})) + .await?; + let Some(hold_grpc_port_configs) = hold_grpc_port_response.get("configs") else { + return Err(anyhow!("Unsopprted listconfigs response!")); + }; + let Some(hold_grpc_port_config) = hold_grpc_port_configs.get("hold-grpc-port") else { + return Err(anyhow!("hold-grpc-port config not found")); + }; + let Some(hold_grpc_port_value) = hold_grpc_port_config.get("value_int") else { + return Err(anyhow!("hold-grpc-port config not a number")); + }; + let hold_grpc_port = if let Some(hgh) = hold_grpc_port_value.as_u64() { + u16::try_from(hgh)? + } else { + return Err(anyhow!("hold-grpc-port config not convertable to integer")); + }; + + let cert_dir = PathBuf::from_str(&plugin.configuration().lightning_dir)?.join("hold"); + + log::debug!( + "Searching {} for hold plugin certs", + cert_dir.to_str().unwrap() + ); + + let max_retries = 10; + let mut retries = 0; + while retries < max_retries && !do_certificates_exist(&cert_dir) { + log::debug!("Hold certificates incomplete. Waiting..."); + time::sleep(Duration::from_millis(500)).await; + retries += 1; + } + + let ca_cert = tokio::fs::read(cert_dir.join("ca.pem")).await?; + let client_cert = tokio::fs::read(cert_dir.join("client.pem")).await?; + let client_key = tokio::fs::read(cert_dir.join("client-key.pem")).await?; + + let identity = Identity::from_pem(client_cert, client_key); + + let ca = Certificate::from_pem(ca_cert); + + let tls_config = ClientTlsConfig::new() + .ca_certificate(ca) + .identity(identity) + .domain_name("hold"); + + let hold_channel = Endpoint::from_shared(format!("https://{hold_grpc_host}:{hold_grpc_port}"))? + .tls_config(tls_config)? + .keep_alive_while_idle(true) + .connect_lazy(); + *plugin.state().hold_client.lock() = Some(HoldClient::new(hold_channel)); + + Ok(()) +} + +fn do_certificates_exist(cert_dir: &Path) -> bool { + let required_files = ["client.pem", "client-key.pem", "ca.pem"]; + + required_files.iter().all(|file| { + let path = cert_dir.join(file); + path.exists() && path.metadata().map(|m| m.len() > 0).unwrap_or(false) + }) +} diff --git a/src/nwc.rs b/src/nwc.rs index 9e9b2fb..3a42f27 100644 --- a/src/nwc.rs +++ b/src/nwc.rs @@ -2,25 +2,28 @@ use std::{borrow::Cow, time::Duration}; use anyhow::anyhow; use cln_plugin::Plugin; -use nostr_sdk::{ - client, +use futures::StreamExt; +use nostr::{ nips::{nip04, nip44, nip47}, - nostr::{Filter, Kind, Tag}, Alphabet, - Client, Event, EventBuilder, EventId, + Filter, Keys, + Kind, PublicKey, - RelayPoolNotification, - RelayStatus, SecretKey, SignerError, SingleLetterTag, + Tag, TagKind, Timestamp, }; +use nostr_sdk::{ + client::{self, Client, ClientNotification, Error}, + relay::RelayStatus, +}; use tokio::{sync::oneshot, time}; use crate::{ @@ -46,38 +49,38 @@ pub async fn run_nwc( plugin: Plugin, label: String, nwc_store: NwcStore, -) -> Result<(), client::Error> { +) -> Result<(), Error> { let (method_capabilities, _) = build_capabilities(is_read_only_nwc(&nwc_store), &plugin); let wallet_keys = Keys::new( SecretKey::from_hex(&nwc_store.walletkey) - .map_err(|e| client::Error::Signer(SignerError::backend(e)))?, + .map_err(|e| Error::Signer(SignerError::backend(e)))?, ); let client_pubkey = Keys::new(nwc_store.uri.secret.clone()).public_key(); - let client = Client::new(wallet_keys.clone()); + let nostr_client = Client::builder().signer(wallet_keys.clone()).build(); log::debug!("relay_count:{}", nwc_store.uri.relays.len()); for relay in &nwc_store.uri.relays { log::debug!("Adding relay: {relay}"); - client.add_relay(relay).await?; + nostr_client.add_relay(relay).await?; } if nwc_store.interval_config.is_some() { start_nwc_budget_job(&plugin, label.clone()); } - let client_clone = client.clone(); + let nostr_client_clone = nostr_client.clone(); let plugin_clone = plugin.clone(); let label_clone = label.clone(); tokio::spawn(async move { loop { - client_clone.connect().await; - client_clone - .wait_for_connection(Duration::from_secs(30)) + nostr_client_clone + .connect() + .and_wait(Duration::from_secs(30)) .await; - let relays = client_clone.relays().await; + let relays = nostr_client_clone.relays().await; if relays.is_empty() { log::info!("No more relays left, we probably shut down. Exiting..."); break; @@ -98,14 +101,14 @@ pub async fn run_nwc( if let Err(e) = send_nwc_info_event( plugin_clone.clone(), - client_clone.clone(), + nostr_client_clone.clone(), method_capabilities.clone(), wallet_keys.clone(), ) .await { log::warn!("{e}"); - client_clone.disconnect().await; + nostr_client_clone.disconnect().await; time::sleep(Duration::from_secs(5)).await; continue; } @@ -115,44 +118,48 @@ pub async fn run_nwc( .author(client_pubkey) .since(Timestamp::now() - STARTUP_DELAY - 1); - if let Err(e) = client_clone.subscribe(filter, None).await { - log::warn!("Could not subscribe to nwc events! {e}"); - client_clone.disconnect().await; - time::sleep(Duration::from_secs(5)).await; - continue; + let mut notifications = nostr_client_clone.notifications(); + + match nostr_client_clone.subscribe(filter).await { + Ok(o) => { + if o.success.is_empty() { + log::warn!("Could not subscribe to any relay!"); + nostr_client_clone.disconnect().await; + time::sleep(Duration::from_secs(5)).await; + continue; + } + } + Err(e) => { + log::warn!("Error subscribing to relays: {e}"); + nostr_client_clone.disconnect().await; + time::sleep(Duration::from_secs(5)).await; + continue; + } } - let client_clone_handler = client_clone.clone(); - match client_clone - .handle_notifications(|notification| { - let client_clone_handler = client_clone_handler.clone(); - let plugin_clone = plugin_clone.clone(); - let label_clone = label_clone.clone(); - let wallet_keys_clone = wallet_keys.clone(); - nwc_request_handler( - notification, - client_clone_handler, - plugin_clone, - label_clone, - wallet_keys_clone, - client_pubkey, - ) - }) + while let Some(notification) = notifications.next().await { + if let Err(e) = nwc_request_handler( + notification, + &nostr_client_clone, + &plugin_clone, + &label_clone, + &wallet_keys, + client_pubkey, + ) .await - { - Ok(()) => { - log::info!("NWC handler for `{label_clone}` stopped"); - break; - } - Err(e) => log::warn!("NWC handler for `{label_clone}` had an error: {e}"), - }; + { + log::warn!("NWC handler for `{label_clone}` had an error: {e}"); + }; + } + + {}; } }); let mut locked_handles = plugin.state().handles.lock().await; locked_handles.insert( label.clone(), - (client, Keys::new(nwc_store.uri.secret).public_key()), + (nostr_client, Keys::new(nwc_store.uri.secret).public_key()), ); Ok(()) } @@ -226,29 +233,29 @@ pub fn stop_nwc_budget_job(plugin: &Plugin, label: &String) { } async fn nwc_request_handler( - notification: RelayPoolNotification, - client: client::Client, - plugin: Plugin, - label: String, - wallet_keys: Keys, + notification: ClientNotification, + nostr_client: &client::Client, + plugin: &Plugin, + label: &String, + wallet_keys: &Keys, client_pubkey: PublicKey, -) -> Result> { +) -> Result<(), Box> { let (relay_url, subscription_id, event) = match notification { - RelayPoolNotification::Event { + ClientNotification::Event { relay_url, subscription_id, event, } => (relay_url, subscription_id, event), - RelayPoolNotification::Message { + ClientNotification::Message { relay_url: _, message: _, - } => return Ok(false), - RelayPoolNotification::Shutdown => return Ok(true), + } => return Ok(()), + ClientNotification::Shutdown => return Ok(()), }; if let Some(expi) = event.tags.expiration() { if *expi < Timestamp::now() { - return Ok(false); + return Ok(()); } } log::debug!("relay_url:{relay_url} subscription_id:{subscription_id} {event:?}"); @@ -264,7 +271,7 @@ async fn nwc_request_handler( multi_pay_invoice(plugin.clone(), multi_pay_invoice_request, &label).await } nip47::RequestParams::PayKeysend(pay_keysend_request) => { - pay_keysend_response(plugin, pay_keysend_request, &label).await + pay_keysend_response(plugin.clone(), pay_keysend_request, &label).await } nip47::RequestParams::MultiPayKeysend(multi_pay_keysend_request) => { multi_pay_keysend(plugin.clone(), multi_pay_keysend_request, &label).await @@ -281,13 +288,13 @@ async fn nwc_request_handler( nip47::RequestParams::GetBalance => get_balance_response(plugin.clone(), &label).await, nip47::RequestParams::GetInfo => get_info_response(plugin.clone(), &label).await, nip47::RequestParams::MakeHoldInvoice(make_hold_invoice_request) => { - make_hold_invoice_response(plugin.clone(), make_hold_invoice_request, &label).await + make_hold_invoice_response(plugin.clone(), make_hold_invoice_request).await } nip47::RequestParams::CancelHoldInvoice(cancel_hold_invoice_request) => { - cancel_hold_invoice_response(plugin.clone(), cancel_hold_invoice_request, &label).await + cancel_hold_invoice_response(plugin.clone(), cancel_hold_invoice_request).await } nip47::RequestParams::SettleHoldInvoice(settle_hold_invoice_request) => { - settle_hold_invoice_response(plugin.clone(), settle_hold_invoice_request, &label).await + settle_hold_invoice_response(plugin.clone(), settle_hold_invoice_request).await } }; for (response, id) in responses { @@ -309,7 +316,7 @@ async fn nwc_request_handler( } }; - let send_result = match client.send_event(&response_event).await { + let send_result = match nostr_client.send_event(&response_event).await { Ok(o) => o, Err(e) => { log::warn!("Error sending response event! {e}"); @@ -330,7 +337,7 @@ async fn nwc_request_handler( log::debug!("SENT RESPONSE {response_event:?}"); } - Ok(false) + Ok(()) } fn check_nip44_support(event: &Event) -> bool { diff --git a/src/nwc_balance.rs b/src/nwc_balance.rs index 1b347bf..db9fbfe 100644 --- a/src/nwc_balance.rs +++ b/src/nwc_balance.rs @@ -1,6 +1,6 @@ use cln_plugin::Plugin; use cln_rpc::{model::requests::ListpeerchannelsRequest, primitives::ChannelState}; -use nostr_sdk::nips::nip47; +use nostr::nips::nip47; use crate::{structs::PluginState, util::load_nwc_store}; diff --git a/src/nwc_hold.rs b/src/nwc_hold.rs index e048d0c..d3b0d8f 100644 --- a/src/nwc_hold.rs +++ b/src/nwc_hold.rs @@ -1,58 +1,242 @@ +use std::str::FromStr; + use cln_plugin::Plugin; -use nostr_sdk::nips::nip47; +use cln_rpc::primitives::Sha256; +use nostr::{nips::nip47, Timestamp}; -use crate::structs::PluginState; +use crate::{ + hold::{invoice_request::Description, CancelRequest, InvoiceRequest, SettleRequest}, + nwc_notifications::holdinvoice_accepted_handler, + structs::PluginState, +}; pub async fn make_hold_invoice_response( - _plugin: Plugin, - _params: nip47::MakeHoldInvoiceRequest, - _label: &str, + plugin: Plugin, + params: nip47::MakeHoldInvoiceRequest, ) -> Vec<(nip47::Response, Option)> { - vec![( - nip47::Response { - result_type: nip47::Method::MakeHoldInvoice, - error: Some(nip47::NIP47Error { - code: nip47::ErrorCode::NotImplemented, - message: "Not implemented".to_owned(), - }), - result: None, - }, - None, - )] + vec![match make_hold_invoice(plugin, params).await { + Ok(o) => ( + nip47::Response { + result_type: nip47::Method::MakeHoldInvoice, + error: None, + result: Some(nip47::ResponseResult::MakeHoldInvoice(o)), + }, + None, + ), + Err(e) => ( + nip47::Response { + result_type: nip47::Method::MakeHoldInvoice, + error: Some(e), + result: None, + }, + None, + ), + }] +} + +async fn make_hold_invoice( + plugin: Plugin, + params: nip47::MakeHoldInvoiceRequest, +) -> Result { + let Some(mut hold_client) = plugin.state().hold_client.lock().clone() else { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::NotImplemented, + message: "No hold plugin found".to_owned(), + }); + }; + + let description: Option = if let Some(d_hash) = ¶ms.description_hash { + if let Some(description) = ¶ms.description { + let my_description_hash = Sha256::const_hash(description.as_bytes()); + let description_hash = Sha256::from_str(d_hash).map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + if my_description_hash != description_hash { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: "description_hash not matching description".to_owned(), + }); + } + } + + let desc_hash_bytes = match hex::decode(d_hash) { + Ok(p) => p, + Err(_e) => { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: "Could not convert description hash to bytes".to_owned(), + }) + } + }; + Some(Description::Hash(desc_hash_bytes)) + } else { + params + .description + .as_ref() + .map(|desc| Description::Memo(desc.clone())) + }; + + let payment_hash = match hex::decode(¶ms.payment_hash) { + Ok(p) => p, + Err(_e) => { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: "Invalid payment hash".to_owned(), + }) + } + }; + + let expiry = params.expiry.unwrap_or(60 * 60); + + let holdinvoice_request = InvoiceRequest { + payment_hash: payment_hash.clone(), + amount_msat: params.amount, + expiry: Some(expiry), + min_final_cltv_expiry: params.min_cltv_expiry_delta.map(u64::from), + routing_hints: Vec::new(), + description, + }; + + let holdinvoice = hold_client + .invoice(holdinvoice_request) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: format!("Error creating hold invoice: {e}"), + })? + .into_inner(); + + let response = nip47::MakeHoldInvoiceResponse { + invoice: Some(holdinvoice.bolt11), + transaction_type: nip47::TransactionType::Incoming, + description: params.description, + description_hash: params.description_hash, + amount: params.amount, + created_at: Timestamp::now(), + expires_at: Timestamp::now() + expiry, + metadata: None, + payment_hash: params.payment_hash, + }; + + tokio::spawn(holdinvoice_accepted_handler(plugin, payment_hash)); + Ok(response) } pub async fn cancel_hold_invoice_response( - _plugin: Plugin, - _params: nip47::CancelHoldInvoiceRequest, - _label: &str, + plugin: Plugin, + params: nip47::CancelHoldInvoiceRequest, ) -> Vec<(nip47::Response, Option)> { - vec![( - nip47::Response { - result_type: nip47::Method::MakeHoldInvoice, - error: Some(nip47::NIP47Error { - code: nip47::ErrorCode::NotImplemented, - message: "Not implemented".to_owned(), - }), - result: None, - }, - None, - )] + vec![match cancel_hold_invoice(plugin, params).await { + Ok(o) => ( + nip47::Response { + result_type: nip47::Method::CancelHoldInvoice, + error: None, + result: Some(nip47::ResponseResult::CancelHoldInvoice(o)), + }, + None, + ), + Err(e) => ( + nip47::Response { + result_type: nip47::Method::CancelHoldInvoice, + error: Some(e), + result: None, + }, + None, + ), + }] +} + +async fn cancel_hold_invoice( + plugin: Plugin, + params: nip47::CancelHoldInvoiceRequest, +) -> Result { + let Some(mut hold_client) = plugin.state().hold_client.lock().clone() else { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::NotImplemented, + message: "No hold plugin found".to_owned(), + }); + }; + + let payment_hash = match hex::decode(¶ms.payment_hash) { + Ok(p) => p, + Err(_e) => { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: "Invalid payment hash".to_owned(), + }) + } + }; + + let hold_cancel_request = CancelRequest { payment_hash }; + + let hold_cancel_response = hold_client.cancel(hold_cancel_request).await; + + match hold_cancel_response { + Ok(_o) => Ok(nip47::CancelHoldInvoiceResponse {}), + Err(e) => Err(nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }), + } } pub async fn settle_hold_invoice_response( - _plugin: Plugin, - _params: nip47::SettleHoldInvoiceRequest, - _label: &str, + plugin: Plugin, + params: nip47::SettleHoldInvoiceRequest, ) -> Vec<(nip47::Response, Option)> { - vec![( - nip47::Response { - result_type: nip47::Method::MakeHoldInvoice, - error: Some(nip47::NIP47Error { - code: nip47::ErrorCode::NotImplemented, - message: "Not implemented".to_owned(), - }), - result: None, - }, - None, - )] + vec![match settle_hold_invoice(plugin, params).await { + Ok(o) => ( + nip47::Response { + result_type: nip47::Method::SettleHoldInvoice, + error: None, + result: Some(nip47::ResponseResult::SettleHoldInvoice(o)), + }, + None, + ), + Err(e) => ( + nip47::Response { + result_type: nip47::Method::SettleHoldInvoice, + error: Some(e), + result: None, + }, + None, + ), + }] +} + +async fn settle_hold_invoice( + plugin: Plugin, + params: nip47::SettleHoldInvoiceRequest, +) -> Result { + let Some(mut hold_client) = plugin.state().hold_client.lock().clone() else { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::NotImplemented, + message: "No hold plugin found".to_owned(), + }); + }; + + let preimage = match hex::decode(¶ms.preimage) { + Ok(p) => p, + Err(_e) => { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: "Invalid preimage".to_owned(), + }) + } + }; + + let hold_settle_request = SettleRequest { + payment_preimage: preimage, + }; + + let hold_settle_response = hold_client.settle(hold_settle_request).await; + + match hold_settle_response { + Ok(_o) => Ok(nip47::SettleHoldInvoiceResponse {}), + Err(e) => Err(nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + }), + } } diff --git a/src/nwc_info.rs b/src/nwc_info.rs index 050edf3..084f51d 100644 --- a/src/nwc_info.rs +++ b/src/nwc_info.rs @@ -1,6 +1,6 @@ use cln_plugin::Plugin; use cln_rpc::model::requests::GetinfoRequest; -use nostr_sdk::nips::nip47; +use nostr::nips::nip47; use crate::{ structs::PluginState, diff --git a/src/nwc_invoice.rs b/src/nwc_invoice.rs index 654a0b0..f521673 100644 --- a/src/nwc_invoice.rs +++ b/src/nwc_invoice.rs @@ -5,7 +5,7 @@ use cln_rpc::{ model::requests::InvoiceRequest, primitives::{Amount, AmountOrAny, Sha256}, }; -use nostr_sdk::nips::nip47; +use nostr::{nips::nip47, types::Timestamp}; use uuid::Uuid; use crate::structs::PluginState; @@ -94,8 +94,8 @@ async fn make_invoice( description_hash: params.description_hash, preimage: None, amount: Some(params.amount), - created_at: Some(nostr_sdk::nostr::Timestamp::now()), - expires_at: Some(nostr_sdk::nostr::Timestamp::from_secs(o.expires_at)), + created_at: Some(Timestamp::now()), + expires_at: Some(Timestamp::from_secs(o.expires_at)), }), Err(e) => Err(nip47::NIP47Error { code: nip47::ErrorCode::Internal, diff --git a/src/nwc_keysend.rs b/src/nwc_keysend.rs index 477db88..fbbe1a0 100644 --- a/src/nwc_keysend.rs +++ b/src/nwc_keysend.rs @@ -5,7 +5,7 @@ use cln_rpc::{ model::requests::KeysendRequest, primitives::{Amount, PublicKey, TlvEntry, TlvStream}, }; -use nostr_sdk::nips::nip47; +use nostr::nips::nip47; use tokio::time; use crate::{ diff --git a/src/nwc_lookups.rs b/src/nwc_lookups.rs index 33bea1f..cef844c 100644 --- a/src/nwc_lookups.rs +++ b/src/nwc_lookups.rs @@ -5,6 +5,7 @@ use cln_rpc::{ model::{ requests::{DecodeRequest, ListinvoicesRequest, ListpaysRequest}, responses::{ + DecodeType, ListinvoicesInvoices, ListinvoicesInvoicesStatus, ListpaysPays, @@ -14,9 +15,12 @@ use cln_rpc::{ primitives::Sha256, ClnRpc, }; -use nostr_sdk::{nips::nip47, Timestamp}; +use nostr::{nips::nip47, Timestamp}; -use crate::structs::{PluginState, NOT_INV_ERR}; +use crate::{ + hold::{self, list_request::Constraint, ListRequest}, + structs::{PluginState, NOT_INV_ERR}, +}; pub async fn lookup_invoice_response( plugin: Plugin, @@ -58,7 +62,7 @@ async fn lookup_invoice( let invoice = if params.payment_hash.is_some() && params.invoice.is_some() { None } else { - params.invoice + params.invoice.clone() }; let invoices = rpc @@ -81,47 +85,234 @@ async fn lookup_invoice( if invoices.len() == 1 { let invoice_response = invoices.into_iter().next().unwrap(); - make_lookup_response_from_listinvoices(&mut rpc, invoice_response).await + return make_lookup_response_from_listinvoices(&mut rpc, invoice_response).await; + } + + let payment_hash_hash = if let Some(hash) = ¶ms.payment_hash { + if let Ok(res) = Sha256::from_str(hash) { + Some(res) + } else { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: "Could not convert payment hash".to_owned(), + }); + } } else { - let payment_hash_hash = if let Some(hash) = params.payment_hash { - if let Ok(res) = Sha256::from_str(&hash) { - Some(res) - } else { + None + }; + + let pays = rpc + .call_typed(&ListpaysRequest { + bolt11: invoice, + index: None, + limit: None, + payment_hash: payment_hash_hash, + start: None, + status: None, + }) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })? + .pays; + + if pays.len() == 1 { + let list_pay = pays.into_iter().next().unwrap(); + + return make_lookup_response_from_listpays(&mut rpc, list_pay).await; + } + + let holdinvoice_support = plugin.state().hold_client.lock().is_some(); + + if holdinvoice_support { + let mut hold_client = plugin.state().hold_client.lock().clone().unwrap(); + + let (hold_invoice, invoice_decoded) = if let Some(ph) = ¶ms.payment_hash { + let payment_hash_hash = match hex::decode(ph) { + Ok(p) => p, + Err(_e) => { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: "Invalid payment hash".to_owned(), + }) + } + }; + let list_request = ListRequest { + constraint: Some(Constraint::PaymentHash(payment_hash_hash)), + }; + let hold_lookup = hold_client + .list(list_request) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: format!("Could not fetch hold invoice: {e}"), + })? + .into_inner(); + + if hold_lookup.invoices.len() != 1 { return Err(nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: "Transaction not found".to_owned(), + }); + } + + let hold_invoice = hold_lookup.invoices.into_iter().next().unwrap(); + let invoice_decoded = rpc + .call_typed(&DecodeRequest { + string: hold_invoice.invoice.clone(), + }) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + (hold_invoice, invoice_decoded) + } else { + let invoice_decoded = rpc + .call_typed(&DecodeRequest { + string: params.invoice.unwrap(), + }) + .await + .map_err(|e| nip47::NIP47Error { code: nip47::ErrorCode::Internal, - message: "Could not convert payment hash".to_owned(), + message: e.to_string(), + })?; + let ph = match invoice_decoded.item_type { + DecodeType::BOLT12_INVOICE => invoice_decoded.invoice_payment_hash.unwrap(), + DecodeType::BOLT11_INVOICE => invoice_decoded.payment_hash.unwrap().to_string(), + _ => todo!(), + }; + let payment_hash_hash = match hex::decode(&ph) { + Ok(p) => p, + Err(_e) => { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: "Invalid payment hash in invoice".to_owned(), + }) + } + }; + + let list_request = ListRequest { + constraint: Some(Constraint::PaymentHash(payment_hash_hash)), + }; + + let hold_lookup = hold_client + .list(list_request) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: format!("Could not fetch hold invoice: {e}"), + })? + .into_inner(); + + if hold_lookup.invoices.len() != 1 { + return Err(nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: "Transaction not found".to_owned(), }); } + + let hold_invoice = hold_lookup.invoices.into_iter().next().unwrap(); + let invoice_decoded = rpc + .call_typed(&DecodeRequest { + string: hold_invoice.invoice.clone(), + }) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + (hold_invoice, invoice_decoded) + }; + let not_invoice_err = Err(nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: NOT_INV_ERR.to_owned(), + }); + + let description = match invoice_decoded.item_type { + DecodeType::BOLT12_INVOICE => invoice_decoded.offer_description, + DecodeType::BOLT11_INVOICE => invoice_decoded.description, + _ => return not_invoice_err, + }; + let description_hash = match invoice_decoded.item_type { + DecodeType::BOLT12_INVOICE => None, + DecodeType::BOLT11_INVOICE => invoice_decoded.description_hash.map(|h| h.to_string()), + _ => return not_invoice_err, + }; + + let created_at = match invoice_decoded.item_type { + DecodeType::BOLT12_INVOICE => { + Timestamp::from_secs(invoice_decoded.invoice_created_at.unwrap()) + } + DecodeType::BOLT11_INVOICE => Timestamp::from_secs(invoice_decoded.created_at.unwrap()), + _ => return not_invoice_err, + }; + + let amount = match invoice_decoded.item_type { + DecodeType::BOLT12_INVOICE => invoice_decoded.invoice_amount_msat.unwrap().msat(), + DecodeType::BOLT11_INVOICE => { + if let Some(amt) = invoice_decoded.amount_msat { + amt.msat() + } else { + // amount: `any` but have to put a value... + 0 + } + } + _ => return not_invoice_err, + }; + + let expires_at = match invoice_decoded.item_type { + DecodeType::BOLT12_INVOICE => invoice_decoded + .invoice_relative_expiry + .map(|e_at| created_at + Timestamp::from_secs(u64::from(e_at))), + DecodeType::BOLT11_INVOICE => invoice_decoded + .expiry + .map(|e_at| created_at + Timestamp::from_secs(e_at)), + _ => return not_invoice_err, + }; + + let state = match hold_invoice.state() { + hold::InvoiceState::Unpaid => nip47::TransactionState::Pending, + hold::InvoiceState::Accepted => nip47::TransactionState::Pending, // TODO ACCEPTED STATE + hold::InvoiceState::Paid => nip47::TransactionState::Settled, + hold::InvoiceState::Cancelled => nip47::TransactionState::Expired, + }; + + let settled_at = if hold_invoice.settled_at() != 0 { + Some(Timestamp::from_secs(hold_invoice.settled_at())) } else { None }; - let pays = rpc - .call_typed(&ListpaysRequest { - bolt11: invoice, - index: None, - limit: None, - payment_hash: payment_hash_hash, - start: None, - status: None, - }) - .await - .map_err(|e| nip47::NIP47Error { - code: nip47::ErrorCode::Internal, - message: e.to_string(), - })? - .pays; - - if pays.len() != 1 { - return Err(nip47::NIP47Error { - code: nip47::ErrorCode::NotFound, - message: "Transaction not found".to_owned(), - }); - } - let list_pay = pays.into_iter().next().unwrap(); + let preimage = if hold_invoice.state() == hold::InvoiceState::Paid { + Some(hex::encode(hold_invoice.preimage())) + } else { + None + }; - make_lookup_response_from_listpays(&mut rpc, list_pay).await + return Ok(nip47::LookupInvoiceResponse { + transaction_type: Some(nip47::TransactionType::Incoming), + invoice: Some(hold_invoice.invoice.clone()), + description, + description_hash, + preimage, + payment_hash: hex::encode(hold_invoice.payment_hash), + amount, + fees_paid: 0, + created_at, + expires_at, + settled_at, + metadata: None, + state: Some(state), + }); } + + Err(nip47::NIP47Error { + code: nip47::ErrorCode::NotFound, + message: "Transaction not found".to_owned(), + }) } pub async fn list_transactions_response( @@ -193,6 +384,118 @@ async fn list_transactions( Err(_e) => (), } } + + let holdinvoice_support = plugin.state().hold_client.lock().is_some(); + + if holdinvoice_support { + let mut hold_client = plugin.state().hold_client.lock().clone().unwrap(); + + let lookup_request = ListRequest { constraint: None }; + + let hold_lookup = hold_client + .list(lookup_request) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Other, + message: format!("Could not fetch hold invoices: {e}"), + })? + .into_inner(); + + for hold_invoice in hold_lookup.invoices { + let invoice_decoded = rpc + .call_typed(&DecodeRequest { + string: hold_invoice.invoice.clone(), + }) + .await + .map_err(|e| nip47::NIP47Error { + code: nip47::ErrorCode::Internal, + message: e.to_string(), + })?; + + let description = match invoice_decoded.item_type { + DecodeType::BOLT12_INVOICE => invoice_decoded.offer_description, + DecodeType::BOLT11_INVOICE => invoice_decoded.description, + _ => continue, + }; + let description_hash = match invoice_decoded.item_type { + DecodeType::BOLT12_INVOICE => None, + DecodeType::BOLT11_INVOICE => { + invoice_decoded.description_hash.map(|h| h.to_string()) + } + _ => continue, + }; + + let created_at = match invoice_decoded.item_type { + DecodeType::BOLT12_INVOICE => { + Timestamp::from_secs(invoice_decoded.invoice_created_at.unwrap()) + } + DecodeType::BOLT11_INVOICE => { + Timestamp::from_secs(invoice_decoded.created_at.unwrap()) + } + _ => continue, + }; + + let amount = match invoice_decoded.item_type { + DecodeType::BOLT12_INVOICE => { + invoice_decoded.invoice_amount_msat.unwrap().msat() + } + DecodeType::BOLT11_INVOICE => { + if let Some(amt) = invoice_decoded.amount_msat { + amt.msat() + } else { + // amount: `any` but have to put a value... + 0 + } + } + _ => continue, + }; + + let expires_at = match invoice_decoded.item_type { + DecodeType::BOLT12_INVOICE => invoice_decoded + .invoice_relative_expiry + .map(|e_at| created_at + Timestamp::from_secs(u64::from(e_at))), + DecodeType::BOLT11_INVOICE => invoice_decoded + .expiry + .map(|e_at| created_at + Timestamp::from_secs(e_at)), + _ => continue, + }; + + let state = match hold_invoice.state() { + hold::InvoiceState::Unpaid => nip47::TransactionState::Pending, + hold::InvoiceState::Accepted => nip47::TransactionState::Pending, // TODO: ACCEPTED STATE + hold::InvoiceState::Paid => nip47::TransactionState::Settled, + hold::InvoiceState::Cancelled => nip47::TransactionState::Expired, + }; + + let settled_at = if hold_invoice.settled_at() != 0 { + Some(Timestamp::from_secs(hold_invoice.settled_at())) + } else { + None + }; + + let preimage = if hold_invoice.state() == hold::InvoiceState::Paid { + Some(hex::encode(hold_invoice.preimage())) + } else { + None + }; + + transactions.push(nip47::LookupInvoiceResponse { + transaction_type: Some(nip47::TransactionType::Incoming), + invoice: Some(hold_invoice.invoice.clone()), + description, + description_hash, + preimage, + payment_hash: hex::encode(hold_invoice.payment_hash), + amount, + fees_paid: 0, + created_at, + expires_at, + settled_at, + metadata: None, + state: Some(state), + }); + } + } } if query_payments { diff --git a/src/nwc_notifications.rs b/src/nwc_notifications.rs index 8f02b6a..8e64a3d 100644 --- a/src/nwc_notifications.rs +++ b/src/nwc_notifications.rs @@ -4,7 +4,7 @@ use anyhow::anyhow; use cln_plugin::Plugin; use cln_rpc::{ model::{ - requests::{DecodeRequest, ListinvoicesRequest, ListpaysRequest}, + requests::{DecodeRequest, ListinvoicesRequest, ListpaysRequest, ListpeerchannelsRequest}, responses::{ DecodeResponse, ListinvoicesInvoices, @@ -16,14 +16,11 @@ use cln_rpc::{ primitives::Sha256, ClnRpc, }; -use nostr_sdk::{ - nips::nip47, - nostr::{key::PublicKey, EventBuilder, Kind, Tag}, - Client, - Timestamp, -}; +use nostr::{key::PublicKey, nips::nip47, EventBuilder, Kind, Tag, Timestamp}; +use nostr_sdk::client::Client; use crate::{ + hold::{list_request::Constraint, InvoiceState, ListRequest, TrackRequest}, structs::{PluginState, NOT_INV_ERR}, OPT_NOTIFICATIONS, }; @@ -340,7 +337,7 @@ async fn send_notification( client: &Client, client_pubkey: &PublicKey, ) -> Result<(), anyhow::Error> { - let signer = client.signer().await?; + let signer = client.signer().unwrap().clone(); log::debug!("NOTIFICATION: {notification}"); let content_encrypted_nip04 = signer.nip04_encrypt(client_pubkey, notification).await?; let event_nip04 = EventBuilder::new(Kind::from_u16(23196), content_encrypted_nip04) @@ -380,3 +377,130 @@ async fn send_notification( Ok(()) } + +pub async fn holdinvoice_accepted_handler( + plugin: Plugin, + payment_hash: Vec, +) -> Result<(), anyhow::Error> { + let mut hold_client = plugin.state().hold_client.lock().clone().unwrap(); + + let track_request = TrackRequest { + payment_hash: payment_hash.clone(), + }; + let mut track_stream = hold_client.track(track_request).await?.into_inner(); + + while let Some(response) = track_stream.message().await? { + log::debug!("Invoice status: {}", response.state().as_str_name()); + if response.state() == InvoiceState::Accepted { + break; + } + } + + let list_request = ListRequest { + constraint: Some(Constraint::PaymentHash(payment_hash.clone())), + }; + + let hold_lookup = hold_client.list(list_request).await?.into_inner(); + + if hold_lookup.invoices.len() != 1 { + return Err(anyhow!("hold plugin did not return exactly one invoice")); + } + + let hold_invoice = hold_lookup.invoices.first().unwrap(); + + let mut rpc = plugin.state().rpc_lock.lock().await; + + let invoice_decoded = rpc + .call_typed(&DecodeRequest { + string: hold_invoice.invoice.clone(), + }) + .await?; + + let amount = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + invoice_decoded.invoice_amount_msat.unwrap().msat() + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + if let Some(amt) = invoice_decoded.amount_msat { + amt.msat() + } else { + // amount: `any` but have to put a value... + 0 + } + } + _ => return Err(anyhow!("hold plugin did not return an invoice string")), + }; + + let created_at = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + Timestamp::from_secs(invoice_decoded.invoice_created_at.unwrap()) + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + Timestamp::from_secs(invoice_decoded.created_at.unwrap()) + } + _ => return Err(anyhow!("hold plugin did not return an invoice string")), + }; + + let expires_at = match invoice_decoded.item_type { + cln_rpc::model::responses::DecodeType::BOLT12_INVOICE => { + created_at + + Timestamp::from_secs(u64::from(invoice_decoded.invoice_relative_expiry.unwrap())) + } + cln_rpc::model::responses::DecodeType::BOLT11_INVOICE => { + created_at + Timestamp::from_secs(invoice_decoded.expiry.unwrap()) + } + _ => return Err(anyhow!("hold plugin did not return an invoice string")), + }; + + let list_peer_channels = rpc + .call_typed(&ListpeerchannelsRequest { + id: None, + short_channel_id: None, + }) + .await? + .channels; + + let payment_hash_hash = Sha256::from_str(&hex::encode(payment_hash))?; + + let mut lowest_htlc_expiry = 0; + + for peer in list_peer_channels { + if let Some(htlcs) = peer.htlcs { + for htlc in htlcs { + if htlc.payment_hash != payment_hash_hash { + continue; + } + if htlc.expiry < lowest_htlc_expiry { + lowest_htlc_expiry = htlc.expiry; + } + } + } + } + + let clients = plugin.state().handles.lock().await; + + let content = nip47::Notification { + notification_type: nip47::NotificationType::HoldInvoiceAccepted, + notification: nip47::NotificationResult::HoldInvoiceAccepted( + nip47::HoldInvoiceAcceptedNotification { + transaction_type: nip47::TransactionType::Incoming, + invoice: hold_invoice.invoice.clone(), + description: None, + description_hash: None, + payment_hash: hex::encode(&hold_invoice.payment_hash), + amount, + created_at, + expires_at, + settle_deadline: lowest_htlc_expiry, + metadata: None, + state: Some(nip47::TransactionState::Accepted), + }, + ), + }; + let notification = serde_json::to_string(&content).unwrap(); + + for (client, client_pubkey) in clients.values() { + send_notification(¬ification, client, client_pubkey).await?; + } + Ok(()) +} diff --git a/src/nwc_pay.rs b/src/nwc_pay.rs index 608a63e..0571575 100644 --- a/src/nwc_pay.rs +++ b/src/nwc_pay.rs @@ -10,7 +10,7 @@ use cln_rpc::{ ClnRpc, RpcError, }; -use nostr_sdk::nips::nip47; +use nostr::nips::nip47; use tokio::time; use crate::{ diff --git a/src/parse.rs b/src/parse.rs index 7c32966..b1dffed 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -3,6 +3,7 @@ use std::path::Path; use anyhow::anyhow; use cln_plugin::ConfiguredPlugin; use cln_rpc::{model::requests::GetinfoRequest, ClnRpc}; +use nostr::types::RelayUrl; use crate::{ structs::{PluginState, TimeUnit}, @@ -37,7 +38,7 @@ pub async fn read_startup_options( config.my_cln_version = version; for relay in relays_str { log::debug!("RELAY:{relay}"); - config.relays.push(nostr_sdk::RelayUrl::parse(&relay)?); + config.relays.push(RelayUrl::parse(&relay)?); } Ok(()) } diff --git a/src/rpc.rs b/src/rpc.rs index bbc12db..fcd549b 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -6,7 +6,7 @@ use cln_rpc::model::requests::{ DeldatastoreRequest, ListdatastoreRequest, }; -use nostr_sdk::{nips::nip47::NostrWalletConnectURI, Keys, SecretKey, Timestamp}; +use nostr::{nips::nip47::NostrWalletConnectUri, Keys, SecretKey, Timestamp}; use serde_json::json; use crate::{ @@ -29,7 +29,7 @@ pub async fn nwc_create( let wallet_keys = Keys::generate(); let client_keys = Keys::generate(); - let uri = NostrWalletConnectURI::new( + let uri = NostrWalletConnectUri::new( wallet_keys.public_key(), config.relays.clone(), client_keys.secret_key().clone(), diff --git a/src/structs.rs b/src/structs.rs index c96db5a..e7eea09 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -1,10 +1,14 @@ use std::{collections::HashMap, path::PathBuf, str::FromStr, sync::Arc}; use cln_rpc::ClnRpc; -use nostr_sdk::{client, nips::nip47, nostr}; +use nostr::{nips::nip47::NostrWalletConnectUri, types::RelayUrl}; +use nostr_sdk::client; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use tokio::sync::oneshot; +use tonic::transport::Channel; + +use crate::hold::hold_client::HoldClient; pub const NOT_INV_ERR: &str = "Not an invoice or invalid invoice"; @@ -14,6 +18,7 @@ pub struct PluginState { pub handles: Arc>>, pub rpc_lock: Arc>, pub budget_jobs: Arc>>>, + pub hold_client: Arc>>>, } impl PluginState { pub async fn new(path: PathBuf) -> Result { @@ -22,13 +27,14 @@ impl PluginState { handles: Arc::new(tokio::sync::Mutex::new(HashMap::new())), rpc_lock: Arc::new(tokio::sync::Mutex::new(ClnRpc::new(path).await?)), budget_jobs: Arc::new(Mutex::new(HashMap::new())), + hold_client: Arc::new(Mutex::new(None)), }) } } #[derive(Clone, Debug)] pub struct Config { - pub relays: Vec, + pub relays: Vec, pub my_cln_version: String, } impl Config { @@ -72,7 +78,7 @@ pub struct BudgetIntervalConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NwcStore { - pub uri: nip47::NostrWalletConnectURI, + pub uri: NostrWalletConnectUri, pub walletkey: String, #[serde(skip_serializing_if = "Option::is_none")] pub budget_msat: Option, diff --git a/src/tasks.rs b/src/tasks.rs index 2437e4a..e46ed40 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -3,7 +3,7 @@ use std::{path::Path, time::Duration}; use anyhow::anyhow; use cln_plugin::Plugin; use cln_rpc::ClnRpc; -use nostr_sdk::Timestamp; +use nostr::types::Timestamp; use tokio::{sync::oneshot, time}; use crate::{ diff --git a/src/util.rs b/src/util.rs index 36cc484..6d769ad 100644 --- a/src/util.rs +++ b/src/util.rs @@ -4,12 +4,14 @@ use cln_rpc::{ model::requests::{DatastoreMode, DatastoreRequest, ListdatastoreRequest}, ClnRpc, }; -use nostr_sdk::nips::nip47; +use nostr::nips::nip47; use crate::{ structs::{NwcStore, PluginState}, OPT_NOTIFICATIONS, PLUGIN_NAME, + WALLET_HOLD_METHODS, + WALLET_HOLD_NOTIFICATIONS, WALLET_NOTIFICATIONS, WALLET_PAY_METHODS, WALLET_READ_METHODS, @@ -124,11 +126,22 @@ pub fn at_or_above_version(my_version: &str, min_version: &str) -> Result) -> (String, String) { + let holdinvoice_support = plugin.state().hold_client.lock().is_some(); + let mut methods = WALLET_READ_METHODS.map(|m| m.to_string()).join(" "); if !is_read_only { methods.push(' '); methods.push_str(WALLET_PAY_METHODS.map(|m| m.to_string()).join(" ").as_str()); } + if holdinvoice_support { + methods.push(' '); + methods.push_str( + WALLET_HOLD_METHODS + .map(|m| m.to_string()) + .join(" ") + .as_str(), + ); + } let mut notifications = String::new(); if plugin.option(&OPT_NOTIFICATIONS).unwrap() { @@ -138,23 +151,41 @@ pub fn build_capabilities(is_read_only: bool, plugin: &Plugin) -> ( .join(" ") .as_str(), ); + if holdinvoice_support { + notifications.push(' '); + notifications.push_str( + WALLET_HOLD_NOTIFICATIONS + .map(|m| m.to_string()) + .join(" ") + .as_str(), + ); + } } (methods, notifications) } -pub fn build_methods_vec(is_read_only: bool, _plugin: &Plugin) -> Vec { +pub fn build_methods_vec(is_read_only: bool, plugin: &Plugin) -> Vec { + let holdinvoice_support = plugin.state().hold_client.lock().is_some(); let mut methods = WALLET_READ_METHODS.to_vec(); if !is_read_only { methods.extend_from_slice(&WALLET_PAY_METHODS); } + if holdinvoice_support { + methods.extend_from_slice(&WALLET_HOLD_METHODS); + } methods } pub fn build_notifications_vec(plugin: &Plugin) -> Vec { + let holdinvoice_support = plugin.state().hold_client.lock().is_some(); + let mut notifications = Vec::new(); if plugin.option(&OPT_NOTIFICATIONS).unwrap() { notifications.extend_from_slice(&WALLET_NOTIFICATIONS.map(|m| m.to_string())); + if holdinvoice_support { + notifications.extend_from_slice(&WALLET_HOLD_NOTIFICATIONS.map(|m| m.to_string())); + } } notifications } diff --git a/tests/config.toml b/tests/config.toml index 062124b..3afabc9 100644 --- a/tests/config.toml +++ b/tests/config.toml @@ -9,4 +9,4 @@ reject_future_seconds = 1800 [limits] limit_scrapers = false [network] -address = "0.0.0.0" +address = "127.0.0.1" diff --git a/tests/setup.sh b/tests/setup.sh index 0fe9440..829a336 100755 --- a/tests/setup.sh +++ b/tests/setup.sh @@ -81,3 +81,17 @@ if [ -d "$proto_path" ]; then exit 1 fi fi + +hold_url="https://github.com/BoltzExchange/hold/releases/download/v0.3.3/hold-linux-amd64.tar.gz" + +if ! curl -L "$hold_url" -o "$script_dir/hold-linux-amd64.tar.gz"; then + echo "Error downloading the file from $hold_url" >&2 + exit 1 +fi + +if ! tar -xzvf "$script_dir/hold-linux-amd64.tar.gz" -C "$script_dir"; then + echo "Error extracting the contents of hold-linux-amd64.tar.gz" >&2 + exit 1 +fi + +mv "$script_dir/build/hold-linux-amd64" "$script_dir/hold" diff --git a/tests/test_cln-nip47.py b/tests/test_cln-nip47.py index f887eb6..b981eeb 100755 --- a/tests/test_cln-nip47.py +++ b/tests/test_cln-nip47.py @@ -4,13 +4,15 @@ import logging import time import asyncio +import secrets +import uuid from datetime import datetime, timedelta from typing import Any, Awaitable, Callable, Union import pytest from pyln.testing.fixtures import * # noqa: F403 from pyln.testing.utils import RpcError, wait_for, TIMEOUT -from util import generate_random_label, get_plugin # noqa: F401 +from util import generate_random_label, get_plugin, get_hold # noqa: F401 from nostr_sdk import ( Alphabet, @@ -75,7 +77,10 @@ async def fetch_event_responses( ) -> tuple[list[Event], Any]: events = [] response_filter = Filter().kind(Kind(event_kind)).pubkey(client_pubkey) - await client.subscribe(response_filter) + + id = uuid.uuid4().hex + LOGGER.info(f"Subscribing with id {id} to {response_filter}") + await client.subscribe_with_id(id, response_filter) handler = NotificationHandler(events, stop_after) task = asyncio.create_task(client.handle_notifications(handler)) @@ -569,10 +574,12 @@ async def test_lookup_invoice(nostr_relay, node_factory, get_plugin): # noqa: F }, ) wait_for( - lambda: l2.rpc.call("listpeerchannels", [l1.info["id"]])["channels"][0][ - "spendable_msat" - ] - > 3001 + lambda: ( + l2.rpc.call("listpeerchannels", [l1.info["id"]])["channels"][0][ + "spendable_msat" + ] + > 3001 + ) ) uri_str = l1.rpc.call("nip47-create", ["test1", 3000])["uri"] LOGGER.info(uri_str) @@ -798,10 +805,12 @@ async def test_list_transactions(nostr_relay, node_factory, get_plugin): # noqa }, ) wait_for( - lambda: l2.rpc.call("listpeerchannels", [l1.info["id"]])["channels"][0][ - "spendable_msat" - ] - > 30001 + lambda: ( + l2.rpc.call("listpeerchannels", [l1.info["id"]])["channels"][0][ + "spendable_msat" + ] + > 30001 + ) ) uri_str = l1.rpc.call("nip47-create", ["test1"])["uri"] LOGGER.info(uri_str) @@ -921,16 +930,20 @@ async def test_notifications(nostr_relay, node_factory, get_plugin): # noqa: F8 pay1_list = l1.rpc.call("listpays", {"bolt11": invoice["bolt11"]})["pays"][0] wait_for( - lambda: l2.rpc.call("listpeerchannels", [l1.info["id"]])["channels"][0][ - "spendable_msat" - ] - > 3000 + lambda: ( + l2.rpc.call("listpeerchannels", [l1.info["id"]])["channels"][0][ + "spendable_msat" + ] + > 3000 + ) ) wait_for( - lambda: l3.rpc.call("listpeerchannels", [l2.info["id"]])["channels"][0][ - "spendable_msat" - ] - > 3000 + lambda: ( + l3.rpc.call("listpeerchannels", [l2.info["id"]])["channels"][0][ + "spendable_msat" + ] + > 3000 + ) ) result = await nwc.make_invoice( @@ -1446,3 +1459,552 @@ async def test_budget_command(nostr_relay, node_factory, get_plugin): # noqa: F info_event.tags().find(TagKind.UNKNOWN("notifications")).content() == "payment_received payment_sent" ) + + +@pytest.mark.asyncio +async def test_hold_invoice( + node_factory, + executor, + get_plugin, # noqa: F811 + get_hold, # noqa: F811 + nostr_relay, +): + url = nostr_relay + l1, l2 = node_factory.line_graph( + 2, + wait_for_announce=True, + opts=[ + { + "log-level": "debug", + "may_reconnect": True, + }, + { + "log-level": "debug", + "plugin": get_plugin, + "important-plugin": get_hold, + "nip47-relays": url, + "may_reconnect": True, + "broken_log": r"Relay receiver exited with error", + }, + ], + ) + uri_res = l2.rpc.call("nip47-create", ["test1", 3010]) + uri_str = uri_res["uri"] + client_pubkey = PublicKey.parse(uri_res["clientkey_public"]) + LOGGER.info(uri_str) + uri = NostrWalletConnectUri.parse(uri_str) + + nwc = Nwc(uri) + + preimage = secrets.token_hex(32) + payment_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() + LOGGER.info(f"preimage: {preimage}") + LOGGER.info(f"payment_hash: {payment_hash}") + content = { + "method": "make_hold_invoice", + "params": { + "amount": 5000, + "payment_hash": payment_hash, + }, + } + content = json.dumps(content) + signer = NostrSigner.keys(Keys(uri.secret())) + encrypted_content = await signer.nip04_encrypt(uri.public_key(), content) + event = ( + await EventBuilder(Kind(23194), encrypted_content) + .tags([Tag.public_key(uri.public_key())]) + .sign(signer) + ) + client = Client(signer) + relay_url = RelayUrl.parse(url) + await client.add_relay(relay_url) + await client.connect() + + await fetch_info_event(client, uri) + + (responses1, _res) = await fetch_event_responses( + client, client_pubkey, 23195, client.send_event(event), 1 + ) + error_events = [] + success_events = [] + for event in responses1: + LOGGER.info(event) + content = await signer.nip04_decrypt(uri.public_key(), event.content()) + content = json.loads(content) + LOGGER.info(content) + if "result" in content and content["result"] is not None: + success_events.append(content) + if "error" in content and content["error"] is not None: + error_events.append(content) + + assert len(success_events) == 1 + assert len(error_events) == 0 + + assert success_events[0]["result_type"] == "make_hold_invoice" + assert success_events[0]["result"]["payment_hash"] == payment_hash + assert success_events[0]["result"]["type"] == "incoming" + assert success_events[0]["result"]["invoice"] is not None + invoice1 = success_events[0]["result"]["invoice"] + assert "description" not in success_events[0]["result"] + assert "description_hash" not in success_events[0]["result"] + assert success_events[0]["result"]["amount"] == 5000 + invoice1_created_at = pytest.approx(int(time.time()), abs=1) + assert success_events[0]["result"]["created_at"] == invoice1_created_at + invoice1_expires_at = pytest.approx(int(time.time()) + 3600, abs=1) + assert success_events[0]["result"]["expires_at"] == invoice1_expires_at + assert "metadata" not in success_events[0]["result"] + + lookup_hold = await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=payment_hash, + invoice=None, + ) + ) + assert lookup_hold.invoice == success_events[0]["result"]["invoice"] + assert lookup_hold.amount == 5000 + assert lookup_hold.description is None + assert lookup_hold.created_at.as_secs() == success_events[0]["result"]["created_at"] + assert lookup_hold.description_hash is None + assert lookup_hold.expires_at.as_secs() == success_events[0]["result"]["expires_at"] + assert lookup_hold.fees_paid == 0 + assert lookup_hold.metadata is None + assert lookup_hold.preimage is None + assert lookup_hold.payment_hash == payment_hash + assert lookup_hold.transaction_type.name == "INCOMING" + assert lookup_hold.state.name == "PENDING" + assert lookup_hold.settled_at is None + + (responses2, _res) = await fetch_event_responses( + client, + client_pubkey, + 23196, + lambda: executor.submit( + l1.rpc.call, "xpay", [success_events[0]["result"]["invoice"]] + ), + 1, + ) + hold_events = [] + for event in responses2: + LOGGER.info(event) + content = await signer.nip04_decrypt(uri.public_key(), event.content()) + content = json.loads(content) + LOGGER.info(content) + if content["notification_type"] == "hold_invoice_accepted": + hold_events.append(content) + assert content["notification"]["payment_hash"] == payment_hash + + lookup_hold = await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=None, + invoice=invoice1, + ) + ) + assert lookup_hold.invoice == invoice1 + assert lookup_hold.amount == 5000 + assert lookup_hold.description is None + assert lookup_hold.created_at.as_secs() == invoice1_created_at + assert lookup_hold.description_hash is None + assert lookup_hold.expires_at.as_secs() == invoice1_expires_at + assert lookup_hold.fees_paid == 0 + assert lookup_hold.metadata is None + assert lookup_hold.preimage is None + assert lookup_hold.payment_hash == payment_hash + assert lookup_hold.transaction_type.name == "INCOMING" + assert lookup_hold.state.name == "PENDING" # TODO ACCEPTED STATE + assert lookup_hold.settled_at is None + + content = { + "method": "settle_hold_invoice", + "params": { + "preimage": preimage, + }, + } + content = json.dumps(content) + encrypted_content = await signer.nip04_encrypt(uri.public_key(), content) + event = ( + await EventBuilder(Kind(23194), encrypted_content) + .tags([Tag.public_key(uri.public_key())]) + .sign(signer) + ) + + (responses3, _res) = await fetch_event_responses( + client, client_pubkey, 23195, client.send_event(event), 1 + ) + error_events = [] + success_events = [] + for event in responses3: + LOGGER.info(event) + content = await signer.nip04_decrypt(uri.public_key(), event.content()) + content = json.loads(content) + LOGGER.info(content) + if ( + "result" in content + and content["result"] is not None + and content["result_type"] == "settle_hold_invoice" + ): + success_events.append(content) + if "error" in content and content["error"] is not None: + error_events.append(content) + + assert len(success_events) == 1 + assert len(error_events) == 0 + for content in success_events: + assert content["result_type"] == "settle_hold_invoice" + lookup_hold = await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=None, + invoice=invoice1, + ) + ) + assert lookup_hold.invoice == invoice1 + assert lookup_hold.amount == 5000 + assert lookup_hold.description is None + assert lookup_hold.created_at.as_secs() == invoice1_created_at + assert lookup_hold.description_hash is None + assert lookup_hold.expires_at.as_secs() == invoice1_expires_at + assert lookup_hold.fees_paid == 0 + assert lookup_hold.metadata is None + assert lookup_hold.preimage == preimage + assert lookup_hold.payment_hash == payment_hash + assert lookup_hold.transaction_type.name == "INCOMING" + assert lookup_hold.state.name == "SETTLED" + assert lookup_hold.settled_at.as_secs() == pytest.approx( + int(time.time()), abs=1 + ) + + wait_for( + lambda: ( + l1.rpc.call("listpays", {"payment_hash": payment_hash})["pays"][0]["status"] + == "complete" + ) + ) + + preimage = secrets.token_hex(32) + payment_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() + content = { + "method": "make_hold_invoice", + "params": { + "amount": 5000, + "payment_hash": payment_hash, + "description": "cancel_hold", + "expiry": 1000, + "min_cltv_expiry_delta": 200, + }, + } + content = json.dumps(content) + signer = NostrSigner.keys(Keys(uri.secret())) + encrypted_content = await signer.nip04_encrypt(uri.public_key(), content) + event = ( + await EventBuilder(Kind(23194), encrypted_content) + .tags([Tag.public_key(uri.public_key())]) + .sign(signer) + ) + + (responses4, _res) = await fetch_event_responses( + client, client_pubkey, 23195, client.send_event(event), 1 + ) + error_events = [] + success_events = [] + for event in responses4: + LOGGER.info(event) + content = await signer.nip04_decrypt(uri.public_key(), event.content()) + content = json.loads(content) + LOGGER.info(content) + if ( + "result" in content + and content["result"] is not None + and content["result_type"] == "make_hold_invoice" + and content["result"]["payment_hash"] == payment_hash + ): + success_events.append(content) + if "error" in content and content["error"] is not None: + error_events.append(content) + + assert len(success_events) == 1 + assert len(error_events) == 0 + for content in success_events: + assert content["result_type"] == "make_hold_invoice" + assert content["result"]["payment_hash"] == payment_hash + invoice2 = content["result"]["invoice"] + invoice2_created_at = pytest.approx(int(time.time()), abs=1) + invoice2_expires_at = pytest.approx(int(time.time()) + 1000, abs=1) + + (responses5, _res) = await fetch_event_responses( + client, + client_pubkey, + 23196, + lambda: executor.submit( + l1.rpc.call, "xpay", [success_events[0]["result"]["invoice"]] + ), + 1, + ) + hold_events = [] + for event in responses5: + LOGGER.info(event) + content = await signer.nip04_decrypt(uri.public_key(), event.content()) + content = json.loads(content) + LOGGER.info(content) + if ( + content["notification_type"] == "hold_invoice_accepted" + and content["notification"]["payment_hash"] == payment_hash + ): + hold_events.append(content) + assert len(hold_events) == 1 + + content = { + "method": "cancel_hold_invoice", + "params": { + "payment_hash": payment_hash, + }, + } + content = json.dumps(content) + encrypted_content = await signer.nip04_encrypt(uri.public_key(), content) + event = ( + await EventBuilder(Kind(23194), encrypted_content) + .tags([Tag.public_key(uri.public_key())]) + .sign(signer) + ) + + (responses6, _res) = await fetch_event_responses( + client, + client_pubkey, + 23195, + client.send_event(event), + 1, + ) + error_events = [] + success_events = [] + for event in responses6: + LOGGER.info(event) + content = await signer.nip04_decrypt(uri.public_key(), event.content()) + content = json.loads(content) + LOGGER.info(content) + if ( + "result" in content + and content["result"] is not None + and content["result_type"] == "cancel_hold_invoice" + ): + success_events.append(content) + if "error" in content and content["error"] is not None: + error_events.append(content) + + assert len(success_events) == 1 + assert len(error_events) == 0 + for content in success_events: + assert content["result_type"] == "cancel_hold_invoice" + lookup_hold = await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=None, + invoice=invoice2, + ) + ) + assert lookup_hold.invoice == invoice2 + assert lookup_hold.amount == 5000 + assert lookup_hold.description == "cancel_hold" + assert lookup_hold.created_at.as_secs() == invoice2_created_at + assert lookup_hold.description_hash is None + assert lookup_hold.expires_at.as_secs() == invoice2_expires_at + assert lookup_hold.fees_paid == 0 + assert lookup_hold.metadata is None + assert lookup_hold.preimage is None + assert lookup_hold.payment_hash == payment_hash + assert lookup_hold.transaction_type.name == "INCOMING" + assert lookup_hold.state.name == "EXPIRED" + assert lookup_hold.settled_at is None + + invoice2_decoded = l1.rpc.call("decode", [invoice2]) + assert invoice2_decoded["min_final_cltv_expiry"] == 200 + + wait_for( + lambda: ( + l1.rpc.call("listpays", {"payment_hash": payment_hash})["pays"][0]["status"] + == "failed" + ) + ) + + nwc = Nwc(uri) + + invoice_lookup1 = await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=payment_hash, + invoice=None, + ) + ) + invoice_lookup2 = await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=None, + invoice=invoice2, + ) + ) + invoice_lookup3 = await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=payment_hash, + invoice=invoice2, + ) + ) + assert invoice_lookup1 == invoice_lookup2 + assert invoice_lookup1 == invoice_lookup3 + + invoice_lookup4 = await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=None, + invoice=invoice1, + ) + ) + + result = await nwc.list_transactions( + ListTransactionsRequest( + _from=None, + until=None, + limit=None, + offset=None, + unpaid=True, + transaction_type=None, + ) + ) + assert len(result) == 2 + assert result == [invoice_lookup1, invoice_lookup4] or result == [ + invoice_lookup4, + invoice_lookup1, + ] + + description3 = "test3" + description_hash3 = hashlib.sha256(description3.encode()).hexdigest() + preimage = secrets.token_hex(32) + payment_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() + content = { + "method": "make_hold_invoice", + "params": { + "amount": 5001, + "payment_hash": payment_hash, + "description": description3, + "description_hash": description_hash3, + }, + } + content = json.dumps(content) + encrypted_content = await signer.nip04_encrypt(uri.public_key(), content) + event = ( + await EventBuilder(Kind(23194), encrypted_content) + .tags([Tag.public_key(uri.public_key())]) + .sign(signer) + ) + await client.send_event(event) + + start_time = datetime.now() + while (datetime.now() - start_time) < timedelta(seconds=10): + time.sleep(1) + try: + await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=payment_hash, + invoice=None, + ) + ) + break + except Exception: + continue + + lookup_hold = await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=payment_hash, + invoice=None, + ) + ) + assert lookup_hold.amount == 5001 + assert lookup_hold.description is None + assert lookup_hold.description_hash == description_hash3 + assert lookup_hold.metadata is None + assert lookup_hold.preimage is None + assert lookup_hold.payment_hash == payment_hash + assert lookup_hold.transaction_type.name == "INCOMING" + assert lookup_hold.state.name == "PENDING" + assert lookup_hold.settled_at is None + + description4 = "test4" + description_hash4 = hashlib.sha256(description4.encode()).hexdigest() + preimage = secrets.token_hex(32) + payment_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() + content = { + "method": "make_hold_invoice", + "params": { + "amount": 5002, + "payment_hash": payment_hash, + "description_hash": description_hash4, + }, + } + content = json.dumps(content) + encrypted_content = await signer.nip04_encrypt(uri.public_key(), content) + event = ( + await EventBuilder(Kind(23194), encrypted_content) + .tags([Tag.public_key(uri.public_key())]) + .sign(signer) + ) + await client.send_event(event) + + start_time = datetime.now() + while (datetime.now() - start_time) < timedelta(seconds=10): + time.sleep(1) + try: + await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=payment_hash, + invoice=None, + ) + ) + break + except Exception: + continue + + lookup_hold = await nwc.lookup_invoice( + LookupInvoiceRequest( + payment_hash=payment_hash, + invoice=None, + ) + ) + assert lookup_hold.amount == 5002 + assert lookup_hold.description is None + assert lookup_hold.description_hash == description_hash4 + assert lookup_hold.metadata is None + assert lookup_hold.preimage is None + assert lookup_hold.payment_hash == payment_hash + assert lookup_hold.transaction_type.name == "INCOMING" + assert lookup_hold.state.name == "PENDING" + assert lookup_hold.settled_at is None + + info_event = await fetch_info_event(client, uri) + assert ( + info_event.content() + == "make_invoice lookup_invoice list_transactions get_balance get_info pay_invoice multi_pay_invoice pay_keysend multi_pay_keysend make_hold_invoice cancel_hold_invoice settle_hold_invoice notifications" + ) + assert ( + info_event.tags().find(TagKind.UNKNOWN("encryption")).content() + == "nip44_v2 nip04" + ) + assert ( + info_event.tags().find(TagKind.UNKNOWN("notifications")).content() + == "payment_received payment_sent hold_invoice_accepted" + ) + + l2.restart() + + l1.rpc.connect(l2.info["id"], "localhost", l2.port) + + (responses7, _res) = await fetch_event_responses( + client, + client_pubkey, + 23196, + lambda: executor.submit(l1.rpc.call, "xpay", [lookup_hold.invoice]), + 1, + ) + hold_events = [] + for event in responses7: + LOGGER.info(event) + content = await signer.nip04_decrypt(uri.public_key(), event.content()) + content = json.loads(content) + LOGGER.info(content) + if ( + content["notification_type"] == "hold_invoice_accepted" + and content["notification"]["payment_hash"] == payment_hash + ): + hold_events.append(content) + assert len(hold_events) == 1 diff --git a/tests/util.py b/tests/util.py index a3f69aa..393a970 100644 --- a/tests/util.py +++ b/tests/util.py @@ -1,8 +1,8 @@ -import logging import os import random import string from pathlib import Path +from hashlib import sha256 import pytest @@ -11,9 +11,11 @@ COMPILED_PATH = plugin_dir / "target" / RUST_PROFILE / "cln-nip47" DOWNLOAD_PATH = plugin_dir / "tests" / "cln-nip47" +DOWNLOAD_HOLD_PATH = plugin_dir / "tests" / "hold" + @pytest.fixture -def get_plugin(directory): +def get_plugin(): if COMPILED_PATH.is_file(): return COMPILED_PATH elif DOWNLOAD_PATH.is_file(): @@ -22,6 +24,14 @@ def get_plugin(directory): raise ValueError("No files were found.") +@pytest.fixture +def get_hold(): + if DOWNLOAD_HOLD_PATH.is_file(): + return DOWNLOAD_HOLD_PATH + else: + raise ValueError("No files were found.") + + def generate_random_label(): label_length = 8 random_label = "".join( @@ -34,15 +44,6 @@ 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.info(f"holdinvoice: Error paying payment hash:{e}") - pass - - def update_config_file_option(lightning_dir, option_name, option_value): with open(lightning_dir + "/config", "r") as file: lines = file.readlines() diff --git a/tests/uv.lock b/tests/uv.lock index cf8cfe4..b528914 100644 --- a/tests/uv.lock +++ b/tests/uv.lock @@ -164,11 +164,11 @@ wheels = [ [[package]] name = "certifi" -version = "2025.11.12" +version = "2026.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] [[package]]