9.2 KiB
Lightweight Cashu Balance at Startup
Problem
The current wallet balance display requires the full NDKCashuWallet machinery:
relay-ui.mjscallsbootstrapWalletForFooter()→walletInit()- Worker runs
loadCashuWalletFromRelays()→ fetches kind 17375, decrypts, instantiatesNDKCashuWallet - Worker runs
ensureWalletStarted()→ callswallet.start()which fetches kind 7375 + 7376 + deletions, decrypts all proofs, sets up subscriptions - 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
5with#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.mjs — handleWorkerMessage() dispatches ndkStartupStatus window events.
Footer Status Display (index.html only)
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:
- Decrypting the kind 7375 token events (NIP-44 encrypted)
- Summing the proof amounts to compute a balance
- Broadcasting the balance via the existing
walletBalanceUpdatedmessage - Updating the balance when new events arrive via the persistent subscription
- Simplifying
relay-ui.mjsto stop callingwalletInit()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:
- Iterates the fetched kind 5 events → populates
lightwalletDeletedIds - Iterates the fetched kind 7375 events → decrypts each via NIP-44, extracts proof amounts, populates
lightwalletTokenEvents, handlesdelfield - Calls
recomputeLightwalletBalance() - Broadcasts
walletBalanceUpdatedwith 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, handledelfield, 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 }
};
}
Step 6: Simplify relay-ui.mjs footer bootstrap
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.
lightwalletHasWalletstays 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
UpdateFooterfunctions 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() |