Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f45c78741 | ||
|
|
6d41680297 | ||
|
|
b2a50376df | ||
|
|
3c4be89ead | ||
|
|
89d2c259f6 | ||
|
|
7b77da7349 | ||
|
|
38661b7fb6 |
406
plans/wallet-page.md
Normal file
406
plans/wallet-page.md
Normal file
@@ -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","<hex>"]]
|
||||
```
|
||||
|
||||
### 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://<pubkey>?relay=<relay>&secret=<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 `<meta http-equiv="refresh">` 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
|
||||
@@ -637,6 +637,7 @@
|
||||
<button id="btnCheckProofs" class="btn cashuBtn" type="button" style="margin-bottom: 10px;">Check Proofs with Mints</button>
|
||||
<div id="divCheckProofsResult">No proof check run yet.</div>
|
||||
<button id="btnRefreshWallet" class="btn cashuBtn" type="button">Refresh Wallet State</button>
|
||||
<button id="btnRepublishWallet" class="btn cashuBtn" type="button" style="margin-top: 5px;">Republish Wallet (kind 17375)</button>
|
||||
</div>
|
||||
|
||||
<div id="divZapsSection" class="sidenavSection">
|
||||
@@ -728,7 +729,7 @@
|
||||
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
||||
|
||||
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
||||
import { createCashuWalletController } from './js/cashu-wallet.mjs?v=mint-discovery-3';
|
||||
import { createCashuWalletController } from './js/cashu-wallet.mjs?v=nip60-republish-1';
|
||||
|
||||
const versionInfo = await getVersion();
|
||||
const VERSION = versionInfo.VERSION;
|
||||
@@ -2348,6 +2349,23 @@
|
||||
}
|
||||
});
|
||||
|
||||
const btnRepublishWallet = document.getElementById('btnRepublishWallet');
|
||||
btnRepublishWallet?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (!walletController?.hasWallet?.()) {
|
||||
setStatus('No wallet to republish', true);
|
||||
return;
|
||||
}
|
||||
setStatus('Republishing wallet event (kind 17375)...');
|
||||
await walletController.republishWallet();
|
||||
await refreshAllWalletViews();
|
||||
setStatus('Wallet republished successfully');
|
||||
} catch (error) {
|
||||
console.error('[cashu.html] Republish wallet failed:', error);
|
||||
setStatus(error.message || 'Republish failed', true);
|
||||
}
|
||||
});
|
||||
|
||||
btnSaveZapsSettings?.addEventListener('click', async () => {
|
||||
try {
|
||||
const amount = Number(inpZapDefaultAmount?.value || 0);
|
||||
|
||||
@@ -342,6 +342,7 @@
|
||||
<div class="panelTitle">Kinds</div>
|
||||
<div class="btnRow">
|
||||
<button id="btnRefreshKinds" class="btnMini" type="button">Refresh Cache</button>
|
||||
<button id="btnGetAllEvents" class="btnMini" type="button">Get all events</button>
|
||||
</div>
|
||||
<div id="divKindsStatus" class="hint">Loading...</div>
|
||||
<div id="divKindsList" class="listWrap"></div>
|
||||
@@ -683,12 +684,16 @@
|
||||
let selectedKindSub = null;
|
||||
let currentDetailEvent = null;
|
||||
let lastDecryptResult = null;
|
||||
let allEventsFetchSub = null;
|
||||
let allEventsFetchInFlight = false;
|
||||
let allEventsFetchTimeoutId = null;
|
||||
|
||||
const eventsByKind = new Map(); // kind -> Map(eventKey, event)
|
||||
const selectedEventKeys = new Set();
|
||||
const eventRelayPresence = new Map(); // eventKey -> Map(relayUrl, boolean)
|
||||
const eventRelayPublishState = new Map(); // eventKey -> Map(relayUrl, 'idle'|'checking'|'publishing'|'failed')
|
||||
let subscribedRelayUrls = [];
|
||||
let readableRelayUrls = []; // read/both relays only (excludes write-only) for Refresh Kind queries
|
||||
let refreshKindDebugInFlight = false;
|
||||
const autoRefreshedKinds = new Set();
|
||||
const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
@@ -703,6 +708,7 @@
|
||||
const divFooterRight = document.getElementById('divFooterRight');
|
||||
|
||||
const btnRefreshKinds = document.getElementById('btnRefreshKinds');
|
||||
const btnGetAllEvents = document.getElementById('btnGetAllEvents');
|
||||
const divKindsStatus = document.getElementById('divKindsStatus');
|
||||
const divKindsList = document.getElementById('divKindsList');
|
||||
|
||||
@@ -1022,12 +1028,26 @@
|
||||
? rows.filter((r) => r?.fromRelayList === true)
|
||||
: rows;
|
||||
|
||||
// subscribedRelayUrls includes all relays (read, write, both) so that
|
||||
// write-only relays still appear as publish-target chips and are valid
|
||||
// targets for the "publish to missing relays" flow.
|
||||
const urls = [...new Set(scopedRows
|
||||
.map((r) => String(r?.url || '').trim())
|
||||
.filter(Boolean))];
|
||||
|
||||
subscribedRelayUrls = urls;
|
||||
|
||||
// readableRelayUrls excludes write-only relays (NIP-65 type === 'write')
|
||||
// so the Refresh Kind query loop never tries to READ from a write-only
|
||||
// relay. Falls back to the full list when type info is unavailable.
|
||||
const hasTypeinfo = scopedRows.some((r) => r?.type);
|
||||
readableRelayUrls = hasTypeinfo
|
||||
? [...new Set(scopedRows
|
||||
.filter((r) => r?.type !== 'write')
|
||||
.map((r) => String(r?.url || '').trim())
|
||||
.filter(Boolean))]
|
||||
: [...urls];
|
||||
|
||||
for (const relayMap of eventRelayPresence.values()) {
|
||||
for (const url of subscribedRelayUrls) {
|
||||
if (!relayMap.has(url)) relayMap.set(url, false);
|
||||
@@ -1396,16 +1416,22 @@
|
||||
};
|
||||
}
|
||||
|
||||
function getCachedKindsAndEventTotals() {
|
||||
const kinds = getKindsSorted();
|
||||
const totalEvents = kinds.reduce((sum, k) => sum + getEventsForKind(k).length, 0);
|
||||
return { kindsCount: kinds.length, totalEvents };
|
||||
}
|
||||
|
||||
function renderKinds() {
|
||||
const kinds = getKindsSorted();
|
||||
if (!kinds.length) {
|
||||
divKindsList.innerHTML = '<div class="hint">No cached events found.</div>';
|
||||
divKindsStatus.textContent = '0 kinds · 0 events';
|
||||
if (!allEventsFetchInFlight) divKindsStatus.textContent = '0 kinds · 0 events';
|
||||
return;
|
||||
}
|
||||
|
||||
const totalEvents = kinds.reduce((sum, k) => sum + getEventsForKind(k).length, 0);
|
||||
divKindsStatus.textContent = `${kinds.length} kinds · ${totalEvents.toLocaleString()} cached events`;
|
||||
if (!allEventsFetchInFlight) divKindsStatus.textContent = `${kinds.length} kinds · ${totalEvents.toLocaleString()} cached events`;
|
||||
|
||||
divKindsList.innerHTML = kinds.map((kind) => {
|
||||
const count = getEventsForKind(kind).length;
|
||||
@@ -1682,6 +1708,63 @@
|
||||
selectedKindSub = subscribe({ kinds: [Number(kind)], authors: [currentPubkey], limit: 500 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
|
||||
}
|
||||
|
||||
function clearAllEventsFetchTimeout() {
|
||||
if (allEventsFetchTimeoutId) {
|
||||
clearTimeout(allEventsFetchTimeoutId);
|
||||
allEventsFetchTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeAllEventsFetchSubscription() {
|
||||
try {
|
||||
if (allEventsFetchSub && typeof allEventsFetchSub.close === 'function') allEventsFetchSub.close();
|
||||
} catch (_error) { }
|
||||
allEventsFetchSub = null;
|
||||
}
|
||||
|
||||
function finalizeGetAllEventsFetch(statusText) {
|
||||
clearAllEventsFetchTimeout();
|
||||
closeAllEventsFetchSubscription();
|
||||
allEventsFetchInFlight = false;
|
||||
if (btnGetAllEvents) btnGetAllEvents.disabled = false;
|
||||
if (statusText) {
|
||||
divKindsStatus.textContent = statusText;
|
||||
} else {
|
||||
const { kindsCount, totalEvents } = getCachedKindsAndEventTotals();
|
||||
divKindsStatus.textContent = `${kindsCount} kinds · ${totalEvents.toLocaleString()} cached events`;
|
||||
}
|
||||
}
|
||||
|
||||
async function runOneShotGetAllEventsFetch() {
|
||||
if (allEventsFetchInFlight) return;
|
||||
if (!currentPubkey) {
|
||||
divKindsStatus.textContent = 'Get all events failed: user pubkey unavailable';
|
||||
return;
|
||||
}
|
||||
|
||||
allEventsFetchInFlight = true;
|
||||
if (btnGetAllEvents) btnGetAllEvents.disabled = true;
|
||||
|
||||
const beforeTotals = getCachedKindsAndEventTotals();
|
||||
divKindsStatus.textContent = `Getting all events from relays for ${shortHex(currentPubkey)}...`;
|
||||
|
||||
try {
|
||||
closeAllEventsFetchSubscription();
|
||||
allEventsFetchSub = subscribe(
|
||||
{ authors: [currentPubkey] },
|
||||
{ closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
|
||||
);
|
||||
|
||||
allEventsFetchTimeoutId = setTimeout(() => {
|
||||
const afterTotals = getCachedKindsAndEventTotals();
|
||||
const added = Math.max(0, afterTotals.totalEvents - beforeTotals.totalEvents);
|
||||
finalizeGetAllEventsFetch(`Get all events timed out · +${added.toLocaleString()} events (${afterTotals.totalEvents.toLocaleString()} total)`);
|
||||
}, 30000);
|
||||
} catch (error) {
|
||||
finalizeGetAllEventsFetch(`Get all events failed: ${error?.message || String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectKind(kind, options = {}) {
|
||||
const { autoRefresh = true } = options;
|
||||
|
||||
@@ -1747,8 +1830,13 @@
|
||||
});
|
||||
|
||||
setSubscribedRelayUrlsFromRelayData(relayData);
|
||||
const relayUrls = [...subscribedRelayUrls];
|
||||
console.log('[event-management] refresh:relayUrls scoped for query', relayUrls);
|
||||
// Use readableRelayUrls (excludes write-only relays) so Refresh Kind
|
||||
// never tries to READ from a write-only relay like sendit.nosflare.com.
|
||||
const relayUrls = [...readableRelayUrls];
|
||||
console.log('[event-management] refresh:relayUrls scoped for query (read-only)', relayUrls, {
|
||||
subscribedRelayUrls: subscribedRelayUrls,
|
||||
readableRelayUrls: readableRelayUrls
|
||||
});
|
||||
|
||||
if (!relayUrls.length) {
|
||||
divKindsStatus.textContent = `Kind ${targetKind}: no relays found in kind 10002 relay list`;
|
||||
@@ -1992,6 +2080,8 @@
|
||||
});
|
||||
|
||||
logoutButton?.addEventListener('click', async () => {
|
||||
closeAllEventsFetchSubscription();
|
||||
clearAllEventsFetchTimeout();
|
||||
try { await logout(); } catch (error) { console.error('[event-management.html] logout failed', error); }
|
||||
});
|
||||
|
||||
@@ -2004,6 +2094,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
btnGetAllEvents?.addEventListener('click', async () => {
|
||||
await runOneShotGetAllEventsFetch();
|
||||
});
|
||||
|
||||
inpEventSearch.addEventListener('input', renderEvents);
|
||||
chkShowSuperseded?.addEventListener('change', renderEvents);
|
||||
|
||||
@@ -2319,6 +2413,15 @@
|
||||
renderEvents();
|
||||
});
|
||||
|
||||
window.addEventListener('ndkEose', (event) => {
|
||||
if (!allEventsFetchInFlight || !allEventsFetchSub?.subId) return;
|
||||
const eoseSubId = String(event?.detail?.subId || '');
|
||||
if (!eoseSubId || eoseSubId !== String(allEventsFetchSub.subId)) return;
|
||||
|
||||
const { kindsCount, totalEvents } = getCachedKindsAndEventTotals();
|
||||
finalizeGetAllEventsFetch(`Get all events complete · ${kindsCount} kinds · ${totalEvents.toLocaleString()} cached events`);
|
||||
});
|
||||
|
||||
window.addEventListener('ndkEvent', (event) => {
|
||||
const evt = event?.detail;
|
||||
if (!evt || !Number.isFinite(Number(evt.kind)) || !evt.id) return;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
walletRemoveMint,
|
||||
walletGetTransactions,
|
||||
walletShutdown,
|
||||
walletRepublish,
|
||||
walletPublishMintList,
|
||||
walletFetchMintList,
|
||||
onWalletStatus,
|
||||
@@ -397,6 +398,16 @@ export async function createCashuWalletController({ pubkey, relayUrls = [] } = {
|
||||
}
|
||||
}
|
||||
|
||||
async function republishWallet() {
|
||||
await ensureWallet();
|
||||
const result = await walletRepublish();
|
||||
if (Array.isArray(result?.mints)) {
|
||||
mintsCache = uniqueMints(result.mints);
|
||||
}
|
||||
applyBalancePayload(result || {});
|
||||
return { mints: [...mintsCache] };
|
||||
}
|
||||
|
||||
cleanupFns.push(onWalletStatus((payload) => {
|
||||
if (typeof payload?.hasWallet === 'boolean') {
|
||||
hasWalletCache = payload.hasWallet;
|
||||
@@ -431,6 +442,7 @@ export async function createCashuWalletController({ pubkey, relayUrls = [] } = {
|
||||
waitForDeposit,
|
||||
getTransactionHistory,
|
||||
publishMintList,
|
||||
republishWallet,
|
||||
fetchRecipientMintList,
|
||||
onWalletEvent,
|
||||
subscribeTransactions,
|
||||
|
||||
@@ -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-lightweight-republish-1';
|
||||
const workerPath = `./ndk-worker.js?v=${encodeURIComponent(VERSION)}&wr=${encodeURIComponent(WORKER_REVISION)}`;
|
||||
|
||||
// Initialize NDK SharedWorker (shared across all tabs/pages)
|
||||
@@ -1425,6 +1425,10 @@ export function walletShutdown() {
|
||||
return sendWorkerRequest('walletShutdown', {}, 10000, 'walletShutdown timeout');
|
||||
}
|
||||
|
||||
export function walletRepublish() {
|
||||
return sendWorkerRequest('walletRepublish', {}, 30000, 'walletRepublish timeout');
|
||||
}
|
||||
|
||||
export function walletPublishMintList(relays = [], receiveMints = []) {
|
||||
return sendWorkerRequest('walletPublishMintList', { relays, receiveMints }, 25000, 'walletPublishMintList timeout');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.7.17",
|
||||
"VERSION_NUMBER": "0.7.17",
|
||||
"BUILD_DATE": "2026-06-09T19:15:08.620Z"
|
||||
"VERSION": "v0.7.24",
|
||||
"VERSION_NUMBER": "0.7.24",
|
||||
"BUILD_DATE": "2026-06-25T18:05:33.223Z"
|
||||
}
|
||||
|
||||
@@ -1179,6 +1179,7 @@ function startRelayHealthCheck() {
|
||||
class MessageBasedSigner {
|
||||
constructor() {
|
||||
this.pubkey = null;
|
||||
this.signedEventsBySig = new Map(); // sig -> signed raw event from signer page
|
||||
}
|
||||
|
||||
async user() {
|
||||
@@ -1196,12 +1197,59 @@ class MessageBasedSigner {
|
||||
|
||||
async sign(event) {
|
||||
console.log('[Worker] MessageBasedSigner: Signing event kind:', event.kind);
|
||||
|
||||
|
||||
// Request signing from page
|
||||
const signedEvent = await this.requestFromPage('signEvent', { event });
|
||||
|
||||
|
||||
const sig = String(signedEvent?.sig || '').trim();
|
||||
if (!sig) {
|
||||
throw new Error('signEvent returned no signature');
|
||||
}
|
||||
|
||||
// Keep a snapshot so caller can align NDKEvent fields to exactly what was signed.
|
||||
const snapshot = {
|
||||
id: String(signedEvent?.id || ''),
|
||||
pubkey: String(signedEvent?.pubkey || ''),
|
||||
created_at: Number(signedEvent?.created_at || 0),
|
||||
kind: Number(signedEvent?.kind),
|
||||
content: String(signedEvent?.content || ''),
|
||||
tags: Array.isArray(signedEvent?.tags)
|
||||
? signedEvent.tags.map((tag) => Array.isArray(tag) ? tag.map((v) => String(v ?? '')) : [String(tag ?? '')])
|
||||
: [],
|
||||
sig
|
||||
};
|
||||
|
||||
this.signedEventsBySig.set(sig, snapshot);
|
||||
if (this.signedEventsBySig.size > 200) {
|
||||
const oldestKey = this.signedEventsBySig.keys().next().value;
|
||||
if (oldestKey) this.signedEventsBySig.delete(oldestKey);
|
||||
}
|
||||
|
||||
console.log('[Worker] MessageBasedSigner: Event signed');
|
||||
return signedEvent.sig;
|
||||
return sig;
|
||||
}
|
||||
|
||||
applySignedSnapshotToEvent(ndkEvent) {
|
||||
if (!ndkEvent) return false;
|
||||
|
||||
const sig = String(ndkEvent?.sig || '').trim();
|
||||
if (!sig) return false;
|
||||
|
||||
const snapshot = this.signedEventsBySig.get(sig);
|
||||
if (!snapshot) return false;
|
||||
|
||||
this.signedEventsBySig.delete(sig);
|
||||
|
||||
if (snapshot.id) ndkEvent.id = snapshot.id;
|
||||
if (snapshot.pubkey) ndkEvent.pubkey = snapshot.pubkey;
|
||||
if (Number.isFinite(snapshot.created_at) && snapshot.created_at > 0) ndkEvent.created_at = snapshot.created_at;
|
||||
if (Number.isFinite(snapshot.kind)) ndkEvent.kind = snapshot.kind;
|
||||
ndkEvent.content = String(snapshot.content || '');
|
||||
ndkEvent.tags = Array.isArray(snapshot.tags)
|
||||
? snapshot.tags.map((tag) => Array.isArray(tag) ? tag.map((v) => String(v ?? '')) : [String(tag ?? '')])
|
||||
: [];
|
||||
ndkEvent.sig = snapshot.sig;
|
||||
return true;
|
||||
}
|
||||
|
||||
async encrypt(recipient, plaintext) {
|
||||
@@ -1307,6 +1355,16 @@ class MessageBasedSigner {
|
||||
// Create message-based signer instance
|
||||
let messageSigner = null;
|
||||
|
||||
async function signEventWithMessageSigner(event, opts) {
|
||||
if (!event) throw new Error('Missing event to sign');
|
||||
await event.sign(messageSigner, opts);
|
||||
try {
|
||||
messageSigner?.applySignedSnapshotToEvent?.(event);
|
||||
} catch (error) {
|
||||
console.warn('[Worker] Failed applying signed snapshot to event:', error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize NDK with Dexie cache and message-based signer
|
||||
async function initNDK() {
|
||||
console.log('[Worker] Initializing NDK...');
|
||||
@@ -1665,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);
|
||||
@@ -1679,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) {
|
||||
@@ -2126,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","<hex>"]]
|
||||
*
|
||||
* Legacy format (what this project previously wrote — kept for backwards compat):
|
||||
* {"mints":["https://mint.example.com"],"nutzap":{"privkey":"<hex>","p2pk":"<pubkey>"}}
|
||||
*
|
||||
* 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;
|
||||
|
||||
@@ -2142,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;
|
||||
|
||||
@@ -2662,7 +2813,7 @@ async function publishDirectProofs(debugContext = null) {
|
||||
});
|
||||
|
||||
log('sign 7375 start', { mintUrl });
|
||||
await tokenEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(tokenEvent);
|
||||
log('sign 7375 done', { mintUrl });
|
||||
|
||||
log('publish 7375 start', { mintUrl });
|
||||
@@ -2687,27 +2838,34 @@ async function publishDirectProofs(debugContext = null) {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
await deletionEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(deletionEvent);
|
||||
await deletionEvent.publish();
|
||||
}
|
||||
log('delete old 7375 done');
|
||||
|
||||
const mintTags = getDirectWalletMints().map((mintUrl) => ['mint', mintUrl]);
|
||||
if (directNutzapP2pk) {
|
||||
mintTags.push(['pubkey', directNutzapP2pk]);
|
||||
// Ensure a nutzap privkey exists before building the wallet payload.
|
||||
// If the user's old 17375 didn't have one (or hydration failed),
|
||||
// this auto-generates one so it's always included in the republish.
|
||||
try {
|
||||
ensureDirectNutzapP2pk();
|
||||
} catch (err) {
|
||||
log('ensureDirectNutzapP2pk failed during publish', { error: err?.message || String(err) });
|
||||
}
|
||||
|
||||
const walletPayloadObj = {
|
||||
mints: getDirectWalletMints(),
|
||||
...(directNutzapPrivateKeyHex
|
||||
? {
|
||||
nutzap: {
|
||||
privkey: directNutzapPrivateKeyHex,
|
||||
p2pk: directNutzapP2pk || null
|
||||
}
|
||||
}
|
||||
: {})
|
||||
};
|
||||
// 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]);
|
||||
|
||||
// NIP-60 standard content format: a JSON array of tag arrays.
|
||||
// [["mint","https://..."],["privkey","<hex>"]]
|
||||
// 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', {
|
||||
@@ -2726,7 +2884,7 @@ async function publishDirectProofs(debugContext = null) {
|
||||
});
|
||||
|
||||
log('sign 17375 start');
|
||||
await walletEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(walletEvent);
|
||||
log('sign 17375 done');
|
||||
|
||||
log('publish 17375 start');
|
||||
@@ -2750,7 +2908,7 @@ async function publishDirectProofs(debugContext = null) {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
await deletionEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(deletionEvent);
|
||||
await deletionEvent.publish();
|
||||
}
|
||||
log('delete old 17375 done');
|
||||
@@ -2951,7 +3109,7 @@ async function publishSpendingHistoryEvent(entry = {}) {
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
|
||||
await historyEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(historyEvent);
|
||||
const relaySet = await historyEvent.publish();
|
||||
if (relaySet && relaySet.size > 0) {
|
||||
for (const relay of relaySet) {
|
||||
@@ -3003,7 +3161,7 @@ async function handleWalletPublishMintList(requestId, relays, receiveMints, port
|
||||
mintListEvent.mints = mints;
|
||||
mintListEvent.relays = relayUrls;
|
||||
if (p2pk) mintListEvent.p2pk = p2pk;
|
||||
await mintListEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(mintListEvent);
|
||||
const relaySet = await mintListEvent.publishReplaceable();
|
||||
eventId = mintListEvent.id || null;
|
||||
publishedRelayCount = relaySet ? relaySet.size : 0;
|
||||
@@ -3019,7 +3177,7 @@ async function handleWalletPublishMintList(requestId, relays, receiveMints, port
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
await mintListEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(mintListEvent);
|
||||
const relaySet = await mintListEvent.publish();
|
||||
eventId = mintListEvent.id || null;
|
||||
publishedRelayCount = relaySet ? relaySet.size : 0;
|
||||
@@ -3233,7 +3391,7 @@ async function handleWalletSendNutzap(
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
|
||||
await nutzapEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(nutzapEvent);
|
||||
|
||||
let relaySet = null;
|
||||
if (recipientRelayUrls.length > 0 && NDKRelaySet?.fromRelayUrls) {
|
||||
@@ -4155,6 +4313,97 @@ async function handleWalletCheckProofs(requestId, port) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight republish of ONLY the kind 17375 wallet event.
|
||||
*
|
||||
* Kind 17375 is a replaceable event (NIP-60, d-tag = ""), so publishing a
|
||||
* new one automatically supersedes the old one on relays — no NIP-09
|
||||
* deletions needed. This skips the heavy publishDirectProofs() which
|
||||
* rewrites all 7375 token events too (100+ signing round-trips).
|
||||
*
|
||||
* Just: ensure privkey → build tag-array payload → encrypt → sign → publish.
|
||||
*/
|
||||
async function handleWalletRepublish(requestId, port) {
|
||||
try {
|
||||
if (!ndk || !currentPubkey) {
|
||||
port.postMessage({ type: 'response', requestId, error: 'NDK not ready' });
|
||||
return;
|
||||
}
|
||||
await ensureDirectWalletLoaded();
|
||||
|
||||
// Ensure a nutzap privkey exists (auto-generate if missing)
|
||||
try {
|
||||
ensureDirectNutzapP2pk();
|
||||
} catch (err) {
|
||||
console.warn('[Worker] handleWalletRepublish: ensureDirectNutzapP2pk failed:', err?.message || err);
|
||||
}
|
||||
|
||||
const NDKEvent = getNDKEventCtor();
|
||||
if (!NDKEvent) {
|
||||
port.postMessage({ type: 'response', requestId, error: 'NDKEvent constructor not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build NIP-60 tag-array payload: [["mint",url],["privkey",hex]]
|
||||
const mintTags = getDirectWalletMints().map((mintUrl) => ['mint', mintUrl]);
|
||||
const walletPayloadObj = [
|
||||
...getDirectWalletMints().map((url) => ['mint', url]),
|
||||
...(directNutzapPrivateKeyHex ? [['privkey', directNutzapPrivateKeyHex]] : [])
|
||||
];
|
||||
const walletPayload = JSON.stringify(walletPayloadObj);
|
||||
|
||||
// NIP-44 encrypt the payload
|
||||
const encryptedWalletResp = await messageSigner.requestFromPage('nip44Encrypt', {
|
||||
recipientPubkey: currentPubkey,
|
||||
plaintext: walletPayload
|
||||
});
|
||||
const encryptedWallet = typeof encryptedWalletResp === 'string' ? encryptedWalletResp : encryptedWalletResp?.ciphertext;
|
||||
|
||||
if (!encryptedWallet) {
|
||||
port.postMessage({ type: 'response', requestId, error: 'Failed to encrypt wallet payload' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build and publish the new 17375 event (replaceable — supersedes old one)
|
||||
const walletEvent = new NDKEvent(ndk, {
|
||||
kind: 17375,
|
||||
tags: mintTags,
|
||||
content: encryptedWallet,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
|
||||
await signEventWithMessageSigner(walletEvent);
|
||||
const relaySet = await walletEvent.publish();
|
||||
|
||||
if (relaySet && relaySet.size > 0) {
|
||||
for (const relay of relaySet) {
|
||||
trackRelayWrite(relay.url);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Worker] handleWalletRepublish: 17375 published', {
|
||||
relayCount: relaySet ? relaySet.size : 0,
|
||||
mintCount: mintTags.length,
|
||||
hasPrivkey: Boolean(directNutzapPrivateKeyHex)
|
||||
});
|
||||
|
||||
const payload = getWalletBalancePayload();
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
data: {
|
||||
success: true,
|
||||
mints: getDirectWalletMints(),
|
||||
relayCount: relaySet ? relaySet.size : 0,
|
||||
...payload
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[Worker] handleWalletRepublish failed:', error?.message || error);
|
||||
port.postMessage({ type: 'response', requestId, error: error.message || 'Republish failed' });
|
||||
}
|
||||
}
|
||||
|
||||
function handleWalletShutdown(requestId, port) {
|
||||
try {
|
||||
clearWalletListeners();
|
||||
@@ -4168,6 +4417,7 @@ function handleWalletShutdown(requestId, port) {
|
||||
directNutzapP2pk = null;
|
||||
directMintLocalUpdatedAt.clear();
|
||||
spendingHistoryLastFetchAtMs = 0;
|
||||
legacyWalletMigrationAttempted = false;
|
||||
spendingHistoryFetchPromise = null;
|
||||
broadcastWalletStatus();
|
||||
port.postMessage({ type: 'response', requestId, data: { success: true } });
|
||||
@@ -4256,7 +4506,7 @@ async function publishUserSettingsNow() {
|
||||
created_at: now
|
||||
});
|
||||
|
||||
await ndkEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(ndkEvent);
|
||||
const relaySet = await ndkEvent.publish();
|
||||
if (relaySet && relaySet.size > 0) {
|
||||
for (const relay of relaySet) {
|
||||
@@ -5304,7 +5554,7 @@ async function handlePublish(requestId, event, port) {
|
||||
|
||||
// Sign using message-based signer (will request signing from page)
|
||||
console.log('[Worker] Requesting signature from page...');
|
||||
await ndkEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(ndkEvent);
|
||||
console.log('[Worker] Event signed, publishing to relays...');
|
||||
|
||||
// Publish to relays
|
||||
@@ -5884,6 +6134,7 @@ function handleDisconnect() {
|
||||
directNutzapP2pk = null;
|
||||
spendingHistoryLastFetchAtMs = 0;
|
||||
spendingHistoryFetchPromise = null;
|
||||
legacyWalletMigrationAttempted = false;
|
||||
}
|
||||
|
||||
// Get relay data for relays page
|
||||
@@ -6349,7 +6600,11 @@ self.onconnect = (event) => {
|
||||
case 'walletCheckProofs':
|
||||
await handleWalletCheckProofs(requestId, port);
|
||||
break;
|
||||
|
||||
|
||||
case 'walletRepublish':
|
||||
await handleWalletRepublish(requestId, port);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('[Worker] Unknown message type:', type);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user