From 73962d351f5ca315cb24575c9b49ce6f50d91d46 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 5 Nov 2026 19:55:20 +0700 Subject: [PATCH 01/10] add plan --- docs/nip60.md | 236 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 docs/nip60.md diff --git a/docs/nip60.md b/docs/nip60.md new file mode 100644 index 00000000..a6808eab --- /dev/null +++ b/docs/nip60.md @@ -0,0 +1,236 @@ +# 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-sqlite`, `cdk-nostr` 0.18.x) + +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): gift-wrapped token events P2PK-locked to the + wallet pubkey. Redemption is `receive` + a `7376` with an `e` tag + marked `redeemed` (this one tag stays unencrypted). +- 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::nip60::*::prepare` (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`: + +```toml +nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip60", "nip98" ] } + +[patch.crates-io] +# cdk publishes against crates.io nostr 0.45.x; coop uses the git source. +# The patch unifies both on one copy of the crate so types cross crate +# boundaries (cdk-nostr inbox events, keys) without conversion. +nostr = { git = "https://github.com/rust-nostr/nostr" } +nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } +``` + +`crates/nip60/Cargo.toml` (all under `cfg(not(wasm32))`): + +```toml +cdk = { version = "0.18", default-features = false, features = ["wallet"] } +cdk-sqlite = { version = "0.18", default-features = false, features = ["wallet"] } +cdk-nostr = { version = "0.18", default-features = false } # inbox, for NIP-61 +``` + +Version-unification is the one risky step; Phase 0 is a spike that proves +`cdk 0.18.x` + git rust-nostr build together. Fallback if the patch fights +back: convert only at the cdk-nostr inbox boundary (hex/serde) and skip the +patch for `nostr-sdk`. + +## 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 sqlite 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 same client/relays coop already uses. Publishing a + `10019` wallet relay list with those relays is a one-liner for interop; + 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 +(rolling up previous event ids into `del`) -> 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)`: `wallet.mint_quote` -> return invoice to UI; + poll / subscribe quote state, 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 gift-wrapped 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 load: `recover_incomplete_sagas` and `mint_unissued_quotes` + (required by cdk to clear interrupted operations), then re-derive 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. +- `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 (small, throwaway) + +1. Enable `nip60` on the `nostr` workspace dep; confirm + `nostr::nip60` types and the four kinds compile. +2. Add the `[patch.crates-io]` block and a scratch dependency on + `cdk`/`cdk-sqlite`/`cdk-nostr`; `cargo check` desktop. Resolve the + nostr unification here before writing any code. + +### Phase 1 — bootstrap + load + balance + +1. `crates/nip60` skeleton: registry, `init`, signer-changed reset. +2. `keys.rs`: wallet key generation, seed derivation, `17375` parse/build. +3. Load path: query cached + subscribed events, build the unspent set, + open cdk wallets, import proofs, expose `balance()` and emit `Updated`. +4. `setup(mint_url)` + mint list update (republish `17375`). + +### Phase 2 — moving money + +1. `ops.rs` sync tail: rollover `7375`, NIP-09 deletes (`k` tag), `7376` + history. +2. `create_mint_quote` (+ claim-on-load), `melt`, `send_token`, + `receive_token`, each with error propagation to the registry event. + +### Phase 3 — NIP-61 redemption (cdk-nostr) + +1. `cdk_nostr::NostrInbox` on the wallet key + relays, started in + `Tokio::spawn`, stopped on reset; rumors parsed into proofs + mint. +2. Redemption path with `p2pk_signing_keys`, `redeemed` history tag. + +### Phase 4 — UI + +- Settings entry for the mint URL (`settings` crate pattern). +- Minimal wallet surface in the workspace (balance, receive invoice, + paste-token receive, send-token, history list) subscribing to + `WalletEvent`. Follows `nip29_ui`'s split if it grows beyond a panel. + +## 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. +- Web target gets no wallet in v1: `cdk-sqlite` (rusqlite bundled) does + 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. -- 2.54.0 From c5dc0ca24241fcfff93c7fa16b6326b2f1c67758 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 6 Nov 2026 07:10:37 +0700 Subject: [PATCH 02/10] add nip60 crate --- Cargo.lock | 996 +++++++++++++++++++++++++++++++++++++++- Cargo.toml | 16 +- crates/nip60/Cargo.toml | 14 + crates/nip60/src/lib.rs | 49 ++ desktop/Cargo.toml | 1 + desktop/src/main.rs | 5 + docs/nip60.md | 72 +-- 7 files changed, 1116 insertions(+), 37 deletions(-) create mode 100644 crates/nip60/Cargo.toml create mode 100644 crates/nip60/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index b132544c..b38c38e6 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,146 @@ 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-nostr" +version = "0.18.0" +source = "git+https://github.com/cashubtc/cdk#578e719fbf683871a8480961627700f01f3ee7ee" +dependencies = [ + "nostr", + "nostr-sdk", + "thiserror 2.0.21", + "tokio", + "tokio-util", + "tracing", +] + +[[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" @@ -1188,6 +1500,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 +1560,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 +1662,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 +1745,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 +1836,7 @@ dependencies = [ "native-theme-gpui", "nip29", "nip4e", + "nip60", "nostr-sdk", "person", "settings", @@ -1685,6 +2077,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 +2141,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 +2201,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 +2384,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 +2475,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 +2487,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 +2571,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 +2649,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 +2984,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 +3337,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -3683,6 +4226,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 +4296,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 +4424,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 +4463,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 +4973,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 +5161,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 +5250,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 +5312,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 +5710,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 +5804,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 +6127,18 @@ dependencies = [ "ui", ] +[[package]] +name = "nip60" +version = "2.0.0-alpha3" +dependencies = [ + "cdk", + "cdk-nostr", + "cdk-redb", + "gpui-pre", + "nostr", + "nostr-sdk", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -5511,7 +6195,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 +6205,7 @@ dependencies = [ "opaquerr 0.2.0", "rand 0.10.3", "scrypt", - "secp256k1", + "secp256k1 0.30.0", "serde", "serde_json", "unicode-normalization", @@ -5744,6 +6428,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 +6982,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 +7033,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 +7174,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 +7310,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 +7498,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 +7624,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 +7678,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 +7939,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 +8111,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 +8280,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 +8337,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 +8777,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 +8862,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 +9121,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 +9172,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 +9326,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 +9905,9 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -9065,6 +9967,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 +9995,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.28.0", "webpki-roots 0.26.11", ] @@ -9187,6 +10105,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 +10221,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 +10307,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 +10507,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 +10527,7 @@ checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" dependencies = [ "base64 0.23.1", "cookie_store", - "der", + "der 0.8.3", "log", "native-tls", "percent-encoding", @@ -9665,6 +10639,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" diff --git a/Cargo.toml b/Cargo.toml index 48581112..b1bf19bd 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,14 @@ 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" ] } +cdk-nostr = { git = "https://github.com/cashubtc/cdk", default-features = false } + # Others anyhow = "1.0.44" tts = { git = "https://github.com/ndarilek/tts-rs" } @@ -66,6 +74,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/crates/nip60/Cargo.toml b/crates/nip60/Cargo.toml new file mode 100644 index 00000000..13002034 --- /dev/null +++ b/crates/nip60/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "nip60" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +gpui.workspace = true +nostr.workspace = true +nostr-sdk.workspace = true + +cdk.workspace = true +cdk-redb.workspace = true +cdk-nostr.workspace = true diff --git a/crates/nip60/src/lib.rs b/crates/nip60/src/lib.rs new file mode 100644 index 00000000..8c0aad9e --- /dev/null +++ b/crates/nip60/src/lib.rs @@ -0,0 +1,49 @@ +#![cfg(not(target_arch = "wasm32"))] + +use gpui::App; +use nostr::prelude::*; + +/// Initialize the wallet registry. +pub fn init(_cx: &mut App) {} + +/// Compile-only phase 0 spike: proves the `nostr::nips::nip60` types, kinds, +/// and the cdk crates resolve against the workspace's git nostr. Replaced by +/// the real implementation. +#[allow(dead_code)] +fn spike() { + let _kinds = ( + Kind::CashuWallet, + Kind::CashuWalletUnspentProof, + Kind::CashuWalletSpendingHistory, + Kind::CashuWalletQuote, + ); + + let _history = + nostr::nips::nip60::SpendingHistory::new(nostr::nips::nip60::TransactionDirection::In, 1); + let _builder = cdk::wallet::WalletBuilder::new(); + let _store: Option = None; + let _inbox: Option = None; + + if let Ok(mint) = nostr::types::Url::parse("https://mint.example.com") { + let proof = nostr::nips::nip60::CashuProof { + id: String::new(), + amount: 1, + secret: String::new(), + c: String::new(), + }; + let _wallet_event = nostr::nips::nip60::WalletEvent::new("privkey", vec![mint.clone()]); + let _token_event = + nostr::nips::nip60::TokenEvent::new(mint, vec![proof]).destroyed("id".to_string()); + } +} + +/// Only compiles when `nostr` and `cdk_nostr` resolve to the same crate. +#[allow(dead_code)] +fn spike_unified(key: nostr::key::SecretKey) -> cdk_nostr::nostr::key::SecretKey { + key +} + +#[allow(dead_code)] +fn spike_sdk_unified(client: nostr_sdk::client::Client) -> cdk_nostr::nostr_sdk::client::Client { + client +} 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 index a6808eab..b4cdcbf0 100644 --- a/docs/nip60.md +++ b/docs/nip60.md @@ -1,7 +1,7 @@ # 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-sqlite`, `cdk-nostr` 0.18.x) +CDK: https://github.com/cashubtc/cdk (crates `cdk`, `cdk-redb`, `cdk-nostr`, from git main) 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 @@ -50,7 +50,8 @@ Follows the existing crate patterns exactly; no new architecture. (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::nip60::*::prepare` (they need an owned `SecretKey`). + 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 @@ -72,31 +73,41 @@ Follows the existing crate patterns exactly; no new architecture. ## Dependencies -Workspace `Cargo.toml`: +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] -# cdk publishes against crates.io nostr 0.45.x; coop uses the git source. -# The patch unifies both on one copy of the crate so types cross crate -# boundaries (cdk-nostr inbox events, keys) without conversion. nostr = { git = "https://github.com/rust-nostr/nostr" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } ``` -`crates/nip60/Cargo.toml` (all under `cfg(not(wasm32))`): +Spike findings (all resolved and verified with `cargo check`): -```toml -cdk = { version = "0.18", default-features = false, features = ["wallet"] } -cdk-sqlite = { version = "0.18", default-features = false, features = ["wallet"] } -cdk-nostr = { version = "0.18", default-features = false } # inbox, for NIP-61 -``` - -Version-unification is the one risky step; Phase 0 is a spike that proves -`cdk 0.18.x` + git rust-nostr build together. Fallback if the patch fights -back: convert only at the cdk-nostr inbox boundary (hex/serde) and skip the -patch for `nostr-sdk`. +- 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 @@ -116,7 +127,7 @@ patch for `nostr-sdk`. ### Local state -- cdk sqlite store at `config_dir().join("cashu")` (one `WalletDatabase` +- 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` @@ -173,13 +184,14 @@ the UI crate when Phase 3 lands). ## Phases -### Phase 0 — dependency spike (small, throwaway) +### Phase 0 — dependency spike (done) -1. Enable `nip60` on the `nostr` workspace dep; confirm - `nostr::nip60` types and the four kinds compile. -2. Add the `[patch.crates-io]` block and a scratch dependency on - `cdk`/`cdk-sqlite`/`cdk-nostr`; `cargo check` desktop. Resolve the - nostr unification here before writing any code. +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 @@ -228,9 +240,13 @@ the UI crate when Phase 3 lands). - 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. -- Web target gets no wallet in v1: `cdk-sqlite` (rusqlite bundled) does - 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. +- 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. -- 2.54.0 From d466cac99e368fcd8c5c4734dd2ec9bee60f785e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 6 Nov 2026 08:16:29 +0700 Subject: [PATCH 03/10] implement nip60 wallet registry --- Cargo.lock | 11 + crates/nip60/Cargo.toml | 12 + crates/nip60/src/keys.rs | 178 +++++++++ crates/nip60/src/lib.rs | 806 +++++++++++++++++++++++++++++++++++++-- docs/nip60.md | 28 +- 5 files changed, 993 insertions(+), 42 deletions(-) create mode 100644 crates/nip60/src/keys.rs diff --git a/Cargo.lock b/Cargo.lock index b38c38e6..36f1d33c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6131,12 +6131,23 @@ dependencies = [ name = "nip60" version = "2.0.0-alpha3" dependencies = [ + "anyhow", "cdk", "cdk-nostr", "cdk-redb", + "common", + "flume 0.11.1", "gpui-pre", + "gpui_tokio", + "hkdf", + "log", "nostr", "nostr-sdk", + "serde_json", + "sha2 0.10.9", + "smallvec", + "state", + "tokio", ] [[package]] diff --git a/crates/nip60/Cargo.toml b/crates/nip60/Cargo.toml index 13002034..b00feac0 100644 --- a/crates/nip60/Cargo.toml +++ b/crates/nip60/Cargo.toml @@ -5,9 +5,21 @@ 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 +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..a6c27e86 --- /dev/null +++ b/crates/nip60/src/keys.rs @@ -0,0 +1,178 @@ +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"; + +pub(crate) struct WalletData { + pub(crate) event_id: EventId, + pub(crate) created_at: Timestamp, + pub(crate) privkey: SecretKey, + pub(crate) mints: Vec, +} + +impl Clone for WalletData { + fn clone(&self) -> Self { + Self { + event_id: self.event_id, + created_at: self.created_at, + privkey: self.privkey.clone(), + mints: self.mints.clone(), + } + } +} + +pub(crate) fn generate_privkey() -> SecretKey { + SecretKey::generate() +} + +/// Derive the cdk seed 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) +} + +pub(crate) 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_wallet_event( + 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(WalletData { + event_id: event.id, + created_at: event.created_at, + 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_wallet_event( + signer: &S, + me: &PublicKey, + privkey: &SecretKey, + mints: &[Url], +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, +{ + let mut rows = vec![vec!["privkey".to_string(), privkey.to_secret_hex()]]; + for mint in 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?) +} + +#[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 = derive_seed(&key).expect("seed derivation cannot fail"); + let second = derive_seed(&key).expect("seed derivation cannot fail"); + assert_eq!(first, second); + + let other = 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 index 8c0aad9e..072ddac8 100644 --- a/crates/nip60/src/lib.rs +++ b/crates/nip60/src/lib.rs @@ -1,49 +1,783 @@ #![cfg(not(target_arch = "wasm32"))] -use gpui::App; -use nostr::prelude::*; +mod keys; -/// Initialize the wallet registry. -pub fn init(_cx: &mut App) {} +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; +use std::sync::Arc; -/// Compile-only phase 0 spike: proves the `nostr::nips::nip60` types, kinds, -/// and the cdk crates resolve against the workspace's git nostr. Replaced by -/// the real implementation. -#[allow(dead_code)] -fn spike() { - let _kinds = ( - Kind::CashuWallet, - Kind::CashuWalletUnspentProof, - Kind::CashuWalletSpendingHistory, - Kind::CashuWalletQuote, - ); +use anyhow::Error; +use cdk::Amount; +use cdk::cdk_database::WalletDatabase; +use cdk::mint_url::MintUrl; +use cdk::nuts::{CurrencyUnit, Id, Proof, State}; +use cdk::secret::Secret; +use cdk::wallet::types::ProofInfo; +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; - let _history = - nostr::nips::nip60::SpendingHistory::new(nostr::nips::nip60::TransactionDirection::In, 1); - let _builder = cdk::wallet::WalletBuilder::new(); - let _store: Option = None; - let _inbox: Option = None; +pub fn init(cx: &mut App) { + WalletRegistry::set_global(cx.new(WalletRegistry::new), cx); +} - if let Ok(mint) = nostr::types::Url::parse("https://mint.example.com") { - let proof = nostr::nips::nip60::CashuProof { - id: String::new(), - amount: 1, - secret: String::new(), - c: String::new(), +struct GlobalWalletRegistry(Entity); + +impl Global for GlobalWalletRegistry {} + +#[derive(Debug, Clone)] +pub enum WalletEvent { + Updated, + Error(String), +} + +impl EventEmitter for WalletRegistry {} + +pub struct WalletRegistry { + wallet: Option, + tokens: HashMap, + /// Token events killed by a kind 5 delete or superseded by a `del` chain + stale: HashSet, + store: Option>, + wallets: HashMap, + loading: bool, + store_opening: bool, + importing: bool, + import_queued: bool, + notification_listener: Option>, + tasks: SmallVec<[Task<()>; 2]>, + _subscriptions: SmallVec<[Subscription; 2]>, +} + +struct LoadedState { + wallet: Option, + tokens: HashMap, + stale: HashSet, +} + +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(), + store: None, + wallets: HashMap::new(), + loading: false, + store_opening: false, + importing: false, + import_queued: false, + 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() + } + + /// 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 _wallet_event = nostr::nips::nip60::WalletEvent::new("privkey", vec![mint.clone()]); - let _token_event = - nostr::nips::nip60::TokenEvent::new(mint, vec![proof]).destroyed("id".to_string()); + + let (privkey, mints) = match self + .wallet + .as_ref() + .map(|wallet| (wallet.privkey.clone(), wallet.mints.clone())) + { + Some((privkey, mut mints)) => { + if mints.contains(&mint) { + return; + } + mints.push(mint.clone()); + (privkey, mints) + } + None => (keys::generate_privkey(), vec![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 = keys::build_wallet_event(&signer, &me, &privkey, &mints).await?; + client.send_event(&event).await?; + client.database().save_event(&event).await?; + + Ok::<_, Error>(keys::WalletData { + event_id: event.id, + created_at: event.created_at, + privkey, + mints, + }) + }); + + 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.store = None; + self.wallets.clear(); + self.loading = false; + self.store_opening = false; + self.importing = false; + self.import_queued = false; + 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 { + let me = signer.get_public_key_async().await.ok(); + let mut notifications = client.notifications(); + + const MAX_PROCESSED: usize = 1000; + let mut processed_events = HashSet::new(); + + while let Some(notification) = notifications.next().await { + if let ClientNotification::Message { message, .. } = notification + && let RelayMessage::Event { event, .. } = *message + && me == Some(event.pubkey) + && (matches!( + event.kind, + Kind::CashuWallet + | Kind::CashuWalletUnspentProof + | Kind::CashuWalletSpendingHistory + ) || is_token_deletion(&event)) + { + // 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::EventDeletion => self.absorb_deletion(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::decrypt_wallet_event(&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| { + 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) => { + this.update(cx, |this, cx| { + this.emit_error(format!("{error:#}"), cx); + }) + .ok(); + 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_import(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_import(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::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.schedule_import(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, 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; + cx.notify(); + this.schedule_import(cx); + }) + .ok(); + } + Err(error) => { + this.update(cx, |this, cx| { + this.store_opening = false; + this.emit_error(format!("{error:#}"), cx); + }) + .ok(); + } + } + })); + } + + /// Mirror the active token events into the cdk store. + fn schedule_import(&mut self, cx: &mut Context) { + if self.importing { + self.import_queued = true; + return; + } + + let Some(store) = self.store.clone() else { + return; + }; + + if self.wallets.is_empty() { + return; + } + + let mut active: Vec<(MintUrl, Vec)> = Vec::new(); + for token in self.tokens.values() { + let Ok(mint_url) = MintUrl::from_str(token.mint.as_str()) else { + continue; + }; + + let mut proofs = Vec::new(); + for proof in &token.proofs { + let Ok(keyset_id) = Id::from_str(&proof.id) else { + continue; + }; + + let Ok(c) = cdk::nuts::PublicKey::from_str(&proof.c) else { + continue; + }; + + proofs.push(Proof::new( + Amount::from(proof.amount), + keyset_id, + Secret::new(proof.secret.clone()), + c, + )); + } + + active.push((mint_url, proofs)); + } + + self.importing = true; + + self.tasks.push(cx.spawn(async move |this, cx| { + let result = Tokio::spawn_result(cx, async move { + let mut added = Vec::new(); + let mut ys = HashSet::new(); + + for (mint_url, proofs) in active { + for proof in proofs { + let info = match ProofInfo::new( + proof, + mint_url.clone(), + State::Unspent, + CurrencyUnit::Sat, + ) { + Ok(info) => info, + Err(error) => { + log::warn!("skipping proof: {error:#}"); + continue; + } + }; + if ys.insert(info.y) { + added.push(info); + } + } + } + + let existing = store + .get_proofs( + None, + Some(CurrencyUnit::Sat), + Some(vec![State::Unspent]), + None, + ) + .await?; + + let removed = existing + .iter() + .filter(|info| !ys.contains(&info.y)) + .map(|info| info.y) + .collect(); + + store.update_proofs(added, removed).await?; + + Ok(()) + }) + .await; + + this.update(cx, |this, cx| { + this.importing = false; + + if this.import_queued { + this.import_queued = false; + this.schedule_import(cx); + } + + if let Err(error) = result { + this.emit_error(format!("{error:#}"), cx); + } + }) + .ok(); + })); + } + + fn apply_loaded(&mut self, state: LoadedState, cx: &mut Context) { + self.wallet = state.wallet; + self.tokens = state.tokens; + self.stale = state.stale; + cx.notify(); + self.ensure_wallets(cx); + self.schedule_import(cx); + } + + 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 = load_inner(&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(); + + // Subscribe only after the cache snapshot; from then on the state + // advances exclusively through the notification listener. + let subscribed = + cx.background_spawn( + async move { subscribe_client.subscribe(wallet_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)); } } -/// Only compiles when `nostr` and `cdk_nostr` resolve to the same crate. -#[allow(dead_code)] -fn spike_unified(key: nostr::key::SecretKey) -> cdk_nostr::nostr::key::SecretKey { - key +async fn load_inner(me: &PublicKey, client: &Client, signer: &S) -> Result +where + S: AsyncNip44 + ?Sized, +{ + let events = client.database().query(wallet_filter(*me)).await?; + + let mut wallet_event: Option<&Event> = None; + let mut token_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::EventDeletion if is_token_deletion(event) => deletions.push(event), + _ => {} + } + } + + 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::decrypt_wallet_event(signer, me, event).await { + Ok(wallet) => Some(wallet), + Err(error) => { + log::warn!("skipping wallet event {}: {error:#}", event.id); + None + } + }, + None => None, + }; + + Ok(LoadedState { + wallet, + tokens, + stale, + }) } -#[allow(dead_code)] -fn spike_sdk_unified(client: nostr_sdk::client::Client) -> cdk_nostr::nostr_sdk::client::Client { - client +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::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); + } + + let recovery = wallets.clone(); + tokio::spawn(async move { + for (_, wallet) in recovery { + if let Err(error) = wallet.recover_incomplete_sagas().await { + log::warn!("saga recovery failed: {error:#}"); + } + if let Err(error) = wallet.mint_unissued_quotes().await { + log::warn!("minting unissued quotes failed: {error:#}"); + } + } + }); + + Ok((store, wallets)) +} + +fn wallet_filter(author: PublicKey) -> Filter { + Filter::new().author(author).kinds([ + Kind::CashuWallet, + Kind::CashuWalletUnspentProof, + Kind::CashuWalletSpendingHistory, + Kind::EventDeletion, + ]) +} + +fn is_token_deletion(event: &Event) -> bool { + event.kind == Kind::EventDeletion + && event + .tags + .iter() + .any(|tag| tag.kind() == "k" && tag.content() == Some("7375")) } diff --git a/docs/nip60.md b/docs/nip60.md index b4cdcbf0..58c3c438 100644 --- a/docs/nip60.md +++ b/docs/nip60.md @@ -193,13 +193,27 @@ 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 +### Phase 1 — bootstrap + load + balance (done) -1. `crates/nip60` skeleton: registry, `init`, signer-changed reset. -2. `keys.rs`: wallet key generation, seed derivation, `17375` parse/build. -3. Load path: query cached + subscribed events, build the unspent set, - open cdk wallets, import proofs, expose `balance()` and emit `Updated`. -4. `setup(mint_url)` + mint list update (republish `17375`). +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 @@ -240,6 +254,8 @@ phase 1. - 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; a guard for proofs + reserved by local in-flight operations lands with phase 2 operations. - 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 -- 2.54.0 From 4b9e2295324799a1f59be730536df92ddef0c983 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 6 Nov 2026 08:42:17 +0700 Subject: [PATCH 04/10] implement nip60 wallet operations --- crates/nip60/src/keys.rs | 73 ++++++++ crates/nip60/src/lib.rs | 386 ++++++++++++++++++++++++++++----------- crates/nip60/src/ops.rs | 334 +++++++++++++++++++++++++++++++++ docs/nip60.md | 39 +++- 4 files changed, 715 insertions(+), 117 deletions(-) create mode 100644 crates/nip60/src/ops.rs diff --git a/crates/nip60/src/keys.rs b/crates/nip60/src/keys.rs index a6c27e86..08cdf66a 100644 --- a/crates/nip60/src/keys.rs +++ b/crates/nip60/src/keys.rs @@ -136,6 +136,79 @@ where .await?) } +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?) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn build_history_event( + signer: &S, + me: &PublicKey, + direction: TransactionDirection, + amount: u64, + created: EventId, + destroyed: &[EventId], +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, +{ + let mut rows = vec![ + vec!["direction".to_string(), direction.to_string()], + vec!["amount".to_string(), 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 content = signer + .nip44_encrypt_async(me, &serde_json::to_string(&rows)?) + .await?; + + Ok(EventBuilder::new(Kind::CashuWalletSpendingHistory, content) + .finalize_async(signer) + .await?) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/nip60/src/lib.rs b/crates/nip60/src/lib.rs index 072ddac8..7f752e2b 100644 --- a/crates/nip60/src/lib.rs +++ b/crates/nip60/src/lib.rs @@ -1,18 +1,15 @@ #![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; -use cdk::Amount; -use cdk::cdk_database::WalletDatabase; use cdk::mint_url::MintUrl; -use cdk::nuts::{CurrencyUnit, Id, Proof, State}; -use cdk::secret::Secret; -use cdk::wallet::types::ProofInfo; +use cdk::nuts::CurrencyUnit; use cdk::wallet::{Wallet, WalletBuilder}; use cdk_redb::WalletRedbDatabase; use common::config_dir; @@ -34,22 +31,33 @@ 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: Url, +} + 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, store: Option>, wallets: HashMap, loading: bool, store_opening: bool, - importing: bool, - import_queued: bool, + job_running: bool, + job_queued: Vec, notification_listener: Option>, tasks: SmallVec<[Task<()>; 2]>, _subscriptions: SmallVec<[Subscription; 2]>, @@ -86,12 +94,13 @@ impl WalletRegistry { wallet: None, tokens: HashMap::new(), stale: HashSet::new(), + quotes: Vec::new(), store: None, wallets: HashMap::new(), loading: false, store_opening: false, - importing: false, - import_queued: false, + job_running: false, + job_queued: Vec::new(), notification_listener: None, tasks: smallvec![], _subscriptions: subscriptions, @@ -110,6 +119,167 @@ impl WalletRegistry { .sum() } + /// Lightning invoices waiting to be paid and claimed. + pub fn mint_quotes(&self) -> &[MintQuoteEntry] { + &self.quotes + } + + /// 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) = self + .wallet + .as_ref() + .and_then(|wallet| wallet.mints.first().cloned()) + else { + self.emit_error("No wallet is set up".to_string(), cx); + return; + }; + + let Ok(mint_url) = MintUrl::from_str(mint.as_str()) else { + self.emit_error("Invalid mint URL".to_string(), cx); + return; + }; + + let Some(wallet) = self.wallets.get(&mint_url).cloned() else { + self.emit_error("Wallet is not ready".to_string(), cx); + return; + }; + + let created = cx.background_spawn(async move { + let quote = wallet + .mint_quote( + cdk::nuts::PaymentMethod::Known(cdk::nuts::nut00::KnownMethod::Bolt11), + Some(cdk::Amount::from(amount)), + None, + None, + ) + .await?; + + Ok::<_, Error>((quote.id, quote.request)) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let (quote_id, request) = match created.await { + Ok(quote) => quote, + Err(error) => { + this.update(cx, |this, cx| { + this.emit_error(format!("{error:#}"), cx); + }) + .ok(); + return; + } + }; + + this.update(cx, |this, cx| { + this.quotes.push(MintQuoteEntry { + quote_id, + request, + amount, + mint: mint.clone(), + }); + this.emit_updated(cx); + }) + .ok(); + })); + } + + /// 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; + }; + + let Ok(mint_url) = MintUrl::from_str(entry.mint.as_str()) else { + self.emit_error("Invalid mint URL".to_string(), cx); + return; + }; + + self.schedule_job( + ops::Job::ClaimQuote { + mint: mint_url, + quote_id, + }, + cx, + ); + } + + /// Pay a lightning invoice from the wallet. + pub fn melt(&mut self, invoice: String, cx: &mut Context) { + let Some(mint) = self + .wallet + .as_ref() + .and_then(|wallet| wallet.mints.first().cloned()) + else { + self.emit_error("No wallet is set up".to_string(), cx); + return; + }; + + let Ok(mint_url) = MintUrl::from_str(mint.as_str()) else { + self.emit_error("Invalid mint URL".to_string(), cx); + 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) = self + .wallet + .as_ref() + .and_then(|wallet| wallet.mints.first().cloned()) + else { + self.emit_error("No wallet is set up".to_string(), cx); + return; + }; + + let Ok(mint_url) = MintUrl::from_str(mint.as_str()) else { + self.emit_error("Invalid mint URL".to_string(), cx); + return; + }; + + self.schedule_job( + ops::Job::SendToken { + mint: mint_url, + amount, + }, + cx, + ); + } + + /// 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 { @@ -191,12 +361,13 @@ impl WalletRegistry { self.wallet = None; self.tokens.clear(); self.stale.clear(); + self.quotes.clear(); self.store = None; self.wallets.clear(); self.loading = false; self.store_opening = false; - self.importing = false; - self.import_queued = false; + self.job_running = false; + self.job_queued.clear(); self.notification_listener = None; self.tasks.clear(); cx.notify(); @@ -339,7 +510,7 @@ impl WalletRegistry { } this.tokens.insert(event_id, token); this.emit_updated(cx); - this.schedule_import(cx); + this.schedule_job(ops::Job::Reconcile, cx); }) .ok(); })); @@ -363,7 +534,7 @@ impl WalletRegistry { } if removed_any { self.emit_updated(cx); - self.schedule_import(cx); + self.schedule_job(ops::Job::Reconcile, cx); } } @@ -416,7 +587,7 @@ impl WalletRegistry { } cx.notify(); - self.schedule_import(cx); + self.schedule_job(ops::Job::Reconcile, cx); return; } @@ -443,7 +614,7 @@ impl WalletRegistry { this.store_opening = false; cx.notify(); - this.schedule_import(cx); + this.schedule_job(ops::Job::Reconcile, cx); }) .ok(); } @@ -458,119 +629,126 @@ impl WalletRegistry { })); } - /// Mirror the active token events into the cdk store. - fn schedule_import(&mut self, cx: &mut Context) { - if self.importing { - self.import_queued = true; + /// 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; } - let Some(store) = self.store.clone() else { - return; - }; - - if self.wallets.is_empty() { + if self.job_running { + self.job_queued.push(job); return; } - let mut active: Vec<(MintUrl, Vec)> = Vec::new(); - for token in self.tokens.values() { - let Ok(mint_url) = MintUrl::from_str(token.mint.as_str()) else { - continue; - }; + self.start_job(job, cx); + } - let mut proofs = Vec::new(); - for proof in &token.proofs { - let Ok(keyset_id) = Id::from_str(&proof.id) else { - continue; - }; - - let Ok(c) = cdk::nuts::PublicKey::from_str(&proof.c) else { - continue; - }; - - proofs.push(Proof::new( - Amount::from(proof.amount), - keyset_id, - Secret::new(proof.secret.clone()), - c, - )); - } - - active.push((mint_url, proofs)); + fn start_job(&mut self, job: ops::Job, cx: &mut Context) { + if self.store.is_none() || self.wallets.is_empty() { + self.emit_error("Wallet is not ready".to_string(), cx); + return; } - self.importing = true; + let wallets = self.wallets.clone(); + let tokens = self.tokens.clone(); + + let signer = NostrRegistry::global(cx).read(cx).signer().clone(); + let client = NostrRegistry::global(cx).read(cx).client().clone(); + self.job_running = true; self.tasks.push(cx.spawn(async move |this, cx| { let result = Tokio::spawn_result(cx, async move { - let mut added = Vec::new(); - let mut ys = HashSet::new(); + let ctx = ops::JobCtx { + wallets, + signer, + client, + tokens, + }; - for (mint_url, proofs) in active { - for proof in proofs { - let info = match ProofInfo::new( - proof, - mint_url.clone(), - State::Unspent, - CurrencyUnit::Sat, - ) { - Ok(info) => info, - Err(error) => { - log::warn!("skipping proof: {error:#}"); - continue; - } - }; - if ys.insert(info.y) { - added.push(info); - } - } - } - - let existing = store - .get_proofs( - None, - Some(CurrencyUnit::Sat), - Some(vec![State::Unspent]), - None, - ) - .await?; - - let removed = existing - .iter() - .filter(|info| !ys.contains(&info.y)) - .map(|info| info.y) - .collect(); - - store.update_proofs(added, removed).await?; - - Ok(()) + ops::run_job(job, ctx).await }) .await; this.update(cx, |this, cx| { - this.importing = false; + this.job_running = false; + this.apply_outcome(result, cx); - if this.import_queued { - this.import_queued = false; - this.schedule_import(cx); - } - - if let Err(error) = result { - this.emit_error(format!("{error:#}"), 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::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::Received { update, amount } => { + self.apply_token_update(update); + self.emit_updated(cx); + log::info!("received {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; cx.notify(); self.ensure_wallets(cx); - self.schedule_import(cx); + self.schedule_job(ops::Job::Reconcile, cx); } fn load(&mut self, cx: &mut Context) { @@ -750,18 +928,6 @@ async fn open_store( wallets.insert(mint_url, wallet); } - let recovery = wallets.clone(); - tokio::spawn(async move { - for (_, wallet) in recovery { - if let Err(error) = wallet.recover_incomplete_sagas().await { - log::warn!("saga recovery failed: {error:#}"); - } - if let Err(error) = wallet.mint_unissued_quotes().await { - log::warn!("minting unissued quotes failed: {error:#}"); - } - } - }); - Ok((store, wallets)) } diff --git a/crates/nip60/src/ops.rs b/crates/nip60/src/ops.rs new file mode 100644 index 00000000..5793dde2 --- /dev/null +++ b/crates/nip60/src/ops.rs @@ -0,0 +1,334 @@ +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +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, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, State}; +use cdk::secret::Secret; +use cdk::wallet::types::ProofInfo; +use cdk::wallet::{ReceiveOptions, SendOptions, Wallet}; +use nostr::nips::nip60::{CashuProof, TokenEvent, TransactionDirection}; +use nostr_sdk::prelude::*; + +use crate::keys; + +/// A job mutating the wallet store or reconciling it with relays. +#[derive(Debug, Clone)] +pub(crate) enum Job { + Reconcile, + ClaimQuote { mint: MintUrl, quote_id: String }, + Melt { mint: MintUrl, invoice: String }, + SendToken { mint: MintUrl, amount: u64 }, + Receive { mint: MintUrl, token: String }, +} + +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, + }, + QuoteMinted { + quote_id: String, + update: TokenUpdate, + amount: u64, + }, + Melted { + update: TokenUpdate, + amount: u64, + }, + Sent { + update: TokenUpdate, + token: String, + }, + Received { + update: TokenUpdate, + amount: u64, + }, +} + +pub(crate) struct JobCtx { + pub(crate) wallets: HashMap, + pub(crate) signer: S, + pub(crate) client: Client, + /// Relay token events at schedule time. + pub(crate) tokens: HashMap, +} + +pub(crate) async fn run_job(job: Job, ctx: JobCtx) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static, +{ + match job { + Job::Reconcile => reconcile(ctx).await, + Job::ClaimQuote { mint, quote_id } => { + let wallet = wallet_for(&ctx, &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 = sync_token_events( + &ctx.signer, + &ctx.client, + wallet, + nostr_mint(&mint)?, + &previous_ids(&ctx.tokens, &mint), + Some((TransactionDirection::In, amount)), + ) + .await?; + + Ok(Outcome::QuoteMinted { + quote_id, + update, + amount, + }) + } + Job::Melt { mint, invoice } => { + let wallet = wallet_for(&ctx, &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 = sync_token_events( + &ctx.signer, + &ctx.client, + wallet, + nostr_mint(&mint)?, + &previous_ids(&ctx.tokens, &mint), + Some((TransactionDirection::Out, amount)), + ) + .await?; + + Ok(Outcome::Melted { update, amount }) + } + Job::SendToken { mint, amount } => { + let wallet = wallet_for(&ctx, &mint)?; + let prepared = wallet + .prepare_send(Amount::from(amount), SendOptions::default()) + .await?; + let token = prepared.confirm(None).await?.to_string(); + + let update = sync_token_events( + &ctx.signer, + &ctx.client, + wallet, + nostr_mint(&mint)?, + &previous_ids(&ctx.tokens, &mint), + Some((TransactionDirection::Out, amount)), + ) + .await?; + + Ok(Outcome::Sent { update, token }) + } + Job::Receive { mint, token } => { + let wallet = wallet_for(&ctx, &mint)?; + let received = wallet.receive(&token, ReceiveOptions::default()).await?; + + let update = sync_token_events( + &ctx.signer, + &ctx.client, + wallet, + nostr_mint(&mint)?, + &previous_ids(&ctx.tokens, &mint), + Some((TransactionDirection::In, u64::from(received))), + ) + .await?; + + Ok(Outcome::Received { + update, + amount: u64::from(received), + }) + } + } +} + +/// Claim paid quotes, then converge each mint. +async fn reconcile(ctx: JobCtx) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync, +{ + let mut tokens = ctx.tokens.clone(); + let mut updates = Vec::new(); + + for (mint_url, wallet) in &ctx.wallets { + let minted = wallet.mint_unissued_quotes().await?; + if minted == Amount::ZERO { + continue; + } + + let update = sync_token_events( + &ctx.signer, + &ctx.client, + wallet, + nostr_mint(mint_url)?, + &previous_ids(&tokens, mint_url), + Some((TransactionDirection::In, u64::from(minted))), + ) + .await?; + + apply_update(&mut tokens, &update); + updates.push(update); + } + + for (mint_url, wallet) in &ctx.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 }) +} + +/// Publish the store's current unspent set as a fresh token event. +async fn sync_token_events( + signer: &S, + client: &Client, + wallet: &Wallet, + mint: Url, + previous: &[EventId], + history: Option<(TransactionDirection, u64)>, +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync + ?Sized, +{ + let me = signer.get_public_key_async().await?; + let unspent = wallet.get_unspent_proofs().await?; + + let mut proofs = Vec::new(); + for proof in &unspent { + proofs.push(cashu_proof(proof)?); + } + + let token = TokenEvent::new(mint, proofs); + let event = keys::build_token_event(signer, &me, &token).await?; + client.send_event(&event).await?; + + if !previous.is_empty() { + let deletion = keys::build_token_deletion(signer, previous).await?; + client.send_event(&deletion).await?; + } + + if let Some((direction, amount)) = history { + let history_event = + keys::build_history_event(signer, &me, direction, amount, event.id, previous).await?; + client.send_event(&history_event).await?; + } + + Ok(TokenUpdate { + event_id: event.id, + token, + destroyed: previous.to_vec(), + }) +} + +fn apply_update(tokens: &mut HashMap, update: &TokenUpdate) { + for id in &update.destroyed { + tokens.remove(id); + } + tokens.insert(update.event_id, update.token.clone()); +} + +fn previous_ids(tokens: &HashMap, mint: &MintUrl) -> Vec { + tokens + .iter() + .filter(|(_, token)| token.mint.as_str() == mint.to_string()) + .map(|(id, _)| *id) + .collect() +} + +fn wallet_for<'a, S>(ctx: &'a JobCtx, mint: &MintUrl) -> Result<&'a Wallet, Error> { + ctx.wallets + .get(mint) + .ok_or_else(|| anyhow!("no open wallet for mint {mint}")) +} + +fn nostr_mint(mint: &MintUrl) -> Result { + Url::parse(&mint.to_string()).map_err(|error| anyhow!("invalid mint url: {error}")) +} + +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 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/docs/nip60.md b/docs/nip60.md index 58c3c438..e73bd1cf 100644 --- a/docs/nip60.md +++ b/docs/nip60.md @@ -215,12 +215,36 @@ 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 +### Phase 2 — moving money (done) -1. `ops.rs` sync tail: rollover `7375`, NIP-09 deletes (`k` tag), `7376` - history. -2. `create_mint_quote` (+ claim-on-load), `melt`, `send_token`, - `receive_token`, each with error propagation to the registry event. +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 (cdk-nostr) @@ -254,8 +278,9 @@ reserved by local in-flight operations (phase 2 guard). - 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; a guard for proofs - reserved by local in-flight operations lands with phase 2 operations. +- 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 -- 2.54.0 From 8c5b816ebfda91dd4941c0cd287bdad4d3ed2c36 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 6 Nov 2026 09:08:44 +0700 Subject: [PATCH 05/10] implement nip61 nutzap redemption --- Cargo.lock | 14 --- Cargo.toml | 1 - crates/nip60/Cargo.toml | 1 - crates/nip60/src/keys.rs | 41 +++++++ crates/nip60/src/lib.rs | 257 ++++++++++++++++++++++++++++++++++++--- crates/nip60/src/ops.rs | 134 +++++++++++++++++--- docs/nip60.md | 41 +++++-- 7 files changed, 437 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36f1d33c..0b5a8d27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1329,19 +1329,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "cdk-nostr" -version = "0.18.0" -source = "git+https://github.com/cashubtc/cdk#578e719fbf683871a8480961627700f01f3ee7ee" -dependencies = [ - "nostr", - "nostr-sdk", - "thiserror 2.0.21", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "cdk-redb" version = "0.18.0" @@ -6133,7 +6120,6 @@ version = "2.0.0-alpha3" dependencies = [ "anyhow", "cdk", - "cdk-nostr", "cdk-redb", "common", "flume 0.11.1", diff --git a/Cargo.toml b/Cargo.toml index b1bf19bd..6a300a48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,6 @@ rand = { version = "0.10", default-features = false, features = [ "std", "sys_rn # 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" ] } -cdk-nostr = { git = "https://github.com/cashubtc/cdk", default-features = false } # Others anyhow = "1.0.44" diff --git a/crates/nip60/Cargo.toml b/crates/nip60/Cargo.toml index b00feac0..fe2ed07c 100644 --- a/crates/nip60/Cargo.toml +++ b/crates/nip60/Cargo.toml @@ -23,4 +23,3 @@ tokio = { version = "1", default-features = false, features = [ "rt" ] } cdk.workspace = true cdk-redb.workspace = true -cdk-nostr.workspace = true diff --git a/crates/nip60/src/keys.rs b/crates/nip60/src/keys.rs index 08cdf66a..c07a4072 100644 --- a/crates/nip60/src/keys.rs +++ b/crates/nip60/src/keys.rs @@ -175,6 +175,8 @@ pub(crate) async fn build_history_event( amount: u64, created: EventId, destroyed: &[EventId], + redeemed: &[EventId], + sender: Option, ) -> Result where S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, @@ -200,11 +202,50 @@ where "created".to_string(), ]); + let mut tags: Vec = redeemed + .iter() + .map(|id| Tag::custom("e", [id.to_hex(), String::new(), "redeemed".to_string()])) + .collect(); + + if let Some(sender) = 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: &[&str], + 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?) } diff --git a/crates/nip60/src/lib.rs b/crates/nip60/src/lib.rs index 7f752e2b..c0e3cb35 100644 --- a/crates/nip60/src/lib.rs +++ b/crates/nip60/src/lib.rs @@ -7,9 +7,9 @@ use std::collections::{HashMap, HashSet}; use std::str::FromStr; use std::sync::Arc; -use anyhow::Error; +use anyhow::{Error, anyhow}; use cdk::mint_url::MintUrl; -use cdk::nuts::CurrencyUnit; +use cdk::nuts::{CurrencyUnit, Proof, Proofs, SpendingConditions}; use cdk::wallet::{Wallet, WalletBuilder}; use cdk_redb::WalletRedbDatabase; use common::config_dir; @@ -52,6 +52,12 @@ pub struct WalletRegistry { /// Token events killed by a kind 5 delete or superseded by a `del` chain stale: HashSet, quotes: 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, @@ -67,6 +73,8 @@ struct LoadedState { wallet: Option, tokens: HashMap, stale: HashSet, + redeemed: HashSet, + nutzaps: Vec, } impl WalletRegistry { @@ -95,6 +103,9 @@ impl WalletRegistry { tokens: HashMap::new(), stale: HashSet::new(), quotes: Vec::new(), + redeemed: HashSet::new(), + pending_nutzaps: Vec::new(), + redeeming: HashSet::new(), store: None, wallets: HashMap::new(), loading: false, @@ -316,9 +327,18 @@ impl WalletRegistry { let publish = cx.background_spawn(async move { let me = signer.get_public_key_async().await?; + let event = keys::build_wallet_event(&signer, &me, &privkey, &mints).await?; client.send_event(&event).await?; - client.database().save_event(&event).await?; + + let info = keys::build_nutzap_info_event( + &signer, + &mints, + &state::BOOTSTRAP_RELAYS, + Keys::new(privkey.clone()).public_key(), + ) + .await?; + client.send_event(&info).await?; Ok::<_, Error>(keys::WalletData { event_id: event.id, @@ -362,6 +382,9 @@ impl WalletRegistry { self.tokens.clear(); self.stale.clear(); self.quotes.clear(); + self.redeemed.clear(); + self.pending_nutzaps.clear(); + self.redeeming.clear(); self.store = None; self.wallets.clear(); self.loading = false; @@ -392,14 +415,22 @@ impl WalletRegistry { while let Some(notification) = notifications.next().await { if let ClientNotification::Message { message, .. } = notification && let RelayMessage::Event { event, .. } = *message - && me == Some(event.pubkey) - && (matches!( - event.kind, - Kind::CashuWallet - | Kind::CashuWalletUnspentProof - | Kind::CashuWalletSpendingHistory - ) || is_token_deletion(&event)) { + let ours = me == Some(event.pubkey); + let wallet_event = ours + && (matches!( + event.kind, + Kind::CashuWallet + | Kind::CashuWalletUnspentProof + | Kind::CashuWalletSpendingHistory + ) || 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(); @@ -430,7 +461,9 @@ impl WalletRegistry { match event.kind { Kind::CashuWallet => self.absorb_wallet(event, cx), Kind::CashuWalletUnspentProof => self.absorb_token(event, cx), + Kind::CashuWalletSpendingHistory => {} Kind::EventDeletion => self.absorb_deletion(event, cx), + Kind::Custom(9321) => self.absorb_nutzap(event, cx), _ => {} } } @@ -538,6 +571,74 @@ impl WalletRegistry { } } + /// 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; + } + + if self.store.is_none() || self.wallets.is_empty() { + self.pending_nutzaps.push(event); + return; + } + + let Some(wallet) = self.wallet.as_ref() else { + return; + }; + + let Some(mint) = nutzap_mint(&event) else { + log::warn!("nutzap {} has no mint", event.id); + return; + }; + + let Ok(mint_url) = MintUrl::from_str(&mint) else { + log::warn!("nutzap {} has an invalid mint: {mint}", event.id); + return; + }; + + if !self.wallets.contains_key(&mint_url) { + log::warn!("nutzap {} is on an unlisted mint: {mint_url}", event.id); + return; + } + + let proofs = match nutzap_proofs(&event) { + Ok(proofs) => proofs, + Err(error) => { + log::warn!("nutzap {} has no usable proofs: {error:#}", event.id); + return; + } + }; + + let target = format!( + "02{}", + Keys::new(wallet.privkey.clone()).public_key().to_hex() + ); + if !proofs.iter().all(|proof| locked_to(proof, &target)) { + log::warn!( + "nutzap {} proofs are not locked to the wallet key", + event.id + ); + return; + } + + self.redeeming.insert(event.id); + self.schedule_job( + ops::Job::RedeemNutzap { + mint: mint_url, + 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 { @@ -587,6 +688,7 @@ impl WalletRegistry { } cx.notify(); + self.drain_pending_nutzaps(cx); self.schedule_job(ops::Job::Reconcile, cx); return; @@ -613,6 +715,7 @@ impl WalletRegistry { } this.store_opening = false; + this.drain_pending_nutzaps(cx); cx.notify(); this.schedule_job(ops::Job::Reconcile, cx); }) @@ -657,12 +760,30 @@ impl WalletRegistry { let signer = NostrRegistry::global(cx).read(cx).signer().clone(); let client = NostrRegistry::global(cx).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, @@ -731,6 +852,17 @@ impl WalletRegistry { 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"); + } } } @@ -746,9 +878,16 @@ impl WalletRegistry { self.wallet = state.wallet; self.tokens = state.tokens; self.stale = state.stale; + self.redeemed = state.redeemed; cx.notify(); self.ensure_wallets(cx); self.schedule_job(ops::Job::Reconcile, 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) { @@ -796,10 +935,10 @@ impl WalletRegistry { // Subscribe only after the cache snapshot; from then on the state // advances exclusively through the notification listener. - let subscribed = - cx.background_spawn( - async move { subscribe_client.subscribe(wallet_filter(me)).await }, - ); + let subscribed = cx.background_spawn(async move { + subscribe_client.subscribe(wallet_filter(me)).await?; + subscribe_client.subscribe(nutzap_filter(me)).await + }); if let Err(error) = subscribed.await { log::warn!("wallet subscription failed: {error:#}"); } @@ -822,9 +961,11 @@ where S: AsyncNip44 + ?Sized, { let events = client.database().query(wallet_filter(*me)).await?; + let nutzap_events = client.database().query(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 { @@ -840,11 +981,25 @@ where } } 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 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() { @@ -901,6 +1056,8 @@ where wallet, tokens, stale, + redeemed, + nutzaps, }) } @@ -940,6 +1097,13 @@ fn wallet_filter(author: PublicKey) -> Filter { ]) } +/// 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()) +} + fn is_token_deletion(event: &Event) -> bool { event.kind == Kind::EventDeletion && event @@ -947,3 +1111,68 @@ fn is_token_deletion(event: &Event) -> bool { .iter() .any(|tag| tag.kind() == "k" && tag.content() == Some("7375")) } + +/// The `u` tag carries the mint URL; fall back to the older `mint` tag +fn nutzap_mint(event: &Event) -> Option { + let tag = event + .tags + .iter() + .find(|tag| tag.kind() == "u" && tag.content().is_some()) + .or_else(|| { + event + .tags + .iter() + .find(|tag| tag.kind() == "mint" && tag.content().is_some()) + })?; + + tag.content().map(str::to_string) +} + +fn nutzap_proofs(event: &Event) -> Result { + 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!("no proofs")); + } + + Ok(Proofs::from(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() + .map(|keys| keys.iter().any(|key| key.to_string() == target)) + .unwrap_or(false) +} + +/// 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() +} diff --git a/crates/nip60/src/ops.rs b/crates/nip60/src/ops.rs index 5793dde2..eb8a5ab6 100644 --- a/crates/nip60/src/ops.rs +++ b/crates/nip60/src/ops.rs @@ -6,7 +6,10 @@ use cdk::Amount; use cdk::amount::SplitTarget; use cdk::mint_url::MintUrl; use cdk::nuts::nut00::KnownMethod; -use cdk::nuts::{CurrencyUnit, Id, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, State}; +use cdk::nuts::{ + CurrencyUnit, Id, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs, SecretKey, + State, +}; use cdk::secret::Secret; use cdk::wallet::types::ProofInfo; use cdk::wallet::{ReceiveOptions, SendOptions, Wallet}; @@ -19,10 +22,38 @@ use crate::keys; #[derive(Debug, Clone)] pub(crate) enum Job { Reconcile, - ClaimQuote { mint: MintUrl, quote_id: String }, - Melt { mint: MintUrl, invoice: String }, - SendToken { mint: MintUrl, amount: u64 }, - Receive { mint: MintUrl, token: String }, + ClaimQuote { + mint: MintUrl, + quote_id: String, + }, + Melt { + mint: MintUrl, + invoice: String, + }, + SendToken { + mint: MintUrl, + amount: u64, + }, + Receive { + mint: MintUrl, + token: String, + }, + RedeemNutzap { + mint: MintUrl, + proofs: Proofs, + nutzap_id: EventId, + sender: PublicKey, + }, +} + +/// History event payload for a token 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) struct TokenUpdate { @@ -55,10 +86,17 @@ pub(crate) enum Outcome { 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. @@ -88,7 +126,12 @@ where wallet, nostr_mint(&mint)?, &previous_ids(&ctx.tokens, &mint), - Some((TransactionDirection::In, amount)), + Some(History { + direction: TransactionDirection::In, + amount: u64::from(quote.amount.unwrap_or_default()), + redeemed: Vec::new(), + sender: None, + }), ) .await?; @@ -126,7 +169,12 @@ where wallet, nostr_mint(&mint)?, &previous_ids(&ctx.tokens, &mint), - Some((TransactionDirection::Out, amount)), + Some(History { + direction: TransactionDirection::Out, + amount, + redeemed: Vec::new(), + sender: None, + }), ) .await?; @@ -145,7 +193,12 @@ where wallet, nostr_mint(&mint)?, &previous_ids(&ctx.tokens, &mint), - Some((TransactionDirection::Out, amount)), + Some(History { + direction: TransactionDirection::Out, + amount, + redeemed: Vec::new(), + sender: None, + }), ) .await?; @@ -161,7 +214,12 @@ where wallet, nostr_mint(&mint)?, &previous_ids(&ctx.tokens, &mint), - Some((TransactionDirection::In, u64::from(received))), + Some(History { + direction: TransactionDirection::In, + amount: u64::from(received), + redeemed: Vec::new(), + sender: None, + }), ) .await?; @@ -170,6 +228,40 @@ where amount: u64::from(received), }) } + Job::RedeemNutzap { + mint, + proofs, + nutzap_id, + sender, + } => { + let wallet = wallet_for(&ctx, &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 = sync_token_events( + &ctx.signer, + &ctx.client, + wallet, + nostr_mint(&mint)?, + &previous_ids(&ctx.tokens, &mint), + Some(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), + }) + } } } @@ -193,7 +285,12 @@ where wallet, nostr_mint(mint_url)?, &previous_ids(&tokens, mint_url), - Some((TransactionDirection::In, u64::from(minted))), + Some(History { + direction: TransactionDirection::In, + amount: u64::from(minted), + redeemed: Vec::new(), + sender: None, + }), ) .await?; @@ -246,7 +343,7 @@ async fn sync_token_events( wallet: &Wallet, mint: Url, previous: &[EventId], - history: Option<(TransactionDirection, u64)>, + history: Option, ) -> Result where S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync + ?Sized, @@ -268,9 +365,18 @@ where client.send_event(&deletion).await?; } - if let Some((direction, amount)) = history { - let history_event = - keys::build_history_event(signer, &me, direction, amount, event.id, previous).await?; + if let Some(history) = history { + let history_event = keys::build_history_event( + signer, + &me, + history.direction, + history.amount, + event.id, + previous, + &history.redeemed, + history.sender, + ) + .await?; client.send_event(&history_event).await?; } diff --git a/docs/nip60.md b/docs/nip60.md index e73bd1cf..91caac12 100644 --- a/docs/nip60.md +++ b/docs/nip60.md @@ -1,7 +1,8 @@ # 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`, `cdk-nostr`, from git main) +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 @@ -28,9 +29,13 @@ Key mechanics: 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): gift-wrapped token events P2PK-locked to the - wallet pubkey. Redemption is `receive` + a `7376` with an `e` tag - marked `redeemed` (this one tag stays unencrypted). + 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). @@ -246,11 +251,31 @@ Implemented in `crates/nip60/src/ops.rs` plus registry wiring: 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 (cdk-nostr) +### Phase 3 — NIP-61 redemption (done) -1. `cdk_nostr::NostrInbox` on the wallet key + relays, started in - `Tokio::spawn`, stopped on reset; rumors parsed into proofs + mint. -2. Redemption path with `p2pk_signing_keys`, `redeemed` history tag. +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. ### Phase 4 — UI -- 2.54.0 From 2d85c563fa38226dd456906eac8a70fabebc883f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 6 Nov 2026 10:35:36 +0700 Subject: [PATCH 06/10] refactor --- crates/nip60/src/keys.rs | 160 +++++----- crates/nip60/src/lib.rs | 658 +++++++++++++++++++-------------------- crates/nip60/src/ops.rs | 590 ++++++++++++++++++----------------- docs/nip60.md | 65 +++- 4 files changed, 746 insertions(+), 727 deletions(-) diff --git a/crates/nip60/src/keys.rs b/crates/nip60/src/keys.rs index c07a4072..27adf39d 100644 --- a/crates/nip60/src/keys.rs +++ b/crates/nip60/src/keys.rs @@ -7,6 +7,8 @@ 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, @@ -14,32 +16,68 @@ pub(crate) struct WalletData { pub(crate) mints: Vec, } -impl Clone for WalletData { - fn clone(&self) -> Self { +impl WalletData { + /// A new wallet with a fresh key. + pub(crate) fn generate(first_mint: Url) -> Self { Self { - event_id: self.event_id, - created_at: self.created_at, - privkey: self.privkey.clone(), - mints: self.mints.clone(), + 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?) + } } -pub(crate) fn generate_privkey() -> SecretKey { - SecretKey::generate() -} - -/// Derive the cdk seed 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) -} - -pub(crate) fn parse_wallet_content(content: &str) -> Result<(SecretKey, Vec), Error> { +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}"))?; @@ -70,29 +108,6 @@ pub(crate) fn parse_wallet_content(content: &str) -> Result<(SecretKey, Vec Ok((privkey, mints)) } -pub(crate) async fn decrypt_wallet_event( - 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(WalletData { - event_id: event.id, - created_at: event.created_at, - privkey, - mints, - }) -} - pub(crate) async fn decrypt_token_event( signer: &S, me: &PublicKey, @@ -113,29 +128,6 @@ where Ok(token) } -pub(crate) async fn build_wallet_event( - signer: &S, - me: &PublicKey, - privkey: &SecretKey, - mints: &[Url], -) -> Result -where - S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, -{ - let mut rows = vec![vec!["privkey".to_string(), privkey.to_secret_hex()]]; - for mint in 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?) -} - pub(crate) async fn build_token_event( signer: &S, me: &PublicKey, @@ -167,23 +159,29 @@ where .await?) } -#[allow(clippy::too_many_arguments)] +/// 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, - direction: TransactionDirection, - amount: u64, + history: &History, created: EventId, destroyed: &[EventId], - redeemed: &[EventId], - sender: Option, ) -> Result where S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, { let mut rows = vec![ - vec!["direction".to_string(), direction.to_string()], - vec!["amount".to_string(), amount.to_string()], + vec!["direction".to_string(), history.direction.to_string()], + vec!["amount".to_string(), history.amount.to_string()], ]; for id in destroyed { @@ -202,12 +200,13 @@ where "created".to_string(), ]); - let mut tags: Vec = redeemed + 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) = sender { + if let Some(sender) = history.sender { tags.push(Tag::custom("p", [sender.to_hex()])); } @@ -224,7 +223,7 @@ where pub(crate) async fn build_nutzap_info_event( signer: &S, mints: &[Url], - relays: &[&str], + relays: &[RelayUrl], wallet_pubkey: PublicKey, ) -> Result where @@ -282,11 +281,12 @@ mod tests { #[test] fn seed_is_deterministic_per_key() { let key = SecretKey::generate(); - let first = derive_seed(&key).expect("seed derivation cannot fail"); - let second = derive_seed(&key).expect("seed derivation cannot fail"); + 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 = derive_seed(&SecretKey::generate()).expect("seed derivation cannot fail"); + 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 index c0e3cb35..bc306152 100644 --- a/crates/nip60/src/lib.rs +++ b/crates/nip60/src/lib.rs @@ -43,7 +43,7 @@ pub struct MintQuoteEntry { pub quote_id: String, pub request: String, pub amount: u64, - pub mint: Url, + pub mint: MintUrl, } pub struct WalletRegistry { @@ -137,61 +137,17 @@ impl WalletRegistry { /// 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) = self - .wallet - .as_ref() - .and_then(|wallet| wallet.mints.first().cloned()) - else { - self.emit_error("No wallet is set up".to_string(), cx); + let Some(mint_url) = self.primary_mint(cx) else { return; }; - let Ok(mint_url) = MintUrl::from_str(mint.as_str()) else { - self.emit_error("Invalid mint URL".to_string(), cx); - return; - }; - - let Some(wallet) = self.wallets.get(&mint_url).cloned() else { - self.emit_error("Wallet is not ready".to_string(), cx); - return; - }; - - let created = cx.background_spawn(async move { - let quote = wallet - .mint_quote( - cdk::nuts::PaymentMethod::Known(cdk::nuts::nut00::KnownMethod::Bolt11), - Some(cdk::Amount::from(amount)), - None, - None, - ) - .await?; - - Ok::<_, Error>((quote.id, quote.request)) - }); - - self.tasks.push(cx.spawn(async move |this, cx| { - let (quote_id, request) = match created.await { - Ok(quote) => quote, - Err(error) => { - this.update(cx, |this, cx| { - this.emit_error(format!("{error:#}"), cx); - }) - .ok(); - return; - } - }; - - this.update(cx, |this, cx| { - this.quotes.push(MintQuoteEntry { - quote_id, - request, - amount, - mint: mint.clone(), - }); - this.emit_updated(cx); - }) - .ok(); - })); + 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. @@ -201,14 +157,9 @@ impl WalletRegistry { return; }; - let Ok(mint_url) = MintUrl::from_str(entry.mint.as_str()) else { - self.emit_error("Invalid mint URL".to_string(), cx); - return; - }; - self.schedule_job( ops::Job::ClaimQuote { - mint: mint_url, + mint: entry.mint.clone(), quote_id, }, cx, @@ -217,17 +168,7 @@ impl WalletRegistry { /// Pay a lightning invoice from the wallet. pub fn melt(&mut self, invoice: String, cx: &mut Context) { - let Some(mint) = self - .wallet - .as_ref() - .and_then(|wallet| wallet.mints.first().cloned()) - else { - self.emit_error("No wallet is set up".to_string(), cx); - return; - }; - - let Ok(mint_url) = MintUrl::from_str(mint.as_str()) else { - self.emit_error("Invalid mint URL".to_string(), cx); + let Some(mint_url) = self.primary_mint(cx) else { return; }; @@ -242,17 +183,7 @@ impl WalletRegistry { /// 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) = self - .wallet - .as_ref() - .and_then(|wallet| wallet.mints.first().cloned()) - else { - self.emit_error("No wallet is set up".to_string(), cx); - return; - }; - - let Ok(mint_url) = MintUrl::from_str(mint.as_str()) else { - self.emit_error("Invalid mint URL".to_string(), cx); + let Some(mint_url) = self.primary_mint(cx) else { return; }; @@ -265,6 +196,22 @@ impl WalletRegistry { ); } + /// 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) { @@ -306,19 +253,15 @@ impl WalletRegistry { } }; - let (privkey, mints) = match self - .wallet - .as_ref() - .map(|wallet| (wallet.privkey.clone(), wallet.mints.clone())) - { - Some((privkey, mut mints)) => { - if mints.contains(&mint) { + let mut data = match self.wallet.clone() { + Some(mut data) => { + if data.mints.contains(&mint) { return; } - mints.push(mint.clone()); - (privkey, mints) + data.mints.push(mint.clone()); + data } - None => (keys::generate_privkey(), vec![mint.clone()]), + None => keys::WalletData::generate(mint.clone()), }; let nostr = NostrRegistry::global(cx); @@ -328,24 +271,30 @@ impl WalletRegistry { let publish = cx.background_spawn(async move { let me = signer.get_public_key_async().await?; - let event = keys::build_wallet_event(&signer, &me, &privkey, &mints).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, - &mints, - &state::BOOTSTRAP_RELAYS, - Keys::new(privkey.clone()).public_key(), + &data.mints, + &relays, + Keys::new(data.privkey.clone()).public_key(), ) .await?; client.send_event(&info).await?; - Ok::<_, Error>(keys::WalletData { - event_id: event.id, - created_at: event.created_at, - privkey, - mints, - }) + data.event_id = event.id; + data.created_at = event.created_at; + + Ok::<_, Error>(data) }); self.tasks.push(cx.spawn(async move |this, cx| { @@ -406,10 +355,10 @@ impl WalletRegistry { 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(); - - const MAX_PROCESSED: usize = 1000; let mut processed_events = HashSet::new(); while let Some(notification) = notifications.next().await { @@ -417,13 +366,15 @@ impl WalletRegistry { && let RelayMessage::Event { event, .. } = *message { let ours = me == Some(event.pubkey); - let wallet_event = ours - && (matches!( - event.kind, - Kind::CashuWallet - | Kind::CashuWalletUnspentProof - | Kind::CashuWalletSpendingHistory - ) || is_token_deletion(&event)); + 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)); @@ -483,7 +434,7 @@ impl WalletRegistry { let decrypted = cx.background_spawn(async move { let me = signer.get_public_key_async().await?; - keys::decrypt_wallet_event(&signer, &me, &event).await + keys::WalletData::decrypt(&signer, &me, &event).await }); self.tasks.push(cx.spawn(async move |this, cx| { @@ -499,9 +450,17 @@ impl WalletRegistry { }; this.update(cx, |this, cx| { - this.wallet = Some(data); - this.ensure_wallets(cx); - this.emit_updated(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(); })); @@ -525,10 +484,7 @@ impl WalletRegistry { let token = match decrypted.await { Ok(token) => token, Err(error) => { - this.update(cx, |this, cx| { - this.emit_error(format!("{error:#}"), cx); - }) - .ok(); + log::warn!("skipping unreadable token event {event_id}: {error:#}"); return; } }; @@ -577,46 +533,33 @@ impl WalletRegistry { 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 Some(wallet) = self.wallet.as_ref() else { - return; - }; - - let Some(mint) = nutzap_mint(&event) else { - log::warn!("nutzap {} has no mint", event.id); - return; - }; - - let Ok(mint_url) = MintUrl::from_str(&mint) else { - log::warn!("nutzap {} has an invalid mint: {mint}", event.id); - return; - }; - - if !self.wallets.contains_key(&mint_url) { - log::warn!("nutzap {} is on an unlisted mint: {mint_url}", event.id); - return; - } - - let proofs = match nutzap_proofs(&event) { - Ok(proofs) => proofs, - Err(error) => { - log::warn!("nutzap {} has no usable proofs: {error:#}", event.id); - return; - } - }; - let target = format!( "02{}", Keys::new(wallet.privkey.clone()).public_key().to_hex() ); - if !proofs.iter().all(|proof| locked_to(proof, &target)) { + 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 {} proofs are not locked to the wallet key", - event.id + "nutzap {} is on an unlisted mint: {}", + event.id, + nutzap.mint ); return; } @@ -624,8 +567,8 @@ impl WalletRegistry { self.redeeming.insert(event.id); self.schedule_job( ops::Job::RedeemNutzap { - mint: mint_url, - proofs, + mint: nutzap.mint, + proofs: nutzap.proofs, nutzap_id: event.id, sender: event.pubkey, }, @@ -661,7 +604,7 @@ impl WalletRegistry { } if let Some(store) = self.store.clone() { - let seed = match keys::derive_seed(&data.privkey) { + let seed = match keys::WalletData::derive_seed(&data.privkey) { Ok(seed) => seed, Err(error) => { self.emit_error(format!("{error:#}"), cx); @@ -703,7 +646,7 @@ impl WalletRegistry { let mints = data.mints.clone(); self.tasks.push(cx.spawn(async move |this, cx| { - match Tokio::spawn_result(cx, open_store(privkey, mints)).await { + match Tokio::spawn_result(cx, Self::open_store(privkey, mints)).await { Ok((store, wallets)) => { this.update(cx, |this, cx| { if this.store.is_none() { @@ -751,15 +694,16 @@ impl WalletRegistry { fn start_job(&mut self, job: ops::Job, cx: &mut Context) { if self.store.is_none() || self.wallets.is_empty() { - self.emit_error("Wallet is not ready".to_string(), cx); + self.job_queued.push(job); return; } let wallets = self.wallets.clone(); let tokens = self.tokens.clone(); - let signer = NostrRegistry::global(cx).read(cx).signer().clone(); - let client = NostrRegistry::global(cx).read(cx).client().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 @@ -789,7 +733,7 @@ impl WalletRegistry { tokens, }; - ops::run_job(job, ctx).await + job.run(ctx).await }) .await; @@ -827,6 +771,20 @@ impl WalletRegistry { 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, @@ -881,7 +839,6 @@ impl WalletRegistry { self.redeemed = state.redeemed; cx.notify(); self.ensure_wallets(cx); - self.schedule_job(ops::Job::Reconcile, cx); // Claimed once the wallets are open; live redemptions continue // through the notification listener @@ -903,7 +860,7 @@ impl WalletRegistry { let subscribe_client = client.clone(); let cached = cx.background_spawn(async move { let me = signer.get_public_key_async().await?; - let state = load_inner(&me, &client, &signer).await?; + let state = LoadedState::load(me, &client, &signer).await?; Ok::<_, Error>((me, state)) }); @@ -933,12 +890,11 @@ impl WalletRegistry { }) .ok(); - // Subscribe only after the cache snapshot; from then on the state - // advances exclusively through the notification listener. let subscribed = cx.background_spawn(async move { - subscribe_client.subscribe(wallet_filter(me)).await?; - subscribe_client.subscribe(nutzap_filter(me)).await + 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:#}"); } @@ -954,154 +910,163 @@ impl WalletRegistry { 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()) + } } -async fn load_inner(me: &PublicKey, client: &Client, signer: &S) -> Result -where - S: AsyncNip44 + ?Sized, -{ - let events = client.database().query(wallet_filter(*me)).await?; - let nutzap_events = client.database().query(nutzap_filter(*me)).await?; +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(); + 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, + 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 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 replace { - wallet_event = Some(event); + if let Ok(id) = EventId::from_hex(hex) { + stale.insert(id); } } - 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 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" { + let mut tokens = HashMap::new(); + for event in token_events { + if stale.contains(&event.id) { continue; } - - let Some(hex) = tag.content() else { - 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; + } }; - - if let Ok(id) = EventId::from_hex(hex) { - stale.insert(id); - } + tokens.insert(event.id, token); } - } - 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; - } + 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, }; - tokens.insert(event.id, token); + + Ok(Self { + wallet, + tokens, + stale, + redeemed, + nutzaps, + }) } - - 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::decrypt_wallet_event(signer, me, event).await { - Ok(wallet) => Some(wallet), - Err(error) => { - log::warn!("skipping wallet event {}: {error:#}", event.id); - None - } - }, - None => None, - }; - - Ok(LoadedState { - wallet, - tokens, - stale, - redeemed, - nutzaps, - }) -} - -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::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()) } fn is_token_deletion(event: &Event) -> bool { @@ -1112,57 +1077,6 @@ fn is_token_deletion(event: &Event) -> bool { .any(|tag| tag.kind() == "k" && tag.content() == Some("7375")) } -/// The `u` tag carries the mint URL; fall back to the older `mint` tag -fn nutzap_mint(event: &Event) -> Option { - let tag = event - .tags - .iter() - .find(|tag| tag.kind() == "u" && tag.content().is_some()) - .or_else(|| { - event - .tags - .iter() - .find(|tag| tag.kind() == "mint" && tag.content().is_some()) - })?; - - tag.content().map(str::to_string) -} - -fn nutzap_proofs(event: &Event) -> Result { - 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!("no proofs")); - } - - Ok(Proofs::from(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() - .map(|keys| keys.iter().any(|key| key.to_string() == target)) - .unwrap_or(false) -} - /// Nutzap event ids our history events marked as redeemed. fn redeemed_ids(event: &Event) -> Vec { event @@ -1176,3 +1090,59 @@ fn redeemed_ids(event: &Event) -> Vec { .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 index eb8a5ab6..5e2e020e 100644 --- a/crates/nip60/src/ops.rs +++ b/crates/nip60/src/ops.rs @@ -22,6 +22,10 @@ use crate::keys; #[derive(Debug, Clone)] pub(crate) enum Job { Reconcile, + CreateQuote { + mint: MintUrl, + amount: u64, + }, ClaimQuote { mint: MintUrl, quote_id: String, @@ -46,14 +50,181 @@ pub(crate) enum Job { }, } -/// History event payload for a token 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, +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::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 { @@ -69,6 +240,12 @@ pub(crate) enum Outcome { Reconciled { updates: Vec, }, + QuoteCreated { + mint: MintUrl, + quote_id: String, + request: String, + amount: u64, + }, QuoteMinted { quote_id: String, update: TokenUpdate, @@ -103,295 +280,130 @@ pub(crate) struct JobCtx { pub(crate) tokens: HashMap, } -pub(crate) async fn run_job(job: Job, ctx: JobCtx) -> Result -where - S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static, -{ - match job { - Job::Reconcile => reconcile(ctx).await, - Job::ClaimQuote { mint, quote_id } => { - let wallet = wallet_for(&ctx, &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 = sync_token_events( - &ctx.signer, - &ctx.client, - wallet, - nostr_mint(&mint)?, - &previous_ids(&ctx.tokens, &mint), - Some(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 = wallet_for(&ctx, &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 = sync_token_events( - &ctx.signer, - &ctx.client, - wallet, - nostr_mint(&mint)?, - &previous_ids(&ctx.tokens, &mint), - Some(History { - direction: TransactionDirection::Out, - amount, - redeemed: Vec::new(), - sender: None, - }), - ) - .await?; - - Ok(Outcome::Melted { update, amount }) - } - Job::SendToken { mint, amount } => { - let wallet = wallet_for(&ctx, &mint)?; - let prepared = wallet - .prepare_send(Amount::from(amount), SendOptions::default()) - .await?; - let token = prepared.confirm(None).await?.to_string(); - - let update = sync_token_events( - &ctx.signer, - &ctx.client, - wallet, - nostr_mint(&mint)?, - &previous_ids(&ctx.tokens, &mint), - Some(History { - direction: TransactionDirection::Out, - amount, - redeemed: Vec::new(), - sender: None, - }), - ) - .await?; - - Ok(Outcome::Sent { update, token }) - } - Job::Receive { mint, token } => { - let wallet = wallet_for(&ctx, &mint)?; - let received = wallet.receive(&token, ReceiveOptions::default()).await?; - - let update = sync_token_events( - &ctx.signer, - &ctx.client, - wallet, - nostr_mint(&mint)?, - &previous_ids(&ctx.tokens, &mint), - Some(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 = wallet_for(&ctx, &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 = sync_token_events( - &ctx.signer, - &ctx.client, - wallet, - nostr_mint(&mint)?, - &previous_ids(&ctx.tokens, &mint), - Some(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), - }) - } - } -} - -/// Claim paid quotes, then converge each mint. -async fn reconcile(ctx: JobCtx) -> Result +impl JobCtx where S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync, { - let mut tokens = ctx.tokens.clone(); - let mut updates = Vec::new(); - - for (mint_url, wallet) in &ctx.wallets { - let minted = wallet.mint_unissued_quotes().await?; - if minted == Amount::ZERO { - continue; - } - - let update = sync_token_events( - &ctx.signer, - &ctx.client, - wallet, - nostr_mint(mint_url)?, - &previous_ids(&tokens, mint_url), - Some(History { - direction: TransactionDirection::In, - amount: u64::from(minted), - redeemed: Vec::new(), - sender: None, - }), - ) - .await?; - - apply_update(&mut tokens, &update); - updates.push(update); + fn wallet(&self, mint: &MintUrl) -> Result<&Wallet, Error> { + self.wallets + .get(mint) + .ok_or_else(|| anyhow!("no open wallet for mint {mint}")) } - for (mint_url, wallet) in &ctx.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); - } - } - } + 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 removed = unspent + + let proofs = unspent .iter() - .filter(|proof| match proof.y() { - Ok(y) => !relay_ys.contains(&y), - Err(_) => false, - }) - .map(|proof| proof.y()) + .map(cashu_proof) .collect::, _>>()?; - wallet.localstore.update_proofs(added, removed).await?; + 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(), + }) } - Ok(Outcome::Reconciled { updates }) -} + /// Claim paid quotes, then converge each mint. + async fn reconcile(self) -> Result { + let mut tokens = self.tokens.clone(); + let mut updates = Vec::new(); -/// Publish the store's current unspent set as a fresh token event. -async fn sync_token_events( - signer: &S, - client: &Client, - wallet: &Wallet, - mint: Url, - previous: &[EventId], - history: Option, -) -> Result -where - S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync + ?Sized, -{ - let me = signer.get_public_key_async().await?; - let unspent = wallet.get_unspent_proofs().await?; + for (mint_url, wallet) in &self.wallets { + wallet.recover_incomplete_sagas().await?; - let mut proofs = Vec::new(); - for proof in &unspent { - proofs.push(cashu_proof(proof)?); + 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 }) } - - let token = TokenEvent::new(mint, proofs); - let event = keys::build_token_event(signer, &me, &token).await?; - client.send_event(&event).await?; - - if !previous.is_empty() { - let deletion = keys::build_token_deletion(signer, previous).await?; - client.send_event(&deletion).await?; - } - - if let Some(history) = history { - let history_event = keys::build_history_event( - signer, - &me, - history.direction, - history.amount, - event.id, - previous, - &history.redeemed, - history.sender, - ) - .await?; - client.send_event(&history_event).await?; - } - - Ok(TokenUpdate { - event_id: event.id, - token, - destroyed: previous.to_vec(), - }) -} - -fn apply_update(tokens: &mut HashMap, update: &TokenUpdate) { - for id in &update.destroyed { - tokens.remove(id); - } - tokens.insert(update.event_id, update.token.clone()); } fn previous_ids(tokens: &HashMap, mint: &MintUrl) -> Vec { @@ -402,16 +414,6 @@ fn previous_ids(tokens: &HashMap, mint: &MintUrl) -> Vec(ctx: &'a JobCtx, mint: &MintUrl) -> Result<&'a Wallet, Error> { - ctx.wallets - .get(mint) - .ok_or_else(|| anyhow!("no open wallet for mint {mint}")) -} - -fn nostr_mint(mint: &MintUrl) -> Result { - Url::parse(&mint.to_string()).map_err(|error| anyhow!("invalid mint url: {error}")) -} - fn cashu_proof(proof: &Proof) -> Result { Ok(CashuProof { id: proof.keyset_id.to_string(), @@ -421,6 +423,10 @@ fn cashu_proof(proof: &Proof) -> Result { }) } +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)?; diff --git a/docs/nip60.md b/docs/nip60.md index 91caac12..8c412721 100644 --- a/docs/nip60.md +++ b/docs/nip60.md @@ -143,43 +143,46 @@ Spike findings (all resolved and verified with `cargo check`): - 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 same client/relays coop already uses. Publishing a - `10019` wallet relay list with those relays is a one-liner for interop; - reading `10019` from other clients is skipped in v1 (coop always uses +- 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 -(rolling up previous event ids into `del`) -> NIP-09 delete old events with +(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)`: `wallet.mint_quote` -> return invoice to UI; - poll / subscribe quote state, then `wallet.mint(quote_id)` -> sync. +- `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 gift-wrapped proofs are P2PK-locked to +- 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 load: `recover_incomplete_sagas` and `mint_unissued_quotes` - (required by cdk to clear interrupted operations), then re-derive the - unspent set from `7375` minus deletes. +- 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. +- `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. @@ -277,6 +280,46 @@ Implemented: - 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`. + +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 - Settings entry for the mint URL (`settings` crate pattern). -- 2.54.0 From 53aa54a034fcc811d21e3d6ae839efc578c39f22 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 6 Nov 2026 18:04:31 +0700 Subject: [PATCH 07/10] add nip60 wallet ui and nutzap sending --- Cargo.lock | 17 + crates/chat_ui/Cargo.toml | 3 + crates/chat_ui/src/lib.rs | 47 +++ crates/nip60/Cargo.toml | 1 + crates/nip60/src/keys.rs | 77 ++++ crates/nip60/src/lib.rs | 114 +++++- crates/nip60/src/ops.rs | 177 ++++++++- crates/nip60_ui/Cargo.toml | 15 + crates/nip60_ui/src/lib.rs | 79 ++++ crates/nip60_ui/src/wallet.rs | 422 +++++++++++++++++++++ crates/workspace/Cargo.toml | 8 +- crates/workspace/src/dialogs/join_group.rs | 6 +- crates/workspace/src/lib.rs | 31 ++ crates/workspace/src/sidebar/list.rs | 14 + docs/nip60.md | 38 +- 15 files changed, 1033 insertions(+), 16 deletions(-) create mode 100644 crates/nip60_ui/Cargo.toml create mode 100644 crates/nip60_ui/src/lib.rs create mode 100644 crates/nip60_ui/src/wallet.rs diff --git a/Cargo.lock b/Cargo.lock index 0b5a8d27..af7a0b33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1462,6 +1462,7 @@ dependencies = [ "gpui-pre", "instant", "itertools 0.13.0", + "nip60_ui", "nostr-sdk", "person", "regex", @@ -6123,6 +6124,7 @@ dependencies = [ "cdk-redb", "common", "flume 0.11.1", + "futures", "gpui-pre", "gpui_tokio", "hkdf", @@ -6136,6 +6138,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "nip60_ui" +version = "2.0.0-alpha3" +dependencies = [ + "anyhow", + "common", + "gpui-kit", + "gpui-pre", + "nip60", + "nostr-sdk", + "smallvec", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -11944,6 +11959,8 @@ dependencies = [ "nip29", "nip29_ui", "nip4e", + "nip60", + "nip60_ui", "nostr-connect", "nostr-sdk", "person", 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 index fe2ed07c..f67f1109 100644 --- a/crates/nip60/Cargo.toml +++ b/crates/nip60/Cargo.toml @@ -17,6 +17,7 @@ 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" ] } diff --git a/crates/nip60/src/keys.rs b/crates/nip60/src/keys.rs index 27adf39d..c0af301d 100644 --- a/crates/nip60/src/keys.rs +++ b/crates/nip60/src/keys.rs @@ -159,6 +159,83 @@ where .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, diff --git a/crates/nip60/src/lib.rs b/crates/nip60/src/lib.rs index bc306152..4e2473ce 100644 --- a/crates/nip60/src/lib.rs +++ b/crates/nip60/src/lib.rs @@ -46,12 +46,23 @@ pub struct MintQuoteEntry { 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) @@ -73,6 +84,7 @@ struct LoadedState { wallet: Option, tokens: HashMap, stale: HashSet, + history: Vec, redeemed: HashSet, nutzaps: Vec, } @@ -103,6 +115,7 @@ impl WalletRegistry { tokens: HashMap::new(), stale: HashSet::new(), quotes: Vec::new(), + history: Vec::new(), redeemed: HashSet::new(), pending_nutzaps: Vec::new(), redeeming: HashSet::new(), @@ -135,6 +148,11 @@ impl WalletRegistry { &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 { @@ -196,6 +214,31 @@ impl WalletRegistry { ); } + /// 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 { @@ -331,6 +374,7 @@ impl WalletRegistry { self.tokens.clear(); self.stale.clear(); self.quotes.clear(); + self.history.clear(); self.redeemed.clear(); self.pending_nutzaps.clear(); self.redeeming.clear(); @@ -412,7 +456,7 @@ impl WalletRegistry { match event.kind { Kind::CashuWallet => self.absorb_wallet(event, cx), Kind::CashuWalletUnspentProof => self.absorb_token(event, cx), - Kind::CashuWalletSpendingHistory => {} + Kind::CashuWalletSpendingHistory => self.absorb_history(event, cx), Kind::EventDeletion => self.absorb_deletion(event, cx), Kind::Custom(9321) => self.absorb_nutzap(event, cx), _ => {} @@ -527,6 +571,46 @@ impl WalletRegistry { } } + 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) { @@ -805,6 +889,11 @@ impl WalletRegistry { 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); @@ -836,6 +925,7 @@ impl WalletRegistry { 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); @@ -996,12 +1086,31 @@ impl LoadedState { // Nutzap events tagged redeemed in our history events are already claimed let mut redeemed = HashSet::new(); - for event in history_events { + 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)) @@ -1063,6 +1172,7 @@ impl LoadedState { wallet, tokens, stale, + history, redeemed, nutzaps, }) diff --git a/crates/nip60/src/ops.rs b/crates/nip60/src/ops.rs index 5e2e020e..722c37e4 100644 --- a/crates/nip60/src/ops.rs +++ b/crates/nip60/src/ops.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; use std::str::FromStr; +use std::time::Duration; use anyhow::{Error, anyhow}; use cdk::Amount; @@ -7,17 +8,21 @@ use cdk::amount::SplitTarget; use cdk::mint_url::MintUrl; use cdk::nuts::nut00::KnownMethod; use cdk::nuts::{ - CurrencyUnit, Id, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs, SecretKey, - State, + 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 { @@ -38,6 +43,12 @@ pub(crate) enum Job { mint: MintUrl, amount: u64, }, + SendNutzap { + recipient: PublicKey, + amount: u64, + target: Option, + comment: Option, + }, Receive { mint: MintUrl, token: String, @@ -167,6 +178,74 @@ impl Job { 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?; @@ -259,6 +338,10 @@ pub(crate) enum Outcome { update: TokenUpdate, token: String, }, + NutzapSent { + update: TokenUpdate, + amount: u64, + }, Received { update: TokenUpdate, amount: u64, @@ -339,7 +422,9 @@ where let mut updates = Vec::new(); for (mint_url, wallet) in &self.wallets { - wallet.recover_incomplete_sagas().await?; + 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 { @@ -414,6 +499,92 @@ fn previous_ids(tokens: &HashMap, mint: &MintUrl) -> Vec( + 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(), diff --git a/crates/nip60_ui/Cargo.toml b/crates/nip60_ui/Cargo.toml new file mode 100644 index 00000000..b049fcda --- /dev/null +++ b/crates/nip60_ui/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "nip60_ui" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +common = { path = "../common" } +nip60 = { path = "../nip60" } + +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..6f489605 --- /dev/null +++ b/crates/nip60_ui/src/lib.rs @@ -0,0 +1,79 @@ +#![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>; + +/// Ask for an amount and send a nutzap to `recipient`, optionally +/// nutzapping the event with `target`. +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..7026f4cd --- /dev/null +++ b/crates/nip60_ui/src/wallet.rs @@ -0,0 +1,422 @@ +use ::nip60::{HistoryEntry, MintQuoteEntry, WalletEvent, WalletRegistry}; +use common::TimestampExt; +use gpui::prelude::FluentBuilder; +use gpui::{ + App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, + ParentElement, Render, SharedString, Styled, Subscription, Window, div, +}; +use gpui_kit::assets::IconName; +use gpui_kit::base::StyledExt as _; +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::clipboard::Clipboard; +use gpui_kit::component::dock::{Panel, PanelEvent}; +use gpui_kit::component::input::{Input, InputState}; +use gpui_kit::component::notification::Notification; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::separator::Separator; +use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex, v_flex}; +use nostr_sdk::prelude::*; +use smallvec::SmallVec; +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| WalletPanel::new(window, cx)) +} + +pub struct WalletPanel { + name: SharedString, + focus_handle: FocusHandle, + + /// Mint URL input for the setup flow + mint_input: Entity, + /// Amount input for sending a cashu token + send_input: Entity, + /// Amount input for the funding invoice + invoice_input: Entity, + /// Pasted token input for receiving ecash + token_input: Entity, + /// The token produced by the last send, shown for copying + sent_token: Option, + + _subscriptions: SmallVec<[Subscription; 1]>, +} + +impl WalletPanel { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint…")); + let send_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)")); + let invoice_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)")); + let token_input = cx.new(|cx| InputState::new(window, cx).placeholder("cashu…")); + + 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(token) => { + this.sent_token = Some(token.clone().into()); + cx.notify(); + } + }, + ), + ); + + Self { + name: "Wallet".into(), + focus_handle: cx.focus_handle(), + mint_input, + send_input, + invoice_input, + token_input, + sent_token: None, + _subscriptions: subscriptions, + } + } + + fn parse_amount(&self, input: &Entity, cx: &Context) -> Option { + match input.read(cx).value().trim().parse::() { + Ok(amount) if amount > 0 => Some(amount), + _ => None, + } + } + + fn setup(&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; + } + + WalletRegistry::global(cx).update(cx, |registry, cx| { + registry.setup(mint, cx); + }); + } + + fn send_token(&mut self, window: &mut Window, cx: &mut Context) { + let Some(amount) = self.parse_amount(&self.send_input, cx) else { + window.push_notification(Notification::error("Enter an amount in sats"), cx); + return; + }; + + WalletRegistry::global(cx).update(cx, |registry, cx| { + registry.send_token(amount, cx); + }); + } + + fn create_invoice(&mut self, window: &mut Window, cx: &mut Context) { + let Some(amount) = self.parse_amount(&self.invoice_input, cx) else { + window.push_notification(Notification::error("Enter an amount in sats"), cx); + return; + }; + + WalletRegistry::global(cx).update(cx, |registry, cx| { + registry.create_mint_quote(amount, cx); + }); + } + + fn claim_quote(&mut self, quote_id: String, cx: &mut Context) { + WalletRegistry::global(cx).update(cx, |registry, cx| { + registry.claim_mint_quote(quote_id, cx); + }); + } + + fn receive_token(&mut self, window: &mut Window, cx: &mut Context) { + let token = self.token_input.read(cx).value().trim().to_string(); + if token.is_empty() { + window.push_notification(Notification::error("Paste a cashu token"), cx); + return; + } + + WalletRegistry::global(cx).update(cx, |registry, cx| { + registry.receive_token(token, cx); + }); + } + + fn render_balance(&self, cx: &Context) -> impl IntoElement { + let balance = WalletRegistry::global(cx).read(cx).balance(); + + h_flex() + .gap_2() + .items_end() + .child( + div() + .text_2xl() + .child(SharedString::from(balance.to_string())), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("sats"), + ) + } + + fn render_setup(&self, cx: &mut Context) -> impl IntoElement { + let has_wallet = WalletRegistry::global(cx).read(cx).has_wallet(); + + v_flex() + .gap_1p5() + .w_full() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(if has_wallet { + "Add a mint:" + } else { + "Set up your wallet with a mint:" + })), + ) + .child( + h_flex() + .gap_1() + .w_full() + .child(Input::new(&self.mint_input).cleanable(true)) + .child( + Button::new("setup") + .label(if has_wallet { "Add" } else { "Set up" }) + .primary() + .small() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.setup(window, cx); + })), + ), + ) + } + + fn render_send(&self, cx: &Context) -> impl IntoElement { + v_flex() + .gap_1p5() + .w_full() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Send ecash:"), + ) + .child( + h_flex() + .gap_1() + .w_full() + .child(Input::new(&self.send_input).cleanable(true)) + .child( + Button::new("send") + .label("Send") + .small() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.send_token(window, cx); + })), + ), + ) + .when_some(self.sent_token.as_ref(), |this, token| { + this.child( + h_flex() + .gap_1() + .w_full() + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_color(cx.theme().muted_foreground) + .child(token.clone()), + ) + .child( + Clipboard::new("copy-token") + .accessibility_label("Copy cashu token") + .value_fn({ + let token = token.clone(); + move |_, _| SharedString::from(token.to_string()) + }), + ), + ) + }) + } + + fn render_receive(&self, cx: &Context) -> impl IntoElement { + let quotes = WalletRegistry::global(cx).read(cx).mint_quotes().to_vec(); + + let mut section = + v_flex() + .gap_1p5() + .w_full() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Receive:"), + ) + .child( + h_flex() + .gap_1() + .w_full() + .child(Input::new(&self.invoice_input).cleanable(true)) + .child(Button::new("invoice").label("Invoice").small().on_click( + cx.listener(move |this, _ev, window, cx| { + this.create_invoice(window, cx); + }), + )), + ); + + for quote in quotes { + section = section.child(self.render_quote("e, cx)); + } + + section.child( + h_flex() + .gap_1() + .w_full() + .child(Input::new(&self.token_input).cleanable(true)) + .child( + Button::new("receive") + .label("Redeem") + .small() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.receive_token(window, cx); + })), + ), + ) + } + + fn render_quote(&self, quote: &MintQuoteEntry, cx: &Context) -> impl IntoElement { + h_flex() + .gap_1() + .w_full() + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(quote.request.clone())), + ) + .child( + Clipboard::new(SharedString::from(format!("copy_{}", quote.quote_id))) + .accessibility_label("Copy lightning invoice") + .value_fn({ + let request = SharedString::from(quote.request.clone()); + move |_, _| request.clone() + }), + ) + .child( + Button::new(SharedString::from(format!("claim_{}", quote.quote_id))) + .label("Claim") + .small() + .on_click({ + let quote_id = quote.quote_id.clone(); + cx.listener(move |this, _ev, _window, cx| { + this.claim_quote(quote_id.clone(), cx); + }) + }), + ) + } + + fn render_history(&self, cx: &Context) -> impl IntoElement { + let history = WalletRegistry::global(cx).read(cx).history().to_vec(); + + let mut section = v_flex().gap_1().w_full().child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("History:"), + ); + + if history.is_empty() { + section = section.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("No transactions yet."), + ); + } + + for entry in history { + section = section.child(self.render_history_entry(&entry, cx)); + } + + section + } + + 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()), + ) + } +} + +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 has_wallet = WalletRegistry::global(cx).read(cx).has_wallet(); + + v_flex() + .p_3() + .gap_3() + .w_full() + .overflow_y_scrollbar() + .text_xs() + .when(!has_wallet, |this| this.child(self.render_setup(cx))) + .when(has_wallet, |this| { + this.child(self.render_balance(cx)) + .child(Separator::horizontal()) + .child(self.render_send(cx)) + .child(self.render_receive(cx)) + .child(Separator::horizontal()) + .child(self.render_history(cx)) + .child(self.render_setup(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/lib.rs b/crates/workspace/src/lib.rs index 3a80a0c9..eed3b675 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -61,6 +61,8 @@ enum Command { ShowContactList, ShowInbox, ShowBrowse, + #[cfg(not(target_arch = "wasm32"))] + ShowWallet, NewChat, NewGroup, JoinGroup, @@ -196,6 +198,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 +333,11 @@ impl Workspace { let panel = browse::init(self.dock.downgrade(), window, cx); self.add_panel_to_dock(panel, DockPlacement::Center, window, cx); } + #[cfg(not(target_arch = "wasm32"))] + Command::ShowWallet => { + 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..8b3b5d0b 100644 --- a/crates/workspace/src/sidebar/list.rs +++ b/crates/workspace/src/sidebar/list.rs @@ -43,6 +43,19 @@ impl Sidebar { // Whether to show the create button for the contact picker let create_button = contacts_open && !self.selected_contacts.is_empty(); + #[cfg(not(target_arch = "wasm32"))] + let wallet_row = Some( + 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) + }), + ); + #[cfg(target_arch = "wasm32")] + let wallet_row: Option