12 KiB
Cashu Direct Wallet — Full NDK Wallet Removal
Goal
Strip out all NDKCashuWallet usage from ndk-worker.js and replace with direct @cashu/cashu-ts operations. The NDK wallet abstraction is unreliable — ephemeral state, slow startup, black-box proof management. We replace it with the same proven pattern from NostrTerminal_5/nt.mjs: own the proofs, call cashu-ts directly.
What gets removed from ndk-worker.js
All of these functions/variables are NDK wallet code that will be deleted:
NDKCashuWalletimport and referencecashuWalletvariable andcashuWalletStatuswalletStartInFlightensureCashuWalletClass()loadCashuWalletFromRelays()ensureCashuWallet()logWalletKindDiagnostics()bindCashuWalletListeners()/clearWalletListeners()/walletTxUnsubscribe/walletBalanceListenerUnsubensureWalletStarted()syncLightwalletFromFullWallet()getWalletDebugSnapshot()getProofBalanceSnapshot()(replaced by simpler direct proof sum)- All
[Worker][diag]diagnostic logging added for NDK wallet debugging
What stays in ndk-worker.js
- NDK core:
initNDK(), relay management, subscriptions, event publishing MessageBasedSignerfor NIP-44 encryption- Lightweight balance system:
lightwalletTokenEvents,lightwalletBalance,recomputeLightwalletBalance(),broadcastLightwalletBalance(),processLightwalletTokenEvent(),hydrateLightwalletFromFetchedEvents() - Startup event downloads:
fetchStartupEventDownloads(),ensureStartupWalletSubscriptions() - User settings system
- All relay/subscription/publish infrastructure
broadcast(),broadcastWalletStatus(),broadcastStartupStatus()
Architecture
cashu.html ──► cashu-wallet.mjs ──► init-ndk.mjs ──► ndk-worker.js
│
DirectCashu
┌──────────┐
│proofStore │ { mint: Proof[] }
│mintList │ string[]
│send() │ cashu-ts wallet.send
│receive() │ cashu-ts wallet.receive
│deposit() │ cashu-ts mint quote
│publish() │ NDK kind 7375/17375
└──────────┘
No changes to cashu.html, cashu-wallet.mjs, or init-ndk.mjs — same worker message API.
Implementation Steps
Step 1: Expose cashu-ts from bundle
File: build/ndk-entry.js
Add re-exports so the worker can access cashu-ts primitives:
export { CashuMint, CashuWallet } from '@cashu/cashu-ts';
export { getEncodedTokenV4, getDecodedToken } from '@cashu/cashu-ts';
Rebuild bundle: node build-ndk-bundle.js
Verify in worker:
const CashuMint = NDKExports?.CashuMint;
const CashuWallet = NDKExports?.CashuWallet;
Step 2: Direct proof store and balance
File: www/ndk-worker.js
New state:
const directProofStore = {}; // { mintUrl: Proof[] }
let directMintList = []; // from kind 17375
let directWalletLoaded = false;
Hydrate from existing lightweight token events — we already download and decrypt kind 7375 events during startup. Instead of just counting sats, parse the full proof objects:
function hydrateDirectProofStore() {
directProofStore = {};
for (const [id, entry] of lightwalletTokenEvents) {
// entry.decrypted contains { mint, proofs }
const data = JSON.parse(entry.decrypted);
if (!directProofStore[data.mint]) directProofStore[data.mint] = [];
directProofStore[data.mint].push(...data.proofs);
}
// Deduplicate proofs by secret
for (const mint of Object.keys(directProofStore)) {
const seen = new Set();
directProofStore[mint] = directProofStore[mint].filter(p => {
if (seen.has(p.secret)) return false;
seen.add(p.secret);
return true;
});
}
directWalletLoaded = true;
}
Balance is just a sum:
function getDirectBalance() {
let total = 0;
const byMint = {};
for (const [mint, proofs] of Object.entries(directProofStore)) {
const mintTotal = proofs.reduce((s, p) => s + p.amount, 0);
byMint[mint] = mintTotal;
total += mintTotal;
}
return { balance: total, balancesByMint: byMint };
}
Step 3: Direct send
File: www/ndk-worker.js
async function directSend(amount, mintUrl) {
const mint = new CashuMint(mintUrl);
const wallet = new CashuWallet(mint);
await wallet.loadMint(); // loads keysets
const proofs = directProofStore[mintUrl] || [];
const { keep, send } = await wallet.send(amount, proofs);
directProofStore[mintUrl] = keep;
const token = getEncodedTokenV4({ mint: mintUrl, proofs: send });
await publishDirectProofs();
return { token, amount, mint: mintUrl, ...getDirectBalance() };
}
Step 4: Direct receive
async function directReceive(encodedToken) {
const decoded = getDecodedToken(encodedToken);
const mint = new CashuMint(decoded.mint);
const wallet = new CashuWallet(mint);
const receiveProofs = await wallet.receive(encodedToken);
if (!directProofStore[decoded.mint]) directProofStore[decoded.mint] = [];
directProofStore[decoded.mint].push(...receiveProofs);
await publishDirectProofs();
return { success: true, ...getDirectBalance() };
}
Step 5: Direct deposit
async function directDeposit(amount, mintUrl) {
const mint = new CashuMint(mintUrl);
const wallet = new CashuWallet(mint);
const quote = await wallet.createMintQuote(amount);
return {
invoice: quote.request,
quoteId: quote.quote,
mint: mintUrl,
amount,
// Caller polls with directCheckDeposit()
};
}
async function directCheckDeposit(mintUrl, quoteId, amount) {
const mint = new CashuMint(mintUrl);
const wallet = new CashuWallet(mint);
const checked = await wallet.checkMintQuote(quoteId);
if (checked.state === 'PAID') {
const proofs = await wallet.mintProofs(amount, quoteId);
if (!directProofStore[mintUrl]) directProofStore[mintUrl] = [];
directProofStore[mintUrl].push(...proofs);
directProofStore[mintUrl].sort((a, b) => a.amount - b.amount);
await publishDirectProofs();
return { paid: true, ...getDirectBalance() };
}
return { paid: false };
}
Step 6: Direct Lightning payment
async function directPayInvoice(invoice, mintUrl) {
const mint = new CashuMint(mintUrl);
const wallet = new CashuWallet(mint);
await wallet.loadMint();
const proofs = directProofStore[mintUrl] || [];
// meltQuote + meltProofs flow
const meltQuote = await wallet.createMeltQuote(invoice);
const totalNeeded = meltQuote.amount + meltQuote.fee_reserve;
const { keep, send } = await wallet.send(totalNeeded, proofs);
const result = await wallet.meltProofs(meltQuote, send);
directProofStore[mintUrl] = keep;
if (result.change && result.change.length > 0) {
directProofStore[mintUrl].push(...result.change);
}
await publishDirectProofs();
return { success: true, preimage: result.preimage, ...getDirectBalance() };
}
Step 7: Nostr persistence — publish proofs
async function publishDirectProofs() {
// 1. Fetch existing kind 7375 events
const existing7375 = await ndk.fetchEvents({
kinds: [7375],
authors: [currentPubkey]
});
// 2. Publish new 7375 for each mint
for (const [mintUrl, proofs] of Object.entries(directProofStore)) {
const content = JSON.stringify({ mint: mintUrl, proofs });
const encrypted = await messageSigner.requestFromPage('nip44Encrypt', {
recipientPubkey: currentPubkey,
plaintext: content
});
const event = new NDKEvent(ndk, {
kind: 7375,
content: typeof encrypted === 'string' ? encrypted : encrypted?.ciphertext
});
await event.publish();
}
// 3. Delete old 7375 events
for (const oldEvent of existing7375) {
const deleteEvent = new NDKEvent(ndk, {
kind: 5,
tags: [['e', oldEvent.id]]
});
await deleteEvent.publish();
}
// 4. Update kind 17375 wallet metadata
const mintTags = Object.keys(directProofStore).map(m => ['mint', m]);
const walletContent = JSON.stringify(mintTags);
const encryptedWallet = await messageSigner.requestFromPage('nip44Encrypt', {
recipientPubkey: currentPubkey,
plaintext: walletContent
});
const walletEvent = new NDKEvent(ndk, {
kind: 17375,
content: typeof encryptedWallet === 'string' ? encryptedWallet : encryptedWallet?.ciphertext
});
await walletEvent.publish();
}
Step 8: Proof checking
async function directCheckProofs() {
let totalChecked = 0;
let totalRemoved = 0;
for (const [mintUrl, proofs] of Object.entries(directProofStore)) {
if (proofs.length === 0) continue;
const mint = new CashuMint(mintUrl);
const wallet = new CashuWallet(mint);
const secrets = proofs.map(p => p.secret);
const states = await wallet.checkProofsStates(proofs);
totalChecked += proofs.length;
const validProofs = proofs.filter((p, i) =>
states[i]?.state === 'UNSPENT'
);
totalRemoved += proofs.length - validProofs.length;
directProofStore[mintUrl] = validProofs;
}
if (totalRemoved > 0) {
await publishDirectProofs();
}
return {
proofsChecked: totalChecked,
proofsRemoved: totalRemoved,
...getDirectBalance()
};
}
Step 9: Replace all handler functions
Replace these handlers to use direct functions instead of NDKCashuWallet:
| Handler | Before | After |
|---|---|---|
handleWalletInit |
loadCashuWalletFromRelays() |
hydrateDirectProofStore() |
handleWalletStart |
ensureWalletStarted() |
no-op (already hydrated) |
handleWalletCreate |
NDKCashuWallet.create() |
publishDirectProofs() with initial mints |
handleWalletGetBalance |
ensureWalletStarted() + stale fields |
getDirectBalance() |
handleWalletGetMints |
cashuWallet.mints |
Object.keys(directProofStore) |
handleWalletSendToken |
ensureWalletStarted() + active.send() |
directSend() |
handleWalletReceiveToken |
active.receiveToken() |
directReceive() |
handleWalletCreateDeposit |
active.deposit() |
directDeposit() |
handleWalletPayInvoice |
active.lnPay() |
directPayInvoice() |
handleWalletCheckProofs |
active.consolidateTokens() |
directCheckProofs() |
handleWalletAddMint |
active.addMint() |
add to directProofStore + publish |
handleWalletRemoveMint |
active.removeMint() |
remove from directProofStore + publish |
handleWalletGetTransactions |
active.fetchTransactions() |
fetch kind 7376 events directly |
handleWalletShutdown |
cashuWallet.stop() |
clear directProofStore |
Step 10: Delete all NDK wallet code
Remove from ndk-worker.js:
NDKCashuWalletimport linecashuWallet,cashuWalletStatus,walletStartInFlightvariablesensureCashuWalletClass()loadCashuWalletFromRelays()ensureCashuWallet()logWalletKindDiagnostics()bindCashuWalletListeners(),clearWalletListeners()walletTxUnsubscribe,walletBalanceListenerUnsubensureWalletStarted()syncLightwalletFromFullWallet()getWalletDebugSnapshot()getProofBalanceSnapshot()- All
[Worker][diag]diagnostic logging pendingDepositsMap (replace with simpler polling)
Deployment Strategy
This is a single deployment — not incremental. All handler replacements happen together because the worker message API stays the same. The UI never knows the difference.
- Modify
build/ndk-entry.jsto export cashu-ts - Rebuild bundle
- Rewrite handler section of
ndk-worker.js - Test locally
- Deploy
Risk Mitigation
- Git tag before changes — can revert to v0.3.45 instantly
- Same Nostr event format — kind 7375/17375 compatible with other wallets
- Same worker message API — cashu.html and cashu-wallet.mjs unchanged
- Proofs already downloaded — lightweight system already has the data we need