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

9.2 KiB

Lightweight Cashu Balance at Startup

Problem

The current wallet balance display requires the full NDKCashuWallet machinery:

  1. relay-ui.mjs calls bootstrapWalletForFooter()walletInit()
  2. Worker runs loadCashuWalletFromRelays() → fetches kind 17375, decrypts, instantiates NDKCashuWallet
  3. Worker runs ensureWalletStarted() → calls wallet.start() which fetches kind 7375 + 7376 + deletions, decrypts all proofs, sets up subscriptions
  4. Only then is a balance available

This is heavy, slow, and fragile. The footer shows "0 sats" for many seconds (or forever if something fails).

Solution

Fetch kind 17375 + 7375 + 5 events at startup in the worker (alongside profile and relay list), keep persistent subscriptions for live updates, decrypt the kind 7375 token events, sum proof amounts, and broadcast the balance to all tabs via the existing walletBalanceUpdated message. The footer balance updates instantly without ever touching NDKCashuWallet.

Key Insight: NDKCashuWallet Benefits from Pre-Caching

NDKCashuWallet.start() in ndk/wallet/src/wallets/cashu/wallet/index.ts:359 explicitly reads from the Dexie cache first:

if (this.ndk.cacheAdapter) {
    const events = await this.ndk.fetchEvents(
        [{ kinds: [NDKKind.CashuToken], authors: [pubkey] }],
        { cacheUsage: NDKSubscriptionCacheUsage.ONLY_CACHE }
    );
    // Process cached events → emit balance_updated
}

Since our startup fetch populates the same Dexie cache, when NDKCashuWallet.start() later runs on cashu.html it will find all token events already cached → instant optimistic balance, then use NDKSync to only fetch the diff.

What's Already Implemented (Phase 1)

Startup Event Downloads

File: www/ndk-worker.js — function fetchStartupEventDownloads(pubkey) (line ~1084)

At startup, handleInit() now fetches these events sequentially with status broadcasting:

  • Kind 3 (Contact List / follows) — limit 1
  • Kind 17375 (Cashu Wallet definition) — limit 5
  • Kind 7375 (Cashu Token events) — limit 500
  • Kind 5 with #k:["7375"] (Token deletions) — limit 500

All fetched events are automatically cached in Dexie by NDK.

Persistent Wallet Subscriptions

File: www/ndk-worker.js — function ensureStartupWalletSubscriptions(pubkey) (line ~1140)

After the initial fetch, a persistent subscription (closeOnEose: false) is opened for:

  • { kinds: [17375], authors: [pubkey] } — wallet metadata changes
  • { kinds: [7375], authors: [pubkey] } — new/updated token events
  • { kinds: [5], authors: [pubkey], '#k': ['7375'] } — token deletions

Events received via subscription are logged to the startup status stream.

Startup Status Broadcasting

File: www/ndk-worker.js — function broadcastStartupStatus(message, meta) (line ~1230)

Every step of handleInit() broadcasts a startupStatus message with human-readable text and metadata (stage, state, count, durationMs). Messages include:

  • Initializing NDK worker...
  • Connecting to relays...
  • Fetching user settings (kind 30078)...
  • Fetching user profile info (kind 0)...
  • Fetching user relay list (kind 10002)...
  • Subscribing to relay updates (kind 10002)...
  • Fetching user follows (kind 3)...done (N)
  • Fetching user wallet information (kind 17375)...done (N)
  • Fetching user wallet tokens (kind 7375)...done (N)
  • Fetching user token deletions (kind 5 for 7375)...done (N)
  • Subscribing to wallet updates (kinds 17375/7375/5)...
  • ✓ Startup complete

Status Event Plumbing

File: www/js/init-ndk.mjshandleWorkerMessage() dispatches ndkStartupStatus window events.

File: www/index.html — Listens for ndkStartupStatus events, displays in divFooterCenter, logs to console. Messages persist for 12 seconds before the periodic UpdateFooter clears them.

What Remains (Phase 2) — Lightweight Balance Computation

Overview

The startup fetch and subscription infrastructure is in place. What's missing is:

  1. Decrypting the kind 7375 token events (NIP-44 encrypted)
  2. Summing the proof amounts to compute a balance
  3. Broadcasting the balance via the existing walletBalanceUpdated message
  4. Updating the balance when new events arrive via the persistent subscription
  5. Simplifying relay-ui.mjs to stop calling walletInit() for the footer balance

Step 1: Add lightweight balance state variables

File: www/ndk-worker.js

