diff --git a/Cargo.lock b/Cargo.lock
index b132544c..6449febc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -235,12 +235,56 @@ dependencies = [
"unicode-width",
]
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "anyhow"
version = "1.0.104"
@@ -520,7 +564,7 @@ dependencies = [
"tokio-happy-eyeballs",
"tokio-rustls",
"tokio-socks",
- "tokio-tungstenite",
+ "tokio-tungstenite 0.28.0",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
@@ -676,6 +720,21 @@ dependencies = [
"windows-link 0.2.1",
]
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
+[[package]]
+name = "base58ck"
+version = "0.1.101"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "365c0acd5b2e8dd0111a46c4faea83fb3cfb6e39a49a7c73a06e090db7b2eff0"
+dependencies = [
+ "bitcoin_hashes 0.14.101",
+]
+
[[package]]
name = "base62"
version = "2.2.6"
@@ -688,6 +747,12 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -706,6 +771,12 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+[[package]]
+name = "bech32"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
+
[[package]]
name = "bech32"
version = "0.12.0"
@@ -737,6 +808,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
dependencies = [
"bitcoin_hashes 0.14.101",
+ "rand 0.8.8",
+ "rand_core 0.6.4",
"serde",
"unicode-normalization",
]
@@ -762,6 +835,24 @@ version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
+[[package]]
+name = "bitcoin"
+version = "0.32.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0ce8bd5baaa0d303a19915a6d93afed161f528654e42da2a7a97d05c59499a"
+dependencies = [
+ "base58ck",
+ "base64 0.21.7",
+ "bech32 0.11.1",
+ "bitcoin-io",
+ "bitcoin-units",
+ "bitcoin_hashes 0.14.101",
+ "hex-conservative 0.2.3",
+ "hex_lit",
+ "secp256k1 0.29.1",
+ "serde",
+]
+
[[package]]
name = "bitcoin-consensus-encoding"
version = "1.3.0"
@@ -794,6 +885,27 @@ dependencies = [
"bitcoin-consensus-encoding",
]
+[[package]]
+name = "bitcoin-payment-instructions"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29f5b43b0e8338cd36ce7940bd6b8a007d54f1cbe25314ec3fbba566f870b085"
+dependencies = [
+ "bitcoin",
+ "lightning",
+ "lightning-invoice",
+]
+
+[[package]]
+name = "bitcoin-units"
+version = "0.1.101"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cb95693f371d089a4b5b6fc41c6f3ea6e01ee8c15388335dfac8ea685173b51"
+dependencies = [
+ "bitcoin-consensus-encoding",
+ "serde",
+]
+
[[package]]
name = "bitcoin_hashes"
version = "0.14.101"
@@ -802,6 +914,7 @@ checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2"
dependencies = [
"bitcoin-io",
"hex-conservative 0.2.3",
+ "serde",
]
[[package]]
@@ -831,6 +944,22 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "bitreq"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2118ef89c7d94696800407a4b0e555da52d36a791f522c3b98d7cceff9b45f51"
+dependencies = [
+ "base64 0.22.1",
+ "rustls",
+ "rustls-webpki",
+ "serde",
+ "serde_json",
+ "tokio",
+ "tokio-rustls",
+ "webpki-roots 1.0.11",
+]
+
[[package]]
name = "bitstream-io"
version = "4.10.0"
@@ -1019,6 +1148,30 @@ dependencies = [
"wayland-client",
]
+[[package]]
+name = "cashu"
+version = "0.18.0"
+source = "git+https://github.com/cashubtc/cdk#578e719fbf683871a8480961627700f01f3ee7ee"
+dependencies = [
+ "bitcoin",
+ "cbor-diag",
+ "ciborium",
+ "lightning",
+ "lightning-invoice",
+ "serde",
+ "serde_bytes",
+ "serde_json",
+ "serde_with",
+ "thiserror 2.0.21",
+ "tracing",
+ "unicode-normalization",
+ "ur",
+ "url",
+ "uuid",
+ "web-time",
+ "zeroize",
+]
+
[[package]]
name = "castaway"
version = "0.2.4"
@@ -1055,6 +1208,25 @@ dependencies = [
"toml 0.8.23",
]
+[[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 7.1.3",
+ "num-bigint",
+ "num-rational",
+ "num-traits",
+ "separator",
+ "url",
+ "uuid",
+]
+
[[package]]
name = "cc"
version = "1.10.0"
@@ -1067,6 +1239,133 @@ dependencies = [
"shlex 2.0.1",
]
+[[package]]
+name = "cdk"
+version = "0.18.0"
+source = "git+https://github.com/cashubtc/cdk#578e719fbf683871a8480961627700f01f3ee7ee"
+dependencies = [
+ "anyhow",
+ "arc-swap",
+ "async-trait",
+ "bitcoin",
+ "bitcoin-payment-instructions",
+ "cdk-common",
+ "cdk-http-client",
+ "cdk-signatory",
+ "futures",
+ "getrandom 0.4.3",
+ "gloo-timers",
+ "lightning",
+ "lightning-invoice",
+ "ring",
+ "rustls",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.21",
+ "tokio",
+ "tokio-util",
+ "tracing",
+ "url",
+ "uuid",
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "cdk-common"
+version = "0.18.0"
+source = "git+https://github.com/cashubtc/cdk#578e719fbf683871a8480961627700f01f3ee7ee"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "bitcoin",
+ "cashu",
+ "cdk-http-client",
+ "futures",
+ "getrandom 0.4.3",
+ "gloo-timers",
+ "jsonwebtoken",
+ "lightning",
+ "lightning-invoice",
+ "parking_lot",
+ "paste",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.21",
+ "tokio",
+ "tonic",
+ "tracing",
+ "url",
+ "uuid",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-time",
+]
+
+[[package]]
+name = "cdk-http-client"
+version = "0.18.0"
+source = "git+https://github.com/cashubtc/cdk#578e719fbf683871a8480961627700f01f3ee7ee"
+dependencies = [
+ "async-trait",
+ "bitreq",
+ "cashu",
+ "futures",
+ "futures-channel",
+ "getrandom 0.4.3",
+ "js-sys",
+ "regex",
+ "rustls",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "thiserror 2.0.21",
+ "tokio-tungstenite 0.26.2",
+ "tracing",
+ "url",
+ "uuid",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "cdk-redb"
+version = "0.18.0"
+source = "git+https://github.com/cashubtc/cdk#578e719fbf683871a8480961627700f01f3ee7ee"
+dependencies = [
+ "async-trait",
+ "cdk-common",
+ "redb",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.21",
+ "tracing",
+ "uuid",
+]
+
+[[package]]
+name = "cdk-signatory"
+version = "0.18.0"
+source = "git+https://github.com/cashubtc/cdk#578e719fbf683871a8480961627700f01f3ee7ee"
+dependencies = [
+ "anyhow",
+ "arc-swap",
+ "async-trait",
+ "bip39",
+ "bitcoin",
+ "cdk-common",
+ "clap",
+ "getrandom 0.4.3",
+ "home",
+ "rustls",
+ "thiserror 2.0.21",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "tracing-subscriber",
+]
+
[[package]]
name = "cesu8"
version = "1.1.0"
@@ -1163,6 +1462,7 @@ dependencies = [
"gpui-pre",
"instant",
"itertools 0.13.0",
+ "nip60_ui",
"nostr-sdk",
"person",
"regex",
@@ -1188,6 +1488,33 @@ dependencies = [
"windows-link 0.2.1",
]
+[[package]]
+name = "ciborium"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
+dependencies = [
+ "ciborium-io",
+ "ciborium-ll",
+ "serde",
+]
+
+[[package]]
+name = "ciborium-io"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
+
+[[package]]
+name = "ciborium-ll"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
+dependencies = [
+ "ciborium-io",
+ "half",
+]
+
[[package]]
name = "cipher"
version = "0.4.4"
@@ -1221,6 +1548,46 @@ dependencies = [
"libloading",
]
+[[package]]
+name = "clap"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
+
[[package]]
name = "cmake"
version = "0.1.58"
@@ -1283,6 +1650,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
[[package]]
name = "combine"
version = "4.6.9"
@@ -1360,6 +1733,12 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
[[package]]
name = "const-oid"
version = "0.10.2"
@@ -1445,6 +1824,7 @@ dependencies = [
"native-theme-gpui",
"nip29",
"nip4e",
+ "nip60",
"nostr-sdk",
"person",
"settings",
@@ -1685,6 +2065,21 @@ dependencies = [
"libc",
]
+[[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
+
[[package]]
name = "crc32fast"
version = "1.5.3"
@@ -1734,6 +2129,18 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -1782,6 +2189,33 @@ dependencies = [
"cmov",
]
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "curve25519-dalek-derive",
+ "digest 0.10.7",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "daachorse"
version = "3.0.3"
@@ -1938,13 +2372,24 @@ dependencies = [
"syn 3.0.9",
]
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid 0.9.6",
+ "pem-rfc7468 0.7.0",
+ "zeroize",
+]
+
[[package]]
name = "der"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004"
dependencies = [
- "pem-rfc7468",
+ "pem-rfc7468 1.0.0",
"zeroize",
]
@@ -2018,6 +2463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
+ "const-oid 0.9.6",
"crypto-common 0.1.7",
"subtle",
]
@@ -2029,7 +2475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
- "const-oid",
+ "const-oid 0.10.2",
"crypto-common 0.2.2",
"ctutils",
]
@@ -2113,6 +2559,15 @@ dependencies = [
"libloading",
]
+[[package]]
+name = "dnssec-prover"
+version = "0.6.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9468f1a08c50bd1e5ad91b151e11ce8e806f8fa1c1eb9b07f66c7011de45a2e"
+dependencies = [
+ "bitcoin_hashes 0.14.101",
+]
+
[[package]]
name = "document-features"
version = "0.2.12"
@@ -2182,18 +2637,77 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der 0.7.10",
+ "digest 0.10.7",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
[[package]]
name = "ed25519-compact"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1454db4f2edb7f0e8fe0c5b375b0c978fc63244cc9f010d160e417eb10139aa8"
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "serde",
+ "sha2 0.10.9",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "either"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest 0.10.7",
+ "ff",
+ "generic-array",
+ "group",
+ "hkdf",
+ "pem-rfc7468 0.7.0",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "embed-resource"
version = "3.0.12"
@@ -2458,6 +2972,22 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
[[package]]
name = "filedescriptor"
version = "0.8.3"
@@ -2795,6 +3325,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
+ "zeroize",
]
[[package]]
@@ -3683,6 +4214,17 @@ dependencies = [
"smallvec",
]
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
[[package]]
name = "h2"
version = "0.4.20"
@@ -3742,6 +4284,12 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+[[package]]
+name = "hashbrown"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
+
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -3864,6 +4412,12 @@ dependencies = [
"arrayvec",
]
+[[package]]
+name = "hex_lit"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd"
+
[[package]]
name = "hkdf"
version = "0.12.4"
@@ -3897,6 +4451,15 @@ version = "1.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad320b3b96fb2a455a0726d16efe0a5afdbd34b71dea5bc53b05ea057714d4e"
+[[package]]
+name = "home"
+version = "0.5.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "hound"
version = "3.5.1"
@@ -4398,6 +4961,12 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
[[package]]
name = "itertools"
version = "0.11.0"
@@ -4580,6 +5149,30 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "jsonwebtoken"
+version = "10.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
+dependencies = [
+ "base64 0.22.1",
+ "ed25519-dalek",
+ "getrandom 0.2.17",
+ "hmac 0.12.1",
+ "js-sys",
+ "p256",
+ "p384",
+ "pem",
+ "rand 0.8.8",
+ "rsa",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "signature",
+ "simple_asn1",
+ "zeroize",
+]
+
[[package]]
name = "khronos-egl"
version = "6.0.0"
@@ -4645,6 +5238,9 @@ name = "lazy_static"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
+dependencies = [
+ "spin 0.9.9",
+]
[[package]]
name = "lebe"
@@ -4704,6 +5300,55 @@ dependencies = [
"vcpkg",
]
+[[package]]
+name = "lightning"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "982ffb82e8362ea95e03a5de8a6004bceb7a577fc1f07d5b02cfc3fc7c930a90"
+dependencies = [
+ "bech32 0.11.1",
+ "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 0.11.1",
+ "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.119",
+]
+
+[[package]]
+name = "lightning-types"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c77c676d4a34cceb2ae3756916e446b4d17f9430a24107e099981f0f9aec77e6"
+dependencies = [
+ "bitcoin",
+]
+
[[package]]
name = "linebender_resource_handle"
version = "0.1.1"
@@ -5053,6 +5698,15 @@ dependencies = [
"xml5ever",
]
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
[[package]]
name = "matrixmultiply"
version = "0.3.11"
@@ -5138,6 +5792,12 @@ dependencies = [
"unicase",
]
+[[package]]
+name = "minicbor"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7244eafd1e09e7c4e33043881997aa6ab6dffff3ed2cee3395a278980481d6e"
+
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -5455,6 +6115,46 @@ dependencies = [
"ui",
]
+[[package]]
+name = "nip60"
+version = "2.0.0-alpha3"
+dependencies = [
+ "anyhow",
+ "cdk",
+ "cdk-redb",
+ "common",
+ "flume 0.11.1",
+ "futures",
+ "gpui-pre",
+ "gpui_tokio",
+ "hkdf",
+ "log",
+ "nostr",
+ "nostr-sdk",
+ "serde_json",
+ "sha2 0.10.9",
+ "smallvec",
+ "state",
+ "tokio",
+]
+
+[[package]]
+name = "nip60_ui"
+version = "2.0.0-alpha3"
+dependencies = [
+ "anyhow",
+ "common",
+ "gpui-kit",
+ "gpui-pre",
+ "nip60",
+ "nostr-sdk",
+ "person",
+ "settings",
+ "smallvec",
+ "state",
+ "ui",
+]
+
[[package]]
name = "no_std_io2"
version = "0.9.4"
@@ -5511,7 +6211,7 @@ source = "git+https://github.com/rust-nostr/nostr#37f139dfbb0a5c5f879cb052df7376
dependencies = [
"aes",
"base64 0.22.1",
- "bech32",
+ "bech32 0.12.0",
"bip39",
"bitcoin_hashes 1.2.0",
"cbc",
@@ -5521,7 +6221,7 @@ dependencies = [
"opaquerr 0.2.0",
"rand 0.10.3",
"scrypt",
- "secp256k1",
+ "secp256k1 0.30.0",
"serde",
"serde_json",
"unicode-normalization",
@@ -5744,6 +6444,22 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "num-bigint-dig"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
+dependencies = [
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand 0.8.8",
+ "smallvec",
+ "zeroize",
+]
+
[[package]]
name = "num-bigint-dig"
version = "0.9.1"
@@ -6282,6 +6998,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
[[package]]
name = "onig"
version = "6.5.3"
@@ -6327,7 +7049,7 @@ dependencies = [
"hmac 0.12.1",
"md-5",
"num",
- "num-bigint-dig",
+ "num-bigint-dig 0.9.1",
"pbkdf2 0.12.2",
"serde",
"serde_bytes",
@@ -6468,6 +7190,30 @@ dependencies = [
"serde",
]
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "p384"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2 0.10.9",
+]
+
[[package]]
name = "page_size"
version = "0.6.0"
@@ -6580,6 +7326,25 @@ dependencies = [
"hmac 0.13.0",
]
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64 0.22.1",
+ "serde_core",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
[[package]]
name = "pem-rfc7468"
version = "1.0.0"
@@ -6749,6 +7514,27 @@ dependencies = [
"futures-io",
]
+[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der 0.7.10",
+ "pkcs8",
+ "spki",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der 0.7.10",
+ "spki",
+]
+
[[package]]
name = "pkg-config"
version = "0.3.34"
@@ -6854,6 +7640,15 @@ dependencies = [
"portable-atomic",
]
+[[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 = "potential_utf"
version = "0.1.6"
@@ -6899,6 +7694,15 @@ dependencies = [
"num-integer",
]
+[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -7151,6 +7955,15 @@ dependencies = [
"rand_core 0.10.1",
]
+[[package]]
+name = "rand_xoshiro"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "662effc7698e08ea324d3acccf8d9d7f7bf79b9785e270a174ea36e56900c91d"
+dependencies = [
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -7314,6 +8127,15 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430"
+[[package]]
+name = "redb"
+version = "3.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -7474,6 +8296,16 @@ dependencies = [
"zune-jpeg 0.5.15",
]
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac 0.12.1",
+ "subtle",
+]
+
[[package]]
name = "rgb"
version = "0.8.53"
@@ -7521,6 +8353,26 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "rsa"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
+dependencies = [
+ "const-oid 0.9.6",
+ "digest 0.10.7",
+ "num-bigint-dig 0.8.6",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
@@ -7941,6 +8793,32 @@ dependencies = [
"syn 3.0.9",
]
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der 0.7.10",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "secp256k1"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
+dependencies = [
+ "bitcoin_hashes 0.14.101",
+ "rand 0.8.8",
+ "secp256k1-sys",
+ "serde",
+]
+
[[package]]
name = "secp256k1"
version = "0.30.0"
@@ -8000,6 +8878,12 @@ dependencies = [
"serde_core",
]
+[[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.229"
@@ -8253,6 +9137,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest 0.10.7",
+ "rand_core 0.6.4",
+]
+
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -8294,6 +9188,18 @@ dependencies = [
"unicode-segmentation",
]
+[[package]]
+name = "simple_asn1"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
+dependencies = [
+ "num-bigint",
+ "num-traits",
+ "thiserror 2.0.21",
+ "time",
+]
+
[[package]]
name = "simplecss"
version = "0.2.2"
@@ -8436,6 +9342,16 @@ dependencies = [
"bitflags 2.13.2",
]
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der 0.7.10",
+]
+
[[package]]
name = "spm_precompiled"
version = "0.1.4"
@@ -9005,7 +9921,9 @@ dependencies = [
"bytes",
"libc",
"mio",
+ "parking_lot",
"pin-project-lite",
+ "signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
@@ -9065,6 +9983,22 @@ dependencies = [
"tokio-util",
]
+[[package]]
+name = "tokio-tungstenite"
+version = "0.26.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
+dependencies = [
+ "futures-util",
+ "log",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls",
+ "tungstenite 0.26.2",
+]
+
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
@@ -9077,7 +10011,7 @@ dependencies = [
"rustls-pki-types",
"tokio",
"tokio-rustls",
- "tungstenite",
+ "tungstenite 0.28.0",
"webpki-roots 0.26.11",
]
@@ -9187,6 +10121,27 @@ version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
+[[package]]
+name = "tonic"
+version = "0.14.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2cdfc673aabd30fe8fee92e65837b644c37c5d47f0f97ae6d5e0f1237d15e1b"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "http",
+ "http-body",
+ "http-body-util",
+ "percent-encoding",
+ "pin-project",
+ "sync_wrapper",
+ "tokio-stream",
+ "tokio-util",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
[[package]]
name = "tower"
version = "0.5.3"
@@ -9282,10 +10237,14 @@ version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
+ "matchers",
"nu-ansi-term",
+ "once_cell",
+ "regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
+ "tracing",
"tracing-core",
"tracing-log",
]
@@ -9364,6 +10323,25 @@ dependencies = [
"zbus",
]
+[[package]]
+name = "tungstenite"
+version = "0.26.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.9.5",
+ "rustls",
+ "rustls-pki-types",
+ "sha1",
+ "thiserror 2.0.21",
+ "utf-8",
+]
+
[[package]]
name = "tungstenite"
version = "0.28.0"
@@ -9545,6 +10523,18 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+[[package]]
+name = "ur"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28bccbac381852c37efd0217ce8c96742e5cf22f6dbb2a4f0f51418c7f3ab980"
+dependencies = [
+ "bitcoin_hashes 1.2.0",
+ "crc",
+ "minicbor",
+ "rand_xoshiro",
+]
+
[[package]]
name = "ureq"
version = "3.5.0"
@@ -9553,7 +10543,7 @@ checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
dependencies = [
"base64 0.23.1",
"cookie_store",
- "der",
+ "der 0.8.3",
"log",
"native-tls",
"percent-encoding",
@@ -9665,6 +10655,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
[[package]]
name = "uuid"
version = "1.29.0"
@@ -10967,6 +11963,8 @@ dependencies = [
"nip29",
"nip29_ui",
"nip4e",
+ "nip60",
+ "nip60_ui",
"nostr-connect",
"nostr-sdk",
"person",
diff --git a/Cargo.toml b/Cargo.toml
index 48581112..6a300a48 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -34,7 +34,7 @@ nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-browser-signer = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr-sqlite = { git = "https://github.com/rust-nostr/nostr" }
-nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] }
+nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip60", "nip98" ] }
# Crypto (NIP-17 encrypted file messages)
aes-gcm = "0.10"
@@ -48,6 +48,13 @@ hmac = "0.12"
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it
rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng", "thread_rng" ] }
+# Cashu (NIP-60). From git: the published 0.18.x line targets nostr 0.44 and
+# cannot unify with the git rust-nostr used here, while main targets 0.45.x
+# (redirected to this repo by the [patch.crates-io] section below). redb
+# storage: pure Rust, avoids the libsqlite3-sys links clash with nostr-sqlite.
+cdk = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
+cdk-redb = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
+
# Others
anyhow = "1.0.44"
tts = { git = "https://github.com/ndarilek/tts-rs" }
@@ -66,6 +73,12 @@ webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
instant = "0.1"
+# cdk's registry nostr/nostr-sdk requirements are redirected onto the same
+# git repo coop already uses, keeping a single copy of each in the graph.
+[patch.crates-io]
+nostr = { git = "https://github.com/rust-nostr/nostr" }
+nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
+
[profile.release]
strip = true
opt-level = "z"
diff --git a/assets/icons/wallet.svg b/assets/icons/wallet.svg
new file mode 100644
index 00000000..4c79e855
--- /dev/null
+++ b/assets/icons/wallet.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/chat_ui/Cargo.toml b/crates/chat_ui/Cargo.toml
index d14400cf..d41e9f78 100644
--- a/crates/chat_ui/Cargo.toml
+++ b/crates/chat_ui/Cargo.toml
@@ -23,3 +23,6 @@ smallvec.workspace = true
serde.workspace = true
regex = "1"
+
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+nip60_ui = { path = "../nip60_ui" }
diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs
index e9719a86..03386f79 100644
--- a/crates/chat_ui/src/lib.rs
+++ b/crates/chat_ui/src/lib.rs
@@ -681,6 +681,41 @@ impl ChatPanel {
});
}
+ /// Open the nutzap dialog for the room's peer; direct chats only
+ fn open_nutzap(&mut self, window: &mut Window, cx: &mut Context) {
+ #[cfg(target_arch = "wasm32")]
+ let _ = (window, cx);
+
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let Some(room) = self.room.upgrade() else {
+ return;
+ };
+
+ let nostr = NostrRegistry::global(cx);
+ let Some(me) = nostr.read(cx).current_user() else {
+ return;
+ };
+
+ let peers: Vec = room
+ .read(cx)
+ .members()
+ .iter()
+ .copied()
+ .filter(|key| *key != me)
+ .collect();
+
+ if let [recipient] = peers[..] {
+ nip60_ui::open_nutzap(window, cx, recipient, None);
+ } else {
+ window.push_notification(
+ Notification::warning("Nutzaps can only be sent in direct chats"),
+ cx,
+ );
+ }
+ }
+ }
+
fn upload(&mut self, window: &mut Window, cx: &mut Context) {
// Get the user's configured blossom server
let server = AppSettings::get_file_server(cx);
@@ -2012,6 +2047,18 @@ impl Render for ChatPanel {
this.upload(window, cx);
})),
)
+ .when(cfg!(not(target_arch = "wasm32")), |this| {
+ this.child(
+ Button::new("nutzap")
+ .icon(gpui_kit::assets::IconName::Coins)
+ .tooltip("Send eCash")
+ .disabled(dictating)
+ .ghost()
+ .on_click(cx.listener(move |this, _ev, window, cx| {
+ this.open_nutzap(window, cx);
+ })),
+ )
+ })
.when(dictating, |this| {
this.child(
h_flex()
diff --git a/crates/nip60/Cargo.toml b/crates/nip60/Cargo.toml
new file mode 100644
index 00000000..f67f1109
--- /dev/null
+++ b/crates/nip60/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "nip60"
+version.workspace = true
+edition.workspace = true
+publish.workspace = true
+
+[dependencies]
+state = { path = "../state" }
+common = { path = "../common" }
+
+gpui.workspace = true
+gpui_tokio.workspace = true
+nostr.workspace = true
+nostr-sdk.workspace = true
+anyhow.workspace = true
+smallvec.workspace = true
+log.workspace = true
+flume.workspace = true
+serde_json.workspace = true
+futures.workspace = true
+hkdf.workspace = true
+sha2.workspace = true
+tokio = { version = "1", default-features = false, features = [ "rt" ] }
+
+cdk.workspace = true
+cdk-redb.workspace = true
diff --git a/crates/nip60/src/keys.rs b/crates/nip60/src/keys.rs
new file mode 100644
index 00000000..c0af301d
--- /dev/null
+++ b/crates/nip60/src/keys.rs
@@ -0,0 +1,369 @@
+use anyhow::{Error, anyhow};
+use hkdf::Hkdf;
+use nostr::nips::nip60::TokenEvent;
+use nostr_sdk::prelude::*;
+use sha2::Sha256;
+
+const SEED_SALT: &[u8] = b"coop nip60 seed salt";
+const SEED_INFO: &[u8] = b"coop cashu seed";
+
+/// Parsed 17375 wallet event: the wallet key and its mint list.
+#[derive(Clone)]
+pub(crate) struct WalletData {
+ pub(crate) event_id: EventId,
+ pub(crate) created_at: Timestamp,
+ pub(crate) privkey: SecretKey,
+ pub(crate) mints: Vec,
+}
+
+impl WalletData {
+ /// A new wallet with a fresh key.
+ pub(crate) fn generate(first_mint: Url) -> Self {
+ Self {
+ event_id: EventId::from_byte_array([0; 32]),
+ created_at: Timestamp::from_secs(0),
+ privkey: SecretKey::generate(),
+ mints: vec![first_mint],
+ }
+ }
+
+ /// The cdk seed, derived deterministically from the wallet key.
+ pub(crate) fn derive_seed(privkey: &SecretKey) -> Result<[u8; 64], Error> {
+ let ikm = privkey.to_secret_bytes();
+ let hk = Hkdf::::new(Some(SEED_SALT), &ikm);
+ let mut seed = [0u8; 64];
+ hk.expand(SEED_INFO, &mut seed)
+ .map_err(|error| anyhow!("seed derivation failed: {error}"))?;
+ Ok(seed)
+ }
+
+ /// Decrypt and parse a 17375 event published by this account.
+ pub(crate) async fn decrypt(signer: &S, me: &PublicKey, event: &Event) -> Result
+ where
+ S: AsyncNip44 + ?Sized,
+ {
+ if event.pubkey != *me {
+ return Err(anyhow!("wallet event was not published by this account"));
+ }
+
+ let content = signer.nip44_decrypt_async(me, &event.content).await?;
+ let (privkey, mints) = parse_wallet_content(&content)?;
+
+ Ok(Self {
+ event_id: event.id,
+ created_at: event.created_at,
+ privkey,
+ mints,
+ })
+ }
+
+ /// Build the encrypted 17375 event for this wallet data.
+ pub(crate) async fn to_event(&self, signer: &S, me: &PublicKey) -> Result
+ where
+ S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
+ {
+ let mut rows = vec![vec!["privkey".to_string(), self.privkey.to_secret_hex()]];
+ for mint in &self.mints {
+ rows.push(vec!["mint".to_string(), mint.to_string()]);
+ }
+
+ let content = signer
+ .nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
+ .await?;
+
+ Ok(EventBuilder::new(Kind::CashuWallet, content)
+ .finalize_async(signer)
+ .await?)
+ }
+}
+
+fn parse_wallet_content(content: &str) -> Result<(SecretKey, Vec), Error> {
+ let rows: Vec> =
+ serde_json::from_str(content).map_err(|error| anyhow!("invalid wallet event: {error}"))?;
+
+ let mut privkey = None;
+ let mut mints = Vec::new();
+ for row in rows {
+ let (Some(key), Some(value)) = (row.first(), row.get(1)) else {
+ continue;
+ };
+ match key.as_str() {
+ "privkey" if privkey.is_none() => privkey = Some(SecretKey::from_hex(value)?),
+ "mint" => {
+ if let Ok(mint) = Url::parse(value) {
+ mints.push(mint);
+ }
+ }
+ _ => {}
+ }
+ }
+
+ let Some(privkey) = privkey else {
+ return Err(anyhow!("wallet event is missing the private key"));
+ };
+ if mints.is_empty() {
+ return Err(anyhow!("wallet event is missing mints"));
+ }
+
+ Ok((privkey, mints))
+}
+
+pub(crate) async fn decrypt_token_event(
+ signer: &S,
+ me: &PublicKey,
+ event: &Event,
+) -> Result
+where
+ S: AsyncNip44 + ?Sized,
+{
+ if event.pubkey != *me {
+ return Err(anyhow!("token event was not published by this account"));
+ }
+
+ let content = signer.nip44_decrypt_async(me, &event.content).await?;
+
+ let token =
+ serde_json::from_str(&content).map_err(|error| anyhow!("invalid token event: {error}"))?;
+
+ Ok(token)
+}
+
+pub(crate) async fn build_token_event(
+ signer: &S,
+ me: &PublicKey,
+ token: &TokenEvent,
+) -> Result
+where
+ S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
+{
+ let content = signer
+ .nip44_encrypt_async(me, &serde_json::to_string(token)?)
+ .await?;
+
+ Ok(EventBuilder::new(Kind::CashuWalletUnspentProof, content)
+ .finalize_async(signer)
+ .await?)
+}
+
+/// NIP-09 deletion carrying the `k: 7375` tag required by NIP-60.
+pub(crate) async fn build_token_deletion(signer: &S, ids: &[EventId]) -> Result
+where
+ S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
+{
+ let mut tags: Vec = ids.iter().map(|id| Tag::event(*id)).collect();
+ tags.push(Tag::custom("k", ["7375".to_string()]));
+
+ Ok(EventBuilder::new(Kind::EventDeletion, "")
+ .tags(tags)
+ .finalize_async(signer)
+ .await?)
+}
+
+/// Decrypted 7376 payload, enough for the wallet panel's history list.
+pub(crate) struct HistoryPayload {
+ pub(crate) direction: TransactionDirection,
+ pub(crate) amount: u64,
+}
+
+pub(crate) async fn decrypt_history_event(
+ signer: &S,
+ me: &PublicKey,
+ event: &Event,
+) -> Result
+where
+ S: AsyncNip44 + ?Sized,
+{
+ if event.pubkey != *me {
+ return Err(anyhow!("history event was not published by this account"));
+ }
+
+ let content = signer.nip44_decrypt_async(me, &event.content).await?;
+ let rows: Vec> = serde_json::from_str(&content)
+ .map_err(|error| anyhow!("invalid history event: {error}"))?;
+
+ let mut direction = None;
+ let mut amount = None;
+ for row in rows {
+ let (Some(key), Some(value)) = (row.first(), row.get(1)) else {
+ continue;
+ };
+ match key.as_str() {
+ "direction" if direction.is_none() => direction = value.parse().ok(),
+ "amount" if amount.is_none() => amount = value.parse().ok(),
+ _ => {}
+ }
+ }
+
+ let Some(direction) = direction else {
+ return Err(anyhow!("history event is missing the direction"));
+ };
+ let Some(amount) = amount else {
+ return Err(anyhow!("history event is missing the amount"));
+ };
+
+ Ok(HistoryPayload { direction, amount })
+}
+
+/// Build the public kind 9321 nutzap event carrying P2PK-locked proofs.
+pub(crate) async fn build_nutzap_event(
+ signer: &S,
+ recipient: PublicKey,
+ mint: &str,
+ proofs: &[String],
+ target: Option,
+ comment: &str,
+) -> Result
+where
+ S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
+{
+ let mut tags: Vec = vec![
+ Tag::custom("p", [recipient.to_hex()]),
+ Tag::custom("u", [mint.to_string()]),
+ Tag::custom("unit", ["sat".to_string()]),
+ ];
+
+ for proof in proofs {
+ tags.push(Tag::custom("proof", [proof.clone()]));
+ }
+
+ if let Some(target) = target {
+ tags.push(Tag::custom("e", [target.to_hex()]));
+ }
+
+ Ok(EventBuilder::new(Kind::Custom(9321), comment)
+ .tags(tags)
+ .finalize_async(signer)
+ .await?)
+}
+
+/// Encrypted 7376 history payload for one state change.
+pub(crate) struct History {
+ pub(crate) direction: TransactionDirection,
+ pub(crate) amount: u64,
+ /// NIP-61 nutzap events redeemed by this state change
+ pub(crate) redeemed: Vec,
+ /// Author of the redeemed nutzap, for the unencrypted `p` tag
+ pub(crate) sender: Option,
+}
+
+pub(crate) async fn build_history_event(
+ signer: &S,
+ me: &PublicKey,
+ history: &History,
+ created: EventId,
+ destroyed: &[EventId],
+) -> Result
+where
+ S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
+{
+ let mut rows = vec![
+ vec!["direction".to_string(), history.direction.to_string()],
+ vec!["amount".to_string(), history.amount.to_string()],
+ ];
+
+ for id in destroyed {
+ rows.push(vec![
+ "e".to_string(),
+ id.to_hex(),
+ String::new(),
+ "destroyed".to_string(),
+ ]);
+ }
+
+ rows.push(vec![
+ "e".to_string(),
+ created.to_hex(),
+ String::new(),
+ "created".to_string(),
+ ]);
+
+ let mut tags: Vec = history
+ .redeemed
+ .iter()
+ .map(|id| Tag::custom("e", [id.to_hex(), String::new(), "redeemed".to_string()]))
+ .collect();
+
+ if let Some(sender) = history.sender {
+ tags.push(Tag::custom("p", [sender.to_hex()]));
+ }
+
+ let content = signer
+ .nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
+ .await?;
+
+ Ok(EventBuilder::new(Kind::CashuWalletSpendingHistory, content)
+ .tags(tags)
+ .finalize_async(signer)
+ .await?)
+}
+
+pub(crate) async fn build_nutzap_info_event(
+ signer: &S,
+ mints: &[Url],
+ relays: &[RelayUrl],
+ wallet_pubkey: PublicKey,
+) -> Result
+where
+ S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
+{
+ let mut tags: Vec = relays
+ .iter()
+ .map(|relay| Tag::custom("relay", [relay.to_string()]))
+ .collect();
+
+ for mint in mints {
+ tags.push(Tag::custom("mint", [mint.to_string(), "sat".to_string()]));
+ }
+
+ tags.push(Tag::custom(
+ "pubkey",
+ [format!("02{}", wallet_pubkey.to_hex())],
+ ));
+
+ Ok(EventBuilder::new(Kind::Custom(10019), "")
+ .tags(tags)
+ .finalize_async(signer)
+ .await?)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_wallet_content() {
+ let privkey = SecretKey::generate();
+ let mint = Url::parse("https://mint.example.com").expect("valid mint URL");
+
+ let content = serde_json::to_string(&vec![
+ vec!["privkey".to_string(), privkey.to_secret_hex()],
+ vec!["mint".to_string(), mint.to_string()],
+ ])
+ .expect("serialization cannot fail");
+
+ let (parsed_key, mints) = parse_wallet_content(&content).expect("valid content");
+ assert_eq!(parsed_key.to_secret_hex(), privkey.to_secret_hex());
+ assert_eq!(mints, vec![mint]);
+ }
+
+ #[test]
+ fn wallet_content_rejects_missing_privkey() {
+ let mint = Url::parse("https://mint.example.com").expect("valid mint URL");
+ let content = serde_json::to_string(&vec![vec!["mint".to_string(), mint.to_string()]])
+ .expect("serialization cannot fail");
+
+ assert!(parse_wallet_content(&content).is_err());
+ }
+
+ #[test]
+ fn seed_is_deterministic_per_key() {
+ let key = SecretKey::generate();
+ let first = WalletData::derive_seed(&key).expect("seed derivation cannot fail");
+ let second = WalletData::derive_seed(&key).expect("seed derivation cannot fail");
+ assert_eq!(first, second);
+
+ let other =
+ WalletData::derive_seed(&SecretKey::generate()).expect("seed derivation cannot fail");
+ assert_ne!(first, other);
+ }
+}
diff --git a/crates/nip60/src/lib.rs b/crates/nip60/src/lib.rs
new file mode 100644
index 00000000..4e2473ce
--- /dev/null
+++ b/crates/nip60/src/lib.rs
@@ -0,0 +1,1258 @@
+#![cfg(not(target_arch = "wasm32"))]
+
+mod keys;
+mod ops;
+
+use std::collections::{HashMap, HashSet};
+use std::str::FromStr;
+use std::sync::Arc;
+
+use anyhow::{Error, anyhow};
+use cdk::mint_url::MintUrl;
+use cdk::nuts::{CurrencyUnit, Proof, Proofs, SpendingConditions};
+use cdk::wallet::{Wallet, WalletBuilder};
+use cdk_redb::WalletRedbDatabase;
+use common::config_dir;
+use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
+use gpui_tokio::Tokio;
+use nostr::nips::nip60::TokenEvent;
+use nostr_sdk::prelude::*;
+use smallvec::{SmallVec, smallvec};
+use state::NostrRegistry;
+
+pub fn init(cx: &mut App) {
+ WalletRegistry::set_global(cx.new(WalletRegistry::new), cx);
+}
+
+struct GlobalWalletRegistry(Entity);
+
+impl Global for GlobalWalletRegistry {}
+
+#[derive(Debug, Clone)]
+pub enum WalletEvent {
+ Updated,
+ TokenCreated(String),
+ Error(String),
+}
+
+impl EventEmitter for WalletRegistry {}
+
+/// A pending lightning invoice waiting to be paid and claimed.
+#[derive(Debug, Clone)]
+pub struct MintQuoteEntry {
+ pub quote_id: String,
+ pub request: String,
+ pub amount: u64,
+ pub mint: MintUrl,
+}
+
+/// One spending history entry, newest first in `WalletRegistry::history`.
+#[derive(Debug, Clone)]
+pub struct HistoryEntry {
+ pub id: EventId,
+ pub direction: TransactionDirection,
+ pub amount: u64,
+ pub created_at: Timestamp,
+}
+
+pub struct WalletRegistry {
+ wallet: Option,
+ tokens: HashMap,
+ /// Token events killed by a kind 5 delete or superseded by a `del` chain
+ stale: HashSet,
+ quotes: Vec,
+ /// Spending history entries, newest first
+ history: Vec,
+ /// NIP-61 nutzap events already claimed, recorded by our 7376 events
+ redeemed: HashSet,
+ /// Nutzaps seen but not yet redeemed (wallet was not ready)
+ pending_nutzaps: Vec,
+ /// Nutzaps with a redemption job in flight
+ redeeming: HashSet,
+ store: Option>,
+ wallets: HashMap,
+ loading: bool,
+ store_opening: bool,
+ job_running: bool,
+ job_queued: Vec,
+ notification_listener: Option>,
+ tasks: SmallVec<[Task<()>; 2]>,
+ _subscriptions: SmallVec<[Subscription; 2]>,
+}
+
+struct LoadedState {
+ wallet: Option,
+ tokens: HashMap,
+ stale: HashSet,
+ history: Vec,
+ redeemed: HashSet,
+ nutzaps: Vec,
+}
+
+impl WalletRegistry {
+ pub fn global(cx: &App) -> Entity {
+ cx.global::().0.clone()
+ }
+
+ fn set_global(state: Entity, cx: &mut App) {
+ cx.set_global(GlobalWalletRegistry(state));
+ }
+
+ fn new(cx: &mut Context) -> Self {
+ let nostr = NostrRegistry::global(cx);
+ let mut subscriptions = smallvec![];
+
+ subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| {
+ if event.signer_changed() {
+ this.reset(cx);
+ this.handle_notifications(cx);
+ this.load(cx);
+ }
+ }));
+
+ Self {
+ wallet: None,
+ tokens: HashMap::new(),
+ stale: HashSet::new(),
+ quotes: Vec::new(),
+ history: Vec::new(),
+ redeemed: HashSet::new(),
+ pending_nutzaps: Vec::new(),
+ redeeming: HashSet::new(),
+ store: None,
+ wallets: HashMap::new(),
+ loading: false,
+ store_opening: false,
+ job_running: false,
+ job_queued: Vec::new(),
+ notification_listener: None,
+ tasks: smallvec![],
+ _subscriptions: subscriptions,
+ }
+ }
+
+ pub fn has_wallet(&self) -> bool {
+ self.wallet.is_some()
+ }
+
+ /// Spendable balance in satoshis across the active token events.
+ pub fn balance(&self) -> u64 {
+ self.tokens
+ .values()
+ .map(|token| token.proofs.iter().map(|proof| proof.amount).sum::())
+ .sum()
+ }
+
+ /// Lightning invoices waiting to be paid and claimed.
+ pub fn mint_quotes(&self) -> &[MintQuoteEntry] {
+ &self.quotes
+ }
+
+ /// Spending history, newest first.
+ pub fn history(&self) -> &[HistoryEntry] {
+ &self.history
+ }
+
+ /// Create a lightning invoice that funds the wallet once paid.
+ pub fn create_mint_quote(&mut self, amount: u64, cx: &mut Context) {
+ let Some(mint_url) = self.primary_mint(cx) else {
+ return;
+ };
+
+ self.schedule_job(
+ ops::Job::CreateQuote {
+ mint: mint_url,
+ amount,
+ },
+ cx,
+ );
+ }
+
+ /// Check a mint quote with the mint and claim it once it is paid.
+ pub fn claim_mint_quote(&mut self, quote_id: String, cx: &mut Context) {
+ let Some(entry) = self.quotes.iter().find(|quote| quote.quote_id == quote_id) else {
+ self.emit_error("Unknown mint quote".to_string(), cx);
+ return;
+ };
+
+ self.schedule_job(
+ ops::Job::ClaimQuote {
+ mint: entry.mint.clone(),
+ quote_id,
+ },
+ cx,
+ );
+ }
+
+ /// Pay a lightning invoice from the wallet.
+ pub fn melt(&mut self, invoice: String, cx: &mut Context) {
+ let Some(mint_url) = self.primary_mint(cx) else {
+ return;
+ };
+
+ self.schedule_job(
+ ops::Job::Melt {
+ mint: mint_url,
+ invoice,
+ },
+ cx,
+ );
+ }
+
+ /// Send ecash as a token string the recipient can paste anywhere.
+ pub fn send_token(&mut self, amount: u64, cx: &mut Context) {
+ let Some(mint_url) = self.primary_mint(cx) else {
+ return;
+ };
+
+ self.schedule_job(
+ ops::Job::SendToken {
+ mint: mint_url,
+ amount,
+ },
+ cx,
+ );
+ }
+
+ /// Send a NIP-61 nutzap to a peer, optionally nutzapping one of their events.
+ pub fn send_nutzap(
+ &mut self,
+ recipient: PublicKey,
+ amount: u64,
+ target: Option,
+ comment: Option,
+ cx: &mut Context,
+ ) {
+ if self.wallet.is_none() {
+ self.emit_error("No wallet is set up".to_string(), cx);
+ return;
+ }
+
+ self.schedule_job(
+ ops::Job::SendNutzap {
+ recipient,
+ amount,
+ target,
+ comment,
+ },
+ cx,
+ );
+ }
+
+ /// The first wallet mint; the UI is effectively single-mint in v1.
+ fn primary_mint(&self, cx: &mut Context) -> Option {
+ let Some(mint) = self.wallet.as_ref().and_then(|wallet| wallet.mints.first()) else {
+ self.emit_error("No wallet is set up".to_string(), cx);
+ return None;
+ };
+
+ match MintUrl::from_str(mint.as_str()) {
+ Ok(mint_url) => Some(mint_url),
+ Err(error) => {
+ self.emit_error(format!("invalid mint url: {error}"), cx);
+ None
+ }
+ }
+ }
+
+ /// Receive a cashu token created elsewhere.
+ pub fn receive_token(&mut self, token: String, cx: &mut Context) {
+ let mint = match cdk::nuts::Token::from_str(&token) {
+ Ok(token) => token.mint_url(),
+ Err(_) => {
+ self.emit_error("Invalid token".to_string(), cx);
+ return;
+ }
+ };
+
+ let mint = match mint {
+ Ok(mint) => mint,
+ Err(_) => {
+ self.emit_error("Token has no mint".to_string(), cx);
+ return;
+ }
+ };
+
+ if !self.wallets.contains_key(&mint) {
+ self.emit_error("Token mint is not in the wallet".to_string(), cx);
+ return;
+ }
+
+ self.schedule_job(ops::Job::Receive { mint, token }, cx);
+ }
+
+ /// Create the wallet (first mint) or add a mint to an existing one.
+ pub fn setup(&mut self, mint: String, cx: &mut Context) {
+ if self.loading {
+ self.emit_error("Wallet is still syncing, try again".to_string(), cx);
+ return;
+ }
+
+ let mint = match Url::parse(&mint) {
+ Ok(mint) => mint,
+ Err(_) => {
+ self.emit_error("Invalid mint URL".to_string(), cx);
+ return;
+ }
+ };
+
+ let mut data = match self.wallet.clone() {
+ Some(mut data) => {
+ if data.mints.contains(&mint) {
+ return;
+ }
+ data.mints.push(mint.clone());
+ data
+ }
+ None => keys::WalletData::generate(mint.clone()),
+ };
+
+ let nostr = NostrRegistry::global(cx);
+ let client = nostr.read(cx).client().clone();
+ let signer = nostr.read(cx).signer().clone();
+
+ let publish = cx.background_spawn(async move {
+ let me = signer.get_public_key_async().await?;
+
+ let event = data.to_event(&signer, &me).await?;
+ client.send_event(&event).await?;
+
+ let filter = Filter::new().kind(Kind::RelayList).author(me).limit(1);
+ let relays = match client.database().query(filter).await?.into_iter().next() {
+ Some(list) => nip65::extract_relay_list(&list)
+ .map(|(relay, _)| relay)
+ .collect::>(),
+ None => Vec::new(),
+ };
+
+ let info = keys::build_nutzap_info_event(
+ &signer,
+ &data.mints,
+ &relays,
+ Keys::new(data.privkey.clone()).public_key(),
+ )
+ .await?;
+ client.send_event(&info).await?;
+
+ data.event_id = event.id;
+ data.created_at = event.created_at;
+
+ Ok::<_, Error>(data)
+ });
+
+ self.tasks.push(cx.spawn(async move |this, cx| {
+ let data = match publish.await {
+ Ok(data) => data,
+ Err(error) => {
+ this.update(cx, |this, cx| {
+ this.emit_error(format!("{error:#}"), cx);
+ })
+ .ok();
+ return;
+ }
+ };
+
+ this.update(cx, |this, cx| {
+ // Our event wins ties so a relay echo of the same event is a no-op
+ let take_over = match &this.wallet {
+ Some(current) => data.created_at >= current.created_at,
+ None => true,
+ };
+
+ if take_over {
+ this.wallet = Some(data);
+ this.ensure_wallets(cx);
+ this.emit_updated(cx);
+ }
+ })
+ .ok();
+ }));
+ }
+
+ fn reset(&mut self, cx: &mut Context) {
+ self.wallet = None;
+ self.tokens.clear();
+ self.stale.clear();
+ self.quotes.clear();
+ self.history.clear();
+ self.redeemed.clear();
+ self.pending_nutzaps.clear();
+ self.redeeming.clear();
+ self.store = None;
+ self.wallets.clear();
+ self.loading = false;
+ self.store_opening = false;
+ self.job_running = false;
+ self.job_queued.clear();
+ self.notification_listener = None;
+ self.tasks.clear();
+ cx.notify();
+ }
+
+ fn handle_notifications(&mut self, cx: &mut Context) {
+ self.notification_listener = None;
+
+ let nostr = NostrRegistry::global(cx);
+ let client = nostr.read(cx).client().clone();
+ let signer = nostr.read(cx).signer().clone();
+
+ let (tx, rx) = flume::bounded::(100);
+
+ self.notification_listener = Some(cx.background_spawn(async move {
+ const MAX_PROCESSED: usize = 1000;
+
+ let me = signer.get_public_key_async().await.ok();
+ let mut notifications = client.notifications();
+ let mut processed_events = HashSet::new();
+
+ while let Some(notification) = notifications.next().await {
+ if let ClientNotification::Message { message, .. } = notification
+ && let RelayMessage::Event { event, .. } = *message
+ {
+ let ours = me == Some(event.pubkey);
+ let is_cashu = matches!(
+ event.kind,
+ Kind::CashuWallet
+ | Kind::CashuWalletUnspentProof
+ | Kind::CashuWalletSpendingHistory
+ );
+
+ let wallet_event = ours && is_cashu || is_token_deletion(&event);
+
+ let nutzap = event.kind == Kind::Custom(9321)
+ && me.is_some_and(|me| event.tags.public_keys().any(|key| key == me));
+
+ if !wallet_event && !nutzap {
+ continue;
+ }
+
+ // Prune the dedup set before it grows unbounded
+ if processed_events.len() >= MAX_PROCESSED {
+ processed_events.clear();
+ }
+
+ if !processed_events.insert(event.id) {
+ continue;
+ }
+
+ if tx.send_async(event.into_owned()).await.is_err() {
+ break;
+ }
+ }
+ }
+ }));
+
+ self.tasks.push(cx.spawn(async move |this, cx| {
+ while let Ok(event) = rx.recv_async().await {
+ this.update(cx, |this, cx| {
+ this.absorb(event, cx);
+ })
+ .ok();
+ }
+ }));
+ }
+
+ fn absorb(&mut self, event: Event, cx: &mut Context) {
+ match event.kind {
+ Kind::CashuWallet => self.absorb_wallet(event, cx),
+ Kind::CashuWalletUnspentProof => self.absorb_token(event, cx),
+ Kind::CashuWalletSpendingHistory => self.absorb_history(event, cx),
+ Kind::EventDeletion => self.absorb_deletion(event, cx),
+ Kind::Custom(9321) => self.absorb_nutzap(event, cx),
+ _ => {}
+ }
+ }
+
+ fn absorb_wallet(&mut self, event: Event, cx: &mut Context) {
+ let newer = match &self.wallet {
+ Some(current) => event.created_at > current.created_at,
+ None => true,
+ };
+
+ if !newer {
+ return;
+ }
+
+ let nostr = NostrRegistry::global(cx);
+ let signer = nostr.read(cx).signer().clone();
+
+ let decrypted = cx.background_spawn(async move {
+ let me = signer.get_public_key_async().await?;
+ keys::WalletData::decrypt(&signer, &me, &event).await
+ });
+
+ self.tasks.push(cx.spawn(async move |this, cx| {
+ let data = match decrypted.await {
+ Ok(data) => data,
+ Err(error) => {
+ this.update(cx, |this, cx| {
+ this.emit_error(format!("{error:#}"), cx);
+ })
+ .ok();
+ return;
+ }
+ };
+
+ this.update(cx, |this, cx| {
+ // A stale decrypt may land after a newer wallet event
+ let take_over = match &this.wallet {
+ Some(current) => data.created_at >= current.created_at,
+ None => true,
+ };
+
+ if take_over {
+ this.wallet = Some(data);
+ this.ensure_wallets(cx);
+ this.emit_updated(cx);
+ }
+ })
+ .ok();
+ }));
+ }
+
+ fn absorb_token(&mut self, event: Event, cx: &mut Context) {
+ if self.stale.contains(&event.id) {
+ return;
+ }
+
+ let nostr = NostrRegistry::global(cx);
+ let signer = nostr.read(cx).signer().clone();
+ let event_id = event.id;
+
+ let decrypted = cx.background_spawn(async move {
+ let me = signer.get_public_key_async().await?;
+ keys::decrypt_token_event(&signer, &me, &event).await
+ });
+
+ self.tasks.push(cx.spawn(async move |this, cx| {
+ let token = match decrypted.await {
+ Ok(token) => token,
+ Err(error) => {
+ log::warn!("skipping unreadable token event {event_id}: {error:#}");
+ return;
+ }
+ };
+
+ this.update(cx, |this, cx| {
+ // The replacement may arrive before the tokens it replaces
+ for old in &token.del {
+ if let Ok(old_id) = EventId::from_hex(old) {
+ this.tokens.remove(&old_id);
+ this.stale.insert(old_id);
+ }
+ }
+ this.tokens.insert(event_id, token);
+ this.emit_updated(cx);
+ this.schedule_job(ops::Job::Reconcile, cx);
+ })
+ .ok();
+ }));
+ }
+
+ fn absorb_deletion(&mut self, event: Event, cx: &mut Context) {
+ let mut removed_any = false;
+ for tag in event.tags.iter() {
+ if tag.kind() != "e" {
+ continue;
+ }
+ let Some(hex) = tag.content() else {
+ continue;
+ };
+ if let Ok(id) = EventId::from_hex(hex) {
+ if self.tokens.remove(&id).is_some() {
+ removed_any = true;
+ }
+ self.stale.insert(id);
+ }
+ }
+ if removed_any {
+ self.emit_updated(cx);
+ self.schedule_job(ops::Job::Reconcile, cx);
+ }
+ }
+
+ fn absorb_history(&mut self, event: Event, cx: &mut Context) {
+ let event_id = event.id;
+ let created_at = event.created_at;
+
+ if self.history.iter().any(|entry| entry.id == event_id) {
+ return;
+ }
+
+ let nostr = NostrRegistry::global(cx);
+ let signer = nostr.read(cx).signer().clone();
+
+ let decrypted = cx.background_spawn(async move {
+ let me = signer.get_public_key_async().await?;
+ keys::decrypt_history_event(&signer, &me, &event).await
+ });
+
+ self.tasks.push(cx.spawn(async move |this, cx| {
+ let payload = match decrypted.await {
+ Ok(payload) => payload,
+ Err(error) => {
+ log::warn!("skipping unreadable history event {event_id}: {error:#}");
+ return;
+ }
+ };
+
+ this.update(cx, |this, cx| {
+ this.history.push(HistoryEntry {
+ id: event_id,
+ direction: payload.direction,
+ amount: payload.amount,
+ created_at,
+ });
+ this.history
+ .sort_by_key(|entry| std::cmp::Reverse(entry.created_at));
+ this.emit_updated(cx);
+ })
+ .ok();
+ }));
+ }
+
+ /// Claim a NIP-61 nutzap.
+ fn absorb_nutzap(&mut self, event: Event, cx: &mut Context) {
+ if self.redeemed.contains(&event.id) || self.redeeming.contains(&event.id) {
+ return;
+ }
+
+ let Some(wallet) = self.wallet.as_ref() else {
+ log::warn!("dropping nutzap {}: no wallet is set up", event.id);
+ return;
+ };
+
+ if self.store.is_none() || self.wallets.is_empty() {
+ self.pending_nutzaps.push(event);
+ return;
+ }
+
+ let target = format!(
+ "02{}",
+ Keys::new(wallet.privkey.clone()).public_key().to_hex()
+ );
+ let nutzap = match Nutzap::from_event(&event, &target) {
+ Ok(nutzap) => nutzap,
+ Err(error) => {
+ log::warn!("skipping nutzap {}: {error:#}", event.id);
+ return;
+ }
+ };
+
+ if !self.wallets.contains_key(&nutzap.mint) {
+ log::warn!(
+ "nutzap {} is on an unlisted mint: {}",
+ event.id,
+ nutzap.mint
+ );
+ return;
+ }
+
+ self.redeeming.insert(event.id);
+ self.schedule_job(
+ ops::Job::RedeemNutzap {
+ mint: nutzap.mint,
+ proofs: nutzap.proofs,
+ nutzap_id: event.id,
+ sender: event.pubkey,
+ },
+ cx,
+ );
+ }
+
+ fn drain_pending_nutzaps(&mut self, cx: &mut Context) {
+ for event in std::mem::take(&mut self.pending_nutzaps) {
+ self.absorb_nutzap(event, cx);
+ }
+ }
+
+ /// Open the cdk store and build a wallet per mint that is not open yet.
+ fn ensure_wallets(&mut self, cx: &mut Context) {
+ let Some(data) = self.wallet.clone() else {
+ return;
+ };
+
+ let pending: Vec = data
+ .mints
+ .iter()
+ .filter(|mint| {
+ MintUrl::from_str(mint.as_str())
+ .map(|mint_url| !self.wallets.contains_key(&mint_url))
+ .unwrap_or(true)
+ })
+ .cloned()
+ .collect();
+
+ if pending.is_empty() {
+ return;
+ }
+
+ if let Some(store) = self.store.clone() {
+ let seed = match keys::WalletData::derive_seed(&data.privkey) {
+ Ok(seed) => seed,
+ Err(error) => {
+ self.emit_error(format!("{error:#}"), cx);
+ return;
+ }
+ };
+ for mint in pending {
+ let Ok(mint_url) = MintUrl::from_str(mint.as_str()) else {
+ continue;
+ };
+
+ match WalletBuilder::new()
+ .mint_url(mint_url.clone())
+ .unit(CurrencyUnit::Sat)
+ .localstore(store.clone())
+ .seed(seed)
+ .build()
+ {
+ Ok(wallet) => {
+ self.wallets.insert(mint_url, wallet);
+ }
+ Err(error) => self.emit_error(format!("{error:#}"), cx),
+ }
+ }
+
+ cx.notify();
+ self.drain_pending_nutzaps(cx);
+ self.schedule_job(ops::Job::Reconcile, cx);
+
+ return;
+ }
+
+ if self.store_opening {
+ return;
+ }
+ self.store_opening = true;
+
+ let privkey = data.privkey.clone();
+ let mints = data.mints.clone();
+
+ self.tasks.push(cx.spawn(async move |this, cx| {
+ match Tokio::spawn_result(cx, Self::open_store(privkey, mints)).await {
+ Ok((store, wallets)) => {
+ this.update(cx, |this, cx| {
+ if this.store.is_none() {
+ this.store = Some(store);
+ }
+
+ for (mint_url, wallet) in wallets {
+ this.wallets.insert(mint_url, wallet);
+ }
+
+ this.store_opening = false;
+ this.drain_pending_nutzaps(cx);
+ cx.notify();
+ this.schedule_job(ops::Job::Reconcile, cx);
+ })
+ .ok();
+ }
+ Err(error) => {
+ this.update(cx, |this, cx| {
+ this.store_opening = false;
+ this.emit_error(format!("{error:#}"), cx);
+ })
+ .ok();
+ }
+ }
+ }));
+ }
+
+ /// Queue a store-touching job.
+ fn schedule_job(&mut self, job: ops::Job, cx: &mut Context) {
+ if let Some(queued) = self.job_queued.last_mut()
+ && matches!(queued, ops::Job::Reconcile)
+ && matches!(job, ops::Job::Reconcile)
+ {
+ return;
+ }
+
+ if self.job_running {
+ self.job_queued.push(job);
+ return;
+ }
+
+ self.start_job(job, cx);
+ }
+
+ fn start_job(&mut self, job: ops::Job, cx: &mut Context) {
+ if self.store.is_none() || self.wallets.is_empty() {
+ self.job_queued.push(job);
+ return;
+ }
+
+ let wallets = self.wallets.clone();
+ let tokens = self.tokens.clone();
+
+ let nostr = NostrRegistry::global(cx);
+ let signer = nostr.read(cx).signer().clone();
+ let client = nostr.read(cx).client().clone();
+
+ let wallet_key = match self
+ .wallet
+ .as_ref()
+ .map(|wallet| cdk::nuts::SecretKey::from_str(&wallet.privkey.to_secret_hex()))
+ {
+ Some(Ok(key)) => key,
+ Some(Err(error)) => {
+ self.emit_error(format!("invalid wallet key: {error}"), cx);
+ return;
+ }
+ None => {
+ self.emit_error("Wallet is not ready".to_string(), cx);
+ return;
+ }
+ };
+
+ self.job_running = true;
+
+ self.tasks.push(cx.spawn(async move |this, cx| {
+ let result = Tokio::spawn_result(cx, async move {
+ let ctx = ops::JobCtx {
+ wallets,
+ wallet_key,
+ signer,
+ client,
+ tokens,
+ };
+
+ job.run(ctx).await
+ })
+ .await;
+
+ this.update(cx, |this, cx| {
+ this.job_running = false;
+ this.apply_outcome(result, cx);
+
+ for job in std::mem::take(&mut this.job_queued) {
+ this.schedule_job(job, cx);
+ }
+ })
+ .ok();
+ }));
+ }
+
+ fn apply_outcome(&mut self, result: Result, cx: &mut Context) {
+ let outcome = match result {
+ Ok(outcome) => outcome,
+ Err(error) => {
+ self.emit_error(format!("{error:#}"), cx);
+ return;
+ }
+ };
+
+ match outcome {
+ ops::Outcome::Idle => {}
+ ops::Outcome::Reconciled { updates } => {
+ let changed = !updates.is_empty();
+
+ for update in updates {
+ self.apply_token_update(update);
+ }
+
+ if changed {
+ self.emit_updated(cx);
+ }
+ }
+ ops::Outcome::QuoteCreated {
+ mint,
+ quote_id,
+ request,
+ amount,
+ } => {
+ self.quotes.push(MintQuoteEntry {
+ quote_id,
+ request,
+ amount,
+ mint,
+ });
+ self.emit_updated(cx);
+ }
+ ops::Outcome::QuoteMinted {
+ quote_id,
+ update,
+ amount,
+ } => {
+ self.quotes.retain(|quote| quote.quote_id != quote_id);
+ self.apply_token_update(update);
+ self.emit_updated(cx);
+ log::info!("minted {amount} sats from quote {quote_id}");
+ }
+ ops::Outcome::Melted { update, amount } => {
+ self.apply_token_update(update);
+ self.emit_updated(cx);
+ log::info!("melted {amount} sats");
+ }
+ ops::Outcome::Sent { update, token } => {
+ self.apply_token_update(update);
+ cx.emit(WalletEvent::TokenCreated(token));
+ self.emit_updated(cx);
+ }
+ ops::Outcome::NutzapSent { update, amount } => {
+ self.apply_token_update(update);
+ self.emit_updated(cx);
+ log::info!("sent {amount} sats as a nutzap");
+ }
+ ops::Outcome::Received { update, amount } => {
+ self.apply_token_update(update);
+ self.emit_updated(cx);
+ log::info!("received {amount} sats");
+ }
+ ops::Outcome::NutzapRedeemed {
+ update,
+ nutzap_id,
+ amount,
+ } => {
+ self.redeeming.remove(&nutzap_id);
+ self.redeemed.insert(nutzap_id);
+ self.apply_token_update(update);
+ self.emit_updated(cx);
+ log::info!("redeemed nutzap {nutzap_id} for {amount} sats");
+ }
+ }
+ }
+
+ fn apply_token_update(&mut self, update: ops::TokenUpdate) {
+ for id in &update.destroyed {
+ self.tokens.remove(id);
+ self.stale.insert(*id);
+ }
+ self.tokens.insert(update.event_id, update.token);
+ }
+
+ fn apply_loaded(&mut self, state: LoadedState, cx: &mut Context) {
+ self.wallet = state.wallet;
+ self.tokens = state.tokens;
+ self.stale = state.stale;
+ self.history = state.history;
+ self.redeemed = state.redeemed;
+ cx.notify();
+ self.ensure_wallets(cx);
+
+ // Claimed once the wallets are open; live redemptions continue
+ // through the notification listener
+ for event in state.nutzaps {
+ self.pending_nutzaps.push(event);
+ }
+ }
+
+ fn load(&mut self, cx: &mut Context) {
+ if self.loading {
+ return;
+ }
+ self.loading = true;
+
+ let nostr = NostrRegistry::global(cx);
+ let client = nostr.read(cx).client().clone();
+ let signer = nostr.read(cx).signer().clone();
+
+ let subscribe_client = client.clone();
+ let cached = cx.background_spawn(async move {
+ let me = signer.get_public_key_async().await?;
+ let state = LoadedState::load(me, &client, &signer).await?;
+ Ok::<_, Error>((me, state))
+ });
+
+ self.tasks.push(cx.spawn(async move |this, cx| {
+ let (me, state) = match cached.await {
+ Ok(loaded) => loaded,
+ Err(error) => {
+ this.update(cx, |this, cx| {
+ this.emit_error(format!("{error:#}"), cx);
+ })
+ .ok();
+ this.update(cx, |this, _cx| {
+ this.loading = false;
+ })
+ .ok();
+ return;
+ }
+ };
+
+ this.update(cx, |this, cx| {
+ this.apply_loaded(state, cx);
+ })
+ .ok();
+
+ this.update(cx, |this, _cx| {
+ this.loading = false;
+ })
+ .ok();
+
+ let subscribed = cx.background_spawn(async move {
+ subscribe_client.subscribe(Self::wallet_filter(me)).await?;
+ subscribe_client.subscribe(Self::nutzap_filter(me)).await
+ });
+
+ if let Err(error) = subscribed.await {
+ log::warn!("wallet subscription failed: {error:#}");
+ }
+ }));
+ }
+
+ fn emit_updated(&self, cx: &mut Context) {
+ cx.emit(WalletEvent::Updated);
+ cx.notify();
+ }
+
+ fn emit_error(&self, message: String, cx: &mut Context) {
+ log::error!("wallet: {message}");
+ cx.emit(WalletEvent::Error(message));
+ }
+
+ /// Open the cdk store and build a wallet per mint.
+ async fn open_store(
+ privkey: SecretKey,
+ mints: Vec,
+ ) -> Result<(Arc, HashMap), Error> {
+ let dir = config_dir()
+ .join("cashu")
+ .join(Keys::new(privkey.clone()).public_key().to_hex());
+ std::fs::create_dir_all(&dir)?;
+
+ let store = Arc::new(WalletRedbDatabase::new(&dir.join("wallet.redb"))?);
+ let seed = keys::WalletData::derive_seed(&privkey)?;
+
+ let mut wallets = HashMap::new();
+ for mint in mints {
+ let mint_url = MintUrl::from_str(mint.as_str())?;
+ let wallet = WalletBuilder::new()
+ .mint_url(mint_url.clone())
+ .unit(CurrencyUnit::Sat)
+ .localstore(store.clone())
+ .seed(seed)
+ .build()?;
+ wallets.insert(mint_url, wallet);
+ }
+
+ Ok((store, wallets))
+ }
+
+ fn wallet_filter(author: PublicKey) -> Filter {
+ Filter::new().author(author).kinds([
+ Kind::CashuWallet,
+ Kind::CashuWalletUnspentProof,
+ Kind::CashuWalletSpendingHistory,
+ Kind::EventDeletion,
+ ])
+ }
+
+ /// NIP-61 nutzaps: public kind 9321 events p-tagging our identity pubkey
+ fn nutzap_filter(me: PublicKey) -> Filter {
+ Filter::new()
+ .kinds([Kind::Custom(9321)])
+ .custom_tag(SingleLetterTag::LOWERCASE_P, me.to_hex())
+ }
+}
+
+impl LoadedState {
+ async fn load(me: PublicKey, client: &Client, signer: &S) -> Result
+ where
+ S: AsyncNip44 + ?Sized,
+ {
+ let events = client
+ .database()
+ .query(WalletRegistry::wallet_filter(me))
+ .await?;
+ let nutzap_events = client
+ .database()
+ .query(WalletRegistry::nutzap_filter(me))
+ .await?;
+
+ let mut wallet_event: Option<&Event> = None;
+ let mut token_events = Vec::new();
+ let mut history_events = Vec::new();
+ let mut deletions = Vec::new();
+
+ for event in &events {
+ match event.kind {
+ Kind::CashuWallet => {
+ let replace = match &wallet_event {
+ Some(current) => event.created_at > current.created_at,
+ None => true,
+ };
+
+ if replace {
+ wallet_event = Some(event);
+ }
+ }
+ Kind::CashuWalletUnspentProof => token_events.push(event),
+ Kind::CashuWalletSpendingHistory => history_events.push(event),
+ Kind::EventDeletion if is_token_deletion(event) => deletions.push(event),
+ _ => {}
+ }
+ }
+
+ // Nutzap events tagged redeemed in our history events are already claimed
+ let mut redeemed = HashSet::new();
+ for event in &history_events {
+ for id in redeemed_ids(event) {
+ redeemed.insert(id);
+ }
+ }
+
+ let mut history = Vec::new();
+ for event in &history_events {
+ let payload = match keys::decrypt_history_event(signer, &me, event).await {
+ Ok(payload) => payload,
+ Err(error) => {
+ log::warn!("skipping unreadable history event {}: {error:#}", event.id);
+ continue;
+ }
+ };
+
+ history.push(HistoryEntry {
+ id: event.id,
+ direction: payload.direction,
+ amount: payload.amount,
+ created_at: event.created_at,
+ });
+ }
+ history.sort_by_key(|entry| std::cmp::Reverse(entry.created_at));
+
+ let nutzaps = nutzap_events
+ .into_iter()
+ .filter(|event| !redeemed.contains(&event.id))
+ .collect();
+
+ let mut stale = HashSet::new();
+ for event in deletions {
+ for tag in event.tags.iter() {
+ if tag.kind() != "e" {
+ continue;
+ }
+
+ let Some(hex) = tag.content() else {
+ continue;
+ };
+
+ if let Ok(id) = EventId::from_hex(hex) {
+ stale.insert(id);
+ }
+ }
+ }
+
+ let mut tokens = HashMap::new();
+ for event in token_events {
+ if stale.contains(&event.id) {
+ continue;
+ }
+ let token = match keys::decrypt_token_event(signer, &me, event).await {
+ Ok(token) => token,
+ Err(error) => {
+ log::warn!("skipping unreadable token event {}: {error:#}", event.id);
+ continue;
+ }
+ };
+ tokens.insert(event.id, token);
+ }
+
+ let superseded: HashSet = tokens
+ .values()
+ .flat_map(|token| token.del.iter())
+ .filter_map(|hex| EventId::from_hex(hex).ok())
+ .collect();
+
+ tokens.retain(|id, _| !stale.contains(id) && !superseded.contains(id));
+ stale.extend(superseded);
+
+ let wallet = match wallet_event {
+ Some(event) => match keys::WalletData::decrypt(signer, &me, event).await {
+ Ok(wallet) => Some(wallet),
+ Err(error) => {
+ log::warn!("skipping wallet event {}: {error:#}", event.id);
+ None
+ }
+ },
+ None => None,
+ };
+
+ Ok(Self {
+ wallet,
+ tokens,
+ stale,
+ history,
+ redeemed,
+ nutzaps,
+ })
+ }
+}
+
+fn is_token_deletion(event: &Event) -> bool {
+ event.kind == Kind::EventDeletion
+ && event
+ .tags
+ .iter()
+ .any(|tag| tag.kind() == "k" && tag.content() == Some("7375"))
+}
+
+/// Nutzap event ids our history events marked as redeemed.
+fn redeemed_ids(event: &Event) -> Vec {
+ event
+ .tags
+ .iter()
+ .filter(|tag| {
+ let slice = tag.as_slice();
+ slice.len() >= 4 && slice[0] == "e" && slice[3] == "redeemed"
+ })
+ .filter_map(|tag| tag.content())
+ .filter_map(|hex| EventId::from_hex(hex).ok())
+ .collect()
+}
+
+/// A NIP-61 nutzap's proofs, validated against the wallet pubkey.
+struct Nutzap {
+ mint: MintUrl,
+ proofs: Proofs,
+}
+
+impl Nutzap {
+ fn from_event(event: &Event, wallet_target: &str) -> Result {
+ let mint = event
+ .tags
+ .iter()
+ .find(|tag| tag.kind() == "u" && tag.content().is_some())
+ .and_then(|tag| tag.content())
+ .ok_or_else(|| anyhow!("nutzap is missing the mint tag"))?;
+ let mint = MintUrl::from_str(mint).map_err(|error| anyhow!("invalid mint url: {error}"))?;
+
+ let mut proofs = Vec::new();
+ for tag in event.tags.iter() {
+ if tag.kind() != "proof" {
+ continue;
+ }
+
+ let Some(value) = tag.content() else {
+ continue;
+ };
+
+ let proof: Proof = serde_json::from_str(value)
+ .map_err(|error| anyhow!("invalid proof tag: {error}"))?;
+ proofs.push(proof);
+ }
+
+ if proofs.is_empty() {
+ return Err(anyhow!("nutzap has no proofs"));
+ }
+
+ let proofs = Proofs::from(proofs);
+ let locked = proofs.iter().all(|proof| locked_to(proof, wallet_target));
+ if !locked {
+ return Err(anyhow!("proofs are not locked to the wallet key"));
+ }
+
+ Ok(Self { mint, proofs })
+ }
+}
+
+/// NIP-61 requires nutzap proofs to be P2PK-locked to the wallet pubkey.
+fn locked_to(proof: &Proof, target: &str) -> bool {
+ let Ok(conditions) = SpendingConditions::try_from(&proof.secret) else {
+ return false;
+ };
+
+ conditions
+ .pubkeys()
+ .is_some_and(|keys| keys.iter().any(|key| key.to_string() == target))
+}
diff --git a/crates/nip60/src/ops.rs b/crates/nip60/src/ops.rs
new file mode 100644
index 00000000..722c37e4
--- /dev/null
+++ b/crates/nip60/src/ops.rs
@@ -0,0 +1,617 @@
+use std::collections::{HashMap, HashSet};
+use std::str::FromStr;
+use std::time::Duration;
+
+use anyhow::{Error, anyhow};
+use cdk::Amount;
+use cdk::amount::SplitTarget;
+use cdk::mint_url::MintUrl;
+use cdk::nuts::nut00::KnownMethod;
+use cdk::nuts::{
+ CurrencyUnit, Id, KeySetInfo, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs,
+ SecretKey, SpendingConditions, State,
+};
+use cdk::secret::Secret;
+use cdk::wallet::types::ProofInfo;
+use cdk::wallet::{ReceiveOptions, SendOptions, Wallet};
+use futures::StreamExt;
+use nostr::nips::nip60::{CashuProof, TokenEvent, TransactionDirection};
+use nostr_sdk::prelude::*;
+
+use crate::keys;
+
+/// How long to wait for relays when fetching a recipient's wallet info
+const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
+
+/// A job mutating the wallet store or reconciling it with relays.
+#[derive(Debug, Clone)]
+pub(crate) enum Job {
+ Reconcile,
+ CreateQuote {
+ mint: MintUrl,
+ amount: u64,
+ },
+ ClaimQuote {
+ mint: MintUrl,
+ quote_id: String,
+ },
+ Melt {
+ mint: MintUrl,
+ invoice: String,
+ },
+ SendToken {
+ mint: MintUrl,
+ amount: u64,
+ },
+ SendNutzap {
+ recipient: PublicKey,
+ amount: u64,
+ target: Option,
+ comment: Option,
+ },
+ Receive {
+ mint: MintUrl,
+ token: String,
+ },
+ RedeemNutzap {
+ mint: MintUrl,
+ proofs: Proofs,
+ nutzap_id: EventId,
+ sender: PublicKey,
+ },
+}
+
+impl Job {
+ pub(crate) async fn run(self, ctx: JobCtx) -> Result
+ where
+ S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static,
+ {
+ match self {
+ Job::Reconcile => ctx.reconcile().await,
+ Job::CreateQuote { mint, amount } => {
+ let wallet = ctx.wallet(&mint)?;
+ let quote = wallet
+ .mint_quote(
+ PaymentMethod::Known(KnownMethod::Bolt11),
+ Some(Amount::from(amount)),
+ None,
+ None,
+ )
+ .await?;
+
+ Ok(Outcome::QuoteCreated {
+ mint,
+ quote_id: quote.id,
+ request: quote.request,
+ amount,
+ })
+ }
+ Job::ClaimQuote { mint, quote_id } => {
+ let wallet = ctx.wallet(&mint)?;
+ let quote = wallet.check_mint_quote("e_id).await?;
+
+ if quote.state != MintQuoteState::Paid {
+ return Ok(Outcome::Idle);
+ }
+
+ let proofs = wallet.mint("e_id, SplitTarget::default(), None).await?;
+ let amount: u64 = proofs.iter().map(|proof| u64::from(proof.amount)).sum();
+
+ let update = ctx
+ .sync_token_events(
+ wallet,
+ nostr_mint(&mint)?,
+ &ctx.previous_ids(&mint),
+ Some(keys::History {
+ direction: TransactionDirection::In,
+ amount: u64::from(quote.amount.unwrap_or_default()),
+ redeemed: Vec::new(),
+ sender: None,
+ }),
+ )
+ .await?;
+
+ Ok(Outcome::QuoteMinted {
+ quote_id,
+ update,
+ amount,
+ })
+ }
+ Job::Melt { mint, invoice } => {
+ let wallet = ctx.wallet(&mint)?;
+ let quote = wallet
+ .melt_quote(
+ PaymentMethod::Known(KnownMethod::Bolt11),
+ invoice,
+ None,
+ None,
+ )
+ .await?;
+
+ let finalized = wallet
+ .prepare_melt("e.id, HashMap::new())
+ .await?
+ .confirm()
+ .await?;
+
+ if finalized.state() != MeltQuoteState::Paid {
+ return Err(anyhow!("melt did not settle: {:?}", finalized.state()));
+ }
+
+ let amount = u64::from(finalized.total_amount());
+ let update = ctx
+ .sync_token_events(
+ wallet,
+ nostr_mint(&mint)?,
+ &ctx.previous_ids(&mint),
+ Some(keys::History {
+ direction: TransactionDirection::Out,
+ amount,
+ redeemed: Vec::new(),
+ sender: None,
+ }),
+ )
+ .await?;
+
+ Ok(Outcome::Melted { update, amount })
+ }
+ Job::SendToken { mint, amount } => {
+ let wallet = ctx.wallet(&mint)?;
+ let prepared = wallet
+ .prepare_send(Amount::from(amount), SendOptions::default())
+ .await?;
+ let token = prepared.confirm(None).await?.to_string();
+
+ let update = ctx
+ .sync_token_events(
+ wallet,
+ nostr_mint(&mint)?,
+ &ctx.previous_ids(&mint),
+ Some(keys::History {
+ direction: TransactionDirection::Out,
+ amount,
+ redeemed: Vec::new(),
+ sender: None,
+ }),
+ )
+ .await?;
+
+ Ok(Outcome::Sent { update, token })
+ }
+ Job::SendNutzap {
+ recipient,
+ amount,
+ target,
+ comment,
+ } => {
+ let (mint_url, mint_tag, p2pk) = recipient_wallet_info(&ctx, recipient).await?;
+ let wallet = ctx.wallet(&mint_url)?;
+
+ let keysets = wallet
+ .keysets(cdk::wallet::types::KeysetLoadPolicy::CacheOnly)
+ .await?;
+
+ let infos: Vec = keysets
+ .iter()
+ .map(|keyset| KeySetInfo {
+ id: keyset.id,
+ unit: keyset.unit.clone(),
+ active: keyset.active.unwrap_or(true),
+ input_fee_ppk: keyset.input_fee_ppk,
+ final_expiry: keyset.final_expiry,
+ })
+ .collect();
+
+ let options = SendOptions {
+ conditions: Some(SpendingConditions::new_p2pk(p2pk, None)),
+ ..Default::default()
+ };
+
+ let token = wallet
+ .prepare_send(Amount::from(amount), options)
+ .await?
+ .confirm(None)
+ .await?;
+ let proofs = token.proofs(&infos)?;
+
+ let mut tags = Vec::new();
+ for proof in &proofs {
+ tags.push(serde_json::to_string(proof)?);
+ }
+
+ let event = keys::build_nutzap_event(
+ &ctx.signer,
+ recipient,
+ &mint_tag,
+ &tags,
+ target,
+ comment.as_deref().unwrap_or(""),
+ )
+ .await?;
+ ctx.client.send_event(&event).await?;
+
+ let update = ctx
+ .sync_token_events(
+ wallet,
+ nostr_mint(&mint_url)?,
+ &ctx.previous_ids(&mint_url),
+ Some(keys::History {
+ direction: TransactionDirection::Out,
+ amount,
+ redeemed: Vec::new(),
+ sender: None,
+ }),
+ )
+ .await?;
+
+ Ok(Outcome::NutzapSent { update, amount })
+ }
+ Job::Receive { mint, token } => {
+ let wallet = ctx.wallet(&mint)?;
+ let received = wallet.receive(&token, ReceiveOptions::default()).await?;
+
+ let update = ctx
+ .sync_token_events(
+ wallet,
+ nostr_mint(&mint)?,
+ &ctx.previous_ids(&mint),
+ Some(keys::History {
+ direction: TransactionDirection::In,
+ amount: u64::from(received),
+ redeemed: Vec::new(),
+ sender: None,
+ }),
+ )
+ .await?;
+
+ Ok(Outcome::Received {
+ update,
+ amount: u64::from(received),
+ })
+ }
+ Job::RedeemNutzap {
+ mint,
+ proofs,
+ nutzap_id,
+ sender,
+ } => {
+ let wallet = ctx.wallet(&mint)?;
+ let options = ReceiveOptions {
+ p2pk_signing_keys: vec![ctx.wallet_key.clone()],
+ ..Default::default()
+ };
+ let received = wallet.receive_proofs(proofs, options, None, None).await?;
+
+ let update = ctx
+ .sync_token_events(
+ wallet,
+ nostr_mint(&mint)?,
+ &ctx.previous_ids(&mint),
+ Some(keys::History {
+ direction: TransactionDirection::In,
+ amount: u64::from(received),
+ redeemed: vec![nutzap_id],
+ sender: Some(sender),
+ }),
+ )
+ .await?;
+
+ Ok(Outcome::NutzapRedeemed {
+ update,
+ nutzap_id,
+ amount: u64::from(received),
+ })
+ }
+ }
+ }
+}
+
+pub(crate) struct TokenUpdate {
+ pub(crate) event_id: EventId,
+ pub(crate) token: TokenEvent,
+ pub(crate) destroyed: Vec,
+}
+
+pub(crate) enum Outcome {
+ /// Nothing changed (e.g. an unpaid quote)
+ Idle,
+ /// Token events published by a reconcile pass
+ Reconciled {
+ updates: Vec,
+ },
+ QuoteCreated {
+ mint: MintUrl,
+ quote_id: String,
+ request: String,
+ amount: u64,
+ },
+ QuoteMinted {
+ quote_id: String,
+ update: TokenUpdate,
+ amount: u64,
+ },
+ Melted {
+ update: TokenUpdate,
+ amount: u64,
+ },
+ Sent {
+ update: TokenUpdate,
+ token: String,
+ },
+ NutzapSent {
+ update: TokenUpdate,
+ amount: u64,
+ },
+ Received {
+ update: TokenUpdate,
+ amount: u64,
+ },
+ NutzapRedeemed {
+ update: TokenUpdate,
+ nutzap_id: EventId,
+ amount: u64,
+ },
+}
+
+pub(crate) struct JobCtx {
+ pub(crate) wallets: HashMap,
+ /// Wallet signing key, unlocking P2PK-locked nutzap proofs
+ pub(crate) wallet_key: SecretKey,
+ pub(crate) signer: S,
+ pub(crate) client: Client,
+ /// Relay token events at schedule time.
+ pub(crate) tokens: HashMap,
+}
+
+impl JobCtx
+where
+ S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync,
+{
+ fn wallet(&self, mint: &MintUrl) -> Result<&Wallet, Error> {
+ self.wallets
+ .get(mint)
+ .ok_or_else(|| anyhow!("no open wallet for mint {mint}"))
+ }
+
+ fn previous_ids(&self, mint: &MintUrl) -> Vec {
+ previous_ids(&self.tokens, mint)
+ }
+
+ /// Publish the store's current unspent set as a fresh token event.
+ async fn sync_token_events(
+ &self,
+ wallet: &Wallet,
+ mint: Url,
+ previous: &[EventId],
+ history: Option,
+ ) -> Result {
+ let me = self.signer.get_public_key_async().await?;
+ let unspent = wallet.get_unspent_proofs().await?;
+
+ let proofs = unspent
+ .iter()
+ .map(cashu_proof)
+ .collect::, _>>()?;
+
+ let mut token = TokenEvent::new(mint, proofs);
+ token.del = previous.iter().map(|id| id.to_hex()).collect();
+ let event = keys::build_token_event(&self.signer, &me, &token).await?;
+ self.client.send_event(&event).await?;
+
+ if !previous.is_empty() {
+ let deletion = keys::build_token_deletion(&self.signer, previous).await?;
+ self.client.send_event(&deletion).await?;
+ }
+
+ if let Some(history) = history {
+ let history_event =
+ keys::build_history_event(&self.signer, &me, &history, event.id, previous).await?;
+ self.client.send_event(&history_event).await?;
+ }
+
+ Ok(TokenUpdate {
+ event_id: event.id,
+ token,
+ destroyed: previous.to_vec(),
+ })
+ }
+
+ /// Claim paid quotes, then converge each mint.
+ async fn reconcile(self) -> Result {
+ let mut tokens = self.tokens.clone();
+ let mut updates = Vec::new();
+
+ for (mint_url, wallet) in &self.wallets {
+ if let Err(error) = wallet.recover_incomplete_sagas().await {
+ log::warn!("saga recovery failed for {mint_url}: {error:#}");
+ }
+
+ let minted = wallet.mint_unissued_quotes().await?;
+ if minted == Amount::ZERO {
+ continue;
+ }
+
+ let update = self
+ .sync_token_events(
+ wallet,
+ nostr_mint(mint_url)?,
+ &previous_ids(&tokens, mint_url),
+ Some(keys::History {
+ direction: TransactionDirection::In,
+ amount: u64::from(minted),
+ redeemed: Vec::new(),
+ sender: None,
+ }),
+ )
+ .await?;
+
+ for id in &update.destroyed {
+ tokens.remove(id);
+ }
+ tokens.insert(update.event_id, update.token.clone());
+ updates.push(update);
+ }
+
+ for (mint_url, wallet) in &self.wallets {
+ let mut relay_ys = HashSet::new();
+ let mut added = Vec::new();
+
+ for token in tokens
+ .values()
+ .filter(|token| token.mint.as_str() == mint_url.to_string())
+ {
+ for proof in &token.proofs {
+ let info = match proof_info(proof, mint_url) {
+ Ok(info) => info,
+ Err(error) => {
+ log::warn!("skipping proof: {error:#}");
+ continue;
+ }
+ };
+ if relay_ys.insert(info.y) {
+ added.push(info);
+ }
+ }
+ }
+
+ let unspent = wallet.get_unspent_proofs().await?;
+ let removed = unspent
+ .iter()
+ .filter(|proof| match proof.y() {
+ Ok(y) => !relay_ys.contains(&y),
+ Err(_) => false,
+ })
+ .map(|proof| proof.y())
+ .collect::, _>>()?;
+
+ wallet.localstore.update_proofs(added, removed).await?;
+ }
+
+ Ok(Outcome::Reconciled { updates })
+ }
+}
+
+fn previous_ids(tokens: &HashMap, mint: &MintUrl) -> Vec {
+ tokens
+ .iter()
+ .filter(|(_, token)| token.mint.as_str() == mint.to_string())
+ .map(|(id, _)| *id)
+ .collect()
+}
+
+/// Resolve a recipient's 10019 wallet info and pick a mint we share with them.
+async fn recipient_wallet_info(
+ ctx: &JobCtx,
+ recipient: PublicKey,
+) -> Result<(MintUrl, String, cdk::nuts::PublicKey), Error>
+where
+ S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync,
+{
+ let filter = Filter::new()
+ .author(recipient)
+ .kind(Kind::Custom(10019))
+ .limit(1);
+
+ let info = match ctx
+ .client
+ .database()
+ .query(filter.clone())
+ .await?
+ .into_iter()
+ .next()
+ {
+ Some(event) => event,
+ None => {
+ let mut stream = ctx
+ .client
+ .stream_events(filter)
+ .timeout(FETCH_TIMEOUT)
+ .await?;
+ let mut found: Option = None;
+
+ while let Some((_url, result)) = stream.next().await {
+ if let Ok(event) = result {
+ let newer = found
+ .as_ref()
+ .is_none_or(|current| event.created_at > current.created_at);
+
+ if newer {
+ found = Some(event);
+ }
+ }
+ }
+
+ found.ok_or_else(|| anyhow!("recipient has no wallet info"))?
+ }
+ };
+
+ let (p2pk, mints) = parse_nutzap_info(&info)?;
+
+ for mint in mints {
+ let Ok(mint_url) = MintUrl::from_str(&mint) else {
+ continue;
+ };
+
+ if ctx.wallets.contains_key(&mint_url) {
+ return Ok((mint_url, mint, p2pk));
+ }
+ }
+
+ Err(anyhow!("no shared mint with the recipient"))
+}
+
+fn parse_nutzap_info(event: &Event) -> Result<(cdk::nuts::PublicKey, Vec), Error> {
+ let mut p2pk = None;
+ let mut mints = Vec::new();
+
+ for tag in event.tags.iter() {
+ let Some(value) = tag.content() else {
+ continue;
+ };
+
+ match tag.kind() {
+ "pubkey" if p2pk.is_none() => {
+ p2pk = Some(cdk::nuts::PublicKey::from_str(value)?);
+ }
+ "mint" => mints.push(value.to_string()),
+ _ => {}
+ }
+ }
+
+ let Some(p2pk) = p2pk else {
+ return Err(anyhow!("recipient has no wallet pubkey"));
+ };
+
+ Ok((p2pk, mints))
+}
+
+fn cashu_proof(proof: &Proof) -> Result {
+ Ok(CashuProof {
+ id: proof.keyset_id.to_string(),
+ amount: u64::from(proof.amount),
+ secret: proof.secret.to_string(),
+ c: proof.c.to_string(),
+ })
+}
+
+fn nostr_mint(mint: &MintUrl) -> Result {
+ Url::parse(&mint.to_string()).map_err(|error| anyhow!("invalid mint url: {error}"))
+}
+
+fn proof_info(proof: &CashuProof, mint: &MintUrl) -> Result {
+ let keyset_id = Id::from_str(&proof.id)?;
+ let c = cdk::nuts::PublicKey::from_str(&proof.c)?;
+ let info = ProofInfo::new(
+ Proof::new(
+ Amount::from(proof.amount),
+ keyset_id,
+ Secret::new(proof.secret.clone()),
+ c,
+ ),
+ mint.clone(),
+ State::Unspent,
+ CurrencyUnit::Sat,
+ )?;
+
+ Ok(info)
+}
diff --git a/crates/nip60_ui/Cargo.toml b/crates/nip60_ui/Cargo.toml
new file mode 100644
index 00000000..33266d0d
--- /dev/null
+++ b/crates/nip60_ui/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "nip60_ui"
+version.workspace = true
+edition.workspace = true
+publish.workspace = true
+
+[dependencies]
+common = { path = "../common" }
+nip60 = { path = "../nip60" }
+person = { path = "../person" }
+settings = { path = "../settings" }
+state = { path = "../state" }
+ui = { path = "../ui" }
+
+gpui.workspace = true
+gpui-kit.workspace = true
+nostr-sdk.workspace = true
+anyhow.workspace = true
+smallvec.workspace = true
diff --git a/crates/nip60_ui/src/lib.rs b/crates/nip60_ui/src/lib.rs
new file mode 100644
index 00000000..ef2bf436
--- /dev/null
+++ b/crates/nip60_ui/src/lib.rs
@@ -0,0 +1,77 @@
+#![cfg(not(target_arch = "wasm32"))]
+
+use std::rc::Rc;
+
+use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px};
+use gpui_kit::component::button::{Button, ButtonVariants};
+use gpui_kit::component::input::{Input, InputState};
+use gpui_kit::component::notification::Notification;
+use gpui_kit::component::{WindowExt, h_flex, v_flex};
+use nip60::WalletRegistry;
+use nostr_sdk::prelude::{EventId, PublicKey};
+
+pub mod wallet;
+
+type SubmitFn = Rc bool>;
+
+pub fn open_nutzap(
+ window: &mut Window,
+ cx: &mut App,
+ recipient: PublicKey,
+ target: Option,
+) {
+ let input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
+
+ let send: SubmitFn = {
+ let input = input.clone();
+ Rc::new(move |_, window, cx| {
+ let amount = match input.read(cx).value().trim().parse::() {
+ Ok(amount) if amount > 0 => amount,
+ _ => {
+ window.push_notification(Notification::error("Enter an amount in sats"), cx);
+ return false;
+ }
+ };
+
+ let wallet = WalletRegistry::global(cx);
+ wallet.update(cx, |registry, cx| {
+ registry.send_nutzap(recipient, amount, target, None, cx);
+ });
+
+ true
+ })
+ };
+
+ window.open_dialog(cx, move |this, _window, _cx| {
+ this.width(px(380.))
+ .title("Send Nutzap")
+ .on_ok({
+ let send = send.clone();
+ move |event, window, cx| send(event, window, cx)
+ })
+ .child(
+ v_flex()
+ .gap_3()
+ .child(Input::new(&input).cleanable(true))
+ .child(
+ h_flex()
+ .gap_1()
+ .justify_end()
+ .child(
+ Button::new("cancel")
+ .label("Cancel")
+ .ghost()
+ .on_click(|_, window, cx| window.close_dialog(cx)),
+ )
+ .child(Button::new("send").label("Send").primary().on_click({
+ let send = send.clone();
+ move |event, window, cx| {
+ if send(event, window, cx) {
+ window.close_dialog(cx);
+ }
+ }
+ })),
+ ),
+ )
+ });
+}
diff --git a/crates/nip60_ui/src/wallet.rs b/crates/nip60_ui/src/wallet.rs
new file mode 100644
index 00000000..38d1a6b0
--- /dev/null
+++ b/crates/nip60_ui/src/wallet.rs
@@ -0,0 +1,787 @@
+use std::ops::Range;
+use std::rc::Rc;
+
+use ::nip60::{HistoryEntry, WalletEvent, WalletRegistry};
+use common::TimestampExt;
+use gpui::prelude::FluentBuilder;
+use gpui::{
+ AnyElement, App, AppContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable,
+ InteractiveElement as _, IntoElement, ParentElement, Render, SharedString,
+ StatefulInteractiveElement as _, Styled, Subscription, Task, Window, div, px, relative,
+ uniform_list,
+};
+use gpui_kit::assets::IconName;
+use gpui_kit::base::StyledExt as _;
+use gpui_kit::component::button::{Button, ButtonVariants as _};
+use gpui_kit::component::dock::{Panel, PanelEvent};
+use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
+use gpui_kit::component::notification::Notification;
+use gpui_kit::component::separator::Separator;
+use gpui_kit::component::{
+ ActiveTheme, Disableable as _, Icon, Sizable, WindowExt, h_flex, v_flex,
+};
+use nostr_sdk::prelude::*;
+use person::Directory;
+use settings::AppSettings;
+use smallvec::SmallVec;
+use state::NostrRegistry;
+use ui::Avatar;
+
+pub fn init(window: &mut Window, cx: &mut App) -> Entity {
+ cx.new(|cx| WalletPanel::new(window, cx))
+}
+
+type SubmitFn = Rc bool>;
+
+pub struct WalletPanel {
+ name: SharedString,
+ focus_handle: FocusHandle,
+ _subscriptions: SmallVec<[Subscription; 1]>,
+}
+
+impl WalletPanel {
+ fn new(window: &mut Window, cx: &mut Context) -> Self {
+ let wallet = WalletRegistry::global(cx);
+ let mut subscriptions = SmallVec::new();
+ subscriptions.push(cx.subscribe_in(
+ &wallet,
+ window,
+ |_this, _wallet, event, window, cx| match event {
+ WalletEvent::Updated => cx.notify(),
+ WalletEvent::Error(error) => {
+ window.push_notification(Notification::error(error.clone()), cx);
+ }
+ WalletEvent::TokenCreated(_) => {}
+ },
+ ));
+
+ Self {
+ name: "Wallet".into(),
+ focus_handle: cx.focus_handle(),
+ _subscriptions: subscriptions,
+ }
+ }
+
+ fn render_balance(&self, cx: &Context) -> impl IntoElement {
+ let balance = WalletRegistry::global(cx).read(cx).balance();
+
+ h_flex()
+ .justify_center()
+ .items_baseline()
+ .gap_2()
+ .child(
+ div()
+ .text_3xl()
+ .line_height(relative(1.0))
+ .child(SharedString::from(balance.to_string())),
+ )
+ .child(
+ div()
+ .italic()
+ .line_height(relative(1.0))
+ .text_color(cx.theme().muted_foreground)
+ .child("sats"),
+ )
+ }
+
+ fn render_actions(&self) -> impl IntoElement {
+ h_flex().w_full().justify_center().child(
+ h_flex()
+ .w_80()
+ .gap_2()
+ .child(
+ Button::new("send")
+ .label("Send")
+ .secondary()
+ .rounded_full()
+ .flex_1()
+ .on_click(|_, window, cx| open_send_dialog(window, cx)),
+ )
+ .child(
+ Button::new("receive")
+ .label("Receive")
+ .secondary()
+ .rounded_full()
+ .flex_1()
+ .on_click(|_, window, cx| open_receive_dialog(window, cx)),
+ ),
+ )
+ }
+
+ fn render_history(
+ &mut self,
+ history: Vec,
+ cx: &mut Context,
+ ) -> impl IntoElement {
+ if history.is_empty() {
+ return v_flex()
+ .items_center()
+ .justify_center()
+ .h_32()
+ .w_full()
+ .text_xs()
+ .text_color(cx.theme().muted_foreground)
+ .border_1()
+ .border_dashed()
+ .border_color(cx.theme().border)
+ .rounded(cx.theme().radius_lg)
+ .child("No transactions yet.")
+ .into_any_element();
+ }
+
+ uniform_list(
+ "wallet-history",
+ history.len(),
+ cx.processor(move |this, range, _window, cx| {
+ this.render_history_rows(range, &history, cx)
+ }),
+ )
+ .flex_1()
+ .min_h_0()
+ .into_any_element()
+ }
+
+ fn render_history_rows(
+ &self,
+ range: std::ops::Range,
+ history: &[HistoryEntry],
+ cx: &Context,
+ ) -> Vec {
+ range
+ .map(|ix| {
+ self.render_history_entry(&history[ix], cx)
+ .into_any_element()
+ })
+ .collect()
+ }
+
+ fn render_history_entry(&self, entry: &HistoryEntry, cx: &Context) -> impl IntoElement {
+ let (label, color) = match entry.direction {
+ TransactionDirection::In => ("Received", cx.theme().success),
+ TransactionDirection::Out => ("Sent", cx.theme().danger),
+ };
+
+ h_flex()
+ .justify_between()
+ .w_full()
+ .py_0p5()
+ .text_xs()
+ .child(
+ h_flex()
+ .gap_2()
+ .child(div().text_color(color).child(label))
+ .child(SharedString::from(format!("{} sats", entry.amount))),
+ )
+ .child(
+ div()
+ .text_color(cx.theme().muted_foreground)
+ .child(entry.created_at.to_human_time()),
+ )
+ }
+}
+
+/// Pick a contact and an amount, then send a NIP-61 nutzap
+fn open_send_dialog(window: &mut Window, cx: &mut App) {
+ let view = cx.new(|cx| SendDialog::new(window, cx));
+
+ window.open_dialog(cx, {
+ let view = view.clone();
+ move |this, _window, _cx| this.width(px(420.)).title("Send eCash").child(view.clone())
+ });
+}
+
+struct SendDialog {
+ /// Search input, filters contacts or accepts an npub / NIP-05 address
+ input: Entity,
+ amount_input: Entity,
+ /// Current user's contacts
+ contacts: Option>,
+ /// Person directory for contact names and avatars
+ directory: Entity,
+ /// The contact chosen to receive the nutzap
+ selected: Option,
+ /// Whether a NIP-05 address is being resolved
+ resolving: bool,
+ error: Option,
+ tasks: Vec>>,
+ _subscriptions: Vec,
+}
+
+impl SendDialog {
+ fn new(window: &mut Window, cx: &mut Context) -> Self {
+ let input = cx.new(|cx| {
+ InputState::new(window, cx).placeholder("Search or enter npub or user@address.me")
+ });
+ let amount_input = cx.new(|cx| {
+ InputState::new(window, cx)
+ .placeholder("Amount (sats)")
+ .step(1.)
+ .min(1.)
+ });
+
+ let subscriptions = vec![
+ cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
+ if let InputEvent::PressEnter { .. } = event {
+ this.submit(window, cx);
+ }
+ }),
+ cx.subscribe_in(&amount_input, window, |this, _input, event, window, cx| {
+ if let InputEvent::PressEnter { .. } = event {
+ this.send(window, cx);
+ }
+ }),
+ ];
+
+ // Load the contacts once the view is mounted
+ cx.defer_in(window, |this, window, cx| {
+ this.load_contacts(window, cx);
+ });
+
+ Self {
+ input,
+ amount_input,
+ contacts: None,
+ directory: Directory::new(cx),
+ selected: None,
+ resolving: false,
+ error: None,
+ tasks: Vec::new(),
+ _subscriptions: subscriptions,
+ }
+ }
+
+ /// Load the signed-in user's contact list.
+ fn load_contacts(&mut self, window: &mut Window, cx: &mut Context) {
+ if self.contacts.is_some() {
+ return;
+ }
+
+ let nostr = NostrRegistry::global(cx);
+ let client = nostr.read(cx).client();
+
+ let Some(current_user) = nostr.read(cx).current_user() else {
+ return;
+ };
+
+ let task: Task, anyhow::Error>> = cx.background_spawn(async move {
+ let filter = Filter::new()
+ .author(current_user)
+ .kind(Kind::ContactList)
+ .limit(1);
+
+ let contacts: Vec = client
+ .database()
+ .query(filter)
+ .await?
+ .into_iter()
+ .next()
+ .map(|event| event.tags.public_keys().collect())
+ .unwrap_or_default();
+
+ Ok(contacts)
+ });
+
+ self.tasks.push(cx.spawn_in(window, async move |this, cx| {
+ let contacts = task.await;
+
+ this.update_in(cx, |this, _window, cx| {
+ match contacts {
+ Ok(contacts) => {
+ this.directory.update(cx, |directory, cx| {
+ directory.track(contacts.iter().copied(), cx)
+ });
+ this.contacts = Some(contacts);
+ }
+ Err(error) => this.set_error(error.to_string(), cx),
+ }
+ cx.notify();
+ })?;
+
+ Ok(())
+ }));
+ }
+
+ /// The contacts matching the current input, or every contact when empty.
+ fn filtered_contacts(&self, cx: &Context) -> Vec {
+ let Some(contacts) = self.contacts.as_ref() else {
+ return Vec::new();
+ };
+
+ let query = self.input.read(cx).value().trim().to_lowercase();
+
+ if query.is_empty() {
+ return contacts.clone();
+ }
+
+ contacts
+ .iter()
+ .filter(|public_key| {
+ if public_key.to_hex().to_lowercase().contains(&query) {
+ return true;
+ }
+
+ let name = self.directory.read(cx).person(public_key, cx).name();
+ name.to_lowercase().contains(&query)
+ })
+ .cloned()
+ .collect()
+ }
+
+ /// Resolve the input into a contact selection.
+ fn submit(&mut self, window: &mut Window, cx: &mut Context) {
+ if self.resolving {
+ return;
+ }
+
+ let query = self.input.read(cx).value().trim().to_string();
+
+ if query.is_empty() {
+ return;
+ }
+
+ // A raw public key or an npub can be selected directly.
+ if let Ok(public_key) = PublicKey::parse(&query) {
+ self.select(public_key, cx);
+ self.clear_input(window, cx);
+ return;
+ }
+
+ // A NIP-05 address is resolved before being selected.
+ if let Ok(address) = Nip05Address::parse(&query) {
+ let nostr = NostrRegistry::global(cx);
+ let task = nostr.read(cx).query_address(address, cx);
+
+ self.set_resolving(true, window, cx);
+
+ self.tasks.push(cx.spawn_in(window, async move |this, cx| {
+ let result = task.await;
+
+ this.update_in(cx, |this, window, cx| {
+ this.set_resolving(false, window, cx);
+
+ match result {
+ Ok(public_key) => {
+ this.select(public_key, cx);
+ this.clear_input(window, cx);
+ }
+ Err(error) => this.set_error(error.to_string(), cx),
+ }
+ })?;
+
+ Ok(())
+ }));
+
+ return;
+ }
+
+ // Fall back to a local search by contact name.
+ let matches = self.filtered_contacts(cx);
+ match matches.len() {
+ 0 => self.set_error("Enter a valid npub or NIP-05 address", cx),
+ 1 => {
+ let public_key = matches[0];
+ self.select(public_key, cx);
+ self.clear_input(window, cx);
+ }
+ _ => self.set_error("Multiple contacts match, pick one from the list", cx),
+ }
+ }
+
+ /// Select a single recipient for the nutzap.
+ fn select(&mut self, public_key: PublicKey, cx: &mut Context) {
+ let nostr = NostrRegistry::global(cx);
+
+ if nostr.read(cx).current_user() == Some(public_key) {
+ self.set_error("You cannot send to yourself", cx);
+ return;
+ }
+
+ self.directory
+ .update(cx, |directory, cx| directory.track([public_key], cx));
+ self.selected = Some(public_key);
+ self.error = None;
+ cx.notify();
+ }
+
+ /// Toggle a contact's selection.
+ fn toggle_selection(&mut self, public_key: &PublicKey, cx: &mut Context) {
+ if self.selected == Some(*public_key) {
+ self.selected = None;
+ cx.notify();
+ } else {
+ self.select(*public_key, cx);
+ }
+ }
+
+ /// Validate the selection and amount, then schedule the nutzap.
+ fn send(&mut self, _window: &mut Window, cx: &mut Context) -> bool {
+ let Some(recipient) = self.selected else {
+ self.set_error("Select a contact to send to", cx);
+ return false;
+ };
+
+ let Some(amount) = parse_amount(&self.amount_input, cx) else {
+ self.set_error("Enter an amount in sats", cx);
+ return false;
+ };
+
+ WalletRegistry::global(cx).update(cx, |registry, cx| {
+ registry.send_nutzap(recipient, amount, None, None, cx);
+ });
+
+ true
+ }
+
+ fn clear_input(&mut self, window: &mut Window, cx: &mut Context) {
+ self.input.update(cx, |this, cx| {
+ this.set_value("", window, cx);
+ });
+ }
+
+ /// Toggle the input's loading spinner while a NIP-05 address is resolved.
+ fn set_resolving(&mut self, resolving: bool, window: &mut Window, cx: &mut Context) {
+ self.input.update(cx, |this, cx| {
+ this.set_loading(resolving, window, cx);
+ });
+ self.resolving = resolving;
+ cx.notify();
+ }
+
+ fn set_error(&mut self, message: impl Into, cx: &mut Context) {
+ self.error = Some(message.into());
+ cx.notify();
+ }
+
+ fn render_contacts(
+ &self,
+ range: Range,
+ contacts: &[PublicKey],
+ cx: &mut Context,
+ ) -> Vec {
+ contacts
+ .get(range)
+ .into_iter()
+ .flatten()
+ .map(|public_key| {
+ let profile = self.directory.read(cx).person(public_key, cx);
+ let public_key = *public_key;
+ let selected = self.selected == Some(public_key);
+ let id = SharedString::from(format!("contact-{}", public_key.to_hex()));
+
+ h_flex()
+ .id(id)
+ .w_full()
+ .h_8()
+ .px_2()
+ .gap_2()
+ .rounded(cx.theme().radius)
+ .text_sm()
+ .cursor_pointer()
+ .hover(|this| this.bg(cx.theme().list_hover))
+ .when(selected, |this| this.bg(cx.theme().list_active))
+ .when(!AppSettings::get_hide_avatar(cx), |this| {
+ this.child(
+ Avatar::new(profile.avatar())
+ .seed(profile.avatar_seed())
+ .small(),
+ )
+ })
+ .child(div().min_w_0().truncate().child(profile.name()))
+ .on_click(cx.listener(move |this, _event, _window, cx| {
+ this.toggle_selection(&public_key, cx);
+ }))
+ .into_any_element()
+ })
+ .collect()
+ }
+
+ fn render_selected(&self, cx: &mut Context) -> impl IntoElement {
+ let Some(public_key) = self.selected else {
+ return div().into_any_element();
+ };
+
+ let profile = self.directory.read(cx).person(&public_key, cx);
+ let id = SharedString::from(format!("selected-{}", public_key.to_hex()));
+
+ h_flex()
+ .gap_1()
+ .child(
+ h_flex()
+ .id(id)
+ .gap_1()
+ .p_0p5()
+ .rounded_full()
+ .bg(cx.theme().secondary)
+ .hover(|this| this.bg(cx.theme().primary))
+ .child(
+ Avatar::new(profile.avatar())
+ .seed(profile.avatar_seed())
+ .xsmall(),
+ )
+ .child(
+ div()
+ .max_w_32()
+ .truncate()
+ .text_xs()
+ .line_height(relative(1.0))
+ .child(profile.name()),
+ )
+ .on_click(cx.listener(move |this, _event, _window, cx| {
+ this.selected = None;
+ cx.notify();
+ })),
+ )
+ .into_any_element()
+ }
+}
+
+impl Render for SendDialog {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ const NOTICE: &str = "Recipients need a NIP-60 wallet on a mint you both use.";
+
+ let contacts = Rc::new(self.filtered_contacts(cx));
+ let contact_count = contacts.len();
+
+ v_flex()
+ .gap_3()
+ .child(NumberInput::new(&self.amount_input))
+ .child(
+ h_flex()
+ .gap_1()
+ .w_full()
+ .child(Input::new(&self.input).flex_1().cleanable(true))
+ .child(
+ Button::new("add")
+ .icon(IconName::Plus)
+ .tooltip("Add person")
+ .ghost()
+ .large()
+ .disabled(self.resolving)
+ .on_click(cx.listener(|this, _event, window, cx| {
+ this.submit(window, cx);
+ })),
+ ),
+ )
+ .when(self.selected.is_some(), |this| {
+ this.child(self.render_selected(cx))
+ })
+ .child(
+ uniform_list(
+ "send-contacts",
+ contact_count,
+ cx.processor(move |this, range, _window, cx| {
+ this.render_contacts(range, contacts.as_slice(), cx)
+ }),
+ )
+ .h_64(),
+ )
+ .map(|this| {
+ if let Some(error) = self.error.clone() {
+ this.child(div().text_xs().text_color(cx.theme().danger).child(error))
+ } else {
+ this.child(
+ div()
+ .text_xs()
+ .text_color(cx.theme().muted_foreground)
+ .child(NOTICE),
+ )
+ }
+ })
+ .child(
+ h_flex()
+ .gap_1()
+ .justify_end()
+ .child(
+ Button::new("cancel")
+ .label("Cancel")
+ .secondary()
+ .on_click(|_, window, cx| window.close_dialog(cx)),
+ )
+ .child(
+ Button::new("send")
+ .label("Send")
+ .primary()
+ .on_click(cx.listener(move |this, _event, window, cx| {
+ if this.send(window, cx) {
+ window.close_dialog(cx);
+ }
+ })),
+ ),
+ )
+ }
+}
+
+/// Create a lightning invoice or redeem a pasted cashu token
+fn open_receive_dialog(window: &mut Window, cx: &mut App) {
+ let amount_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
+ let token_input = cx.new(|cx| InputState::new(window, cx).placeholder("cashu…"));
+
+ let create_invoice: SubmitFn = {
+ let wallet = WalletRegistry::global(cx);
+ let input = amount_input.clone();
+ Rc::new(move |_, window, cx| {
+ let Some(amount) = parse_amount(&input, cx) else {
+ window.push_notification(Notification::error("Enter an amount in sats"), cx);
+ return false;
+ };
+
+ wallet.update(cx, |registry, cx| {
+ registry.create_mint_quote(amount, cx);
+ });
+
+ true
+ })
+ };
+
+ let redeem: SubmitFn = {
+ let wallet = WalletRegistry::global(cx);
+ let input = token_input.clone();
+ Rc::new(move |_, window, cx| {
+ let token = input.read(cx).value().trim().to_string();
+
+ if token.is_empty() {
+ window.push_notification(Notification::error("Paste a cashu token"), cx);
+ return false;
+ }
+
+ wallet.update(cx, |registry, cx| {
+ registry.receive_token(token, cx);
+ });
+
+ true
+ })
+ };
+
+ window.open_dialog(cx, move |this, _window, _cx| {
+ this.width(px(420.))
+ .title("Receive")
+ .on_ok({
+ let token_input = token_input.clone();
+ let create_invoice = create_invoice.clone();
+ let redeem = redeem.clone();
+ move |_event, window, cx| {
+ if !token_input.read(cx).value().trim().is_empty() {
+ redeem(_event, window, cx)
+ } else {
+ create_invoice(_event, window, cx)
+ }
+ }
+ })
+ .child(
+ v_flex()
+ .gap_3()
+ .child(
+ v_flex()
+ .gap_1p5()
+ .child(div().text_xs().font_semibold().child("Lightning invoice:"))
+ .child(
+ h_flex()
+ .gap_1()
+ .w_full()
+ .child(Input::new(&amount_input).cleanable(true))
+ .child(
+ Button::new("invoice")
+ .label("Invoice")
+ .secondary()
+ .w_24()
+ .on_click({
+ let create_invoice = create_invoice.clone();
+ move |event, window, cx| {
+ if create_invoice(event, window, cx) {
+ window.close_dialog(cx);
+ }
+ }
+ }),
+ ),
+ ),
+ )
+ .child(Separator::horizontal())
+ .child(
+ v_flex()
+ .gap_1p5()
+ .child(div().text_xs().font_semibold().child("Cashu token:"))
+ .child(
+ h_flex()
+ .gap_1()
+ .w_full()
+ .child(Input::new(&token_input).cleanable(true))
+ .child(
+ Button::new("redeem")
+ .label("Redeem")
+ .secondary()
+ .w_24()
+ .on_click({
+ let redeem = redeem.clone();
+ move |event, window, cx| {
+ if redeem(event, window, cx) {
+ window.close_dialog(cx);
+ }
+ }
+ }),
+ ),
+ ),
+ ),
+ )
+ });
+}
+
+fn parse_amount(input: &Entity, cx: &App) -> Option {
+ match input.read(cx).value().trim().parse::() {
+ Ok(amount) if amount > 0 => Some(amount),
+ _ => None,
+ }
+}
+
+impl gpui_kit::component::dock::BasePanel for WalletPanel {
+ fn panel_name(&self) -> &'static str {
+ "Wallet"
+ }
+}
+
+impl Panel for WalletPanel {
+ fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ h_flex()
+ .gap_1p5()
+ .child(
+ Icon::new(IconName::Wallet)
+ .small()
+ .text_color(cx.theme().muted_foreground),
+ )
+ .child(self.name.clone())
+ .into_any_element()
+ }
+}
+
+impl EventEmitter for WalletPanel {}
+
+impl Focusable for WalletPanel {
+ fn focus_handle(&self, _: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl Render for WalletPanel {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let history = WalletRegistry::global(cx).read(cx).history().to_vec();
+
+ v_flex()
+ .size_full()
+ .p_3()
+ .gap_4()
+ .text_xs()
+ .child(self.render_balance(cx))
+ .child(self.render_actions())
+ .child(
+ v_flex()
+ .gap_2()
+ .child(
+ div()
+ .text_xs()
+ .font_semibold()
+ .text_color(cx.theme().muted_foreground)
+ .child("History"),
+ )
+ .child(self.render_history(history, cx)),
+ )
+ }
+}
diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml
index 1972576c..088eef75 100644
--- a/crates/workspace/Cargo.toml
+++ b/crates/workspace/Cargo.toml
@@ -9,11 +9,11 @@ ui = { path = "../ui" }
gpui-kit.workspace = true
common = { path = "../common" }
state = { path = "../state" }
+nip29 = { path = "../nip29" }
+nip29_ui = { path = "../nip29_ui" }
nip4e = { path = "../nip4e" }
chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
-nip29 = { path = "../nip29" }
-nip29_ui = { path = "../nip29_ui" }
settings = { path = "../settings" }
speech = { path = "../speech" }
person = { path = "../person" }
@@ -30,3 +30,7 @@ serde.workspace = true
log.workspace = true
rand.workspace = true
smallvec.workspace = true
+
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+nip60 = { path = "../nip60" }
+nip60_ui = { path = "../nip60_ui" }
diff --git a/crates/workspace/src/dialogs/join_group.rs b/crates/workspace/src/dialogs/join_group.rs
index 8ff715f5..e96107e7 100644
--- a/crates/workspace/src/dialogs/join_group.rs
+++ b/crates/workspace/src/dialogs/join_group.rs
@@ -1,11 +1,11 @@
+use std::rc::Rc;
+
+use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px};
use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{WindowExt, h_flex, v_flex};
-use std::rc::Rc;
-
-use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px};
use nip29::{GroupReference, GroupsRegistry};
type SubmitFn = Rc bool>;
diff --git a/crates/workspace/src/dialogs/settings.rs b/crates/workspace/src/dialogs/settings.rs
index 4800a987..eb3adb5f 100644
--- a/crates/workspace/src/dialogs/settings.rs
+++ b/crates/workspace/src/dialogs/settings.rs
@@ -41,6 +41,8 @@ pub struct Preferences {
radius_input: Entity,
radius_lg_input: Entity,
#[cfg(not(target_arch = "wasm32"))]
+ mint_input: Entity,
+ #[cfg(not(target_arch = "wasm32"))]
_speech_poll: Task<()>,
/// Keeps the control subscriptions alive while the dialog is open
_subscriptions: Vec,
@@ -90,6 +92,9 @@ impl Preferences {
.max(RADIUS_RANGE.1 as f64)
});
+ #[cfg(not(target_arch = "wasm32"))]
+ let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint…"));
+
let subscriptions = vec![
Self::wire_number_input(&font_size_input, FONT_SIZE_RANGE, cx, |value, cx| {
if let Some(font_size) = value {
@@ -140,6 +145,8 @@ impl Preferences {
radius_input,
radius_lg_input,
#[cfg(not(target_arch = "wasm32"))]
+ mint_input,
+ #[cfg(not(target_arch = "wasm32"))]
_speech_poll,
_subscriptions: subscriptions,
}
@@ -185,6 +192,56 @@ impl Preferences {
}
}
+ /// Create the NIP-60 wallet or add a mint to it
+ #[cfg(not(target_arch = "wasm32"))]
+ fn setup_mint(&mut self, window: &mut Window, cx: &mut Context) {
+ let mint = self.mint_input.read(cx).value().trim().to_string();
+ if mint.is_empty() {
+ window.push_notification(Notification::error("Enter a mint URL"), cx);
+ return;
+ }
+
+ nip60::WalletRegistry::global(cx).update(cx, |registry, cx| {
+ registry.setup(mint, cx);
+ });
+ }
+
+ /// The NIP-60 wallet block; absent from the web build
+ fn render_wallet(
+ &mut self,
+ #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] cx: &mut Context,
+ ) -> AnyElement {
+ #[cfg(target_arch = "wasm32")]
+ {
+ return div().into_any_element();
+ }
+
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ const WALLET: &str = "Cashu mint for your NIP-60 wallet. Set up creates the wallet, Add extends it with another mint.";
+ let has_wallet = nip60::WalletRegistry::global(cx).read(cx).has_wallet();
+
+ settings_block(
+ "Wallet",
+ WALLET,
+ h_flex()
+ .gap_1()
+ .child(div().flex_1().child(Input::new(&self.mint_input)))
+ .child(
+ Button::new("setup-mint")
+ .label(if has_wallet { "Add" } else { "Set up" })
+ .ghost()
+ .size_8()
+ .on_click(cx.listener(move |this, _ev, window, cx| {
+ this.setup_mint(window, cx);
+ })),
+ ),
+ cx,
+ )
+ .into_any_element()
+ }
+ }
+
/// Set the appearance mode (system, light or dark)
fn set_appearance_mode(mode: AppearanceMode, cx: &mut App) {
AppSettings::update_appearance_mode(mode, cx);
@@ -546,5 +603,7 @@ impl Render for Preferences {
),
cx,
))
+ .child(Separator::horizontal())
+ .child(self.render_wallet(cx))
}
}
diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs
index 3a80a0c9..cc9a72b1 100644
--- a/crates/workspace/src/lib.rs
+++ b/crates/workspace/src/lib.rs
@@ -61,6 +61,7 @@ enum Command {
ShowContactList,
ShowInbox,
ShowBrowse,
+ ShowWallet,
NewChat,
NewGroup,
JoinGroup,
@@ -196,6 +197,30 @@ impl Workspace {
}),
);
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let wallet = ::nip60::WalletRegistry::global(cx);
+
+ subscriptions.push(cx.subscribe_in(
+ &wallet,
+ window,
+ |_this, _wallet, event, window, cx| match event {
+ ::nip60::WalletEvent::Error(error) => {
+ window.push_notification(Notification::error(error.clone()), cx);
+ }
+ ::nip60::WalletEvent::TokenCreated(_) => {
+ window.push_notification(
+ Notification::new()
+ .message("Cashu token created, copy it from the wallet panel")
+ .with_type(NotificationType::Success),
+ cx,
+ );
+ }
+ ::nip60::WalletEvent::Updated => {}
+ },
+ ));
+ }
+
App::on_action(cx, {
let workspace = workspace.clone();
move |action, cx| {
@@ -307,6 +332,13 @@ impl Workspace {
let panel = browse::init(self.dock.downgrade(), window, cx);
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
}
+ Command::ShowWallet => {
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let panel = nip60_ui::wallet::init(window, cx);
+ self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
+ }
+ }
Command::NewChat => {
new_chat::open(self.dock.downgrade(), window, cx);
}
diff --git a/crates/workspace/src/sidebar/list.rs b/crates/workspace/src/sidebar/list.rs
index 094e03b5..ec846d49 100644
--- a/crates/workspace/src/sidebar/list.rs
+++ b/crates/workspace/src/sidebar/list.rs
@@ -39,7 +39,6 @@ impl Sidebar {
let requests_open = self.requests_open;
let contacts_open = self.contacts_open && !requests_open;
let rows = Rc::new(self.rows_for(cx));
-
// Whether to show the create button for the contact picker
let create_button = contacts_open && !self.selected_contacts.is_empty();
@@ -60,12 +59,25 @@ impl Sidebar {
.flex_1()
.min_h_0()
.relative()
+ .border_l_1()
+ .border_color(cx.theme().sidebar_border)
.child(
v_flex()
.px_2()
.gap_1()
.items_start()
.justify_start()
+ .when(cfg!(not(target_arch = "wasm32")), |this| {
+ this.child(
+ Nav::new("wallet")
+ .prefix(Icon::new(gpui_kit::assets::IconName::Wallet).small())
+ .label("Wallet")
+ .text_sm()
+ .on_click(|_, window, cx| {
+ window.dispatch_action(Box::new(crate::Command::ShowWallet), cx)
+ }),
+ )
+ })
.child(
Nav::new("browse")
.prefix(Icon::new(IconName::Compass).small())
diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml
index e2bd9ebf..6c710c20 100644
--- a/desktop/Cargo.toml
+++ b/desktop/Cargo.toml
@@ -37,6 +37,7 @@ workspace = { path = "../crates/workspace" }
ui = { path = "../crates/ui" }
state = { path = "../crates/state" }
nip4e = { path = "../crates/nip4e" }
+nip60 = { path = "../crates/nip60" }
chat = { path = "../crates/chat" }
chat_ui = { path = "../crates/chat_ui" }
nip29 = { path = "../crates/nip29" }
diff --git a/desktop/src/main.rs b/desktop/src/main.rs
index f1e34dcd..5a2e3665 100644
--- a/desktop/src/main.rs
+++ b/desktop/src/main.rs
@@ -91,6 +91,11 @@ fn main() {
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
nip4e::init(cx);
+ // Initialize cashu wallet registry
+ //
+ // NIP-60: https://github.com/nostr-protocol/nips/blob/master/60.md
+ nip60::init(cx);
+
// Initialize app registry
chat::init(cx);
diff --git a/docs/nip60.md b/docs/nip60.md
new file mode 100644
index 00000000..5665305c
--- /dev/null
+++ b/docs/nip60.md
@@ -0,0 +1,387 @@
+# NIP-60: cashu wallet — implementation plan
+
+Spec: https://github.com/nostr-protocol/nips/blob/master/60.md
+CDK: https://github.com/cashubtc/cdk (crates `cdk`, `cdk-redb`, from git main)
+NIP-61: https://github.com/nostr-protocol/nips/blob/master/61.md
+
+NIP-60 stores a user's cashu wallet state on relays: the wallet config
+(kind `17375`), its unspent proofs (kind `7375`), and an optional spending
+history (kind `7376`). All content is NIP-44 encrypted to self. This plan
+implements it inside coop using the CDK for all cashu/mint operations and
+the app's existing nostr client/signer for events.
+
+## Analysis
+
+| Kind | Replaceable | Content (NIP-44 to self) | Purpose |
+| ------ | ----------- | ----------------------------------------------- | -------------------------------- |
+| 17375 | yes `[[privkey, hex], [mint, url], ...]` | wallet config: P2PK key + mints |
+| 7375 | no `{mint, unit, proofs[], del[]}` | unspent proofs (per mint) |
+| 7376 | no `[[direction], [amount], [unit], [e, ...]]` | spend/receive history (optional) |
+| 7374 | no `quote-id` + `mint`/`expiration` tags | pending mint quote (optional) |
+| 10019 | yes empty, `relay` tags | wallet relay list |
+
+Key mechanics:
+
+- Wallet state transitions are rollover: spending part of a token event
+ publishes a new `7375` with the unspent proofs + `del: [old-event-id]`,
+ then NIP-09 deletes the old event (the delete MUST carry `["k", "7375"]`).
+- The `privkey` in `17375` is a wallet-only key (never the nostr identity
+ key). It exists to unlock P2PK-locked ecash, i.e. redeeming NIP-61
+ nutzaps.
+- NIP-60 itself does not move money to/from others. Receiving ecash from
+ peers is NIP-61 (nutzaps): public `kind:9321` events published by the
+ sender, p-tagging the recipient's nostr identity pubkey, carrying
+ P2PK-locked proofs (`proof` tags) on a trusted mint (`u` tag). Redemption
+ is `receive_proofs` with the wallet signing key + a `7376` whose
+ unencrypted tags mark the nutzap `redeemed` and carry the sender pubkey.
+- NIP-61 discovery runs through a `kind:10019` informational event
+ (relays, mints, wallet pubkey) published alongside the wallet event.
+- History (7376) and quote events (7374) are optional; the spec recommends
+ local state for quotes. We publish 7376, skip 7374 (cdk keeps quotes in
+ its local DB).
+
+## How it fits coop
+
+Follows the existing crate patterns exactly; no new architecture.
+
+- One protocol crate: `crates/nip60`, mirroring `nip4e`/`nip29` — a
+ registry entity set as GPUI global by `init(cx)`, `EventEmitter` for
+ UI updates, `SmallVec` + `SmallVec` fields, flume
+ signal channel for background results, reset/reload on
+ `NostrRegistry::signer_changed` (see `crates/nip29/src/lib.rs`).
+- Desktop-only: crate-level `#![cfg(not(target_arch = "wasm32"))]` like
+ `crates/auto_update/src/lib.rs`. Wallet UI is only wired on desktop.
+- Tokio bridge: all cdk calls run through `gpui_tokio::Tokio::spawn(cx, ...)`
+ (runtime is already initialized by `state::init`). The `cdk-nostr`
+ inbox listener also runs on that runtime.
+- Signer-agnostic encryption: the signer may be a bunker/extension, so we
+ never use `nostr::nips::nip60` prepare helpers (they need an owned
+ `SecretKey`).
+ We build payloads as plain JSON, encrypt with
+ `signer.nip44_encrypt(self_pubkey, json)` and sign with
+ `EventBuilder::new(kind, content).finalize_async(signer)` — the same
+ pattern as `crates/nip4e/src/storage.rs`. Parsing is decrypt-then-parse:
+ `TokenEvent` derives `Deserialize`; `17375` content is a trivial
+ `Vec>` parse.
+- Existing facts we build on:
+ - rust-nostr (git, already a dependency) ships `nostr::nip60` with the
+ event data types (`WalletEvent`, `TokenEvent`, `SpendingHistory`,
+ `QuoteEvent`, `CashuProof`) and kinds
+ (`Kind::CashuWallet` = 17375, `CashuWalletUnspentProof` = 7375,
+ `CashuWalletSpendingHistory` = 7376, `CashuWalletQuote` = 7374).
+ It is behind the `nip60` feature, which coop does not enable yet.
+ - `state::NostrRegistry` gives the `Client` (LMDB cache + bootstrap
+ relays + gossip) and the `UniversalSigner`.
+ - Local file storage precedent: `NostrLmdb::open(config_dir().join("nostr"))`
+ in `crates/state/src/lib.rs`; cdk's sqlite store goes to
+ `config_dir().join("cashu")`.
+
+## Dependencies
+
+Workspace `Cargo.toml` (final state after the phase 0 spike):
+
+```toml
+nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip60", "nip98" ] }
+
+# Cashu (NIP-60). From git: the published 0.18.x line targets nostr 0.44 and
+# cannot unify with the git rust-nostr used here, while main targets 0.45.x
+cdk = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
+cdk-redb = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
+cdk-nostr = { git = "https://github.com/cashubtc/cdk", default-features = false }
+
+[patch.crates-io]
+nostr = { git = "https://github.com/rust-nostr/nostr" }
+nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
+```
+
+Spike findings (all resolved and verified with `cargo check`):
+
+- The cdk family hard-pins `cdk-common` with `=` per release, so all crates
+ must come from one release train. crates.io 0.18.3 pins nostr 0.44 and
+ cannot coexist with coop's git nostr 0.45.x; the git main line pins
+ nostr ^0.45.3, which the `[patch.crates-io]` block redirects onto the
+ same git rust-nostr checkout. The lockfile ends with exactly one `nostr`
+ and one `nostr-sdk`, proven by compile-time type-identity checks between
+ `nostr` and `cdk_nostr::nostr`.
+- Storage is `cdk-redb` (`WalletRedbDatabase::new(&Path)`, pure Rust), not
+ `cdk-sqlite`: published cdk-sqlite pins rusqlite ^0.31, whose
+ `libsqlite3-sys` clashes at the `links` level with the `nostr-sqlite`
+ wasm dependency of the `state` crate.
+- rust-nostr 0.45.x has no root re-exports: the module is
+ `nostr::nips::nip60`, and cdk-nostr re-exports the SDK as
+ `cdk_nostr::nostr` / `cdk_nostr::nostr_sdk`.
+- The `nip60` crate exists (`crates/nip60`) with a compile-only spike in
+ `lib.rs` and a no-op `init(cx)` wired into `desktop/src/main.rs`; both
+ are replaced in phase 1.
+
+## Design
+
+### Key material
+
+- On first `setup(mint_url)` (explicit user action, no auto-creation):
+ generate `SecretKey::generate()` as the wallet key, publish `17375`
+ containing it + the mint list (encrypted to self, signed by the user).
+- The cdk seed is derived deterministically from the wallet key
+ (`HKDF-SHA256(wallet_privkey, info = "coop cashu seed")` -> `[u8; 64]`).
+ No extra local storage, and a fresh device recovers the full wallet from
+ the relay state alone. Tradeoff: the derivation is coop-specific (NIP-60
+ does not standardize seed derivation), so it does not restore into other
+ apps — acceptable, see open questions.
+- Proof-level secrets/counters live in cdk's sqlite store; NUT-13 restore
+ from mints works because the seed is deterministic.
+
+### Local state
+
+- cdk redb store at `config_dir().join("cashu")` (one `WalletDatabase`
+ shared by all mint wallets).
+- `WalletRegistry` holds: parsed `17375` (`WalletEvent` + event id), the
+ active `7375` events per mint (event id + `TokenEvent`), cdk `Wallet`
+ per mint (`CurrencyUnit::Sat` only in v1), and the subscription handle.
+
+### Relay sync
+
+- One subscription: `authors: [me], kinds: [17375, 7375, 7376]` plus
+ kind `5` with `k: 7375`, on the existing client (bootstrap relays).
+ Notifications flow through the nip29-style flume channel.
+- Published relays: the `10019` nutzap info event advertises the user's
+ NIP-65 relay list (queried from the local database), never the bootstrap
+ relays; subscriptions and publishes ride the SDK's NIP-65 handling.
+ Reading `10019` from other clients is skipped in v1 (coop always uses
+ its own relay set).
+
+### Operations
+
+Each op runs on tokio and ends with the same `sync` tail:
+read unspent proofs from cdk -> publish one `7375` per changed mint
+(with `del` set to the previous event ids) -> NIP-09 delete old events with
+a `k:7375` tag -> publish `7376` history -> update registry state and emit.
+
+- `setup(mint_url)`: create wallet key + seed, publish `17375`, build cdk
+ `Wallet`, open subscription.
+- `create_mint_quote(amount)`: a job in the queue — `wallet.mint_quote` ->
+ invoice recorded in `mint_quotes()` for the UI; poll /
+ `claim_mint_quote(id)`, then `wallet.mint(quote_id)` -> sync.
+ On load, `mint_unissued_quotes` claims quotes paid elsewhere.
+- `melt(invoice)`: `wallet.melt` -> sync (change proofs roll over).
+- `send_token(amount)`: `wallet.send` -> return token string for the user
+ to share -> sync. (No in-chat token delivery in v1.)
+- `receive_token(token)`: `wallet.receive` -> sync.
+- Redeem nutzap (NIP-61): verify the proofs are P2PK-locked to
+ the wallet pubkey, then `wallet.receive_proofs(proofs, ReceiveOptions {
+ p2pk_signing_keys: [wallet_key] })` -> `7376` with unencrypted
+ `e "" redeemed` tag -> sync.
+- Remote `7375` from another device: import its unspent proofs into the
+ cdk store as the new local truth (cdk proof insertion + state check via
+ `check_proofs_spent`); superseded `7375`s reconcile through `del`/NIP-09.
+- On every reconcile: `recover_incomplete_sagas` and
+ `mint_unissued_quotes` (required by cdk to clear interrupted operations),
+ then converge the unspent set from `7375` minus deletes.
+
+### Crate layout (3 files, kept small on purpose)
+
+- `lib.rs` — `init`, `WalletRegistry`, `WalletEvent` (`Updated`, `Error`),
+ subscription/notification handling, `reset` on signer change.
+- `keys.rs` — wallet key/seed derivation, `17375` build/parse via signer,
+ `7375`/deletion/`7376` event builders, the `History` payload.
+- `ops.rs` — cdk operations + the shared `sync` tail (rollover, deletes,
+ history), NIP-61 redemption.
+
+Wiring: `nip60::init(cx)` in `desktop/src/main.rs` after `nip4e::init(cx)`;
+`nip60 = { path = "../crates/nip60" }` added to `desktop/Cargo.toml` (and
+the UI crate when Phase 3 lands).
+
+## Phases
+
+### Phase 0 — dependency spike (done)
+
+Outcome recorded in the Dependencies section: cdk family on git main,
+`[patch.crates-io]` unification, cdk-redb storage, `nip60` crate skeleton
+wired into desktop. `cargo check -p nip60` and the full desktop check pass.
+
+The remaining spike code in `crates/nip60/src/lib.rs` is deleted in
+phase 1.
+
+### Phase 1 — bootstrap + load + balance (done)
+
+Implemented in `crates/nip60` (`lib.rs`, `keys.rs`):
+
+- `WalletRegistry` follows the nip29/nip4e pattern: global entity, signer
+ reset, flume notification listener, task + subscription SmallVecs.
+- `keys.rs`: wallet key generation, HKDF seed derivation, `17375`
+ encrypt/parse through the signer (works with bunkers), unit tests.
+- Load: subscribe + query cache for 17375/7375/7376 and kind 5 `k:7375`,
+ decrypt via signer, prune deleted (`k:7375` deletes) and superseded
+ (`del` chains) token events, expose `has_wallet()`/`balance()`.
+- cdk: redb store per wallet pubkey under `config_dir()/cashu`, one `Wallet`
+ per mint (`sat`), unspent proofs imported/diffed against the store on
+ every sync, `recover_incomplete_sagas` + `mint_unissued_quotes` fire on
+ first store open.
+- `setup(mint)` creates the wallet (first mint) or adds a mint, publishing
+ `17375` and saving it to the local cache.
+
+Deferred to later phases: token rollover publishing (phase 2), remote
+proof validation at the mint (phase 2 reconciliation), replacing proofs
+reserved by local in-flight operations (phase 2 guard).
+
+### Phase 2 — moving money (done)
+
+Implemented in `crates/nip60/src/ops.rs` plus registry wiring:
+
+- All store mutations go through a serialized job queue
+ (`schedule_job`): one job at a time on the background executor,
+ snapshots taken at schedule time, one queued job slot. This keeps ops
+ and relay-driven imports from interleaving on the store, which a loose
+ sync tail would not guarantee.
+- Jobs: `Reconcile` (claim paid quotes via `mint_unissued_quotes`, then
+ converge each mint — publish the store's unspent set when it diverges
+ from the relay view, and import relay-only proofs into the store),
+ `ClaimQuote`, `Melt`, `SendToken`, `Receive`.
+- The sync tail (`sync_token_events`) publishes a fresh `7375` with the
+ store's unspent proofs, NIP-09-deletes the previous token events with
+ the required `k: 7375` tag, and publishes a `7376` history event with
+ `created`/`destroyed` markers.
+- Registry API: `create_mint_quote(amount)` (invoice stored in
+ `mint_quotes()`, UI polls `claim_mint_quote(id)`), `melt(invoice)`,
+ `send_token(amount)` (token delivered via `WalletEvent::TokenCreated`),
+ `receive_token(token)` (mint resolved from the token).
+- Claim-on-load moved from a fire-and-forget task into `Reconcile`:
+ the fire-and-forget variant raced the import pass, which could wipe
+ freshly claimed proofs from the store before they were published.
+- History amounts: mint/receive record what was received; melt records
+ invoice amount + fee (`FinalizedMelt::total_amount`); send records the
+ sent amount.
+
+Deferred: NUT-20/quote events (`7374`, per spec local state is fine),
+sending tokens in-chat (v1 copies the token string).
+
+### Phase 3 — NIP-61 redemption (done)
+
+Spec note: NIP-61 changed since this plan was first written — nutzaps are
+now public `kind:9321` events p-tagging the recipient's nostr identity
+pubkey (the old draft's `kind:938` + gift-wrap shape is gone). So
+`cdk_nostr::NostrInbox` (a NIP-17 gift-wrap listener) is not the right
+tool; the dependency was dropped and reception rides the app's existing
+nostr client.
+
+Implemented:
+
+- `setup` also publishes a `kind:10019` nutzap info event (bootstrap
+ relays, wallet mints, wallet pubkey) so other clients can nutzap us.
+- A second subscription (`kinds: [9321], #p: me`) feeds nutzaps through
+ the same notification listener; the cache load scans stored `9321`s
+ too, so unredeemed nutzaps survive restarts.
+- `absorb_nutzap` parses `proof`/`u` tags, resolves the mint against the
+ open wallets, verifies every proof is P2PK-locked to the wallet pubkey
+ (NUT-10 spending conditions), and enqueues `Job::RedeemNutzap` through
+ the serialized job queue.
+- The job redeems via `wallet.receive_proofs` with the wallet key in
+ `p2pk_signing_keys`, then syncs with a `7376` history event whose
+ unencrypted tags mark the nutzap `redeemed` and tag the sender.
+- Already-redeemed nutzaps (from cached `7376` tags) and ones with a job
+ in flight are skipped; failed redemptions are retried on relay echo.
+
+### Audit (post phase 3) — done
+
+Backend review before Phase 4: keep it simple, no premature edge-case
+handling, struct/impl blocks over standalone functions. Applied:
+
+- Code shape: `keys::WalletData` carries generate/derive_seed/decrypt/
+ to_event as an impl block; `ops::Job::run` and the `JobCtx` helpers
+ (`wallet`, `previous_ids`, `sync_token_events`, `reconcile`) are impl
+ methods; `LoadedState::load`, `WalletRegistry::open_store` and the
+ filters are associated functions; nutzap tag parsing moved into a
+ `Nutzap` struct (dropped the speculative `mint` tag fallback for the
+ current `u` tag). `History` moved to `keys.rs` so `build_history_event`
+ takes one struct instead of eight arguments.
+- Spec gap: the sync tail never set `TokenEvent.del` — new `7375`s now
+ carry the previous event ids, so superseded tokens stay pruned even if
+ a NIP-09 delete is dropped by a relay.
+- Bug: `start_job` errored "Wallet is not ready" for the auto `Reconcile`
+ scheduled before the redb store finished opening. Jobs scheduled while
+ the store is opening now queue and run once `ensure_wallets` completes;
+ `apply_loaded` no longer schedules a redundant `Reconcile`.
+- Bug: a late-landing stale wallet decrypt could overwrite a newer
+ `17375`; `absorb_wallet` now re-checks `created_at` when applying.
+- Consistency: an unreadable live token event logs a warning instead of
+ surfacing an error event (same as the load path); nutzaps arriving with
+ no wallet set up are dropped (a fresh setup would generate a different
+ key, so they were unredeemable anyway).
+- Invariant: quote creation moved into the serialized job queue
+ (`Job::CreateQuote` / `Outcome::QuoteCreated`) — `mint_quote` writes to
+ the cdk store, so it must not interleave with running jobs. This also
+ collapsed the mint-resolution boilerplate shared by quote/melt/send
+ into `primary_mint`, and `MintQuoteEntry.mint` is now a `cdk::MintUrl`.
+- Reconcile now calls `recover_incomplete_sagas` (the plan promised it;
+ it was missing), before `mint_unissued_quotes`. It is best-effort: the
+ cdk-redb fresh-store init does not create the `wallet_sagas` table
+ (upstream bug), so the first recovery attempts fail with "table does
+ not exist" until a saga is written — logged, never fatal.
+
+Not changed, deliberately: per-outcome `Outcome` variants, the bounded
+notification dedup set, the single queued Reconcile collapse, and the
+remaining small conversion helpers in `ops.rs` (`cashu_proof`,
+`proof_info`, `nostr_mint`) — they are simple, and further abstraction
+would be over-engineering.
+
+### Phase 4 — UI (done)
+
+Implemented in a new `crates/nip60_ui` (crate-level wasm gate, so it
+compiles to nothing on web; consumers list it under
+`[target.'cfg(not(target_arch = "wasm32"))'.dependencies]`):
+
+- Backend additions: `WalletRegistry::history()` (`HistoryEntry` list,
+ newest first, from live + cached `7376`s), and `send_nutzap(recipient,
+ amount, target, comment)` — `Job::SendNutzap` fetches the recipient's
+ `10019` from the local database (falling back to `stream_events`),
+ picks the first shared mint, P2PK-locks a `prepare_send` to their
+ wallet pubkey, publishes a public `9321` (`p`/`u`/`unit`/`proof` tags,
+ optional `e`), then runs the usual sync tail with an out-history.
+- `wallet.rs` — `WalletPanel` in the left dock: balance, setup/add-mint,
+ send-token (token shown for copy), receive (invoice per quote with
+ copy + claim, paste-token redeem), history list. Subscribes to
+ `WalletEvent`; errors surface as notifications here and, via a
+ workspace-level subscription, even when the panel is closed.
+- `lib.rs` — `open_nutzap` dialog (amount prompt) reused by chat.
+- Sidebar: "Wallet" row under Browse (gated off on wasm), dispatching
+ `Command::ShowWallet` -> dock the panel.
+- `chat_ui`: a coins button in the composer opens the nutzap dialog for
+ the room's peer; group rooms are declined with a warning (v1 is
+ direct-chat only).
+- `nip29_ui`: "Send Nutzap" context-menu item on messages, nutzapping
+ the author with an `e` tag on the message.
+
+Not rendered yet: nutzaps/tokens do not appear in the chat timeline; the
+wallet panel history is the only transaction view.
+
+## Tests
+
+- `keys.rs` round-trips: encrypt/decrypt `17375` payloads against the
+ rust-nostr reference types (mirrors `nostr::nip60` unit tests).
+- Rollover logic: given prior token events + a new unspent set, assert the
+ produced `7375`/delete/`7376` trio (pure functions, no network).
+- Integration (optional, behind `#[ignore]`): testnut mint mint -> melt
+ cycle against cdk's memory store.
+
+## Open questions / tradeoffs
+
+- Seed derivation is coop-specific; cross-app portability of proof
+ restores is unsolved by NIP-60 generally. Revisit if a derivation
+ convention lands in the NIP or cdk.
+- v1 is single-unit (`sat`) and effectively single-mint in the UI, even
+ though the event format and registry support many mints.
+- Concurrent spends from two devices double-spend; recovery is the spec's
+ Appendix-1 validation (check proofs at the mint, roll over survivors) —
+ implemented as reconciliation on sync, not real-time locking.
+- Import replaces the unspent proof set wholesale; the serialized job
+ queue closes the in-flight-operation window, and mint-side double-spend
+ rejection remains the backstop.
+- Tracking cdk via git main is a moving target; the lockfile pins a rev
+ and must be bumped deliberately (same policy as the rust-nostr git dep).
+ Watch for the next cdk release line built against nostr 0.45.x — moving
+ back to crates.io then removes the moving-target concern.
+- Web target gets no wallet in v1: cdk store backends (redb, sqlite) need
+ a real filesystem and do not build on wasm32; the crate compiles to
+ nothing there (auto_update pattern). A wasm store would need a cdk
+ `WalletDatabase` over IndexedDB — explicitly out of scope.
+- `7374` quote events are skipped (local cdk state per the spec); pending
+ quotes are not visible to other devices until claimed.