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

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:

  • NDKCashuWallet import and reference
  • cashuWallet variable and cashuWalletStatus
  • walletStartInFlight
  • ensureCashuWalletClass()
  • loadCashuWalletFromRelays()
  • ensureCashuWallet()
  • logWalletKindDiagnostics()
  • bindCashuWalletListeners() / clearWalletListeners() / walletTxUnsubscribe / walletBalanceListenerUnsub
  • ensureWalletStarted()
  • 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
  • MessageBasedSigner for 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:

  • NDKCashuWallet import line
  • cashuWallet, cashuWalletStatus, walletStartInFlight variables
  • ensureCashuWalletClass()
  • loadCashuWalletFromRelays()
  • ensureCashuWallet()
  • logWalletKindDiagnostics()
  • bindCashuWalletListeners(), clearWalletListeners()
  • walletTxUnsubscribe, walletBalanceListenerUnsub
  • ensureWalletStarted()
  • syncLightwalletFromFullWallet()
  • getWalletDebugSnapshot()
  • getProofBalanceSnapshot()
  • All [Worker][diag] diagnostic logging
  • pendingDeposits Map (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.

  1. Modify build/ndk-entry.js to export cashu-ts
  2. Rebuild bundle
  3. Rewrite handler section of ndk-worker.js
  4. Test locally
  5. 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