diff --git a/plans/wallet-page.md b/plans/wallet-page.md new file mode 100644 index 0000000..8823ddc --- /dev/null +++ b/plans/wallet-page.md @@ -0,0 +1,406 @@ +# Unified Wallet Page (`wallet.html`) — Implementation Plan + +## Overview + +Create a new unified wallet page (`www/wallet.html`) that mirrors Amethyst's multi-rail wallet architecture, bringing together four payment rails into a single page: + +1. **Bitcoin (Onchain / Taproot)** — NIP-BC onchain zaps, Taproot address display, onchain transaction history +2. **Cashu (NIP-60 ecash)** — Fix the existing kind 17375 content format bug, then migrate cashu.html functionality into the new page +3. **NWC (Nostr Wallet Connect / NIP-47)** — Lightning wallet connections via `nostr+walletconnect://` URIs +4. **CLINK Debit** — Spend-only debit pointers (`ndebit1...`) for CLINK protocol + +This plan also fixes the critical NIP-60 compliance bug discovered during investigation: the current `ndk-worker.js` writes a proprietary JSON object as the kind 17375 encrypted content instead of the NIP-60 standard tag-array format, which makes Amethyst (and any standard NIP-60 client) unable to read the wallet's mints or privkey. + +--- + +## Architecture + +```mermaid +flowchart TD + subgraph Page["wallet.html (UI)"] + A[Header + Sidenav] + B[Bitcoin Onchain Section] + C[Cashu Wallet Section] + D[NWC Wallet List] + E[CLINK Debit List] + F[Add Wallet Modal] + end + + subgraph Modules["JS Modules"] + G["wallet-ui.mjs - new orchestrator"] + H["cashu-wallet.mjs - existing, updated"] + I["nwc-wallet.mjs - new"] + J["onchain-wallet.mjs - new"] + K["clink-wallet.mjs - new"] + end + + subgraph Worker["ndk-worker.js"] + L[NDK Core + Relays] + M[Direct Cashu proof store] + N["NWC request/response - new"] + O["Onchain zap events - new"] + end + + subgraph NDK["ndk-core.bundle.js"] + P[NDKCashuWallet] + Q[NDKNWCWallet] + R[NDKEvent / NDKKind] + S[cashu-ts CashuWallet] + end + + A --> G + B --> J + C --> H + D --> I + E --> K + F --> G + G --> H + G --> I + G --> J + G --> K + H --> M + I --> N + J --> O + K --> N + M --> L + N --> L + O --> L + H --> S + I --> Q + H --> P + J --> R +``` + +### File Structure + +| File | Type | Purpose | +|------|------|---------| +| `www/wallet.html` | New | Page HTML + page-specific styles + inline module script | +| `www/js/wallet-ui.mjs` | New | Top-level orchestrator that renders all four rail sections | +| `www/js/cashu-wallet.mjs` | Existing, updated | Cashu controller — update to use NIP-60 tag-array format | +| `www/js/nwc-wallet.mjs` | New | NWC wallet list management, balance fetch, pay invoice | +| `www/js/onchain-wallet.mjs` | New | Bitcoin onchain section — Taproot address, onchain zap send/history | +| `www/js/clink-wallet.mjs` | New | CLINK debit pointer management, budget requests | + +The module approach follows the existing pattern used by `cashu-wallet.mjs` and `post-interactions.mjs`. + +--- + +## Phase 1: Fix NIP-60 Compliance Bug (Critical, do first) + +### Problem + +In [`www/ndk-worker.js`](www/ndk-worker.js:2758) lines 2758–2769, the worker hand-rolls the kind 17375 wallet event with a **proprietary JSON object** as the encrypted content: + +```js +// CURRENT (wrong — proprietary) +const walletPayloadObj = { + mints: getDirectWalletMints(), + ...(directNutzapPrivateKeyHex + ? { nutzap: { privkey: directNutzapPrivateKeyHex, p2pk: directNutzapP2pk } } + : {}) +}; +``` + +NIP-60 requires the decrypted content to be a **JSON array of tag arrays** (the same format NDK's `payloadForEvent` and Amethyst's `CashuWalletEvent.build()` produce): + +```json +[["mint","https://mint.example.com"],["privkey",""]] +``` + +### Fix + +- [ ] **1.1** Change the wallet payload construction in `ndk-worker.js` to the NIP-60 tag-array format: + +```js +// FIXED (NIP-60 standard) +const walletPayloadObj = [ + ...getDirectWalletMints().map((url) => ["mint", url]), + ...(directNutzapPrivateKeyHex ? [["privkey", directNutzapPrivateKeyHex]] : []) +]; +const walletPayload = JSON.stringify(walletPayloadObj); +``` + +- [ ] **1.2** Remove the non-standard `pubkey` tag from the public tags of the 17375 event (line 2755). The p2pk pubkey belongs on the kind 10019 `NutzapInfoEvent`, not on kind 17375. Amethyst reads the p2pk from the decrypted `privkey` tag, not from a public `pubkey` tag. + +- [ ] **1.3** Update the wallet event **reading** path in `ndk-worker.js` (`hydrateNutzapKeyFromWalletEvents` and any other 17375 decryption) to parse the tag-array format instead of the object format. Add a backwards-compatibility shim: if the decrypted content parses as an object (old format), extract `mints` and `nutzap.privkey` from it; if it parses as an array, extract `mint` and `privkey` tags. + +- [ ] **1.4** Add a one-time migration: when the worker detects an old-format 17375 event on startup, automatically republish it in the new tag-array format and NIP-09 delete the old one. This ensures existing users get upgraded transparently. + +- [ ] **1.5** Test with Amethyst: after republishing, verify that Amethyst shows the wallet's mints and the "Could not read the existing wallet key" error no longer appears. + +--- + +## Phase 2: Create `wallet.html` Page Shell + +- [ ] **2.1** Create `www/wallet.html` based on the [`www/template.html`](www/template.html) pattern — standard header, hamburger, sidenav with AI section, footer with relay status. + +- [ ] **2.2** Add a sidenav nav entry to [`www/index.html`](www/index.html:543) — replace or augment the existing `cashu` entry with a `wallet` entry pointing to `wallet.html`. + +- [ ] **2.3** Page layout — single-column centered (like `cashu.html` and `post.html`), with four stacked sections: + +``` +┌─────────────────────────────────────┐ +│ HEADER (hamburger + "WALLET") │ +├─────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ BITCOIN (Onchain) │ │ +│ │ ₿ Taproot address │ │ +│ │ Balance: 0 sats │ │ +│ │ [Send Onchain Zap] [History]│ │ +│ └─────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ CASHU (NIP-60 ecash) │ │ +│ │ Balance: 1,234 sats │ │ +│ │ [Receive] [Send] [Deposit] │ │ +│ │ [Withdraw] [Mints] [History]│ │ +│ └─────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ LIGHTNING (NWC) │ │ +│ │ ┌─ Alby ──────── 500 sats ┐│ │ +│ │ └──────────────────────────┘│ │ +│ │ ┌─ Mutiny ────── 1,200 sats┐│ │ +│ │ └──────────────────────────┘│ │ +│ │ [+ Add NWC Connection] │ │ +│ └─────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ CLINK DEBIT │ │ +│ │ ┌─ My Debit ─── spend-only ┐│ │ +│ │ └──────────────────────────┘│ │ +│ │ [+ Add CLINK Pointer] │ │ +│ └─────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ [+ ADD WALLET] │ │ +│ │ Choose: Cashu | NWC | CLINK│ │ +│ └─────────────────────────────┘ │ +│ │ +├─────────────────────────────────────┤ +│ FOOTER (relay status + balance) │ +└─────────────────────────────────────┘ +``` + +- [ ] **2.4** Sidenav sections: keep the existing AI section, relay section. Move the Cashu mint-discovery and zap-settings sections from `cashu.html` into the `wallet.html` sidenav. + +--- + +## Phase 3: Cashu Section (Migrate from cashu.html) + +- [ ] **3.1** Port the Cashu UI from [`www/cashu.html`](www/cashu.html) into the Cashu section of `wallet.html` — balance card, action buttons (Receive/Send/Deposit/Withdraw), action panels, transaction history, mint management. + +- [ ] **3.2** Reuse the existing [`www/js/cashu-wallet.mjs`](www/js/cashu-wallet.mjs) controller — it already wraps the worker wallet API. No changes needed to the controller itself (the fix is in the worker, Phase 1). + +- [ ] **3.3** Port the mint-discovery sidebar and zap-settings sidebar from `cashu.html` into `wallet.html`'s sidenav. + +- [ ] **3.4** Port the nutzap mint-list (kind 10019) publishing UI from `cashu.html`. + +- [ ] **3.5** After `wallet.html` is complete, redirect `cashu.html` to `wallet.html` (or keep `cashu.html` as a thin redirect) and update the sidenav link in `index.html`. + +--- + +## Phase 4: NWC (Nostr Wallet Connect) Section + +NIP-47 (NWC) allows connecting to external Lightning wallets via `nostr+walletconnect://` URIs. The NDK bundle already includes [`NDKNWCWallet`](www/ndk-core.bundle.js:30422) which handles the NIP-47 protocol. + +### Storage + +NWC connection URIs are stored as a **kind 37550** application event (or in localStorage as a simpler approach initially). Each entry contains: +- Wallet name (user-defined label) +- NWC URI (`nostr+walletconnect://?relay=&secret=`) +- Whether it's the default wallet + +### Tasks + +- [ ] **4.1** Create `www/js/nwc-wallet.mjs` with: + - `loadNwcWallets()` — read stored NWC connections from localStorage (key: `nwcWallets`) + - `addNwcWallet(name, uri)` — parse and validate the URI, store it + - `removeNwcWallet(id)` — remove a connection + - `setDefaultNwcWallet(id)` — set the default wallet for zaps + - `fetchNwcBalance(walletId)` — use `NDKNWCWallet` to call `get_balance` via NIP-47 + - `payInvoiceViaNwc(walletId, bolt11)` — use `NDKNWCWallet` to call `pay_invoice` + - `getNwcInfo(walletId)` — call `get_info` for wallet capabilities + +- [ ] **4.2** Add NWC worker support in `ndk-worker.js`: + - New message handlers: `nwcAddWallet`, `nwcRemoveWallet`, `nwcGetBalance`, `nwcPayInvoice`, `nwcGetInfo` + - Use `NDKNWCWallet` from the bundle to make NIP-47 requests + - The NWC wallet sends kind 23194 events to the wallet service's relay and listens for responses + +- [ ] **4.3** NWC UI in `wallet.html`: + - Wallet list — each row shows name, balance, default badge, delete button + - "Add NWC Connection" form — name input + URI input + paste/scan + - Wallet detail view — balance, transactions, send Lightning payment + - URI validation: must start with `nostr+walletconnect://` or `nostrwalletconnect://` + +- [ ] **4.4** Integrate NWC as a zap payment source in [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs:1585) — when sending a zap, if the user has an NWC wallet configured, use it to pay the LN invoice automatically (replacing the current "open external wallet" fallback). + +--- + +## Phase 5: Bitcoin Onchain Section + +NIP-BC (Onchain Zaps) uses Taproot addresses derived from the user's Nostr pubkey to send on-chain Bitcoin zaps. Amethyst's [`OnchainSection.kt`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt:91) shows the user's Taproot address, balance, and a send dialog. + +### Tasks + +- [ ] **5.1** Create `www/js/onchain-wallet.mjs` with: + - `getTaprootAddress(pubkey)` — derive the Taproot address from the user's Nostr pubkey (NIP-BC spec: tweak the pubkey with a standard script tree) + - `fetchOnchainBalance(address)` — query a block explorer API (mempool.space) for the address UTXO balance + - `fetchOnchainTransactions(address)` — query mempool.space for address transactions + - `sendOnchainZap(recipientPubkey, amountSats)` — build and broadcast an onchain zap (NIP-BC kind 8333 receipt event + Bitcoin tx) + - Subscribe to kind 8333 (`OnchainZapEvent`) for incoming/outgoing zap history + +- [ ] **5.2** Onchain UI in `wallet.html`: + - Taproot address display with copy button and QR code + - Balance display (fetched from mempool.space) + - "Send Onchain Zap" button — opens a dialog to select recipient (by npub or address) and amount + - Transaction history list — incoming (green) and outgoing (orange) onchain zaps with counterparty info + - Public address warning (same as Amethyst: "This address is public — anyone can see your balance") + +- [ ] **5.3** Add onchain zap event subscription in `ndk-worker.js`: + - Subscribe to kind 8333 events where `p` tag = user pubkey (incoming) and where author = user pubkey (outgoing) + - Broadcast onchain zap events to the page via `onchainZapReceived` / `onchainZapSent` messages + +- [ ] **5.4** Integrate onchain zaps as a payment option in `post-interactions.mjs` — when zapping, offer the choice between Lightning (NWC), Cashu (nutzap), and Onchain (NIP-BC), similar to Amethyst's unified zap chip. + +--- + +## Phase 6: CLINK Debit Section + +CLINK (NIP-CLINK) is a spend-only debit protocol where the user adds a debit pointer (`ndebit1...`) that authorizes payments against a CLINK service. Amethyst's [`AddClinkDebitWalletScreen.kt`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddClinkDebitWalletScreen.kt:73) shows the add flow. + +### Tasks + +- [ ] **6.1** Create `www/js/clink-wallet.mjs` with: + - `loadClinkDebits()` — read stored CLINK debit pointers from localStorage (key: `clinkDebits`) + - `addClinkDebit(name, ndebitUri)` — parse the `ndebit1...` pointer, validate, store + - `removeClinkDebit(id)` — remove a debit pointer + - `requestClinkBudget(debitId, amountSats, frequency)` — request a spending budget from the CLINK service + - `payViaClinkDebit(debitId, bolt11)` — pay a Lightning invoice via the CLINK debit + +- [ ] **6.2** CLINK UI in `wallet.html`: + - Debit list — each row shows name, "spend-only" badge, budget info, delete button + - "Add CLINK Debit" form — name input + `ndebit1...` pointer input + - Budget dialog — set spending limit (amount + frequency: one-time / daily / weekly / monthly) + - No balance display (CLINK debits are spend-only, no balance to show — same as Amethyst) + +- [ ] **6.3** Integrate CLINK as a zap payment source in `post-interactions.mjs` — when the user has a CLINK debit configured, offer it as a payment option for Lightning zaps. + +--- + +## Phase 7: Unified "Add Wallet" Flow + +- [ ] **7.1** Add an "Add Wallet" button/card at the bottom of the wallet list that opens a modal with three choices (matching Amethyst's [`AddWalletScreen.kt`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt:64)): + + | Option | Icon | Description | + |--------|------|-------------| + | Cashu Wallet | Cashew nut | Create a NIP-60 ecash wallet with mints | + | NWC Connection | Lightning bolt | Connect a Lightning wallet via NIP-47 | + | CLINK Debit | Debit card | Add a spend-only CLINK debit pointer | + + (Bitcoin onchain is always available — no "add" needed, the Taproot address is derived from the pubkey automatically.) + +- [ ] **7.2** Each choice opens the corresponding add-form (reuse the forms built in Phases 3–6). + +- [ ] **7.3** Default wallet selection — let the user set which wallet is the default for zaps (a star/radio button on each wallet card, matching Amethyst's `setDefaultWallet`). + +--- + +## Phase 8: Unified Zap Routing + +- [ ] **8.1** Update [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs:1585) zap handler to use a unified payment router that tries rails in priority order: + +```mermaid +flowchart TD + A[User clicks Zap] --> B{Recipient has nutzap mint list?} + B -->|Yes| C{Sender has Cashu wallet with matching mint?} + C -->|Yes| D[Send Cashu Nutzap] + C -->|No| E{Sender has NWC or CLINK?} + B -->|No| E + E -->|NWC| F[Pay LN invoice via NWC] + E -->|CLINK| G[Pay LN invoice via CLINK] + E -->|None| H[Show invoice for external wallet] + F --> I{Recipient supports onchain zaps?} + G --> I + H --> I + I -->|Yes| J[Offer onchain zap alternative] + I -->|No| K[Done] + D --> K + J --> K +``` + +- [ ] **8.2** Add a zap method selector UI (like Amethyst's zap chip) that shows available rails for the current recipient and lets the user choose. + +--- + +## Phase 9: Worker Updates + +- [ ] **9.1** Add NWC message handlers to `ndk-worker.js` (Phase 4.2) +- [ ] **9.2** Add onchain zap subscription to `ndk-worker.js` (Phase 5.3) +- [ ] **9.3** Add CLINK payment request handler to `ndk-worker.js` (Phase 6.3) +- [ ] **9.4** Add `walletInit` expansion — the existing `walletInit` should also load NWC wallets and onchain state, not just Cashu +- [ ] **9.5** Add new worker-to-page events: `nwcBalanceUpdated`, `nwcPaymentResult`, `onchainZapReceived`, `onchainZapSent`, `clinkPaymentResult` +- [ ] **9.6** Update [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs:454) to dispatch the new worker events to the page + +--- + +## Phase 10: Migration & Cleanup + +- [ ] **10.1** Redirect `cashu.html` → `wallet.html` (replace content with a `` redirect or a JS redirect) +- [ ] **10.2** Update sidenav in `index.html` — change `cashu` entry to `wallet` +- [ ] **10.3** Update any internal links that point to `cashu.html` (search all HTML files) +- [ ] **10.4** Update the footer balance display (currently in `relay-ui.mjs`) to show the unified wallet balance (Cashu + NWC + Onchain) +- [ ] **10.5** Keep `cashu-wallet.mjs` as-is (it's the Cashu controller, reused by `wallet.html`) + +--- + +## NIP Compliance Reference + +| Rail | NIP | Kind(s) | Status in this project | +|------|-----|---------|----------------------| +| Cashu Wallet | NIP-60 | 17375 (wallet), 7375 (token), 7376 (tx), 375 (backup) | **Bug: content format non-standard** — fixed in Phase 1 | +| Cashu Nutzap | NIP-61 | 10019 (mint list), 9321 (nutzap) | Working, but depends on 17375 fix for privkey | +| NWC | NIP-47 | 23194 (request/response) | **Not implemented** — Phase 4 | +| Onchain Zap | NIP-BC | 8333 (onchain zap receipt) | **Not implemented** — Phase 5 | +| CLINK Debit | NIP-CLINK | ndebit1 pointer | **Not implemented** — Phase 6 | + +--- + +## Key Files to Modify + +| File | Changes | +|------|---------| +| [`www/ndk-worker.js`](www/ndk-worker.js) | Fix 17375 content format (Phase 1), add NWC/onchain/CLINK handlers (Phases 4-6) | +| [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs) | Add new worker event dispatchers (Phase 9.6) | +| [`www/js/cashu-wallet.mjs`](www/js/cashu-wallet.mjs) | No changes (reused as-is) | +| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Unified zap routing (Phase 8) | +| [`www/index.html`](www/index.html) | Sidenav entry update (Phase 10.2) | + +## Key Files to Create + +| File | Purpose | +|------|---------| +| `www/wallet.html` | New unified wallet page | +| `www/js/wallet-ui.mjs` | Page orchestrator | +| `www/js/nwc-wallet.mjs` | NWC wallet management | +| `www/js/onchain-wallet.mjs` | Bitcoin onchain section | +| `www/js/clink-wallet.mjs` | CLINK debit management | + +--- + +## Implementation Order + +The phases are ordered by dependency and impact: + +1. **Phase 1** (NIP-60 fix) — do this first; it's a bug fix that benefits the existing `cashu.html` immediately +2. **Phase 2** (page shell) — creates the container for everything else +3. **Phase 3** (Cashu migration) — moves existing working functionality into the new page +4. **Phase 4** (NWC) — adds the first new rail (Lightning) +5. **Phase 5** (Onchain) — adds Bitcoin onchain zaps +6. **Phase 6** (CLINK) — adds CLINK debit support +7. **Phase 7** (Add wallet flow) — ties the rails together with a unified add modal +8. **Phase 8** (Unified zap routing) — integrates all rails into the zap flow +9. **Phase 9** (Worker updates) — can be done incrementally alongside Phases 4-6 +10. **Phase 10** (Migration & cleanup) — final step after everything works diff --git a/www/js/init-ndk.mjs b/www/js/init-ndk.mjs index 62eea43..9367ec0 100644 --- a/www/js/init-ndk.mjs +++ b/www/js/init-ndk.mjs @@ -184,7 +184,7 @@ export async function initNDKPage(options = {}) { // All pages in www/ directory, so worker path is always the same. // Include explicit worker revision token so updated SharedWorker code is guaranteed to restart. - const WORKER_REVISION = 'relay-events-db-6'; + const WORKER_REVISION = 'nip60-tagarray-fix-1'; const workerPath = `./ndk-worker.js?v=${encodeURIComponent(VERSION)}&wr=${encodeURIComponent(WORKER_REVISION)}`; // Initialize NDK SharedWorker (shared across all tabs/pages) diff --git a/www/js/version.json b/www/js/version.json index f27ed5b..3124516 100644 --- a/www/js/version.json +++ b/www/js/version.json @@ -1,5 +1,5 @@ { - "VERSION": "v0.7.18", - "VERSION_NUMBER": "0.7.18", - "BUILD_DATE": "2026-06-14T12:07:35.533Z" + "VERSION": "v0.7.19", + "VERSION_NUMBER": "0.7.19", + "BUILD_DATE": "2026-06-25T16:24:48.108Z" } diff --git a/www/ndk-worker.js b/www/ndk-worker.js index c4ab07c..1169d00 100644 --- a/www/ndk-worker.js +++ b/www/ndk-worker.js @@ -1723,6 +1723,51 @@ async function processLightwalletTokenEvent(tokenEvent, pubkey) { } } +let legacyWalletMigrationAttempted = false; + +/** + * One-time migration: if the user has an old-format kind 17375 wallet event + * (proprietary {mints, nutzap} object instead of NIP-60 tag-array), republish + * it in the standard format so other NIP-60 clients (Amethyst, NDK) can read it. + * The migration is fire-and-forget — publishDirectProofs() writes the new + * tag-array format and NIP-09 deletes the old event. + */ +async function migrateLegacyWalletEventIfNeeded(pubkey, walletEvents = []) { + if (legacyWalletMigrationAttempted) return; + if (!pubkey || !messageSigner) return; + const events = Array.isArray(walletEvents) ? walletEvents : []; + if (events.length === 0) return; + + for (const evt of events) { + if (!evt?.content) continue; + try { + const decrypted = await messageSigner.requestFromPage('nip44Decrypt', { + senderPubkey: pubkey, + ciphertext: evt.content + }); + const plaintext = typeof decrypted === 'string' ? decrypted : decrypted?.plaintext; + if (!plaintext) continue; + const parsed = JSON.parse(plaintext); + // Legacy format is an object; NIP-60 standard is an array. + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + console.log('[Worker] NIP-60 migration: detected legacy wallet event, republishing in tag-array format'); + legacyWalletMigrationAttempted = true; + try { + await publishDirectProofs({ traceId: 'nip60-migration' }); + console.log('[Worker] NIP-60 migration: republish complete'); + } catch (err) { + console.warn('[Worker] NIP-60 migration: republish failed:', err?.message || err); + } + return; + } + } catch (_error) { + // Ignore decrypt/parse failures — might be a different encryption scheme + } + } + // No legacy events found; mark as checked so we don't re-scan every startup. + legacyWalletMigrationAttempted = true; +} + async function hydrateLightwalletFromFetchedEvents(pubkey, walletEvents = [], tokenEvents = [], deletionEvents = []) { lightwalletHasWallet = Array.isArray(walletEvents) && walletEvents.length > 0; await hydrateNutzapKeyFromWalletEvents(pubkey, walletEvents); @@ -1737,6 +1782,12 @@ async function hydrateLightwalletFromFetchedEvents(pubkey, walletEvents = [], to hydrateDirectProofStore(); broadcastLightwalletBalance('startup-fetch'); + + // Fire-and-forget: migrate legacy {mints,nutzap} wallet events to NIP-60 + // tag-array format so other clients (Amethyst, NDK) can read our wallet. + void migrateLegacyWalletEventIfNeeded(pubkey, walletEvents).catch((err) => { + console.warn('[Worker] NIP-60 migration check failed:', err?.message || err); + }); } async function fetchStartupEventDownloads(pubkey) { @@ -2184,6 +2235,59 @@ function ensureDirectNutzapP2pk() { }; } +/** + * Parse the decrypted content of a kind 17375 Cashu wallet event. + * + * NIP-60 standard format (what we now write and what Amethyst/NDK expect): + * [["mint","https://mint.example.com"],["privkey",""]] + * + * Legacy format (what this project previously wrote — kept for backwards compat): + * {"mints":["https://mint.example.com"],"nutzap":{"privkey":"","p2pk":""}} + * + * Returns { mints: string[], privkey: string|null, p2pk: string|null, isLegacy: boolean } + */ +function parseWalletContent(plaintext) { + if (!plaintext) return { mints: [], privkey: null, p2pk: null, isLegacy: false }; + const parsed = JSON.parse(plaintext); + if (!parsed || typeof parsed !== 'object') { + return { mints: [], privkey: null, p2pk: null, isLegacy: false }; + } + + // Standard NIP-60 tag-array format: [["mint",url],["privkey",hex]] + if (Array.isArray(parsed)) { + const mints = []; + let privkey = null; + let p2pk = null; + for (const tag of parsed) { + if (!Array.isArray(tag) || tag.length < 2) continue; + if (tag[0] === 'mint' && typeof tag[1] === 'string') { + mints.push(normalizeMintUrl(tag[1])); + } else if (tag[0] === 'privkey' && typeof tag[1] === 'string') { + privkey = normalizeHexKey(tag[1]); + } else if (tag[0] === 'p2pk' && typeof tag[1] === 'string') { + p2pk = normalizeCashuP2pk(tag[1]); + } + } + return { mints: mints.filter(Boolean), privkey, p2pk, isLegacy: false }; + } + + // Legacy object format: { mints: [...], nutzap: { privkey, p2pk } } + const mints = Array.isArray(parsed.mints) + ? parsed.mints.map(normalizeMintUrl).filter(Boolean) + : []; + const privkey = normalizeHexKey([ + parsed?.nutzap?.privkey, + parsed?.nutzapPrivkey, + parsed?.nutzap_private_key + ].find(Boolean) || ''); + const p2pk = normalizeCashuP2pk([ + parsed?.nutzap?.p2pk, + parsed?.nutzapP2pk, + parsed?.nutzap_p2pk + ].find(Boolean) || ''); + return { mints, privkey: privkey || null, p2pk: p2pk || null, isLegacy: true }; +} + async function hydrateNutzapKeyFromWalletEvents(pubkey, walletEvents = []) { if (!messageSigner || !pubkey) return; @@ -2200,20 +2304,9 @@ async function hydrateNutzapKeyFromWalletEvents(pubkey, walletEvents = []) { const plaintext = typeof decrypted === 'string' ? decrypted : decrypted?.plaintext; if (!plaintext) continue; - const parsed = JSON.parse(plaintext); - if (!parsed || typeof parsed !== 'object') continue; - - const candidatePrivkey = normalizeHexKey([ - parsed?.nutzap?.privkey, - parsed?.nutzapPrivkey, - parsed?.nutzap_private_key - ].find(Boolean) || ''); - - const candidateP2pk = normalizeCashuP2pk([ - parsed?.nutzap?.p2pk, - parsed?.nutzapP2pk, - parsed?.nutzap_p2pk - ].find(Boolean) || ''); + const parsed = parseWalletContent(plaintext); + const candidatePrivkey = parsed.privkey; + const candidateP2pk = parsed.p2pk; if (!candidatePrivkey && !candidateP2pk) continue; @@ -2750,22 +2843,20 @@ async function publishDirectProofs(debugContext = null) { } log('delete old 7375 done'); + // NIP-60 standard: public "mint" tags are visible on the event; + // the privkey lives in the encrypted content as a "privkey" tag. + // The "pubkey" tag does NOT belong on kind 17375 (it belongs on + // kind 10019 NutzapInfoEvent). Removed for NIP-60 compliance. const mintTags = getDirectWalletMints().map((mintUrl) => ['mint', mintUrl]); - if (directNutzapP2pk) { - mintTags.push(['pubkey', directNutzapP2pk]); - } - const walletPayloadObj = { - mints: getDirectWalletMints(), - ...(directNutzapPrivateKeyHex - ? { - nutzap: { - privkey: directNutzapPrivateKeyHex, - p2pk: directNutzapP2pk || null - } - } - : {}) - }; + // NIP-60 standard content format: a JSON array of tag arrays. + // [["mint","https://..."],["privkey",""]] + // This matches NDK's payloadForEvent() and Amethyst's + // CashuWalletEvent.build() so other NIP-60 clients can read our wallet. + const walletPayloadObj = [ + ...getDirectWalletMints().map((url) => ['mint', url]), + ...(directNutzapPrivateKeyHex ? [['privkey', directNutzapPrivateKeyHex]] : []) + ]; const walletPayload = JSON.stringify(walletPayloadObj); log('encrypt wallet payload start', { mintTagCount: mintTags.length }); const encryptedWalletResp = await messageSigner.requestFromPage('nip44Encrypt', { @@ -4226,6 +4317,7 @@ function handleWalletShutdown(requestId, port) { directNutzapP2pk = null; directMintLocalUpdatedAt.clear(); spendingHistoryLastFetchAtMs = 0; + legacyWalletMigrationAttempted = false; spendingHistoryFetchPromise = null; broadcastWalletStatus(); port.postMessage({ type: 'response', requestId, data: { success: true } }); @@ -5942,6 +6034,7 @@ function handleDisconnect() { directNutzapP2pk = null; spendingHistoryLastFetchAtMs = 0; spendingHistoryFetchPromise = null; + legacyWalletMigrationAttempted = false; } // Get relay data for relays page