Files
client/plans/cashu-worker-migration.md
2026-04-17 16:52:51 -04:00

10 KiB

Cashu Wallet — Move NDKCashuWallet into SharedWorker

Goal

Move all Cashu wallet operations into the SharedWorker so that:

  1. Any page can query wallet balance, send/receive tokens, and get wallet state
  2. No page creates its own private NDK instance
  3. Wallet state is shared across all open tabs
  4. 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:770 with sign(), 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

  1. Add wallet state variables at the top of the worker:

    let cashuWallet = null;
    let walletStatus = 'initial'; // initial, loading, ready, failed
    
  2. Add NDKCashuWallet to the imports from NDKExports:

    const NDKCashuWallet = NDKExports?.NDKCashuWallet;
    const NDKKind = NDKExports?.NDKKind;
    
  3. Implement wallet handler functions:

    • handleWalletInit(pubkey, port) — fetch kind 17375, create NDKCashuWallet from event
    • handleWalletCreate(mints, relays, port) — NDKCashuWallet.create()
    • handleWalletStart(pubkey, port) — wallet.start()
    • handleWalletGetBalance(requestId, port) — return balance
    • handleWalletGetMints(requestId, port) — return mints
    • handleWalletReceiveToken(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)
  4. Add message type cases to the onconnect switch statement

  5. 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.mjs instead of cashu-wallet.mjs
  • Remove the createCashuWalletController call
  • Use the new wallet API functions directly
  • Listen for walletBalanceUpdated broadcasts 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

  1. NIP-44 encryption in worker — The MessageBasedSigner already handles encrypt() and decrypt() by proxying to the page's window.nostr. NDKCashuWallet uses NIP-44 for wallet event content. This should work transparently since the signer interface is the same.

  2. Deposit monitoringNDKCashuDeposit polls the mint for payment status. This runs fine in the worker. The worker broadcasts walletDepositConfirmed when payment arrives. If the user closes the tab, the worker keeps monitoring (SharedWorker stays alive as long as any tab is open).

  3. Wallet event subscriptionswallet.subscribeTransactions() creates NDK subscriptions. These run in the worker and broadcast to all pages.

  4. Error handling — Worker errors need to be serialized and sent back to pages. Use { success: false, error: message } pattern.

  5. 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:

  1. First commit and push current working state
  2. Add worker-side wallet handlers (can coexist with old code)
  3. Add init-ndk.mjs wallet API functions
  4. Switch cashu.html to use new API
  5. Remove old cashu-wallet.mjs private NDK code
  6. Test thoroughly