10 KiB
Cashu Wallet — Move NDKCashuWallet into SharedWorker
Goal
Move all Cashu wallet operations into the SharedWorker so that:
- Any page can query wallet balance, send/receive tokens, and get wallet state
- No page creates its own private NDK instance
- Wallet state is shared across all open tabs
- The existing relay connections and signer are reused
Current Architecture
graph TD
subgraph Page - cashu.html
A[cashu.html UI]
B[cashu-wallet.mjs]
C[Private NDK Instance]
D[NDKNip07Signer]
E[NDKCashuWallet]
end
subgraph SharedWorker - ndk-worker.js
F[Worker NDK Instance]
G[MessageBasedSigner]
H[Dexie Cache]
end
A --> B
B --> C
C --> D
C --> E
E --> D
A -.-> F
F --> G
F --> H
Problem: The page creates its own NDK at cashu-wallet.mjs:56 with its own relay connections and signer. This duplicates connections, races with the worker, and can't be shared across tabs.
Target Architecture
graph TD
subgraph Any Page
A[Page UI]
B[init-ndk.mjs]
end
subgraph SharedWorker - ndk-worker.js
C[Worker NDK Instance]
D[MessageBasedSigner]
E[Dexie Cache]
F[NDKCashuWallet]
G[Wallet Message Handlers]
end
A --> B
B <-->|message protocol| G
G --> F
F --> C
C --> D
C --> E
All wallet operations go through the SharedWorker message protocol. Pages send messages like walletGetBalance, walletSend, walletReceive, etc. The worker manages the NDKCashuWallet instance.
Why This Works
The worker already has:
- NDK instance with relay connections at
ndk-worker.js:883 - MessageBasedSigner at
ndk-worker.js:770withsign(),encrypt(),decrypt(), and NIP-44 support - NDKCashuWallet class available via
NDKExports.NDKCashuWallet - Dexie cache for event storage
- Message protocol for request/response with pages
NDKCashuWallet needs:
- An NDK instance ✅ (worker has one)
- A signer for NIP-44 encryption ✅ (MessageBasedSigner handles this)
- Relay connections ✅ (worker manages these)
- Event publishing ✅ (worker can publish)
New Worker Message Types
Wallet Lifecycle
| Message Type | Direction | Params | Response |
|---|---|---|---|
walletInit |
page → worker | { pubkey } |
{ hasWallet, status } |
walletCreate |
page → worker | { mints, relays } |
{ success, mints } |
walletStart |
page → worker | { pubkey } |
{ success, status } |
walletShutdown |
page → worker | — | { success } |
Balance & State
| Message Type | Direction | Params | Response |
|---|---|---|---|
walletGetBalance |
page → worker | — | { balance, balancesByMint } |
walletGetMints |
page → worker | — | { mints } |
walletHasWallet |
page → worker | — | { hasWallet } |
Token Operations
| Message Type | Direction | Params | Response |
|---|---|---|---|
walletReceiveToken |
page → worker | { token, description } |
{ balance, balancesByMint } |
walletSendToken |
page → worker | { amount, memo, mint } |
{ token, amount, mint, balance } |
Lightning Deposit
| Message Type | Direction | Params | Response |
|---|---|---|---|
walletCreateDeposit |
page → worker | { amount, mint } |
{ invoice, quoteId, mint, amount } |
Note: Deposit monitoring happens inside the worker. The worker broadcasts walletDepositConfirmed to all pages when payment arrives.
Mint Management
| Message Type | Direction | Params | Response |
|---|---|---|---|
walletAddMint |
page → worker | { mintUrl } |
{ mints } |
walletRemoveMint |
page → worker | { mintUrl } |
{ mints } |
Transaction History
| Message Type | Direction | Params | Response |
|---|---|---|---|
walletGetTransactions |
page → worker | — | { transactions } |
Worker → Page Broadcasts
| Message Type | Direction | Data |
|---|---|---|
walletBalanceUpdated |
worker → all pages | { balance, balancesByMint } |
walletDepositConfirmed |
worker → all pages | { amount, mint } |
walletTransactionReceived |
worker → all pages | { direction, amount, mint } |
walletStatusChanged |
worker → all pages | { status } |
Implementation Steps
Phase 1: Worker-Side Wallet Manager
File: www/ndk-worker.js
-
Add wallet state variables at the top of the worker:
let cashuWallet = null; let walletStatus = 'initial'; // initial, loading, ready, failed -
Add
NDKCashuWalletto the imports fromNDKExports:const NDKCashuWallet = NDKExports?.NDKCashuWallet; const NDKKind = NDKExports?.NDKKind; -
Implement wallet handler functions:
handleWalletInit(pubkey, port)— fetch kind 17375, create NDKCashuWallet from eventhandleWalletCreate(mints, relays, port)— NDKCashuWallet.create()handleWalletStart(pubkey, port)— wallet.start()handleWalletGetBalance(requestId, port)— return balancehandleWalletGetMints(requestId, port)— return mintshandleWalletReceiveToken(requestId, token, description, port)handleWalletSendToken(requestId, amount, memo, mint, port)handleWalletCreateDeposit(requestId, amount, mint, port)handleWalletAddMint(requestId, mintUrl, port)handleWalletRemoveMint(requestId, mintUrl, port)handleWalletGetTransactions(requestId, port)handleWalletShutdown(port)
-
Add message type cases to the
onconnectswitch statement -
Set up wallet event listeners to broadcast balance updates to all pages
Phase 2: Page-Side Wallet API in init-ndk.mjs
File: www/js/init-ndk.mjs
Add exported functions that send messages to the worker and return promises:
export async function walletInit() { ... }
export async function walletCreate(mints, relays) { ... }
export async function walletStart() { ... }
export async function walletGetBalance() { ... }
export async function walletGetMints() { ... }
export async function walletHasWallet() { ... }
export async function walletReceiveToken(token, description) { ... }
export async function walletSendToken(amount, memo, mint) { ... }
export async function walletCreateDeposit(amount, mint) { ... }
export async function walletAddMint(mintUrl) { ... }
export async function walletRemoveMint(mintUrl) { ... }
export async function walletGetTransactions() { ... }
export async function walletShutdown() { ... }
Each function follows the existing pattern: create a requestId, post message to worker, return a promise that resolves when the worker responds.
Add event listeners for wallet broadcasts:
export function onWalletBalanceUpdated(callback) { ... }
export function onWalletDepositConfirmed(callback) { ... }
export function onWalletTransactionReceived(callback) { ... }
Phase 3: Rewrite cashu-wallet.mjs as Thin Wrapper
File: www/js/cashu-wallet.mjs
Replace the entire module with a thin wrapper that imports from init-ndk.mjs:
import { walletInit, walletCreate, walletStart, walletGetBalance, ... } from './init-ndk.mjs';
export async function createCashuWalletController({ pubkey }) {
const initResult = await walletInit();
return {
hasWallet: () => initResult.hasWallet,
getBalance: walletGetBalance,
getBalanceByMint: async () => (await walletGetBalance()).balancesByMint,
getMints: walletGetMints,
createWallet: walletCreate,
startWallet: walletStart,
addMint: walletAddMint,
removeMint: walletRemoveMint,
receiveToken: walletReceiveToken,
sendToken: walletSendToken,
createDeposit: walletCreateDeposit,
getTransactionHistory: walletGetTransactions,
shutdown: walletShutdown,
// Event subscriptions
onWalletEvent: (name, handler) => { ... },
subscribeTransactions: (callback) => { ... }
};
}
Or alternatively, remove cashu-wallet.mjs entirely and have cashu.html import wallet functions directly from init-ndk.mjs.
Phase 4: Update cashu.html
File: www/cashu.html
- Import wallet functions from
init-ndk.mjsinstead ofcashu-wallet.mjs - Remove the
createCashuWalletControllercall - Use the new wallet API functions directly
- Listen for
walletBalanceUpdatedbroadcasts to update the UI - Remove the private NDK instance entirely
Phase 5: Enable Wallet on Other Pages
Once the wallet lives in the SharedWorker, any page can:
- Show balance in the header/footer
- Accept cashu tokens via paste
- Display wallet status
This is a future step but the architecture enables it.
Files to Modify
| File | Changes |
|---|---|
www/ndk-worker.js |
Add NDKCashuWallet import, wallet state, handler functions, message cases, broadcast listeners |
www/js/init-ndk.mjs |
Add wallet API functions, wallet broadcast event handlers |
www/js/cashu-wallet.mjs |
Rewrite as thin wrapper or remove entirely |
www/cashu.html |
Update imports, use new wallet API, remove private NDK references |
Key Risks & Mitigations
-
NIP-44 encryption in worker — The
MessageBasedSigneralready handlesencrypt()anddecrypt()by proxying to the page'swindow.nostr. NDKCashuWallet uses NIP-44 for wallet event content. This should work transparently since the signer interface is the same. -
Deposit monitoring —
NDKCashuDepositpolls the mint for payment status. This runs fine in the worker. The worker broadcastswalletDepositConfirmedwhen payment arrives. If the user closes the tab, the worker keeps monitoring (SharedWorker stays alive as long as any tab is open). -
Wallet event subscriptions —
wallet.subscribeTransactions()creates NDK subscriptions. These run in the worker and broadcast to all pages. -
Error handling — Worker errors need to be serialized and sent back to pages. Use
{ success: false, error: message }pattern. -
Multiple tabs — Only one wallet instance exists in the worker. All tabs share it. Balance updates broadcast to all tabs simultaneously.
Migration Order
The safest order is:
- First commit and push current working state
- Add worker-side wallet handlers (can coexist with old code)
- Add init-ndk.mjs wallet API functions
- Switch cashu.html to use new API
- Remove old cashu-wallet.mjs private NDK code
- Test thoroughly