diff --git a/Cargo.lock b/Cargo.lock index d276670..252d003 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -128,10 +138,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "itoa", "matchit", @@ -144,7 +154,7 @@ dependencies = [ "serde_path_to_error", "serde_urlencoded", "sha1", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-tungstenite 0.28.0", "tower", @@ -161,12 +171,12 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", @@ -333,6 +343,9 @@ dependencies = [ "getrandom 0.3.4", "lightning 0.3.0+git", "lightning-invoice 0.34.0+git", + "reqwest 0.11.27", + "serde", + "serde_json", ] [[package]] @@ -356,6 +369,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -483,6 +502,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -553,6 +582,24 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "digest" version = "0.10.7" @@ -601,13 +648,22 @@ dependencies = [ "byteorder", "libc", "log", - "rustls", + "rustls 0.23.37", "serde", "serde_json", "webpki-roots 0.25.4", "winapi", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -633,7 +689,7 @@ dependencies = [ "bitcoin", "hex-conservative", "log", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tokio", @@ -669,6 +725,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -822,6 +884,44 @@ dependencies = [ "wasip3", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.13.2" @@ -874,6 +974,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex-conservative" version = "0.2.2" @@ -907,6 +1013,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -917,6 +1034,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -924,7 +1052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -935,8 +1063,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -952,6 +1080,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.8.1" @@ -962,8 +1114,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http", - "http-body", + "h2 0.4.14", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -974,19 +1127,33 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", - "hyper", + "http 1.4.0", + "hyper 1.8.1", "hyper-util", - "rustls", + "rustls 0.23.37", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", "webpki-roots 1.0.6", ] @@ -1001,14 +1168,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -1257,9 +1424,9 @@ dependencies = [ "log", "prost", "rand 0.9.2", - "reqwest", + "reqwest 0.12.28", "rusqlite", - "rustls", + "rustls 0.23.37", "serde", "serde_json", "tokio", @@ -1299,9 +1466,9 @@ dependencies = [ "log", "prost", "rand 0.9.2", - "reqwest", + "reqwest 0.12.28", "rusqlite", - "rustls", + "rustls 0.23.37", "serde", "serde_json", "tokio", @@ -1699,6 +1866,8 @@ version = "0.0.2" dependencies = [ "axum", "base64 0.22.1", + "bech32", + "bitcoin-payment-instructions", "chrono", "clap", "corepc-node", @@ -1709,7 +1878,7 @@ dependencies = [ "ldk-node 0.7.0 (git+https://github.com/moneydevkit/ldk-node?rev=c3aba2a47a6314968979de8fe772d9d1bcb3ee6e)", "ldk-node 0.7.0 (git+https://github.com/moneydevkit/ldk-node?rev=e5fcce065fa97c522de0f8fec87beb9e7e541456)", "log", - "reqwest", + "reqwest 0.12.28", "rusqlite", "serde", "serde_json", @@ -1723,6 +1892,7 @@ dependencies = [ "utoipa", "utoipa-axum", "utoipa-scalar", + "wiremock", ] [[package]] @@ -1792,6 +1962,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2010,8 +2190,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", - "socket2", + "rustls 0.23.37", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -2030,7 +2210,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls", + "rustls 0.23.37", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -2048,7 +2228,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -2139,7 +2319,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -2171,6 +2351,47 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-rustls 0.24.1", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 0.25.4", + "winreg", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -2182,25 +2403,25 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.37", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower", "tower-http", "tower-service", @@ -2231,7 +2452,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags", + "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2251,7 +2472,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2264,13 +2485,25 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.37" @@ -2281,11 +2514,20 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.10", "subtle", "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -2296,6 +2538,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.10" @@ -2325,6 +2577,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "secp256k1" version = "0.29.1" @@ -2477,6 +2739,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -2527,6 +2799,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2547,6 +2825,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2637,7 +2936,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -2653,13 +2952,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.37", "tokio", ] @@ -2762,7 +3071,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -2775,11 +3084,11 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -2833,7 +3142,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.2", @@ -2850,7 +3159,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.2", @@ -2998,7 +3307,7 @@ dependencies = [ "prost", "prost-build", "rand 0.8.5", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tokio", @@ -3121,7 +3430,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -3504,6 +3813,39 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http 1.4.0", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3562,7 +3904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 1f9cb03..2abfb7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,10 @@ demo = [] # Branch: https://github.com/moneydevkit/ldk-node/tree/lsp-0.7.0_socks-support ldk-node = { git = "https://github.com/moneydevkit/ldk-node", rev = "c3aba2a47a6314968979de8fe772d9d1bcb3ee6e" } +# Pinned to the same git rev as ldk-node's transitive pull to avoid duplicate +# crate compilation. Verify with `cargo tree -d | grep bitcoin-payment-instructions`. +bitcoin-payment-instructions = { git = "https://github.com/moneydevkit/bitcoin-payment-instructions", rev = "6796e87525d6c564e1332354a808730e2ba2ebf8", default-features = false, features = ["http"] } + axum = { version = "0.8", features = ["json", "ws"] } utoipa = { version = "5.4", features = ["axum_extras"] } utoipa-axum = "0.2" @@ -45,3 +49,5 @@ ldk-node-lsp = { package = "ldk-node", git = "https://github.com/moneydevkit/ldk tempfile = "3" tokio-tungstenite = "0.26" tower = "0.5.3" +wiremock = "0.6" +bech32 = "0.11" diff --git a/src/daemon/api/mod.rs b/src/daemon/api/mod.rs index 92995f2..28744e3 100644 --- a/src/daemon/api/mod.rs +++ b/src/daemon/api/mod.rs @@ -7,6 +7,7 @@ pub mod info; pub mod invoices; pub mod onchain; pub mod pay; +pub mod pay_any; pub mod websocket; use std::sync::Arc; @@ -32,7 +33,7 @@ use crate::daemon::types::{ DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, GetBalanceResponse, GetInfoResponse, IncomingPaymentResponse, ListOutgoingPaymentsRequest, ListPaymentsRequest, OutgoingPaymentResponse, PayInvoiceRequest, PayInvoiceResponse, - SendToAddressRequest, + PayRequest, PayResponse, SendToAddressRequest, }; #[derive(Clone)] @@ -89,6 +90,7 @@ pub fn router(state: AppState) -> Router { .routes(routes!(close_channel)) .routes(routes!(send_to_address)) .routes(routes!(pay_invoice)) + .routes(routes!(pay)) .layer(middleware::from_fn(auth::require_full_access)); let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi()) @@ -320,3 +322,20 @@ async fn pay_invoice( ) -> Result, AppError> { Ok(Json(pay::handle_pay_invoice(state.node, &req).await?)) } + +#[utoipa::path( + post, path = "/pay", tag = "send", + request_body(content = PayRequest, content_type = "application/x-www-form-urlencoded"), + responses( + (status = 200, body = PayResponse), + (status = 400, body = ApiError), + (status = 500, body = ApiError), + ), + security(("basic_auth" = [])) +)] +async fn pay( + State(state): State, + Form(req): Form, +) -> Result, AppError> { + Ok(Json(pay_any::handle_pay(state, &req).await?)) +} diff --git a/src/daemon/api/pay_any.rs b/src/daemon/api/pay_any.rs new file mode 100644 index 0000000..6f4caeb --- /dev/null +++ b/src/daemon/api/pay_any.rs @@ -0,0 +1,379 @@ +use std::str::FromStr; +use std::time::Duration; + +use bitcoin_payment_instructions::amount::Amount as InstructionAmount; +use bitcoin_payment_instructions::http_resolver::HTTPHrnResolver; +use bitcoin_payment_instructions::{ + ConfigurableAmountPaymentInstructions, FixedAmountPaymentInstructions, ParseError, + PaymentInstructions, PaymentMethod, +}; +use hex::DisplayHex; +use ldk_node::lightning::ln::channelmanager::PaymentId; +use ldk_node::lightning::offers::offer::Offer; +use ldk_node::lightning_invoice::Bolt11Invoice; +use ldk_node::payment::{PaymentDetails, PaymentKind, PaymentStatus}; +use log::error; +use tokio::sync::broadcast; + +use mdk::types::MdkEvent; + +use crate::daemon::api::error::AppError; +use crate::daemon::api::AppState; +use crate::daemon::types::{PayRequest, PayResponse, PayStatus}; + +/// Hard cap on caller-supplied wait. Higher values would routinely +/// exceed reverse-proxy idle timeouts (ALB/CloudFront default 60s). +const MAX_WAIT_SECS: u64 = 50; +const DEFAULT_WAIT_SECS: u64 = 30; + +enum PaymentTarget { + Bolt11 { + invoice: Bolt11Invoice, + amount_msat: u64, + }, + Bolt12 { + offer: Box, + amount_msat: u64, + }, +} + +pub async fn handle_pay(state: AppState, req: &PayRequest) -> Result { + if let Some(amount_sat) = req.amount_sat { + if amount_sat == 0 { + return Err(AppError::BadRequest( + "amountSat must be greater than zero".into(), + )); + } + } + + let wait_secs = req.wait_for_payment_secs.unwrap_or(DEFAULT_WAIT_SECS); + if wait_secs > MAX_WAIT_SECS { + return Err(AppError::BadRequest(format!( + "waitForPaymentSecs must be <= {MAX_WAIT_SECS}" + ))); + } + + let amount_msat = match req.amount_sat { + Some(s) => Some( + s.checked_mul(1000) + .ok_or_else(|| AppError::BadRequest("amountSat overflow".into()))?, + ), + None => None, + }; + + let network = state.node.config().network; + let resolver = HTTPHrnResolver::new(); + let instructions = PaymentInstructions::parse(req.destination.trim(), network, &resolver, true) + .await + .map_err(map_parse_error)?; + + let target = match instructions { + PaymentInstructions::FixedAmount(fixed) => { + resolve_fixed(fixed, amount_msat, &req.destination)? + } + PaymentInstructions::ConfigurableAmount(configurable) => { + let requested_msat = amount_msat.ok_or_else(|| { + AppError::BadRequest( + "amountSat is required for variable-amount destinations".into(), + ) + })?; + resolve_configurable(configurable, requested_msat, &resolver).await? + } + }; + + if matches!(target, PaymentTarget::Bolt11 { .. }) { + if req.payer_note.is_some() { + return Err(AppError::BadRequest( + "payerNote is only supported on BOLT12 destinations".into(), + )); + } + if req.quantity.is_some() { + return Err(AppError::BadRequest( + "quantity is only supported on BOLT12 destinations".into(), + )); + } + } + + if wait_secs == 0 { + let payment_id = dispatch(&state, &target, req)?; + let details = state.node.payment(&payment_id); + return Ok(build_response( + payment_id, + details, + &target, + PayStatus::Pending, + )); + } + + // Subscribe BEFORE dispatch to avoid losing a fast-failing event. + let mut rx = state.mdk_client.subscribe(); + let payment_id = dispatch(&state, &target, req)?; + let payment_id_hex = payment_id.0.to_lower_hex_string(); + + let _ = tokio::time::timeout(Duration::from_secs(wait_secs), async { + loop { + match rx.recv().await { + Ok(MdkEvent::PaymentSuccessful { ref payment_id, .. }) + | Ok(MdkEvent::PaymentFailed { ref payment_id, .. }) + if *payment_id == payment_id_hex => + { + return; + } + Ok(_) => continue, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + // Sender dropped — treat as pending; the payment may still settle. + Err(broadcast::error::RecvError::Closed) => return, + } + } + }) + .await; + + let details = state.node.payment(&payment_id); + let fallback = match &details { + Some(d) => map_status(d.status), + None => PayStatus::Pending, + }; + Ok(build_response(payment_id, details, &target, fallback)) +} + +fn resolve_fixed( + fixed: FixedAmountPaymentInstructions, + requested_msat: Option, + destination: &str, +) -> Result { + let invoice_msat = fixed + .ln_payment_amount() + .ok_or_else(|| AppError::BadRequest("destination has no lightning amount".into()))? + .milli_sats(); + + if let Some(requested) = requested_msat { + if requested != invoice_msat { + return Err(AppError::BadRequest(format!( + "amountSat ({}) does not match invoice amount ({} sat)", + requested / 1000, + invoice_msat / 1000 + ))); + } + } + + pick_method(fixed.methods(), invoice_msat, destination) +} + +async fn resolve_configurable( + configurable: ConfigurableAmountPaymentInstructions, + requested_msat: u64, + resolver: &HTTPHrnResolver, +) -> Result { + let amount = InstructionAmount::from_milli_sats(requested_msat) + .map_err(|_| AppError::BadRequest("amountSat exceeds maximum".into()))?; + let fixed = configurable + .set_amount(amount, resolver) + .await + .map_err(map_set_amount_error)?; + + let target = pick_method(fixed.methods(), requested_msat, "")?; + + // Mirror lightning-js's malicious-LNURL check. The library already + // enforces this internally on resolve_lnurl_to_invoice, but we keep + // an explicit check as a defense-in-depth gate. + if let PaymentTarget::Bolt11 { invoice, .. } = &target { + if let Some(inv_msat) = invoice.amount_milli_satoshis() { + if inv_msat != requested_msat { + return Err(AppError::BadRequest(format!( + "resolved invoice amount ({inv_msat}msat) does not match requested ({requested_msat}msat)" + ))); + } + } + } + + Ok(target) +} + +fn pick_method( + methods: &[PaymentMethod], + amount_msat: u64, + _destination: &str, +) -> Result { + // Prefer BOLT11 over BOLT12 when both are present (lower routing latency). + let bolt11 = methods.iter().find_map(|m| match m { + PaymentMethod::LightningBolt11(inv) => Some(inv), + _ => None, + }); + + if let Some(inv) = bolt11 { + // Convert across crate-version boundary via serialization. The + // bitcoin-payment-instructions crate pins a different rust-lightning + // git rev than ldk-node, so the two `Bolt11Invoice` types are + // distinct even though the wire format is identical. + let parsed = Bolt11Invoice::from_str(&inv.to_string()) + .map_err(|e| AppError::Internal(format!("failed to re-parse resolved bolt11: {e}")))?; + return Ok(PaymentTarget::Bolt11 { + invoice: parsed, + amount_msat, + }); + } + + let bolt12 = methods.iter().find_map(|m| match m { + PaymentMethod::LightningBolt12(offer) => Some(offer), + _ => None, + }); + + if let Some(offer) = bolt12 { + let parsed = Offer::from_str(&offer.to_string()).map_err(|e| { + AppError::Internal(format!("failed to re-parse resolved bolt12: {e:?}")) + })?; + return Ok(PaymentTarget::Bolt12 { + offer: Box::new(parsed), + amount_msat, + }); + } + + Err(AppError::BadRequest( + "no supported lightning payment method (need BOLT11 or BOLT12)".into(), + )) +} + +fn dispatch( + state: &AppState, + target: &PaymentTarget, + req: &PayRequest, +) -> Result { + match target { + PaymentTarget::Bolt11 { + invoice, + amount_msat, + } => { + let bolt11 = state.node.bolt11_payment(); + match invoice.amount_milli_satoshis() { + Some(_) => bolt11 + .send(invoice, None) + .map_err(|e| AppError::Internal(format!("pay failed: {e}"))), + None => bolt11 + .send_using_amount(invoice, *amount_msat, None) + .map_err(|e| AppError::Internal(format!("pay failed: {e}"))), + } + } + PaymentTarget::Bolt12 { offer, amount_msat } => state + .node + .bolt12_payment() + .send_using_amount( + offer, + *amount_msat, + req.quantity, + req.payer_note.clone(), + None, + ) + .map_err(|e| AppError::Internal(format!("pay failed: {e}"))), + } +} + +fn build_response( + payment_id: PaymentId, + details: Option, + target: &PaymentTarget, + fallback_status: PayStatus, +) -> PayResponse { + // The BOLT11 invoice always knows its hash; surface it even if LDK + // hasn't recorded the payment yet. + let target_hash = match target { + PaymentTarget::Bolt11 { invoice, .. } => Some(invoice.payment_hash().to_string()), + PaymentTarget::Bolt12 { .. } => None, + }; + + let payment_id_hex = payment_id.0.to_lower_hex_string(); + + match details { + Some(d) => { + let details_hash = extract_hash(&d.kind); + let preimage = extract_preimage(&d.kind); + let status = map_status(d.status); + let reason = if matches!(status, PayStatus::Failed) { + Some("payment failed".to_string()) + } else { + None + }; + PayResponse { + payment_id: payment_id_hex, + payment_hash: target_hash.or(details_hash), + preimage, + fee_sat: d.fee_paid_msat.map(|m| m / 1000), + status, + reason, + } + } + None => PayResponse { + payment_id: payment_id_hex, + payment_hash: target_hash, + preimage: None, + fee_sat: None, + status: fallback_status, + reason: None, + }, + } +} + +fn map_status(status: PaymentStatus) -> PayStatus { + match status { + PaymentStatus::Succeeded => PayStatus::Succeeded, + PaymentStatus::Failed => PayStatus::Failed, + PaymentStatus::Pending => PayStatus::Pending, + } +} + +fn extract_hash(kind: &PaymentKind) -> Option { + match kind { + PaymentKind::Bolt11 { hash, .. } | PaymentKind::Bolt11Jit { hash, .. } => { + Some(hash.to_string()) + } + PaymentKind::Spontaneous { hash, .. } => Some(hash.to_string()), + PaymentKind::Bolt12Offer { hash, .. } | PaymentKind::Bolt12Refund { hash, .. } => { + hash.map(|h| h.to_string()) + } + PaymentKind::Onchain { .. } => None, + } +} + +fn extract_preimage(kind: &PaymentKind) -> Option { + match kind { + PaymentKind::Bolt11 { preimage, .. } + | PaymentKind::Bolt11Jit { preimage, .. } + | PaymentKind::Bolt12Offer { preimage, .. } + | PaymentKind::Bolt12Refund { preimage, .. } + | PaymentKind::Spontaneous { preimage, .. } => preimage.map(|p| p.to_string()), + PaymentKind::Onchain { .. } => None, + } +} + +fn map_parse_error(err: ParseError) -> AppError { + match err { + // Resolver transport classification: if the resolver returned a + // "fetch"-style message, treat as 500. Other resolver responses + // (bad tag, bad metadata, etc.) are caller-controllable input. + // The library lumps reqwest send errors and JSON parse errors + // under the same "Failed to fetch ..." string, so unparseable JSON + // also lands here as 500. + ParseError::HrnResolutionError(msg) if is_transport_msg(msg) => { + error!("LNURL/HRN resolver transport failure: {msg}"); + AppError::Internal(format!("destination resolver failed: {msg}")) + } + ParseError::HrnResolutionError(msg) => { + AppError::BadRequest(format!("destination resolver returned invalid data: {msg}")) + } + e => AppError::BadRequest(format!("invalid destination: {e:?}")), + } +} + +fn map_set_amount_error(msg: &'static str) -> AppError { + if is_transport_msg(msg) { + error!("LNURL callback transport failure: {msg}"); + AppError::Internal(format!("destination resolver failed: {msg}")) + } else if msg.contains("wrong amount") { + AppError::BadRequest(format!("malicious LNURL: {msg}")) + } else { + AppError::BadRequest(format!("destination resolver returned invalid data: {msg}")) + } +} + +fn is_transport_msg(msg: &str) -> bool { + msg.contains("Failed to fetch") || msg.contains("callback failed") +} diff --git a/src/daemon/types.rs b/src/daemon/types.rs index 2ccc3f0..f3020ee 100644 --- a/src/daemon/types.rs +++ b/src/daemon/types.rs @@ -266,3 +266,36 @@ pub struct ApiError { pub error: String, pub code: String, } + +#[derive(Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PayRequest { + pub destination: String, + pub amount_sat: Option, + pub wait_for_payment_secs: Option, + pub payer_note: Option, + pub quantity: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PayStatus { + Succeeded, + Failed, + Pending, +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PayResponse { + pub payment_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub payment_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preimage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fee_sat: Option, + pub status: PayStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 6659c47..12f3e24 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -697,3 +697,14 @@ pub fn random_mnemonic() -> String { .expect("12-word mnemonic") .to_string() } + +// --------------------------------------------------------------------------- +// LNURL bech32 helper +// --------------------------------------------------------------------------- + +/// bech32-encode an HTTP URL into an `lnurl1...` string the +/// `bitcoin-payment-instructions` resolver can decode and fetch. +pub fn encode_lnurl(url: &str) -> String { + let hrp = bech32::Hrp::parse("lnurl").expect("valid hrp"); + bech32::encode::(hrp, url.as_bytes()).expect("bech32 encode") +} diff --git a/tests/integration.rs b/tests/integration.rs index 122a4e2..49b7ad5 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -3,8 +3,8 @@ mod common; use std::time::Duration; use common::{ - fund_lsp, random_mnemonic, setup_payer_lsp_channel, LspNode, MdkdHandle, PayerNode, - TestBitcoind, WebhookReceiver, + encode_lnurl, fund_lsp, random_mnemonic, setup_payer_lsp_channel, LspNode, MdkdHandle, + PayerNode, TestBitcoind, WebhookReceiver, }; const TEST_MNEMONIC: &str = @@ -1403,3 +1403,475 @@ async fn test_payinvoice_amount_mismatch_400() { resp.text().await.unwrap_or_default() ); } + +// --------------------------------------------------------------------------- +// /pay — unified send endpoint +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread")] +async fn test_pay_invalid_destination_400() { + let bitcoind = TestBitcoind::new(); + let server = MdkdHandle::start(&bitcoind, None, None, &random_mnemonic()).await; + + let resp = server + .post_form("/pay", &[("destination", "garbage-not-a-real-destination")]) + .await; + assert_eq!(resp.status(), 400); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["code"].as_str().unwrap(), "bad_request"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_pay_amount_sat_zero_400() { + let bitcoind = TestBitcoind::new(); + let server = MdkdHandle::start(&bitcoind, None, None, &random_mnemonic()).await; + + // amountSat=0 should fail before destination resolution; pass any string. + let resp = server + .post_form( + "/pay", + &[ + ("destination", "lnbcrt1pseventually-irrelevant"), + ("amountSat", "0"), + ], + ) + .await; + assert_eq!(resp.status(), 400); + let body: serde_json::Value = resp.json().await.unwrap(); + let err = body["error"].as_str().unwrap().to_lowercase(); + assert!(err.contains("amountsat"), "unexpected error: {err}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_pay_wait_secs_over_cap_400() { + let bitcoind = TestBitcoind::new(); + let server = MdkdHandle::start(&bitcoind, None, None, &random_mnemonic()).await; + + let resp = server + .post_form( + "/pay", + &[("destination", "anything"), ("waitForPaymentSecs", "51")], + ) + .await; + assert_eq!(resp.status(), 400); + let body: serde_json::Value = resp.json().await.unwrap(); + let err = body["error"].as_str().unwrap().to_lowercase(); + assert!( + err.contains("waitforpaymentsecs"), + "unexpected error: {err}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_pay_bolt11_amount_mismatch_400() { + let bitcoind = TestBitcoind::new(); + let server = MdkdHandle::start(&bitcoind, None, None, &random_mnemonic()).await; + + let payer = PayerNode::new(&bitcoind); + let invoice = payer.create_invoice(10_000, "mismatch test", 600); + + let resp = server + .post_form("/pay", &[("destination", &invoice), ("amountSat", "5000")]) + .await; + assert_eq!(resp.status(), 400); + let body: serde_json::Value = resp.json().await.unwrap(); + let err = body["error"].as_str().unwrap().to_lowercase(); + assert!(err.contains("does not match"), "unexpected error: {err}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_pay_payer_note_on_bolt11_400() { + let bitcoind = TestBitcoind::new(); + let server = MdkdHandle::start(&bitcoind, None, None, &random_mnemonic()).await; + + let payer = PayerNode::new(&bitcoind); + let invoice = payer.create_invoice(10_000, "payer note test", 600); + + let resp = server + .post_form("/pay", &[("destination", &invoice), ("payerNote", "hello")]) + .await; + assert_eq!(resp.status(), 400); + let body: serde_json::Value = resp.json().await.unwrap(); + let err = body["error"].as_str().unwrap().to_lowercase(); + assert!( + err.contains("payernote") || err.contains("bolt12"), + "unexpected: {err}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_pay_quantity_on_bolt11_400() { + let bitcoind = TestBitcoind::new(); + let server = MdkdHandle::start(&bitcoind, None, None, &random_mnemonic()).await; + + let payer = PayerNode::new(&bitcoind); + let invoice = payer.create_invoice(10_000, "qty test", 600); + + let resp = server + .post_form("/pay", &[("destination", &invoice), ("quantity", "3")]) + .await; + assert_eq!(resp.status(), 400); + let body: serde_json::Value = resp.json().await.unwrap(); + let err = body["error"].as_str().unwrap().to_lowercase(); + assert!( + err.contains("quantity") || err.contains("bolt12"), + "unexpected: {err}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_pay_lnurl_dns_failure_500() { + let bitcoind = TestBitcoind::new(); + let server = MdkdHandle::start(&bitcoind, None, None, &random_mnemonic()).await; + + // Bind a port, then drop the listener so the next connect refuses. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + + let lnurl = encode_lnurl(&format!("http://127.0.0.1:{port}/lnurl-init")); + + let resp = server + .post_form("/pay", &[("destination", &lnurl), ("amountSat", "1000")]) + .await; + assert_eq!(resp.status(), 500); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["code"].as_str().unwrap(), "internal_error"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_pay_lnurl_amount_mismatch_400() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let bitcoind = TestBitcoind::new(); + let server = MdkdHandle::start(&bitcoind, None, None, &random_mnemonic()).await; + + // PayerNode mints a real, signed BOLT11 with amount 10_000 sats. + let payer = PayerNode::new(&bitcoind); + let invoice = payer.create_invoice(10_000, "lnurl mismatch", 600); + + let mock_server = MockServer::start().await; + let metadata = "[[\"text/plain\",\"test\"]]"; + let init = serde_json::json!({ + "tag": "payRequest", + "callback": format!("{}/callback", mock_server.uri()), + "minSendable": 1_000, + "maxSendable": 100_000_000, + "metadata": metadata, + }); + Mock::given(method("GET")) + .and(path("/lnurl-init")) + .respond_with(ResponseTemplate::new(200).set_body_json(init)) + .mount(&mock_server) + .await; + // Callback returns the 10_000-sat invoice no matter what amount was requested. + Mock::given(method("GET")) + .and(path("/callback")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "pr": invoice, + "routes": [], + }))) + .mount(&mock_server) + .await; + + let lnurl = encode_lnurl(&format!("{}/lnurl-init", mock_server.uri())); + + // Caller requests 5_000 sats, server hands back invoice for 10_000. + let resp = server + .post_form("/pay", &[("destination", &lnurl), ("amountSat", "5000")]) + .await; + assert_eq!(resp.status(), 400); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["code"].as_str().unwrap(), "bad_request"); + let err = body["error"].as_str().unwrap().to_lowercase(); + assert!( + err.contains("wrong amount") || err.contains("malicious") || err.contains("does not match"), + "unexpected error: {err}", + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_pay_dispatch_failure_returns_500() { + let bitcoind = TestBitcoind::new(); + // No LSP, no channels — mdkd has zero outbound capacity. Dispatch fails + // synchronously and we must return ApiError (not PayResponse{failed}). + let server = MdkdHandle::start(&bitcoind, None, None, &random_mnemonic()).await; + + let payer = PayerNode::new(&bitcoind); + let invoice = payer.create_invoice(1_000, "dispatch fail", 600); + + let resp = server + .post_form( + "/pay", + &[ + ("destination", &invoice), + // Skip any wait so we observe the synchronous dispatch error. + ("waitForPaymentSecs", "0"), + ], + ) + .await; + + // Two acceptable shapes here: + // - 500 ApiError if ldk-node rejects the send synchronously + // - 200 PayResponse{status: pending} if ldk-node accepts and the + // failure surfaces only via a later PaymentFailed event + // The contract we lock down: if 200, the body MUST be a PayResponse + // with paymentId; if 500, the body is ApiError WITHOUT paymentId. + let status = resp.status(); + let body: serde_json::Value = resp.json().await.unwrap(); + if status == 500 { + assert_eq!(body["code"].as_str().unwrap(), "internal_error"); + assert!( + body.get("paymentId").is_none(), + "ApiError must not contain paymentId, got {body:?}", + ); + } else if status == 200 { + assert!( + body.get("paymentId").is_some(), + "PayResponse must have paymentId" + ); + assert_eq!(body["status"].as_str().unwrap(), "PENDING"); + } else { + panic!("unexpected status {status}: {body:?}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_pay_bolt11_blocks_until_succeeded() { + let bitcoind = TestBitcoind::new(); + let lsp = LspNode::new(&bitcoind); + fund_lsp(&bitcoind, &lsp).await; + + let server = MdkdHandle::start(&bitcoind, None, Some(&lsp), &random_mnemonic()).await; + let payer = PayerNode::new(&bitcoind); + setup_payer_lsp_channel(&bitcoind, &payer, &lsp, 500_000).await; + + // Fund mdkd's outbound side via a JIT incoming payment. + let invoice: serde_json::Value = server + .post_form( + "/createinvoice", + &[ + ("amountSat", "200000"), + ("description", "fund-mdkd-for-pay-test"), + ("expirySeconds", "3600"), + ], + ) + .await + .json() + .await + .unwrap(); + let inbound_invoice = invoice["serialized"].as_str().unwrap(); + let inbound_hash = invoice["paymentHash"].as_str().unwrap().to_string(); + payer.pay_invoice(inbound_invoice); + + let start = std::time::Instant::now(); + loop { + let resp: serde_json::Value = server + .get(&format!("/payments/incoming/{inbound_hash}")) + .await + .json() + .await + .unwrap(); + if resp["isPaid"].as_bool().unwrap_or(false) { + break; + } + if start.elapsed() > Duration::from_secs(60) { + panic!("Timed out funding mdkd via LSP JIT channel"); + } + bitcoind.mine_blocks(1); + tokio::time::sleep(Duration::from_secs(2)).await; + } + + // Issue an outbound invoice from PayerNode and call /pay. + let outbound_invoice = payer.create_invoice(50_000, "pay-any test", 3600); + + // Mine in the background so the payment can settle on regtest while + // mdkd is blocking on /pay (sync_wallets / route discovery may need + // tip blocks). 30s default wait. + let bitcoind_url = bitcoind.bitcoind.rpc_url(); + let _ = bitcoind_url; // placeholder; we just rely on test wall clock for now + + let resp = server + .post_form( + "/pay", + &[ + ("destination", &outbound_invoice), + ("waitForPaymentSecs", "45"), + ], + ) + .await; + assert_eq!(resp.status(), 200, "/pay returned non-200"); + let body: serde_json::Value = resp.json().await.unwrap(); + let payment_id = body["paymentId"].as_str().unwrap().to_string(); + assert_eq!(payment_id.len(), 64); + assert_eq!(body["paymentHash"].as_str().unwrap().len(), 64); + + // Allow either SUCCEEDED (terminal) or PENDING (event missed during the + // 45s budget on a slow regtest); on PENDING we then poll outgoing. + let status = body["status"].as_str().unwrap(); + if status == "SUCCEEDED" { + assert!(body["preimage"].as_str().is_some(), "missing preimage"); + assert!(body["feeSat"].as_u64().is_some(), "missing feeSat"); + } else { + assert_eq!(status, "PENDING", "unexpected status: {status}"); + } + + // Whether SUCCEEDED or PENDING, GET /payments/outgoing/{id} must converge + // and the field shape must match. + let start = std::time::Instant::now(); + let settled: serde_json::Value = loop { + let r: serde_json::Value = server + .get(&format!("/payments/outgoing/{payment_id}")) + .await + .json() + .await + .unwrap(); + if r["isPaid"].as_bool().unwrap_or(false) { + break r; + } + if start.elapsed() > Duration::from_secs(60) { + panic!("Timed out waiting for outgoing payment to settle: {:?}", r); + } + bitcoind.mine_blocks(1); + tokio::time::sleep(Duration::from_secs(1)).await; + }; + + assert_eq!(settled["paymentId"].as_str().unwrap(), payment_id); + assert_eq!( + settled["paymentHash"].as_str().unwrap(), + body["paymentHash"].as_str().unwrap() + ); + assert!(settled["preimage"].as_str().is_some()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_pay_wait_zero_returns_pending() { + let bitcoind = TestBitcoind::new(); + let lsp = LspNode::new(&bitcoind); + fund_lsp(&bitcoind, &lsp).await; + + let server = MdkdHandle::start(&bitcoind, None, Some(&lsp), &random_mnemonic()).await; + let payer = PayerNode::new(&bitcoind); + setup_payer_lsp_channel(&bitcoind, &payer, &lsp, 500_000).await; + + // Fund mdkd's outbound side via a JIT incoming payment. + let invoice: serde_json::Value = server + .post_form( + "/createinvoice", + &[ + ("amountSat", "200000"), + ("description", "fund-mdkd-for-pay-zero-wait"), + ("expirySeconds", "3600"), + ], + ) + .await + .json() + .await + .unwrap(); + let inbound_invoice = invoice["serialized"].as_str().unwrap(); + let inbound_hash = invoice["paymentHash"].as_str().unwrap().to_string(); + payer.pay_invoice(inbound_invoice); + + let start = std::time::Instant::now(); + loop { + let r: serde_json::Value = server + .get(&format!("/payments/incoming/{inbound_hash}")) + .await + .json() + .await + .unwrap(); + if r["isPaid"].as_bool().unwrap_or(false) { + break; + } + if start.elapsed() > Duration::from_secs(60) { + panic!("Timed out funding mdkd"); + } + bitcoind.mine_blocks(1); + tokio::time::sleep(Duration::from_secs(2)).await; + } + + let outbound_invoice = payer.create_invoice(40_000, "wait zero", 3600); + + let t0 = std::time::Instant::now(); + let resp = server + .post_form( + "/pay", + &[ + ("destination", &outbound_invoice), + ("waitForPaymentSecs", "0"), + ], + ) + .await; + let elapsed = t0.elapsed(); + assert_eq!(resp.status(), 200); + + // wait=0 must short-circuit. Allow generous slack (3s) for the post-dispatch + // node.payment() lookup; this still rules out the default 30s block. + assert!( + elapsed < Duration::from_secs(3), + "wait=0 should short-circuit, took {elapsed:?}", + ); + + let body: serde_json::Value = resp.json().await.unwrap(); + let payment_id = body["paymentId"].as_str().unwrap().to_string(); + assert_eq!(payment_id.len(), 64); + let status = body["status"].as_str().unwrap(); + // Status should be PENDING; in a pathological race it could be SUCCEEDED + // before the lookup, but never FAILED for this funded path. + assert!( + status == "PENDING" || status == "SUCCEEDED", + "got: {status}" + ); + // Optional fields not yet known should be omitted (no nulls on the wire). + if status == "PENDING" { + assert!( + body.get("preimage").is_none(), + "preimage must be absent on PENDING" + ); + assert!( + body.get("feeSat").is_none(), + "feeSat must be absent on PENDING" + ); + assert!( + body.get("reason").is_none(), + "reason must be absent on PENDING" + ); + } + + // Poll until terminal — proves the dispatched payment really did fly. + let start = std::time::Instant::now(); + loop { + let r: serde_json::Value = server + .get(&format!("/payments/outgoing/{payment_id}")) + .await + .json() + .await + .unwrap(); + if r["isPaid"].as_bool().unwrap_or(false) { + break; + } + if start.elapsed() > Duration::from_secs(60) { + panic!("Timed out waiting for outgoing payment to settle: {:?}", r); + } + bitcoind.mine_blocks(1); + tokio::time::sleep(Duration::from_secs(1)).await; + } +} + +// Existing /payinvoice contract must still work after wiring /pay. +#[tokio::test(flavor = "multi_thread")] +async fn test_payinvoice_still_works_after_pay() { + let bitcoind = TestBitcoind::new(); + let server = MdkdHandle::start(&bitcoind, None, None, &random_mnemonic()).await; + + // Garbage invoice → 400, just like before. + let resp = server + .post_form("/payinvoice", &[("invoice", "not-bolt11")]) + .await; + assert_eq!(resp.status(), 400); + + // Suppress unused-import warnings inherited from common in tests that + // don't otherwise touch them. + let _ = WebhookReceiver::start; +} diff --git a/wallet.html b/wallet.html index b8a4dcf..5bfe4d9 100644 --- a/wallet.html +++ b/wallet.html @@ -259,6 +259,7 @@

MDK Wallet

Create Invoice
Payments
Decode
+
Send Lightning
Send On-chain
@@ -405,6 +406,41 @@

Decode Invoice / Offer

+ + +