Add alongside existing worker state (near line 99):

// Lightweight wallet balance state (independent of NDKCashuWallet)
let lightwalletBalance = 0;
let lightwalletBalanceByMint = {};  // mintUrl -> amount
let lightwalletTokenEvents = new Map();  // eventId -> { mint, unit, proofTotal }
let lightwalletDeletedIds = new Set();   // event IDs that have been NIP-09 deleted
let lightwalletHasWallet = false;

Step 2: Add helper functions

decryptTokenEvent(event, pubkey) — Decrypts kind 7375 content via NIP-44:

async function decryptTokenEvent(event, pubkey) {
    const decrypted = await messageSigner.requestFromPage('nip44Decrypt', {
        senderPubkey: pubkey,
        ciphertext: event.content
    });
    const plaintext = typeof decrypted === 'string' ? decrypted : decrypted?.plaintext;
    return JSON.parse(plaintext);
}

recomputeLightwalletBalance() — Sums proofs, skips deleted tokens:

function recomputeLightwalletBalance() {
    let total = 0;
    const byMint = {};
    for (const [eventId, token] of lightwalletTokenEvents) {
        if (lightwalletDeletedIds.has(eventId)) continue;
        total += token.proofTotal;
        byMint[token.mint] = (byMint[token.mint] || 0) + token.proofTotal;
    }
    lightwalletBalance = total;
    lightwalletBalanceByMint = byMint;
}

Step 3: Process fetched events in fetchStartupEventDownloads()

After the existing fetch loop completes, add a processing step that:

  1. Iterates the fetched kind 5 events → populates lightwalletDeletedIds
  2. Iterates the fetched kind 7375 events → decrypts each via NIP-44, extracts proof amounts, populates lightwalletTokenEvents, handles del field
  3. Calls recomputeLightwalletBalance()
  4. Broadcasts walletBalanceUpdated with the computed balance

Important: The fetched events are already in the NDK result sets from fetchStartupEventDownloads(). We need to capture those result sets (currently discarded) and pass them to the processing step.

Step 4: Process subscription events in ensureStartupWalletSubscriptions()

Update the existing subscription event handler to:

  • On kind 7375: decrypt, add to lightwalletTokenEvents, handle del field, recompute, broadcast
  • On kind 5: add referenced event IDs to lightwalletDeletedIds, recompute, broadcast

Step 5: Modify getWalletBalancePayload() fallback

When NDKCashuWallet is not active, fall back to lightweight balance:

function getWalletBalancePayload() {
    if (cashuWallet && cashuWalletStatus === 'ready') {
        // existing full wallet logic
        ...
    }
    // Fallback to lightweight balance
    return {
        balance: lightwalletBalance,
        balancesByMint: { ...lightwalletBalanceByMint }
    };
}

Remove the walletInit() call from bootstrapWalletForFooter(). The balance arrives automatically via ndkWalletBalance events from the worker's lightweight computation. Keep the existing event listeners as-is.

NIP-44 Decryption Consideration

The kind 7375 token events are NIP-44 encrypted. The worker already has messageSigner.requestFromPage() for NIP-44 decryption (used for user settings kind 30078). The same mechanism will be used here.

Important: Decryption requires a connected page with signing capability. Since handleInit() is called from a page that just authenticated, the signing port will be available. For the persistent subscription, new events will be decrypted as they arrive (the signing port remains active as long as any tab is open).

Risk Mitigation

  • Decryption failure: If NIP-44 decryption fails for any token event, skip it and log a warning. The balance will be a lower bound.
  • No wallet: If no kind 17375 event exists, skip balance computation. lightwalletHasWallet stays false.
  • Race with NDKCashuWallet: When the full wallet starts on cashu.html, it takes over balance reporting via getWalletBalancePayload(). No conflict.
  • Footer center conflicts: Startup status messages auto-clear. Page UpdateFooter functions naturally take over.

Files Modified

File Phase 1 (Done) Phase 2 (Remaining)
www/ndk-worker.js broadcastStartupStatus(), fetchStartupEventDownloads(), ensureStartupWalletSubscriptions(), status calls in handleInit() Lightwallet state vars, decryptTokenEvent(), recomputeLightwalletBalance(), process events in fetch + subscription, modify getWalletBalancePayload()
www/js/init-ndk.mjs startupStatus message handler No changes needed
www/index.html ndkStartupStatus listener + console logging No changes needed
www/js/relay-ui.mjs No changes yet Remove walletInit() from bootstrapWalletForFooter()