diff --git a/core/Cargo.lock b/core/Cargo.lock index da7df2e5..e8bf6346 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -120,6 +120,7 @@ dependencies = [ "blake3", "bs58", "bytes", + "cashu", "chacha20poly1305", "chrono", "ciborium", @@ -186,7 +187,7 @@ dependencies = [ "futures", "hex", "hyper 0.14.32", - "indexmap", + "indexmap 2.13.0", "log", "reqwest 0.11.27", "serde", @@ -450,8 +451,8 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" dependencies = [ - "bitcoin-internals 0.3.0", - "bitcoin_hashes 0.14.1", + "bitcoin-internals", + "bitcoin_hashes", ] [[package]] @@ -499,11 +500,11 @@ checksum = "597bb81c80a54b6a4381b23faba8d7774b144c94cbd1d6fe3f1329bd776554ab" [[package]] name = "bip39" -version = "2.1.0" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes 0.13.0", + "bitcoin_hashes", "rand 0.8.5", "rand_core 0.6.4", "serde", @@ -526,27 +527,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026" dependencies = [ "base58ck", + "base64 0.21.7", "bech32", - "bitcoin-internals 0.3.0", + "bitcoin-internals", "bitcoin-io", "bitcoin-units", - "bitcoin_hashes 0.14.1", - "hex-conservative 0.2.2", + "bitcoin_hashes", + "hex-conservative", "hex_lit", "secp256k1", + "serde", ] -[[package]] -name = "bitcoin-internals" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" - [[package]] name = "bitcoin-internals" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" +dependencies = [ + "serde", +] [[package]] name = "bitcoin-io" @@ -560,17 +560,8 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" dependencies = [ - "bitcoin-internals 0.3.0", -] - -[[package]] -name = "bitcoin_hashes" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" -dependencies = [ - "bitcoin-internals 0.2.0", - "hex-conservative 0.1.2", + "bitcoin-internals", + "serde", ] [[package]] @@ -580,7 +571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ "bitcoin-io", - "hex-conservative 0.2.2", + "hex-conservative", "serde", ] @@ -707,6 +698,32 @@ dependencies = [ "serde", ] +[[package]] +name = "cashu" +version = "0.17.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd7216af2b980e203d10677076d8c6c5c30610cbdea6549b8a9020cfa0cf47b" +dependencies = [ + "bitcoin", + "cbor-diag", + "ciborium", + "lightning", + "lightning-invoice", + "once_cell", + "serde", + "serde_json", + "serde_with", + "strum 0.27.2", + "strum_macros 0.27.2", + "thiserror 2.0.18", + "tracing", + "unicode-normalization", + "url", + "uuid", + "web-time", + "zeroize", +] + [[package]] name = "cbc" version = "0.1.2" @@ -716,6 +733,25 @@ dependencies = [ "cipher", ] +[[package]] +name = "cbor-diag" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc245b6ecd09b23901a4fbad1ad975701fd5061ceaef6afa93a2d70605a64429" +dependencies = [ + "bs58", + "chrono", + "data-encoding", + "half", + "nom", + "num-bigint", + "num-rational", + "num-traits", + "separator", + "url", + "uuid", +] + [[package]] name = "cc" version = "1.2.54" @@ -1092,8 +1128,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -1110,13 +1156,37 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn 2.0.114", ] @@ -1147,6 +1217,37 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "der" version = "0.7.10" @@ -1187,6 +1288,9 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] [[package]] name = "derive_arbitrary" @@ -1214,7 +1318,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.114", @@ -1314,6 +1418,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "dnssec-prover" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869bf72abc8c654b350aa8d881c5d9957b85e1e1ed6569c10cd68e9f505d5435" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ed25519" version = "2.2.3" @@ -1774,7 +1890,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -1793,7 +1909,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.4.0", - "indexmap", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -1829,6 +1945,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1887,12 +2009,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hex-conservative" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" - [[package]] name = "hex-conservative" version = "0.2.2" @@ -2391,6 +2507,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -2507,7 +2634,7 @@ dependencies = [ "rustls-pki-types", "serde", "smallvec", - "strum", + "strum 0.28.0", "time", "tokio", "tokio-stream", @@ -2595,7 +2722,7 @@ dependencies = [ "rand 0.10.1", "rustls 0.23.36", "simple-dns", - "strum", + "strum 0.28.0", "tokio", "tracing", "url", @@ -2675,7 +2802,7 @@ dependencies = [ "rustls-pki-types", "serde", "serde_bytes", - "strum", + "strum 0.28.0", "tokio", "tokio-rustls 0.26.4", "tokio-util", @@ -2765,6 +2892,59 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -2911,6 +3091,55 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "lightning" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab16d2a714c0b26d7230bd388ac383a30fce231c8927c62752afc0471a36dc6" +dependencies = [ + "bech32", + "bitcoin", + "dnssec-prover", + "hashbrown 0.13.2", + "libm", + "lightning-invoice", + "lightning-macros", + "lightning-types", + "possiblyrandom", +] + +[[package]] +name = "lightning-invoice" +version = "0.34.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d83bd798e04ab9eecc8bbef1fa17d3808859bcdc0406bd16c55d51c8834444" +dependencies = [ + "bech32", + "bitcoin", + "lightning-types", + "serde", +] + +[[package]] +name = "lightning-macros" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c717494cdc2c8bb85bee7113031248f5f6c64f8802b33c1c9e2d98e594aa71" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "lightning-types" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77c676d4a34cceb2ae3756916e446b4d17f9430a24107e099981f0f9aec77e6" +dependencies = [ + "bitcoin", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -3415,7 +3644,7 @@ dependencies = [ "base64 0.22.1", "bech32", "bip39", - "bitcoin_hashes 0.14.1", + "bitcoin_hashes", "cbc", "chacha20 0.9.1", "chacha20poly1305", @@ -3517,6 +3746,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3896,7 +4136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", - "indexmap", + "indexmap 2.13.0", "quick-xml", "serde", "time", @@ -3934,6 +4174,15 @@ dependencies = [ "serde", ] +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portmapper" version = "0.19.0" @@ -3973,6 +4222,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "possiblyrandom" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c564dbf654befd49035528299f1208a40508f6e07efb11c163444e304e4484f" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "postcard" version = "1.1.3" @@ -4643,6 +4901,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4692,7 +4974,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "bitcoin_hashes 0.14.1", + "bitcoin_hashes", "rand 0.8.5", "secp256k1-sys", "serde", @@ -4758,6 +5040,12 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +[[package]] +name = "separator" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f97841a747eef040fcd2e7b3b9a220a7205926e60488e673d9e4926d27772ce5" + [[package]] name = "serde" version = "1.0.228" @@ -4842,13 +5130,46 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.13.0", "itoa", "ryu", "serde", @@ -5137,13 +5458,31 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + [[package]] name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "strum_macros", + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] @@ -5606,7 +5945,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.13.0", "serde", "serde_spanned", "toml_datetime 0.6.11", @@ -5620,7 +5959,7 @@ version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap", + "indexmap 2.13.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.3", @@ -5823,9 +6162,9 @@ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-normalization" -version = "0.1.22" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ "tinyvec", ] @@ -5903,6 +6242,7 @@ checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom 0.3.4", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -6080,7 +6420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "wasm-encoder", "wasmparser", ] @@ -6119,7 +6459,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.13.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "semver", ] @@ -6678,7 +7018,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "prettyplease", "syn 2.0.114", "wasm-metadata", @@ -6709,7 +7049,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.13.0", - "indexmap", + "indexmap 2.13.0", "log", "serde", "serde_derive", @@ -6728,7 +7068,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", "semver", "serde", @@ -6959,7 +7299,7 @@ dependencies = [ "crossbeam-utils", "displaydoc", "flate2", - "indexmap", + "indexmap 2.13.0", "memchr", "thiserror 2.0.18", "zopfli", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 0ad655fc..56ff0a3d 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -72,7 +72,7 @@ bs58 = "0.5" chrono = "0.4" # BIP-39 mnemonic seed generation + BIP-32 HD key derivation -bip39 = { version = "=2.1.0", features = ["rand"] } +bip39 = { version = "2.1", features = ["rand"] } bitcoin = { version = "=0.32.5", features = ["rand-std"] } # Configuration @@ -143,6 +143,7 @@ async-trait = "0.1" iroh = { version = "1", optional = true } iroh-blobs = { version = "0.103", optional = true } lofty = "0.24.0" +cashu = { version = "0.17.5", default-features = false, features = ["wallet"] } [dev-dependencies] tokio-test = "0.4" diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 2b42e34e..401b3c77 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -266,6 +266,8 @@ impl RpcHandler { "wallet.ecash-send" => self.handle_wallet_ecash_send(params).await, "wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await, "wallet.ecash-history" => self.handle_wallet_ecash_history().await, + "wallet.ecash-network" => self.handle_wallet_ecash_network().await, + "wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await, "wallet.networking-profits" => self.handle_wallet_networking_profits().await, // Fedimint ecash (via fedimint-clientd sidecar) "wallet.fedimint-list" => self.handle_wallet_fedimint_list().await, diff --git a/core/archipelago/src/api/rpc/wallet.rs b/core/archipelago/src/api/rpc/wallet.rs index 67e9c379..a994081e 100644 --- a/core/archipelago/src/api/rpc/wallet.rs +++ b/core/archipelago/src/api/rpc/wallet.rs @@ -36,6 +36,51 @@ impl RpcHandler { })) } + /// `wallet.ecash-network` — which ecash network this node is on, and the + /// balance sitting in the *other* one so the UI can say what switching + /// would reveal rather than appearing to lose money. + pub(super) async fn handle_wallet_ecash_network(&self) -> Result { + let current = ecash::load_network(&self.config.data_dir).await; + let wallet = ecash::load_wallet(&self.config.data_dir).await?; + Ok(serde_json::json!({ + "network": current, + "is_test": current.is_test(), + "mint_url": wallet.mint_url, + "balance_sats": wallet.balance(), + })) + } + + /// `wallet.ecash-set-network` — switch between real and test ecash. + /// + /// Each network keeps its own wallet file, so this never moves, merges or + /// deletes coins: switching away parks the current balance and switching + /// back finds it exactly as it was. + pub(super) async fn handle_wallet_ecash_set_network( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let requested = params + .get("network") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing network ('mainnet' or 'testnet')"))?; + let network = match requested { + "mainnet" => ecash::EcashNetwork::Mainnet, + "testnet" => ecash::EcashNetwork::Testnet, + other => anyhow::bail!("Unknown ecash network '{other}' — use 'mainnet' or 'testnet'"), + }; + + ecash::save_network(&self.config.data_dir, network).await?; + let wallet = ecash::load_wallet(&self.config.data_dir).await?; + tracing::info!(?network, "ecash network switched by operator"); + Ok(serde_json::json!({ + "network": network, + "is_test": network.is_test(), + "mint_url": wallet.mint_url, + "balance_sats": wallet.balance(), + })) + } + pub(super) async fn handle_wallet_ecash_mint( &self, params: Option, diff --git a/core/archipelago/src/wallet/cashu.rs b/core/archipelago/src/wallet/cashu.rs index 762e14de..c0be9e48 100644 --- a/core/archipelago/src/wallet/cashu.rs +++ b/core/archipelago/src/wallet/cashu.rs @@ -20,6 +20,13 @@ use anyhow::{Context, Result}; use bitcoin::secp256k1::PublicKey; +// Protocol types from the reference implementation (`cashu`, the crate CDK +// itself is built on). Used for the parts of NUT-00/02 that move with the +// spec — token parsing and keyset ids — while the structs below stay ours +// because they are also the on-disk format (see docs/cashu-cdk-migration-plan.md). +use cashu::nuts::nut02::{ + Id as CdkId, KeySetInfo as CdkKeySetInfo, ShortKeysetId as CdkShortKeysetId, +}; use serde::{Deserialize, Serialize}; /// Prefix for V3 (JSON) tokens. @@ -222,6 +229,26 @@ impl CashuToken { } } +/// Resolve a token's (possibly short) keyset id against the mint's keyset +/// list, using the reference implementation's NUT-02 rules. +/// +/// A v1 id is complete at 8 bytes; a v2 id is 33 bytes and may legitimately +/// travel in a token as a shorter prefix, which only the mint's keyset list +/// can expand. Upstream `Id::from_short_keyset_id` implements exactly that, +/// including the "8 bytes but `0x01`-versioned" case that a wallet written +/// against the old format produces (framework-pt, 2026-08-17). +/// +/// Returns the full hex id to send to the mint, or `None` when the id is +/// already complete or cannot be resolved — the caller passes those through +/// untouched so the mint's own error still reaches the operator. +pub fn resolve_keyset_id(id_hex: &str, mint_keysets: &[CdkKeySetInfo]) -> Option { + let bytes = hex::decode(id_hex).ok()?; + let short = CdkShortKeysetId::from_bytes(&bytes).ok()?; + let full = CdkId::from_short_keyset_id(&short, mint_keysets).ok()?; + let full_hex = hex::encode(full.to_bytes()); + (full_hex != id_hex).then_some(full_hex) +} + /// NUT-02 keyset ID: hex for either 8 bytes (v1, the `00…` short form) or /// 33 bytes (v2, version-byte + hash). /// diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index 30fa5871..8f837d2d 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -14,6 +14,7 @@ use tracing::{debug, info, warn}; const WALLET_FILE: &str = "wallet/ecash.json"; const MINTS_FILE: &str = "wallet/accepted_mints.json"; +const NETWORK_FILE: &str = "wallet/network.json"; /// Transaction type for history tracking. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -213,12 +214,96 @@ impl WalletState { } } +/// Which ecash network this node's wallet is operating on. +/// +/// Cashu itself has no notion of a testnet — a "test" wallet is simply one +/// pointed at a mint that issues valueless coins (the public `testnut` mint). +/// Modelling it as a network setting rather than "just add a mint" matters +/// because the two must never share a purse: test proofs and real proofs in +/// one file would be spendable interchangeably, and a balance would be a lie. +/// +/// So each network gets its own wallet and its own accepted-mints list. +/// **Mainnet keeps the original filenames**, so an existing node's funds file +/// is untouched by this feature and by switching back and forth. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EcashNetwork { + #[default] + Mainnet, + Testnet, +} + +impl EcashNetwork { + fn wallet_file(&self) -> &'static str { + match self { + Self::Mainnet => WALLET_FILE, + Self::Testnet => "wallet/ecash.testnet.json", + } + } + + fn mints_file(&self) -> &'static str { + match self { + Self::Mainnet => MINTS_FILE, + Self::Testnet => "wallet/accepted_mints.testnet.json", + } + } + + /// The mint a fresh wallet on this network starts out trusting. + pub fn default_mint(&self) -> String { + match self { + Self::Mainnet => default_mint_url(), + // Public test mint: issues coins with no monetary value, and hands + // them out freely, so every route (mint/melt/send/receive/swap) + // can be exercised end to end without risking real sats. + Self::Testnet => "https://testnut.cashu.space".to_string(), + } + } + + pub fn is_test(&self) -> bool { + matches!(self, Self::Testnet) + } +} + +/// Read the node's ecash network. Absent file = mainnet, so nodes that never +/// touch this setting behave exactly as before. +pub async fn load_network(data_dir: &Path) -> EcashNetwork { + let path = data_dir.join(NETWORK_FILE); + let Ok(content) = fs::read_to_string(&path).await else { + return EcashNetwork::Mainnet; + }; + serde_json::from_str::(&content) + .map(|c| c.network) + .unwrap_or_default() +} + +/// Switch the node's ecash network. The other network's wallet is left on +/// disk untouched, so switching is reversible and loses nothing. +pub async fn save_network(data_dir: &Path, network: EcashNetwork) -> Result<()> { + let dir = data_dir.join("wallet"); + fs::create_dir_all(&dir) + .await + .context("Failed to create wallet dir")?; + let content = serde_json::to_string_pretty(&NetworkConfig { network }) + .context("Failed to serialize ecash network")?; + fs::write(data_dir.join(NETWORK_FILE), content) + .await + .context("Failed to write ecash network")?; + Ok(()) +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +struct NetworkConfig { + #[serde(default)] + network: EcashNetwork, +} + /// Load wallet state from disk. pub async fn load_wallet(data_dir: &Path) -> Result { - let path = data_dir.join(WALLET_FILE); + let network = load_network(data_dir).await; + let path = data_dir.join(network.wallet_file()); if !path.exists() { return Ok(WalletState { - mint_url: default_mint_url(), + mint_url: network.default_mint(), ..Default::default() }); } @@ -229,7 +314,7 @@ pub async fn load_wallet(data_dir: &Path) -> Result { // Set default mint URL if empty if wallet.mint_url.is_empty() { - wallet.mint_url = default_mint_url(); + wallet.mint_url = network.default_mint(); } Ok(wallet) @@ -241,7 +326,7 @@ pub async fn save_wallet(data_dir: &Path, wallet: &WalletState) -> Result<()> { fs::create_dir_all(&dir) .await .context("Failed to create wallet dir")?; - let path = data_dir.join(WALLET_FILE); + let path = data_dir.join(load_network(data_dir).await.wallet_file()); let content = serde_json::to_string_pretty(wallet).context("Failed to serialize wallet")?; fs::write(&path, content) .await @@ -251,17 +336,18 @@ pub async fn save_wallet(data_dir: &Path, wallet: &WalletState) -> Result<()> { /// Load accepted mints list. pub async fn load_accepted_mints(data_dir: &Path) -> Result { - let path = data_dir.join(MINTS_FILE); + let network = load_network(data_dir).await; + let path = data_dir.join(network.mints_file()); if !path.exists() { return Ok(AcceptedMints { - mints: vec![default_mint_url()], + mints: vec![network.default_mint()], }); } let content = fs::read_to_string(&path) .await .context("Failed to read accepted mints")?; let mints: AcceptedMints = serde_json::from_str(&content).unwrap_or(AcceptedMints { - mints: vec![default_mint_url()], + mints: vec![network.default_mint()], }); Ok(mints) } @@ -272,7 +358,7 @@ pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Resu fs::create_dir_all(&dir) .await .context("Failed to create wallet dir")?; - let path = data_dir.join(MINTS_FILE); + let path = data_dir.join(load_network(data_dir).await.mints_file()); let content = serde_json::to_string_pretty(mints).context("Failed to serialize accepted mints")?; fs::write(&path, content) @@ -1885,4 +1971,73 @@ mod tests { other => panic!("expected swap into liquid target, got {:?}", other), } } + + #[tokio::test] + async fn ecash_network_defaults_to_mainnet_and_leaves_files_alone() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + assert_eq!(load_network(dir).await, EcashNetwork::Mainnet); + // A node that never touches this setting has no new file. + assert!(!dir.join(NETWORK_FILE).exists()); + assert_eq!( + load_wallet(dir).await.unwrap().mint_url, + default_mint_url(), + "mainnet must keep the original default mint" + ); + } + + #[tokio::test] + async fn switching_to_testnet_uses_a_separate_purse_and_test_mint() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + + // Put real coins in the mainnet wallet. + let mut real = load_wallet(dir).await.unwrap(); + real.proofs.push(StoredProof { + proof: Proof { + amount: 1000, + id: "009a1f293253e41e".into(), + secret: "real".into(), + c: "02".into(), + }, + mint_url: default_mint_url(), + spent: false, + reserved: false, + created_at: "2026-01-01T00:00:00Z".into(), + }); + save_wallet(dir, &real).await.unwrap(); + assert_eq!(load_wallet(dir).await.unwrap().balance(), 1000); + + // Switching to testnet must show an EMPTY purse pointed at the test + // mint — never the real coins. + save_network(dir, EcashNetwork::Testnet).await.unwrap(); + let test_wallet = load_wallet(dir).await.unwrap(); + assert_eq!(test_wallet.balance(), 0, "test wallet must not see real coins"); + assert!(test_wallet.mint_url.contains("testnut")); + assert!(load_accepted_mints(dir).await.unwrap().mints[0].contains("testnut")); + + // Test coins are written to their own file... + let mut t = test_wallet; + t.proofs.push(StoredProof { + proof: Proof { + amount: 7, + id: "009a1f293253e41e".into(), + secret: "test".into(), + c: "02".into(), + }, + mint_url: EcashNetwork::Testnet.default_mint(), + spent: false, + reserved: false, + created_at: "2026-01-01T00:00:00Z".into(), + }); + save_wallet(dir, &t).await.unwrap(); + assert!(dir.join("wallet/ecash.testnet.json").exists()); + + // ...and switching back finds the real balance exactly as it was. + save_network(dir, EcashNetwork::Mainnet).await.unwrap(); + let back = load_wallet(dir).await.unwrap(); + assert_eq!(back.balance(), 1000, "real funds must survive a round trip"); + assert_eq!(back.proofs.len(), 1); + assert_eq!(back.proofs[0].proof.secret, "real"); + } } diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index e5df450f..6053be32 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -530,7 +530,9 @@ impl MintClient { return proofs.to_vec(); } - let known = match self.get_keysets().await { + // The mint's own keyset list, in the reference implementation's shape + // so its NUT-02 resolver can consume it directly. + let known = match self.get_cdk_keysets().await { Ok(k) => k, Err(e) => { debug!("Could not list keysets to repair truncated keyset ids: {e:#}"); @@ -542,28 +544,42 @@ impl MintClient { .iter() .cloned() .map(|mut p| { - if !is_truncated_v2_keyset_id(&p.id) { - return p; - } - // Prefer an active keyset when a prefix somehow matches more - // than one; ambiguity beyond that is left to the mint. - let mut matches = known - .iter() - .filter(|k| k.id.len() == 66 && k.id.starts_with(&p.id)) - .collect::>(); - matches.sort_by_key(|k| !k.active); - if let Some(full) = matches.first() { - debug!( - "Expanded truncated v2 keyset id {} to {} for swap", - p.id, full.id - ); - p.id = full.id.clone(); + if let Some(full) = super::cashu::resolve_keyset_id(&p.id, &known) { + debug!("Expanded short keyset id {} to {} for swap", p.id, full); + p.id = full; } p }) .collect() } + /// The mint's keysets as upstream `KeySetInfo`, for NUT-02 id resolution. + async fn get_cdk_keysets(&self) -> Result> { + let url = format!("{}/v1/keysets", self.url); + let res = self + .client + .get(&url) + .send() + .await + .context("Failed to fetch mint keysets")?; + if !res.status().is_success() { + anyhow::bail!("Mint keysets request failed: {}", res.status()); + } + let body: serde_json::Value = res.json().await.context("Failed to parse mint keysets")?; + // Deserialize per-entry and keep what parses: a mint may advertise a + // keyset in a unit or format this build doesn't model, and one such + // entry must not block resolving the id we actually need. + let list = body + .get("keysets") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + Ok(list + .into_iter() + .filter_map(|v| serde_json::from_value::(v).ok()) + .collect()) + } + pub async fn receive_token(&self, token: &CashuToken) -> Result> { let mut all_new_proofs = Vec::new(); diff --git a/neode-ui/src/components/WalletSettingsModal.vue b/neode-ui/src/components/WalletSettingsModal.vue index b7024ea7..592f3444 100644 --- a/neode-ui/src/components/WalletSettingsModal.vue +++ b/neode-ui/src/components/WalletSettingsModal.vue @@ -24,6 +24,35 @@
+ +
+
+
+

{{ ecashIsTest ? 'Test ecash' : 'Real ecash' }}

+

+ {{ ecashIsTest + ? 'Practice coins with no value, from a public test mint.' + : 'Real, spendable sats.' }} +

+
+ +
+

+ Your real ecash balance is safe and untouched — it reappears when you turn test mode off. +

+

{{ networkError }}

+
+

Cashu ecash tokens can only be received from mints in this list. Add a mint's URL to accept tokens issued by it.

@@ -359,12 +388,48 @@ watch( (open) => { if (open) { loadMints() + loadEcashNetwork() if (fedimintBackendReady) loadFederations() loadArk() } }, ) +// ---- Ecash network (real vs test coins) ---- +const ecashIsTest = ref(false) +const switchingNetwork = ref(false) +const networkError = ref('') + +async function loadEcashNetwork() { + try { + const res = await rpcClient.call<{ is_test: boolean }>({ method: 'wallet.ecash-network' }) + ecashIsTest.value = !!res.is_test + } catch { + // Older node without the setting: treat as real ecash, which is the + // safe reading — never imply a real balance is "test". + ecashIsTest.value = false + } +} + +async function setEcashNetwork(test: boolean) { + switchingNetwork.value = true + networkError.value = '' + try { + await rpcClient.call({ + method: 'wallet.ecash-set-network', + params: { network: test ? 'testnet' : 'mainnet' }, + }) + ecashIsTest.value = test + // The mint list is per-network, so it must be re-read after a switch. + await loadMints() + } catch (err: unknown) { + networkError.value = err instanceof Error ? err.message : 'Failed to switch network' + await loadEcashNetwork() + } finally { + switchingNetwork.value = false + } +} + async function loadMints() { loadingMints.value = true mintError.value = '' diff --git a/scripts/test-ecash-routes.sh b/scripts/test-ecash-routes.sh new file mode 100755 index 00000000..1b3d4983 --- /dev/null +++ b/scripts/test-ecash-routes.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# Headless exercise of every Cashu route the node exposes. +# +# Runs against the node's RPC exactly as the UI does, so it covers the real +# path: dispatcher -> handler -> wallet -> mint HTTP. Intended to be run on +# the node itself. +# +# ./scripts/test-ecash-routes.sh # uses testnet (safe) +# ECASH_TEST_NETWORK=mainnet ./scripts/... # REAL COINS, opt-in only +# +# Requires: the node's web password. +# ARCHY_PASSWORD='...' ./scripts/test-ecash-routes.sh +# +# Exit code is the number of failed checks, so CI can gate on it. + +set -uo pipefail + +HOST="${ARCHY_HOST:-127.0.0.1}" +SCHEME="${ARCHY_SCHEME:-http}" +BASE="$SCHEME://$HOST" +NETWORK="${ECASH_TEST_NETWORK:-testnet}" +JAR="$(mktemp -t ecash-routes-XXXXXX.jar)" +trap 'rm -f "$JAR"' EXIT + +PASS=0 +FAIL=0 +ORIGINAL_NETWORK="" + +log() { printf '%s\n' "$*"; } +ok() { PASS=$((PASS+1)); printf ' \033[32mPASS\033[0m %s\n' "$*"; } +bad() { FAIL=$((FAIL+1)); printf ' \033[31mFAIL\033[0m %s\n' "$*"; } + +# rpc [json-params] -> prints the full JSON-RPC response +rpc() { + local method="$1" params="${2:-}" body csrf + csrf="$(awk '/csrf_token/{print $NF}' "$JAR" 2>/dev/null | tail -1)" + if [ -n "$params" ]; then + body="{\"method\":\"$method\",\"params\":$params}" + else + body="{\"method\":\"$method\"}" + fi + curl -s --max-time 90 -b "$JAR" -H 'Content-Type: application/json' \ + ${csrf:+-H "X-CSRF-Token: $csrf"} \ + -X POST "$BASE/rpc/v1" -d "$body" +} + +jqf() { python3 -c " +import json,sys +try: d=json.load(sys.stdin) +except Exception: print(''); sys.exit() +cur=d +for k in sys.argv[1].split('.'): + if isinstance(cur,dict): cur=cur.get(k) + else: cur=None +print('' if cur is None else cur)" "$1"; } + +err_of() { jqf error.message; } + +login() { + local pw="${ARCHY_PASSWORD:-}" + if [ -z "$pw" ]; then + log "ARCHY_PASSWORD is not set — cannot authenticate."; exit 2 + fi + curl -s -c "$JAR" --max-time 30 -H 'Content-Type: application/json' \ + -X POST "$BASE/rpc/v1" \ + -d "{\"method\":\"auth.login\",\"params\":{\"password\":\"$pw\"}}" >/dev/null + if ! grep -q session "$JAR" 2>/dev/null; then + log "Login failed — check ARCHY_PASSWORD."; exit 2 + fi +} + +log "== ecash route check ==" +log "node: $BASE" +log "network: $NETWORK" +login + +# ── network routes ──────────────────────────────────────────────────────── +res="$(rpc wallet.ecash-network)" +ORIGINAL_NETWORK="$(printf '%s' "$res" | jqf result.network)" +[ -n "$ORIGINAL_NETWORK" ] && ok "ecash-network reports '$ORIGINAL_NETWORK'" \ + || bad "ecash-network: $(printf '%s' "$res" | err_of)" + +# Always restore whatever the node was on, even on failure. +restore_network() { + if [ -n "$ORIGINAL_NETWORK" ]; then + rpc wallet.ecash-set-network "{\"network\":\"$ORIGINAL_NETWORK\"}" >/dev/null + log "restored network to '$ORIGINAL_NETWORK'" + fi +} +trap 'restore_network; rm -f "$JAR"' EXIT + +res="$(rpc wallet.ecash-set-network "{\"network\":\"$NETWORK\"}")" +[ "$(printf '%s' "$res" | jqf result.network)" = "$NETWORK" ] \ + && ok "switched to $NETWORK" || bad "set-network: $(printf '%s' "$res" | err_of)" + +res="$(rpc wallet.ecash-set-network '{"network":"bogus"}')" +[ -n "$(printf '%s' "$res" | err_of)" ] \ + && ok "unknown network rejected" || bad "unknown network was accepted" + +MINT="$(rpc wallet.ecash-network | jqf result.mint_url)" +log "mint: $MINT" + +# ── read routes ─────────────────────────────────────────────────────────── +res="$(rpc wallet.ecash-balance)" +START_BAL="$(printf '%s' "$res" | jqf result.cashu_sats)" +[ -n "$START_BAL" ] && ok "ecash-balance = ${START_BAL} sats" \ + || bad "ecash-balance: $(printf '%s' "$res" | err_of)" + +res="$(rpc wallet.ecash-history)" +[ -z "$(printf '%s' "$res" | err_of)" ] && ok "ecash-history" \ + || bad "ecash-history: $(printf '%s' "$res" | err_of)" + +# ── mint reachability (the route every other one depends on) ────────────── +if curl -s --max-time 20 "$MINT/v1/keysets" | grep -q keysets; then + ok "mint keysets reachable" +else + bad "mint $MINT unreachable — remaining checks will fail" +fi + +# ── mint quote (invoice issuance) ───────────────────────────────────────── +res="$(rpc wallet.ecash-mint '{"amount_sats":16}')" +QUOTE="$(printf '%s' "$res" | jqf result.quote_id)" +[ -n "$QUOTE" ] && ok "ecash-mint issued quote ${QUOTE:0:12}…" \ + || bad "ecash-mint: $(printf '%s' "$res" | err_of)" + +# The test mint pays its own quotes, so the claim can be attempted directly. +# On mainnet this is expected to stay unpaid — that is not a failure. +if [ -n "$QUOTE" ]; then + res="$(rpc wallet.ecash-mint-claim "{\"quote_id\":\"$QUOTE\",\"amount_sats\":16}")" + claimed="$(printf '%s' "$res" | jqf result.amount_sats)" + if [ -n "$claimed" ]; then + ok "ecash-mint-claim minted ${claimed} sats" + elif [ "$NETWORK" = mainnet ]; then + ok "ecash-mint-claim correctly unpaid on mainnet" + else + bad "ecash-mint-claim: $(printf '%s' "$res" | err_of)" + fi +fi + +BAL="$(rpc wallet.ecash-balance | jqf result.cashu_sats)" +log "balance after mint: ${BAL:-?} sats" + +# ── send + receive round trip (the route that broke) ────────────────────── +if [ "${BAL:-0}" -ge 4 ] 2>/dev/null; then + res="$(rpc wallet.ecash-send '{"amount_sats":4}')" + TOKEN="$(printf '%s' "$res" | jqf result.token)" + if [ -n "$TOKEN" ]; then + ok "ecash-send produced a token (${#TOKEN} chars, ${TOKEN:0:7}…)" + + res="$(rpc wallet.ecash-receive "{\"token\":\"$TOKEN\"}")" + got="$(printf '%s' "$res" | jqf result.received_sats)" + [ -n "$got" ] && ok "ecash-receive redeemed ${got} sats" \ + || bad "ecash-receive: $(printf '%s' "$res" | err_of)" + + # Double-spend must be refused, not silently accepted. + res="$(rpc wallet.ecash-receive "{\"token\":\"$TOKEN\"}")" + [ -n "$(printf '%s' "$res" | err_of)" ] \ + && ok "double-redeem refused" || bad "double-redeem was ACCEPTED" + else + bad "ecash-send: $(printf '%s' "$res" | err_of)" + fi +else + log " (skipping send/receive — balance ${BAL:-0} sats too low)" +fi + +# ── malformed input handling ────────────────────────────────────────────── +res="$(rpc wallet.ecash-receive '{"token":"not-a-token"}')" +[ -n "$(printf '%s' "$res" | err_of)" ] \ + && ok "garbage token rejected" || bad "garbage token was accepted" + +# ── melt quote (spend to Lightning) ─────────────────────────────────────── +if [ -n "${ECASH_TEST_BOLT11:-}" ]; then + res="$(rpc wallet.ecash-melt "{\"bolt11\":\"$ECASH_TEST_BOLT11\"}")" + mq="$(printf '%s' "$res" | jqf result.quote_id)" + [ -n "$mq" ] && ok "ecash-melt quoted ${mq:0:12}…" \ + || bad "ecash-melt: $(printf '%s' "$res" | err_of)" +else + log " (skipping melt — set ECASH_TEST_BOLT11 to a payable invoice)" +fi + +log "" +log "== $PASS passed, $FAIL failed ==" +exit "$FAIL"