From 4b5fa3dfc6614a7de800eb3805712315f4a81a7e Mon Sep 17 00:00:00 2001 From: pablof7z Date: Fri, 20 Dec 2024 22:46:32 +0000 Subject: [PATCH] Breaking apart NDKCashuWallet --- ndk-mobile/src/providers/session/index.tsx | 24 +- ndk-mobile/src/stores/wallet.ts | 8 +- ndk-wallet/src/index.ts | 6 +- ndk-wallet/src/nutzap-monitor/index.ts | 97 ++-- ndk-wallet/src/utils/ln.ts | 12 + ndk-wallet/src/wallets/cashu/deposit.ts | 71 +-- ndk-wallet/src/wallets/cashu/history.ts | 19 +- ndk-wallet/src/wallets/cashu/mint.ts | 60 +++ ndk-wallet/src/wallets/cashu/pay.ts | 66 --- ndk-wallet/src/wallets/cashu/pay/ln.ts | 95 ++-- ndk-wallet/src/wallets/cashu/pay/nut.ts | 177 ++++--- ndk-wallet/src/wallets/cashu/quote.ts | 2 +- ndk-wallet/src/wallets/cashu/send.ts | 6 - ndk-wallet/src/wallets/cashu/token.ts | 45 +- ndk-wallet/src/wallets/cashu/validate.ts | 5 +- ndk-wallet/src/wallets/cashu/wallet.test.ts | 459 ----------------- .../cashu/{wallet.ts => wallet/index.ts} | 480 ++++-------------- .../src/wallets/cashu/wallet/payment.ts | 86 ++++ ndk-wallet/src/wallets/cashu/wallet/state.ts | 305 +++++++++++ ndk-wallet/src/wallets/cashu/wallet/txs.ts | 95 ++++ ndk-wallet/src/wallets/index.ts | 1 - pnpm-workspace.yaml | 1 + 22 files changed, 953 insertions(+), 1167 deletions(-) create mode 100644 ndk-wallet/src/wallets/cashu/mint.ts delete mode 100644 ndk-wallet/src/wallets/cashu/send.ts delete mode 100644 ndk-wallet/src/wallets/cashu/wallet.test.ts rename ndk-wallet/src/wallets/cashu/{wallet.ts => wallet/index.ts} (57%) create mode 100644 ndk-wallet/src/wallets/cashu/wallet/payment.ts create mode 100644 ndk-wallet/src/wallets/cashu/wallet/state.ts create mode 100644 ndk-wallet/src/wallets/cashu/wallet/txs.ts diff --git a/ndk-mobile/src/providers/session/index.tsx b/ndk-mobile/src/providers/session/index.tsx index 3c8ad537..3ebec72a 100644 --- a/ndk-mobile/src/providers/session/index.tsx +++ b/ndk-mobile/src/providers/session/index.tsx @@ -40,7 +40,7 @@ const NDKSessionProvider = ({ children, ...opts }: PropsWithChildren state.balances); const setBalances = useWalletStore((state) => state.setBalances); - + const setNutzapMonitor = useWalletStore((state) => state.setNutzapMonitor); const processFollowEvent = (event: NDKEvent, relay: NDKRelay) => { if (followEvent && followEvent.created_at! > event.created_at!) return; @@ -88,20 +88,19 @@ const NDKSessionProvider = ({ children, ...opts }: PropsWithChildren { ndk.wallet = wallet; - if (wallet instanceof NDKCashuWallet) { - setBalances(wallet.balance()); - } const updateBalance = () => { if (!wallet) return; + console.log('Updating balance from balance_updated event') setBalances(wallet.balance()); } - wallet?.on("ready", () => { - setBalances(wallet.balance()); - }); - if (wallet) { + wallet.on("ready", () => { + console.log('Updating balance from ready event') + setBalances(wallet.balance()); + }); + wallet.on('balance_updated', () => { updateBalance(); }); @@ -118,6 +117,8 @@ const NDKSessionProvider = ({ children, ...opts }: PropsWithChildren { console.log('zap redeemed', zap.rawEvent()); }); + setNutzapMonitor(monitor); + monitor.start(); } } @@ -226,6 +227,12 @@ async function loadWallet(ndk: NDK, settingsStore: SettingsStore, setActiveWalle // Load remotely const freshEvent = await ndk.fetchEvent(event.encode(), { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }, relaySet); + if (!freshEvent) { + console.log("Refreshing the event came back empty, has the wallet been deleted?") + setActiveWallet(null); + return null; + } + if (freshEvent.hasTag('deleted')) { alert('This wallet has been deleted'); setActiveWallet(null); @@ -244,6 +251,7 @@ async function loadWallet(ndk: NDK, settingsStore: SettingsStore, setActiveWalle return wallet; } catch (e) { console.error('Error activating wallet', e); + console.log(payload) } } diff --git a/ndk-mobile/src/stores/wallet.ts b/ndk-mobile/src/stores/wallet.ts index 4ba32191..cdc8cdb9 100644 --- a/ndk-mobile/src/stores/wallet.ts +++ b/ndk-mobile/src/stores/wallet.ts @@ -1,4 +1,4 @@ -import { NDKWallet, NDKWalletBalance } from "@nostr-dev-kit/ndk-wallet" +import { NDKNutzapMonitor, NDKWallet, NDKWalletBalance } from "@nostr-dev-kit/ndk-wallet" import { create } from "zustand" interface WalletState { @@ -7,6 +7,9 @@ interface WalletState { balances: NDKWalletBalance[], setBalances: (balances: NDKWalletBalance[]) => void + + nutzapMonitor: NDKNutzapMonitor | undefined + setNutzapMonitor: (monitor: NDKNutzapMonitor) => void } export const useWalletStore = create((set) => ({ @@ -18,4 +21,7 @@ export const useWalletStore = create((set) => ({ console.log('Setting balances to:', balances); set({ balances }); }, + + nutzapMonitor: undefined, + setNutzapMonitor: (monitor: NDKNutzapMonitor) => set({ nutzapMonitor: monitor }), })) \ No newline at end of file diff --git a/ndk-wallet/src/index.ts b/ndk-wallet/src/index.ts index 1a6819f9..4b3e13f7 100644 --- a/ndk-wallet/src/index.ts +++ b/ndk-wallet/src/index.ts @@ -1,7 +1,7 @@ export * from "./nutzap-monitor/index.js"; export * from "./wallets/index.js"; -export * from "./wallets/cashu/wallet.js"; +export * from "./wallets/cashu/wallet/index.js"; export * from "./wallets/cashu/token.js"; export * from "./wallets/cashu/deposit.js"; export * from "./wallets/cashu/history.js"; @@ -10,4 +10,6 @@ export * from "./wallets/cashu/mint/utils"; export * from "./wallets/webln/index.js"; export * from "./wallets/nwc/index.js"; -export * from "./wallets/nwc/types.js"; \ No newline at end of file +export * from "./wallets/nwc/types.js"; + +export * from "./utils/ln.js"; \ No newline at end of file diff --git a/ndk-wallet/src/nutzap-monitor/index.ts b/ndk-wallet/src/nutzap-monitor/index.ts index ff22ed4e..2799bdab 100644 --- a/ndk-wallet/src/nutzap-monitor/index.ts +++ b/ndk-wallet/src/nutzap-monitor/index.ts @@ -13,7 +13,7 @@ import NDK, { } from "@nostr-dev-kit/ndk"; import { EventEmitter } from "tseep"; import createDebug from "debug"; -import { NDKCashuWallet } from "../wallets/cashu/wallet"; +import { NDKCashuWallet } from "../wallets/cashu/wallet/index.js"; import { Proof } from "@cashu/cashu-ts"; const d = createDebug("ndk-wallet:nutzap-monitor"); @@ -33,7 +33,7 @@ export class NDKNutzapMonitor extends EventEmitter<{ /** * Emitted when a new nutzap is successfully redeemed */ - redeem: (event: NDKNutzap) => void; + redeem: (event: NDKNutzap, amount: number) => void; /** * Emitted when a nutzap has been seen @@ -87,6 +87,7 @@ export class NDKNutzapMonitor extends EventEmitter<{ * Start the monitor. */ public async start(mintList?: NDKCashuMintList) { + const authors = [this.user.pubkey]; // if we are already running, stop the current subscription if (this.sub) { this.sub.stop(); @@ -95,15 +96,25 @@ export class NDKNutzapMonitor extends EventEmitter<{ // if we don't have a mint list, we need to get one if (!mintList) { const list = await this.ndk.fetchEvent([ - { kinds: [NDKKind.CashuMintList], authors: [this.user.pubkey] }, - ]); - if (!list) { - return false; - } + { kinds: [NDKKind.CashuMintList], authors }, + ], { groupable: false, closeOnEose: true }); + if (!list) return false; mintList = NDKCashuMintList.from(list); } + // get the most recent token even + let wallet: NDKCashuWallet | undefined; + let since: number | undefined; + + if (mintList?.p2pk) { + wallet = this.walletByP2pk.get(mintList.p2pk) + const mostRecentToken = await this.ndk.fetchEvent([ + { kinds: [NDKKind.CashuToken], authors, limit: 1 }, + ], { closeOnEose: true, groupable: false }, wallet?.relaySet) + if (mostRecentToken) since = mostRecentToken.created_at!; + } + // set the relay set this.relaySet = mintList.relaySet; @@ -112,8 +123,10 @@ export class NDKNutzapMonitor extends EventEmitter<{ throw new Error("no relay set provided"); } + console.log('starting nutzap monitor with', { since }) + this.sub = this.ndk.subscribe( - { kinds: [NDKKind.Nutzap], "#p": [this.user.pubkey] }, + { kinds: [NDKKind.Nutzap], "#p": [this.user.pubkey], since }, { subId: "ndk-wallet:nutzap-monitor", cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, @@ -135,7 +148,6 @@ export class NDKNutzapMonitor extends EventEmitter<{ private eoseHandler() { this.eosed = true; - console.log('eose'); this.redeemQueue.forEach(nutzap => { this.redeem(nutzap); @@ -176,55 +188,20 @@ export class NDKNutzapMonitor extends EventEmitter<{ const { proofs, mint } = nutzap; d('nutzap has %d proofs: %o', proofs.length, proofs); - let privkey: string | undefined; let wallet: NDKCashuWallet | undefined; - if (nutzap.p2pk) { - wallet = this.findWalletForNutzap(nutzap); - - if (!wallet) { - // if nutzap is p2pk to the active user, check if we have the private key - if (nutzap.p2pk === this.user.pubkey) { - if (this.ndk.signer instanceof NDKPrivateKeySigner) - privkey = (this.ndk.signer as NDKPrivateKeySigner).privateKey; - else { - throw new Error("nutzap p2pk to the active user directly and we don't have access to the private key"); - } - } - - // find the wallet that has one of these mints - const normalizedMint = normalizeUrl(mint); - wallet = this.allWallets.find(w => w.mints - .map(normalizeUrl) - .includes(normalizedMint)); + wallet = this.findWalletForNutzap(nutzap); + if (!wallet) throw new Error("wallet not found for nutzap"); - if (!wallet) throw new Error("wallet not found for nutzap (mint: " + normalizedMint + ")"); + await wallet.redeemNutzap( + nutzap, + { + onRedeemed: (res) => { + const amount = res.reduce((acc, proof) => acc + proof.amount, 0); + this.emit("redeem", nutzap, amount); + } } - } - - if (!wallet) throw new Error("wallet not found for nutzap"); - privkey = wallet.privkey; - - const _wallet = await wallet.walletForMint(mint); - if (!_wallet) throw new Error("unable to load wallet for mint " + mint); - const proofsWeHave = wallet.proofsForMint(mint); - - try { - const res = await _wallet.receive( - { proofs, mint, }, - { privkey, proofsWeHave } - ); - d("redeemed nutzap %o", nutzap.rawEvent()); - this.emit("redeem", nutzap); - - const receivedAmount = computeBalanceDifference(res, proofsWeHave); - - // save new proofs in wallet - wallet.saveProofs(res, mint, { nutzap, amount: receivedAmount }); - } catch (e: any) { - console.log("failed to redeem nutzap", nutzap.id, e.message); - this.emit("failed", nutzap, e.message); - } + ); } catch (e: any) { console.trace(e); this.emit("failed", nutzap, e.message); @@ -232,12 +209,22 @@ export class NDKNutzapMonitor extends EventEmitter<{ } private findWalletForNutzap(nutzap: NDKNutzap): NDKCashuWallet | undefined { - const p2pk = nutzap.p2pk; + const {p2pk, mint} = nutzap; let wallet: NDKCashuWallet | undefined; if (p2pk) wallet = this.walletByP2pk.get(p2pk); wallet ??= this.walletByP2pk.values().next().value; + if (!wallet) { + // find the wallet that has one of these mints + const normalizedMint = normalizeUrl(mint); + wallet = this.allWallets.find(w => w.mints + .map(normalizeUrl) + .includes(normalizedMint)); + + if (!wallet) throw new Error("wallet not found for nutzap (mint: " + normalizedMint + ")"); + } + return wallet; } } diff --git a/ndk-wallet/src/utils/ln.ts b/ndk-wallet/src/utils/ln.ts index 49a02a56..13845888 100644 --- a/ndk-wallet/src/utils/ln.ts +++ b/ndk-wallet/src/utils/ln.ts @@ -12,3 +12,15 @@ export function getBolt11ExpiresAt(bolt11: string): number | undefined { return undefined; } + +export function getBolt11Amount(bolt11: string): number | undefined { + const decoded = decodeBolt11(bolt11); + const val = decoded.sections.find((section: { name: string; }) => section.name === 'amount')?.value; + return Number(val); +} + +export function getBolt11Description(bolt11: string): string | undefined { + const decoded = decodeBolt11(bolt11); + const val = decoded.sections.find((section: { name: string; }) => section.name === 'description')?.value; + return val; +} \ No newline at end of file diff --git a/ndk-wallet/src/wallets/cashu/deposit.ts b/ndk-wallet/src/wallets/cashu/deposit.ts index 9343ee5e..68972770 100644 --- a/ndk-wallet/src/wallets/cashu/deposit.ts +++ b/ndk-wallet/src/wallets/cashu/deposit.ts @@ -1,12 +1,12 @@ import type { Proof } from "@cashu/cashu-ts"; import { CashuWallet } from "@cashu/cashu-ts"; -import type { NDKCashuWallet } from "./wallet"; +import type { NDKCashuWallet } from "./wallet/index.js"; import { EventEmitter } from "tseep"; import { NDKCashuToken } from "./token"; import createDebug from "debug"; -import { NDKEvent, NDKKind, NDKTag, NostrEvent } from "@nostr-dev-kit/ndk"; -import { NDKWalletChange } from "./history"; +import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKCashuQuote } from "./quote"; +import { createInTxEvent } from "./wallet/txs.js"; const d = createDebug("ndk-wallet:cashu:deposit"); @@ -61,10 +61,8 @@ export class NDKCashuDeposit extends EventEmitter<{ * @returns */ async start(pollTime: number = 2500) { - const w = await this.wallet.walletForMint(this.mint); - if (!w) throw new Error("unable to load wallet for mint " + this.mint); - this._wallet = w; - const quote = await this._wallet.createMintQuote(this.amount); + const cashuWallet = await this.wallet.cashuWallet(this.mint); + const quote = await cashuWallet.createMintQuote(this.amount); d("created quote %s for %d %s", quote.quote, this.amount, this.mint); this.quoteId = quote.quote; @@ -113,9 +111,6 @@ export class NDKCashuDeposit extends EventEmitter<{ setTimeout(() => { this.runCheck(); this.checkIntervalLength += 500; - if (this.checkIntervalLength > 30000) { - this.checkIntervalLength = 30000; - } }, this.checkIntervalLength); } @@ -140,13 +135,28 @@ export class NDKCashuDeposit extends EventEmitter<{ try { d("Checking for minting status of %s", this.quoteId); - const w = await this.wallet.walletForMint(this.mint); - if (!w) throw new Error("unable to load wallet for mint " + this.mint); - this._wallet = w; - proofs = await this._wallet.mintProofs(this.amount, this.quoteId); + const cashuWallet = await this.wallet.cashuWallet(this.mint); + const proofsWeHave = await this.wallet.proofsForMint(this.mint); + proofs = await cashuWallet.mintProofs(this.amount, this.quoteId, { + proofsWeHave, + }); if (proofs.length === 0) return; } catch (e: any) { if (e.message.match(/not paid/i)) return; + + if (e.message.match(/already issued/i)) { + d("Mint is saying the quote has already been issued, destroying quote event: %s", e.message); + this.destroyQuoteEvent(); + this.finalized = true; + return; + } + + if (e.message.match(/rate limit/i)) { + d("Mint seems to be rate limiting, lowering check interval"); + this.checkIntervalLength += 5000; + return; + } + d(e.message); return; } @@ -154,35 +164,30 @@ export class NDKCashuDeposit extends EventEmitter<{ try { this.finalized = true; - const tokenEvent = new NDKCashuToken(this.wallet.ndk); - tokenEvent.proofs = proofs; - tokenEvent.mint = this.mint; - tokenEvent.wallet = this.wallet; + const updateRes = await this.wallet.state.update({ + store: proofs, + mint: this.mint, + }); - await tokenEvent.publish(this.wallet.relaySet); + const tokenEvent = updateRes.created; + if (!tokenEvent) throw new Error("no token event created"); - const historyEvent = new NDKWalletChange(this.wallet.ndk); - historyEvent.direction = 'in'; - historyEvent.amount = tokenEvent.amount; - historyEvent.unit = this.unit; - historyEvent.createdTokens = [ tokenEvent ]; - historyEvent.description = "Deposit"; - historyEvent.mint = this.mint; - historyEvent.publish(this.wallet.relaySet); + createInTxEvent(this.wallet, proofs, this.wallet.unit, this.mint, updateRes, { description: "Deposit" }); this.emit("success", tokenEvent); // delete the quote event if it exists - if (this.quoteEvent) { - console.log("destroying quote event", this.quoteEvent.id); - const deleteEvent = await this.quoteEvent.delete(undefined, false); - deleteEvent.publish(this.wallet.relaySet); - } - + this.destroyQuoteEvent(); } catch (e: any) { console.log("relayset", this.wallet.relaySet); this.emit("error", e.message); console.error(e); } } + + private async destroyQuoteEvent() { + if (!this.quoteEvent) return; + const deleteEvent = await this.quoteEvent.delete(undefined, false); + deleteEvent.publish(this.wallet.relaySet); + } } diff --git a/ndk-wallet/src/wallets/cashu/history.ts b/ndk-wallet/src/wallets/cashu/history.ts index da4d25a0..92ae6835 100644 --- a/ndk-wallet/src/wallets/cashu/history.ts +++ b/ndk-wallet/src/wallets/cashu/history.ts @@ -1,4 +1,4 @@ -import type { NDKTag, NostrEvent } from "@nostr-dev-kit/ndk"; +import type { NDKEventId, NDKTag, NostrEvent } from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk"; import createDebug from "debug"; @@ -11,6 +11,7 @@ const MARKERS = { REDEEMED: "redeemed", CREATED: "created", DESTROYED: "destroyed", + RESERVED: "reserved", }; export type DIRECTIONS = 'in' | 'out'; @@ -108,7 +109,13 @@ export class NDKWalletChange extends NDKEvent { */ set destroyedTokens(events: NDKCashuToken[]) { for (const event of events) { - this.tag(event, MARKERS.DESTROYED) + this.tags.push(event.tagReference(MARKERS.DESTROYED)); + } + } + + set destroyedTokenIds(ids: NDKEventId[]) { + for (const id of ids) { + this.tags.push(['e', id, "", MARKERS.DESTROYED]) } } @@ -117,7 +124,13 @@ export class NDKWalletChange extends NDKEvent { */ set createdTokens(events: NDKCashuToken[]) { for (const event of events) { - this.tag(event, MARKERS.CREATED) + this.tags.push(event.tagReference(MARKERS.CREATED)) + } + } + + set reservedTokens(events: NDKCashuToken[]) { + for (const event of events) { + this.tags.push(event.tagReference(MARKERS.RESERVED)) } } diff --git a/ndk-wallet/src/wallets/cashu/mint.ts b/ndk-wallet/src/wallets/cashu/mint.ts new file mode 100644 index 00000000..5d223a23 --- /dev/null +++ b/ndk-wallet/src/wallets/cashu/mint.ts @@ -0,0 +1,60 @@ +import { CashuWallet, CashuMint } from "@cashu/cashu-ts"; +import { MintUrl } from "./mint/utils"; + +const mintWallets = new Map(); +const mintWalletPromises = new Map>(); + +function mintKey(mint: MintUrl, unit: string, pk?: Uint8Array) { + if (unit === 'sats') { + unit = 'sat'; + } + + if (pk) { + const pkStr = new TextDecoder().decode(pk); + return `${mint}-${unit}-${pkStr}`; + } + + return `${mint}-${unit}`; +} + +export async function walletForMint( + mint: MintUrl, + unit: string, + pk?: Uint8Array, + timeout = 5000 +): Promise { + if (unit === 'sats' || unit.startsWith('msat')) { + unit = 'sat'; + } + + const key = mintKey(mint, unit, pk); + + if (mintWallets.has(key)) return mintWallets.get(key) as CashuWallet; + + if (mintWalletPromises.has(key)) { + return mintWalletPromises.get(key) as Promise; + } + + const wallet = new CashuWallet(new CashuMint(mint), { unit, bip39seed: pk }); + console.log("[WALLET] loading mint", mint, { withPk: pk ? true : false }); + + const loadPromise = new Promise(async (resolve) => { + try { + const timeoutPromise = new Promise((_, rejectTimeout) => { + setTimeout(() => rejectTimeout(new Error("timeout loading mint")), timeout); + }); + await Promise.race([wallet.loadMint(), timeoutPromise]); + console.log("[WALLET] loaded mint", mint); + mintWallets.set(key, wallet); + mintWalletPromises.delete(key); + resolve(wallet); + } catch (e) { + console.error("[WALLET] error loading mint", mint, e.message); + mintWalletPromises.delete(key); + resolve(null); + } + }); + + mintWalletPromises.set(key, loadPromise); + return loadPromise; +} \ No newline at end of file diff --git a/ndk-wallet/src/wallets/cashu/pay.ts b/ndk-wallet/src/wallets/cashu/pay.ts index e80d4567..6f012f5a 100644 --- a/ndk-wallet/src/wallets/cashu/pay.ts +++ b/ndk-wallet/src/wallets/cashu/pay.ts @@ -2,7 +2,6 @@ import { Proof } from "@cashu/cashu-ts"; import type { NDKCashuWallet } from "./wallet"; import createDebug from "debug"; import type { CashuPaymentInfo, LnPaymentInfo, NDKZapDetails } from "@nostr-dev-kit/ndk"; -import type { NutPayment } from "./pay/nut.js"; import { payLn } from "./pay/ln.js"; import { decode as decodeBolt11 } from "light-bolt11-decoder"; @@ -13,68 +12,3 @@ export function correctP2pk(p2pk?: string) { return p2pk; } - -/** - * Uses cashu balance to make a payment, whether a cashu swap or a lightning - */ -// export class NDKCashuPay { -// public wallet: NDKCashuWallet; -// public info: NDKZapDetails; -// public type: "ln" | "nut" = "ln"; -// public debug = createDebug("ndk-wallet:cashu:pay"); -// public unit: string = "sat"; - -// constructor(wallet: NDKCashuWallet, info: NDKZapDetails) { -// this.wallet = wallet; - -// if ((info as LnPaymentInfo).pr) { -// this.type = "ln"; -// this.info = info as NDKZapDetails; -// } else { -// this.type = "nut"; -// this.info = info as NDKZapDetails; -// if (this.info.unit.startsWith("msat")) { -// this.info.unit = "sat"; -// this.info.amount = this.info.amount / 1000; -// this.info.p2pk = correctP2pk(this.info.p2pk); -// } - -// this.debug("nut payment %o", this.info); -// } -// } - -// public getAmount() { -// if (this.type === "ln") { -// const bolt11 = (this.info as LnPaymentInfo).pr; -// const { sections } = decodeBolt11(bolt11); -// for (const section of sections) { -// if (section.name === "amount") { -// const { value } = section; -// return Number(value); -// } -// } -// // stab -// return 1; -// } else { -// return (this.info as NutPayment).amount; -// } -// } - -// /** -// * -// * @param description A description of what this payment is for to be added to the wallet history -// * @param nutzap If this payment is a nutzap, this is the nutzap to be added to the wallet history -// * @returns -// */ -// public async pay(payment: NDKZapDetails) { -// if (this.type === "ln") { -// return this.payLn(payment as NDKZapDetails) -// } else { -// return this.payNut(payment as NDKZapDetails); -// } -// } - -// public payNut = createTokenForPayment.bind(this); - -// public payLn = payLn.bind(this); -// } diff --git a/ndk-wallet/src/wallets/cashu/pay/ln.ts b/ndk-wallet/src/wallets/cashu/pay/ln.ts index d3c3ed7e..9646e9f0 100644 --- a/ndk-wallet/src/wallets/cashu/pay/ln.ts +++ b/ndk-wallet/src/wallets/cashu/pay/ln.ts @@ -1,23 +1,29 @@ -import { CashuWallet, CashuMint, Proof, MeltQuoteState, SendResponse } from "@cashu/cashu-ts"; -import { NDKEvent, NDKTag, NDKUser, NDKZapDetails, type LnPaymentInfo } from "@nostr-dev-kit/ndk"; -import { rollOverProofs, type TokenSelection } from "../proofs"; -import type { MintUrl } from "../mint/utils"; -import { NDKCashuWallet } from "../wallet"; -import { NDKWalletChange } from "../history"; +import { Proof, MeltQuoteState } from "@cashu/cashu-ts"; +import { NDKCashuWallet } from "../wallet/index.js"; +import { getBolt11Amount } from "../../../utils/ln.js"; +import { WalletChange } from "../wallet/state.js"; -type LNPaymentResult = SendResponse & { preimage: string, change: Proof[], mint: MintUrl }; +export type LNPaymentResult = { + walletChange: WalletChange, + preimage: string, + fee?: number +}; export async function payLn( wallet: NDKCashuWallet, - amount: number, pr: string, ): Promise { - const eligibleMints = wallet.getMintsWithBalance(amount); - console.log("eligible mints", eligibleMints, {amount}); + let invoiceAmount = getBolt11Amount(pr); + if (!invoiceAmount) throw new Error("invoice amount is required"); + + invoiceAmount = invoiceAmount / 1000; // msat + + const eligibleMints = wallet.getMintsWithBalance(invoiceAmount); + console.log("eligible mints", eligibleMints, {invoiceAmount}); for (const mint of eligibleMints) { try { - const result = await executePayment(mint, pr, amount, wallet); + const result = await executePayment(mint, pr, invoiceAmount, wallet); if (result) { return result; } @@ -52,8 +58,8 @@ async function executePayment( wallet: NDKCashuWallet, ): Promise { console.log("executing payment from mint", mint); - const _wallet = await wallet.walletForMint(mint); - if (!_wallet) throw new Error("unable to load wallet for mint " + mint); + const result: LNPaymentResult = { walletChange: { mint }, preimage: "" }; + const cashuWallet = await wallet.cashuWallet(mint); const mintProofs = wallet.proofsForMint(mint); // Add up the amounts of the proofs @@ -61,59 +67,25 @@ async function executePayment( if (amountAvailable < amount) return null; try { - const meltQuote = await _wallet.createMeltQuote(pr); + const meltQuote = await cashuWallet.createMeltQuote(pr); const amountToSend = meltQuote.amount + meltQuote.fee_reserve; - const proofs = _wallet.selectProofsToSend(mintProofs, amountToSend); - - const meltResult = await _wallet.meltProofs(meltQuote, proofs.send); - console.log("Melt result: %o", meltResult); + const proofs = cashuWallet.selectProofsToSend(mintProofs, amountToSend); + console.log('proofs to send', proofs) - const fee = calculateFee(amount, mintProofs, meltResult.change); + result.walletChange.destroy = proofs.send; - function calculateFee(sentAmount: number, proofs: Proof[], change: Proof[]) { - let fee = -sentAmount; - for (const proof of proofs) fee += proof.amount; - for (const proof of change) fee -= proof.amount; - return fee; - } + const meltResult = await cashuWallet.meltProofs(meltQuote, proofs.send); + console.log("Melt result: %o", meltResult); // generate history event - if (meltResult.quote.state === MeltQuoteState.PAID && meltResult.quote.payment_preimage) { + if (meltResult.quote.state === MeltQuoteState.PAID) { console.log("Payment successful"); + result.walletChange.store = meltResult.change; + result.fee = calculateFee(amount, proofs.send, meltResult.change); + result.preimage = meltResult.quote.payment_preimage ?? ""; - // const historyEvent = new NDKWalletChange(wallet.ndk); - // historyEvent.destroyedTokens = sendProofs; - // historyEvent.createdTokens = meltResult.change; - // if (wallet.event) historyEvent.tag(wallet.event); - // historyEvent.direction = 'out'; - // historyEvent.description = payment.paymentDescription; - - // if (payment.target) { - // let tag: NDKTag | undefined; - - // if (payment.target instanceof NDKEvent) { - // tag = payment.target.tagReference(); - // } else if (payment.target instanceof NDKUser && !payment.recipientPubkey) { - // tag = ['p', payment.target.pubkey]; - // } - - // if (tag) { - // console.log("adding tag", tag); - // historyEvent.tags.push(tag); - // } - // } - - // if (payment.recipientPubkey) { - // historyEvent.tags.push(['p', payment.recipientPubkey]); - // } - - // historyEvent.tags.push(['preimage', meltResult.quote.payment_preimage]); - // historyEvent.amount = meltResult.quote.amount; - // historyEvent.fee = fee; - // historyEvent.publish(wallet.relaySet); - - return { preimage: meltResult.quote.payment_preimage, change: meltResult.change, ...proofs, mint }; + return result; } return null; @@ -129,4 +101,11 @@ async function executePayment( return null; } +} + +function calculateFee(sentAmount: number, proofs: Proof[], change: Proof[]) { + let fee = -sentAmount; + for (const proof of proofs) fee += proof.amount; + for (const proof of change) fee -= proof.amount; + return fee; } \ No newline at end of file diff --git a/ndk-wallet/src/wallets/cashu/pay/nut.ts b/ndk-wallet/src/wallets/cashu/pay/nut.ts index 524f38fd..f16019fd 100644 --- a/ndk-wallet/src/wallets/cashu/pay/nut.ts +++ b/ndk-wallet/src/wallets/cashu/pay/nut.ts @@ -1,9 +1,12 @@ import { SendResponse, type Proof } from "@cashu/cashu-ts"; import type { MintUrl } from "../mint/utils"; -import { NDKCashuWallet } from "../wallet"; -import { CashuPaymentInfo, NDKZapDetails, normalizeUrl } from "@nostr-dev-kit/ndk"; +import { NDKCashuWallet } from "../wallet/index.js"; +import { CashuPaymentInfo, normalizeUrl } from "@nostr-dev-kit/ndk"; import { correctP2pk } from "../pay"; -import { NDKCashuDeposit } from "../deposit"; +import { payLn } from "./ln"; +import { getBolt11Amount } from "../../../utils/ln"; +import { walletForMint } from "../mint"; +import { WalletChange } from "../wallet/state.js"; export type NutPayment = CashuPaymentInfo & { amount: number; unit: string; }; @@ -19,7 +22,7 @@ export async function createToken( unit: string, recipientMints: MintUrl[], p2pk?: string, -): Promise { +): Promise { p2pk = correctP2pk(p2pk); const senderMints = wallet.mints; const mintsInCommon = findMintsInCommon([recipientMints, senderMints]); @@ -30,37 +33,10 @@ export async function createToken( for (const mint of mintsInCommon) { try { - console.log("attempting payment with mint %s", mint); const res = await createTokenInMint(wallet, mint, amount, p2pk); if (res) { - console.log('updating wallet state'); - const isP2pk = (p: Proof) => p.secret.startsWith('["P2PK"'); - const isNotP2pk = (p: Proof) => !isP2pk(p); - - // fee could be calculated here with the difference between the - const totalSent = res.send.reduce((acc, p) => acc + p.amount, 0); - const totalChange = res.keep.reduce((acc, p) => acc + p.amount, 0); - const fee = totalSent - amount - totalChange; - - console.log("fee for mint payment calculated", { - fee, - totalSent, - totalChange, - amount, - }); - - if (fee > 0) { - res.fee = fee; - } - - wallet.updateState({ - reserve: res.send.filter(isNotP2pk), - destroy: res.send.filter(isP2pk), // no point in reserving p2pk proofs since they will be published already - store: res.keep, - mint: res.mint, - }); - + console.log("result of paying within the same mint", res); return res; } } catch (e) { @@ -68,9 +44,7 @@ export async function createToken( } } - // console.log("attempting payment with mint transfer"); - - return await createTokenForPaymentWithMintTransfer(wallet, amount, unit, recipientMints, p2pk); + return await createTokenWithMintTransfer(wallet, amount, unit, recipientMints, p2pk); } /** @@ -84,20 +58,31 @@ async function createTokenInMint( mint: MintUrl, amount: number, p2pk?: string, -): Promise { - const _wallet = await wallet.walletForMint(mint); - if (!_wallet) throw new Error("unable to load wallet for mint " + mint); +): Promise { + const walletChange: WalletChange = { mint }; + + const cashuWallet = await wallet.cashuWallet(mint); try { console.log("Attempting with mint %s", mint); + const proofsWeHave = wallet.proofsForMint(mint); - console.log("proofs we have: %o", proofsWeHave); - const res = await _wallet.send(amount, proofsWeHave, { + const proofs = cashuWallet.selectProofsToSend(proofsWeHave, amount); + console.log('keeping %d proofs, providing proofs to send: %o', proofs.keep.length, proofs.send) + + const sendResult = await cashuWallet.send(amount, proofs.send, { pubkey: p2pk, proofsWeHave, }); - console.log("token preparation result: %o", res); + console.log("token preparation result: %o", sendResult); + + walletChange.destroy = proofs.send; + walletChange.store = sendResult.keep; - return { ...res, mint }; + return { + walletChange, + send: { proofs: sendResult.send, mint }, + fee: calculateFee(proofs.send, [...sendResult.send, ...sendResult.keep]) + }; } catch (e: any) { console.log( "failed to pay with mint %s using proofs %o: %s", @@ -107,63 +92,131 @@ async function createTokenInMint( } } +/** + * Calculates the difference between the provided proofs and the return proof totals; + * @param providedProofs + * @param returnedProofs + */ +function calculateFee(providedProofs: Proof[], returnedProofs: Proof[]) { + const totalProvided = providedProofs.reduce((acc, p) => acc + p.amount, 0); + const totalReturned = returnedProofs.reduce((acc, p) => acc + p.amount, 0); + + if (totalProvided < totalReturned) { + console.log("BUG: calculate fee thinks we received back a higher amount of proofs than we sent to the mint", { + providedProofs, + returnedProofs + }) + } + + return totalProvided - totalReturned; +} + /** * Iterate through the mints to find one that can satisfy a minting request * for the desired amount in any of the mints the recipient accepts. */ -async function createTokenForPaymentWithMintTransfer( +async function createTokenWithMintTransfer( wallet: NDKCashuWallet, amount: number, unit: string, recipientMints: MintUrl[], p2pk?: string, -): Promise { +): Promise { const generateQuote = async () => { const generateQuoteFromSomeMint = async (mint: MintUrl) => { - const _wallet = await wallet.walletForMint(mint); - if (!_wallet) throw new Error("unable to load wallet for mint " + mint); - const quote = await _wallet.createMintQuote(amount); - return { quote, mint }; + const targetMintWallet = await walletForMint(mint, unit); + if (!targetMintWallet) throw new Error("unable to load wallet for mint " + mint); + const quote = await targetMintWallet.createMintQuote(amount); + console.log('received a quote from mint', {quoteId: quote.quote, mint}) + return { quote, mint, targetMintWallet }; }; const quotesPromises = recipientMints.map(generateQuoteFromSomeMint); - const { quote, mint } = await Promise.any(quotesPromises); + const { quote, mint, targetMintWallet } = await Promise.any(quotesPromises); if (!quote) { console.log("failed to get quote from any mint"); throw new Error("failed to get quote from any mint"); } - console.log("quote from mint %s: %o", mint, quote); + console.log("quote from mint %s: %o", mint, quote, targetMintWallet.mint); - return { quote, mint }; + return { quote, mint, targetMintWallet }; } - const { quote, mint } = await generateQuote(); + // generate quote + const { quote, mint: targetMint, targetMintWallet } = await generateQuote(); if (!quote) return; // TODO: create a CashuDeposit event? - const res = await wallet.lnPay({ pr: quote.request, amount, unit }); - console.log("payment result: %o", res); + console.log('instructing local wallet to pay', {quoteId: quote.quote, targetMint, m: targetMintWallet.mint}) - if (!res) { + const invoiceAmount = getBolt11Amount(quote.request); + if (!invoiceAmount) throw new Error("invoice amount is required"); + const invoiceAmountInSat = invoiceAmount / 1000; + if (invoiceAmountInSat > amount) throw new Error(`invoice amount is more than the amount passed in (${invoiceAmountInSat} vs ${amount})`); + + const payLNResult = await payLn(wallet, quote.request); + console.log("LN payment result: %o", payLNResult); + + if (!payLNResult) { console.log("payment failed"); return; } - const _wallet = await wallet.walletForMint(mint); - if (!_wallet) throw new Error("unable to load wallet for mint " + mint); - const proofs = await _wallet.mintProofs(amount, quote.quote, { - pubkey: p2pk, - }); + let proofs: Proof[] = []; + + try { + console.log('will try to mint proofs', { w: targetMintWallet.mint, quoteId: quote.quote }) + proofs = await targetMintWallet.mintProofs(amount, quote.quote, { + pubkey: p2pk, + }); + } catch (e) { + console.log("failed to mint proofs, fuck, the mint ate the cashu", e); + + // return new Promise((resolve, reject) => { + // const retryInterval = setInterval(async () => { + // console.log("retrying mint proofs", { quote: quote.quote, mint: targetMintWallet.mint }); + // try { + // proofs = await targetMintWallet.mintProofs(amount, quote.quote, { + // pubkey: p2pk, + // }); + // clearInterval(retryInterval); + // resolve({ keep: res.change, send: proofs, mint: targetMint, fee: res.fee }); + // } catch (e) { + // console.log("failed to mint proofs", e); + // } + // }, 5000); + // setTimeout(() => { + // reject(e); + // }, 1000); + // }); + } console.log("minted tokens with proofs %o", proofs); - return { keep: [], send: proofs, mint }; + return { + walletChange: payLNResult.walletChange, + send: { proofs, mint: targetMint }, + fee: payLNResult.fee, + } +} + +/** + * The result of generating proofs to pay something, whether it's funded with a swap or LN. + */ +export type TokenCreationResult = { + /** + * Information of the wallet state change. The proofs that were used and the proofs that + * were created that we need to store. + */ + walletChange: WalletChange, + send: { proofs: Proof[], mint: MintUrl }, + fee?: number } -type TokenWithMint = SendResponse & { mint: MintUrl, fee?: number }; +export type TokenWithMint = SendResponse & { mint: MintUrl, fee?: number }; /** * Finds mints in common in the intersection of the arrays of mints diff --git a/ndk-wallet/src/wallets/cashu/quote.ts b/ndk-wallet/src/wallets/cashu/quote.ts index fc748afa..1b5e38e0 100644 --- a/ndk-wallet/src/wallets/cashu/quote.ts +++ b/ndk-wallet/src/wallets/cashu/quote.ts @@ -4,7 +4,7 @@ import { NDKKind } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import { decrypt } from "./decrypt"; -import { NDKCashuWallet } from "./wallet"; +import { NDKCashuWallet } from "./wallet/index.js"; import { getBolt11ExpiresAt } from "../../utils/ln"; export class NDKCashuQuote extends NDKEvent { diff --git a/ndk-wallet/src/wallets/cashu/send.ts b/ndk-wallet/src/wallets/cashu/send.ts deleted file mode 100644 index 0e806c87..00000000 --- a/ndk-wallet/src/wallets/cashu/send.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { EventEmitter } from "tseep"; -import type { NDKCashuWallet } from "./wallet"; - -class NDKCashuSend extends EventEmitter { - constructor(wallet: NDKCashuWallet, target: NDKUser | NDKEvent); -} diff --git a/ndk-wallet/src/wallets/cashu/token.ts b/ndk-wallet/src/wallets/cashu/token.ts index e056faef..8bc5f097 100644 --- a/ndk-wallet/src/wallets/cashu/token.ts +++ b/ndk-wallet/src/wallets/cashu/token.ts @@ -1,10 +1,9 @@ -import type { MintKeys } from "@cashu/cashu-ts"; import { type Proof } from "@cashu/cashu-ts"; import type { NDKRelay, NDKRelaySet, NostrEvent } from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKKind, normalizeUrl } from "@nostr-dev-kit/ndk"; -import type { NDKCashuWallet } from "./wallet"; -import { decrypt } from "./decrypt"; +import type { NDKCashuWallet } from "./wallet/index.js"; +import { decrypt } from "./decrypt.js"; export function proofsTotalBalance(proofs: Proof[]): number { for (const proof of proofs) { @@ -17,7 +16,7 @@ export function proofsTotalBalance(proofs: Proof[]): number { } export class NDKCashuToken extends NDKEvent { - public proofs: Proof[] = []; + private _proofs: Proof[] = []; private original: NDKEvent | undefined; constructor(ndk?: NDK, event?: NostrEvent | NDKEvent) { @@ -46,6 +45,25 @@ export class NDKCashuToken extends NDKEvent { return token; } + get proofs(): Proof[] { + return this._proofs; + } + + set proofs(proofs: Proof[]) { + const cs = new Set(); + + this._proofs = []; + for (const proof of proofs) { + if (cs.has(proof.C)) { + console.warn("Passed in proofs had duplicates, ignoring", proof.C); + continue; + } + + this._proofs.push(proof); + cs.add(proof.C); + } + } + /** * Strips out anything we don't necessarily have to store. */ @@ -106,22 +124,3 @@ export class NDKCashuToken extends NDKEvent { } } } - -export class NDKCashuWalletKey extends NDKEvent { - constructor(ndk?: NDK, event?: NostrEvent) { - super(ndk, event); - this.kind ??= 37376; - } - - set keys(payload: MintKeys) { - this.content = JSON.stringify(payload); - } - - get keys(): MintKeys { - return JSON.parse(this.content); - } - - set wallet(wallet: NDKCashuWallet) { - this.dTag = wallet.walletId; - } -} diff --git a/ndk-wallet/src/wallets/cashu/validate.ts b/ndk-wallet/src/wallets/cashu/validate.ts index 1c2aa2d8..372b51bc 100644 --- a/ndk-wallet/src/wallets/cashu/validate.ts +++ b/ndk-wallet/src/wallets/cashu/validate.ts @@ -1,10 +1,11 @@ import { CheckStateEnum, ProofState, type Proof } from "@cashu/cashu-ts"; import { NDKCashuToken } from "./token"; import createDebug from "debug"; -import type { NDKCashuWallet } from "./wallet"; +import type { NDKCashuWallet } from "./wallet/index.js"; import { hashToCurve } from '@cashu/crypto/modules/common'; import { rollOverProofs } from "./proofs"; import { NDKEvent, NDKKind, NostrEvent } from "@nostr-dev-kit/ndk"; +import { walletForMint } from "./mint"; const d = createDebug("ndk-wallet:cashu:validate"); @@ -55,7 +56,7 @@ export async function consolidateMintTokens( wallet: NDKCashuWallet ) { const allProofs = tokens.map((t) => t.proofs).flat(); - const _wallet = await wallet.walletForMint(mint); + const _wallet = await walletForMint(mint, wallet.unit); if (!_wallet) return; d( "checking %d proofs in %d tokens for spent proofs for mint %s", diff --git a/ndk-wallet/src/wallets/cashu/wallet.test.ts b/ndk-wallet/src/wallets/cashu/wallet.test.ts deleted file mode 100644 index 35b2941a..00000000 --- a/ndk-wallet/src/wallets/cashu/wallet.test.ts +++ /dev/null @@ -1,459 +0,0 @@ -import { NDKCashuWallet, WalletChange } from './wallet'; -import { NDKCashuToken } from './token'; -import { NDKZapDetails, CashuPaymentInfo, NDKUser, NDKEvent, NDKPrivateKeySigner, NDKRelaySet } from '@nostr-dev-kit/ndk'; -import NDK from '@nostr-dev-kit/ndk'; -import { jest } from '@jest/globals'; -import { Proof } from '@cashu/cashu-ts'; -import { NDKRelay } from '@nostr-dev-kit/ndk'; - -const ndk = new NDK({ signer: NDKPrivateKeySigner.generate() }); -const relay = new NDKRelay('wss://example.com', undefined, ndk); -ndk.addExplicitRelay(relay, undefined, false); - -describe('NDKCashuWallet', () => { - let wallet: NDKCashuWallet; - - const paymentDetails: NDKZapDetails = { - amount: 200, - unit: 'sats', - mints: ['https://testnut.cashu.space'], - recipientPubkey: 'some-pubkey', - target: new NDKUser({ pubkey: 'fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52'}), - relays: ['wss://relay.example.com'] - }; - - beforeEach(() => { - wallet = new NDKCashuWallet(ndk); - }); - - fdescribe("addToken", () => { - it('adds a token to the wallet', () => { - const { tokens } = createMockTokens(mockPayNutResult, false, 3); - - wallet.addToken(tokens[0]); - - expect(wallet.tokens.length).toBe(1); - expect(wallet.tokens[0].id).toBe(tokens[0].id); - }); - - it('skips adding the same token twice', () => { - const { tokens } = createMockTokens(mockPayNutResult, false, 3); - - wallet.addToken(tokens[0]); - wallet.addToken(tokens[0]); - - expect(wallet.tokens.length).toBe(1); - expect(wallet.tokens[0].id).toBe(tokens[0].id); - }) - - fit("discards older tokens when both include the same proof", () => { - const { tokens } = createMockTokens(mockPayNutResult, false, 3); - - tokens[1].created_at! = Math.floor(Date.now() / 1000); - tokens[0].created_at! = tokens[0].created_at! - 1000; - - tokens[1].proofs.push(tokens[0].proofs[0]); - - wallet.addToken(tokens[0]); - - expect(wallet.addToken(tokens[1])).toBe(true); - expect(wallet.tokens.length).toBe(1); - expect(wallet.knownTokens.size).toBe(2); - expect(wallet.tokens[0].id).toBe(tokens[1].id); - }) - - fit("refuses to add older tokens when both include the same proof", () => { - const { tokens } = createMockTokens(mockPayNutResult, false, 3); - - tokens[0].created_at! = Math.floor(Date.now() / 1000); - tokens[1].created_at! = tokens[0].created_at! - 1000; - - tokens[1].proofs.push(tokens[0].proofs[0]); - - wallet.addToken(tokens[0]); - - expect(wallet.addToken(tokens[1])).toBe(false); - expect(wallet.tokens.length).toBe(1); - expect(wallet.tokens[0].id).toBe(tokens[0].id); - }) - }) - - it('should successfully pay with cashu when wallet has enough tokens', async () => { - const { tokens, modifiedMockResult } = createMockTokens(mockPayNutResult, false, 3); - wallet.tokens.push(...tokens); - - // jest.spyOn(NDKCashuPay.prototype, 'payNut').mockResolvedValue(modifiedMockResult); - - const result = await wallet.cashuPay(paymentDetails); - - expect(result).toBeDefined(); - expect(result?.proofs).toBeDefined(); - - expect(modifiedMockResult.send.length).toBe(result?.proofs.length); - }); - - async function mockTokenWithProofs( - amounts: number[] - ): Promise { - const token = new NDKCashuToken(ndk); - const proofs: Proof[] = []; - - for (const amount of amounts) { - proofs.push({ - amount, - C: `02${Math.random().toString(16).substring(2)}`, - secret: Math.random().toString(16).substring(2), - id: Math.random().toString(16).substring(2, 10) - }) - } - - token.proofs = proofs; - token.mint = 'https://testnut.cashu.space'; - await token.sign(); - return token; - } - - describe('calculateNewState', () => { - it('works when a single token with two proofs is getting one proof spent', async () => { - const token = await mockTokenWithProofs([16, 8]); - wallet.tokens.push(token); - - const walletChange: WalletChange = { - "reserve": [], - "destroy": [ { "amount": 8, "C": "037ae2cd8439b753fb86522a8fa445c613cda148eeffea7703ac065aaf6e4e3f35", "id": "009a1f293253e41e", "secret": "[\"P2PK\",{\"nonce\":\"ee6b11bc642e5df2251dbfb052b6356f8df8c988429dde5d84714d16dcfd86f0\",\"data\":\"026448ff8c43d98d84a50e9176b2746c580adc354d7fc43aebfc3a12462514f34f\"}]" } ], - "store": [{ "amount": 16, "C": "02e1a8b3b633b57b062eecf3c3bd1865950d1fbae8670a7b405786701f0ea5380f", "id": "009a1f293253e41e", "secret": "f30b744120dac5f42deaa091cae94d168f13c23e19f048cf915b169d42fc0e15", } ], - "mint": "https://testnut.cashu.space" - } - - const res = await wallet.calculateNewState(walletChange); - - expect(Array.from(res.deletedTokenIds)[0]).toBe(token.id); - expect(res.saveProofs.length).toBe(1); - - expect(res.saveProofs[0].C).toBe("02e1a8b3b633b57b062eecf3c3bd1865950d1fbae8670a7b405786701f0ea5380f"); - }) - - it('deletes a token and rollsover the remaining proof', async () => { - const token = await mockTokenWithProofs([16, 8]); - wallet.tokens.push(token); - - const walletChange: WalletChange = { - "reserve": [], - "destroy": [ { "amount": 8, "C": "037ae2cd8439b753fb86522a8fa445c613cda148eeffea7703ac065aaf6e4e3f35", "id": "009a1f293253e41e", "secret": "[\"P2PK\",{\"nonce\":\"ee6b11bc642e5df2251dbfb052b6356f8df8c988429dde5d84714d16dcfd86f0\",\"data\":\"026448ff8c43d98d84a50e9176b2746c580adc354d7fc43aebfc3a12462514f34f\"}]" } ], - "store": [{ "amount": 16, "C": "02e1a8b3b633b57b062eecf3c3bd1865950d1fbae8670a7b405786701f0ea5380f", "id": "009a1f293253e41e", "secret": "f30b744120dac5f42deaa091cae94d168f13c23e19f048cf915b169d42fc0e15", } ], - "mint": "https://testnut.cashu.space" - } - - const res = await wallet.calculateNewState(walletChange); - - expect(Array.from(res.deletedTokenIds).length).toBe(1); - expect(res.saveProofs.length).toBe(1); - }) - - it('when the entire token is spent we dont create a new token', async () => { - const tokens = [ - await mockTokenWithProofs([4]), - await mockTokenWithProofs([8]) - ]; - wallet.tokens.push(...tokens); - - const walletChange: WalletChange = { - "reserve": [], - "destroy": [{ "amount": 8, "C": "037ae2cd8439b753fb86522a8fa445c613cda148eeffea7703ac065aaf6e4e3f35", "id": "009a1f293253e41e", "secret": "[\"P2PK\",{\"nonce\":\"ee6b11bc642e5df2251dbfb052b6356f8df8c988429dde5d84714d16dcfd86f0\",\"data\":\"026448ff8c43d98d84a50e9176b2746c580adc354d7fc43aebfc3a12462514f34f\"}]" }], - "store": tokens[0].proofs, // the entire first token is kept - "mint": "https://testnut.cashu.space" - } - - const res = await wallet.calculateNewState(walletChange); - - expect(res.saveProofs.length).toBe(0); - expect(Array.from(res.deletedTokenIds).length).toBe(1); - }) - - it('when multiple proofs in the same token are getting deleted and some are still in the new state it doesnt duplicate the kept proofs', async () => { - const token = await mockTokenWithProofs([1, 1, 1, 1, 1, 8, 2, 4]); - wallet.tokens.push(token); - - const walletChange: WalletChange = { - "reserve": [], - "destroy": [ - // we are sending tokens with amounts 2 and 4 - { "amount": 6, "C": "02e1a8b3b633b57b062eecf3c3bd1865950d1fbae8670a7b405786701f0ea5380f", "id": "009a1f293253e41e", "secret": "f30b744120dac5f42deaa091cae94d168f13c23e19f048cf915b169d42fc0e15", }], - "store": token.proofs.filter((p) => p.amount !== 2 && p.amount !== 4), - "mint": "https://testnut.cashu.space" - } - - const res = await wallet.calculateNewState(walletChange); - - expect(res.saveProofs.length).toBe(token.proofs.length - 2); - expect(res.deletedTokenIds.size).toBe(1); - const savedTotalAmount = res.saveProofs.reduce((acc, p) => acc + p.amount, 0); - // 1 + 1 + 1 + 1 + 1 + 1 + 1 + 8 = 13 - expect(savedTotalAmount).toBe(13); - }) - - it('deletes a token and doesnt rollver anything when the proof was in a different token', async () => { - const tokens = [ - await mockTokenWithProofs([16]), - await mockTokenWithProofs([8]) - ] - wallet.tokens.push(...tokens); - - const walletChange: WalletChange = { - "reserve": [], - "destroy": [ { "amount": 8, "C": "037ae2cd8439b753fb86522a8fa445c613cda148eeffea7703ac065aaf6e4e3f35", "id": "009a1f293253e41e", "secret": "[\"P2PK\",{\"nonce\":\"ee6b11bc642e5df2251dbfb052b6356f8df8c988429dde5d84714d16dcfd86f0\",\"data\":\"026448ff8c43d98d84a50e9176b2746c580adc354d7fc43aebfc3a12462514f34f\"}]" } ], - "store": [{ "amount": 16, "C": tokens[0].proofs[0].C, "id": "009a1f293253e41e", "secret": "f30b744120dac5f42deaa091cae94d168f13c23e19f048cf915b169d42fc0e15", } ], - "mint": "https://testnut.cashu.space" - } - - const res = await wallet.calculateNewState(walletChange); - - expect(Array.from(res.deletedTokenIds).length).toBe(1); - expect(res.saveProofs.length).toBe(0); - }) - - it('works when there are no proofs to save from a deleted token', async () => { - const tokens = [ - await mockTokenWithProofs([16]), - await mockTokenWithProofs([8]), - ] - - wallet.tokens.push(...tokens); - - const walletChange: WalletChange = { - "reserve": [], - "destroy": [ - { - "amount": 4, - "C": "037ae2cd8439b753fb86522a8fa445c613cda148eeffea7703ac065aaf6e4e3f35", - "id": "009a1f293253e41e", - "secret": "[\"P2PK\",{\"nonce\":\"ee6b11bc642e5df2251dbfb052b6356f8df8c988429dde5d84714d16dcfd86f0\",\"data\":\"026448ff8c43d98d84a50e9176b2746c580adc354d7fc43aebfc3a12462514f34f\"}]" - } - ], - "store": [ - // unchanged token - { - "amount": 16, - "C": tokens[0].proofs[0].C, - "id": "009a1f293253e41e", "secret": "f30b744120dac5f42deaa091cae94d168f13c23e19f048cf915b169d42fc0e15", - }, - - // new token with three proofs - { - "amount": 1, - "C": "0212bb43a96b28c193fcd6d6dd09f4b074b3df22cb4e79bbb997be0afb4e1b71d0", - "id": "009a1f293253e41e", "secret": "ddbba008f1a54bf442affaed90e48e1de61b4836433145efee3bffe12542faef", - }, - { - "amount": 1, - "C": "02d5ae3323e2ed934ff46a17d4293383ecd02b09b61d2ffd4277b136ed9225b61a", - "id": "009a1f293253e41e", "secret": "d9c4d46beab3e8da3b87b85093797c38921cc6154f480eab02bfb0ef979bc858", - }, - { - "amount": 1, - "C": "030a115ad9644121a9fd658cc6672fe8419b3b6b277494f5179c62cd4ed88b5949", - "id": "009a1f293253e41e", "secret": "531448fe0f0693114793959c9060c74bcd14c393d264d0c4ad1caddb2d7ad83f", - } - ], - "mint": "https://testnut.cashu.space" - } - - const res = await wallet.calculateNewState(walletChange); - - expect(res.saveProofs.length).toBe(3); - expect(Array.from(res.deletedTokenIds).length).toBe(1); - }); - }); -}); - -const mockPayNutResult = { - keep: [ - { - amount: 4, - C: "03f156e6003ce31135581ee1721b648ce6de541bf6c5485ac2fafac322edd9e6ac", - id: "009a1f293253e41e", - secret: "80e689463af88e81ae8986aa29b23fd34d4cf3e4a3c6c01327764823685b88db" - }, { - amount: 1, - C: "026e85bfe207f3763e5d51ad0977c2c7a76c4d3ec44c5a23865ef40d30620dd6fa", - id: "009a1f293253e41e", - secret: "89f9a2d7325f9a2dde3eaa3e065d2f25580d65feb611be5830a2dfdd513d0758", - witness: undefined - }, { - amount: 1, - C: "029c1a92c08e483bd6e61e09b2bf61df61fb477f3ea2ee9afc753622cc0c1837b5", - id: "009a1f293253e41e", - secret: "102237b2ca75db7be737771ec08d0fabe3cd2c623c48cf3e652373f90cce0462", - witness: undefined - }, { - amount: 1, - C: "02e4b800131c285d403a37069c2902170386866bfa523a4f2576fca52620d67e46", - id: "009a1f293253e41e", - secret: "5764c88e38354b90f3a84795a7f147aea98ae638729a00ac26b8184833ad3406", - witness: undefined - } - ], - send: [ - { - amount: 4, - C: "02670095418aa3f32dcb07f7e36e522bfee60443254ecf6c68e9c014383eb3cb43", - id: "009a1f293253e41e", - secret: "[\"P2PK\",{\"nonce\":\"2bcfab22387225f87ecb1e2f8d861c1490672d90627b49f529a8f8575f4098a4\",\"data\":\"026448ff8c43d98d84a50e9176b2746c580adc354d7fc43aebfc3a12462514f34f\"}]", - witness: undefined, - } - ], - mint: "https://testnut.cashu.space" -} - -/** - * Creates mock Cashu tokens for testing purposes by distributing proofs across tokens - * @param mockResult - Object containing keep/send proofs and mint info from a Cashu transaction - * @param mixProofs - When true, combines keep/send proofs into single tokens. When false, creates separate tokens for keep/send proofs - * @param numTokens - Number of tokens to distribute the proofs across - * @returns Array of NDKCashuToken instances with distributed proofs - * - * Behavior: - * - With mixProofs=false (default): - * - Creates separate tokens for 'keep' and 'send' proofs - * - Evenly distributes keep proofs across numTokens tokens - * - Evenly distributes send proofs across numTokens tokens - * - With mixProofs=true: - * - Combines all proofs into a single array - * - Evenly distributes combined proofs across numTokens tokens - * - */ -function createMockTokens(mockResult: any, mixProofs: boolean = false, numTokens: number = 1) { - const ndk = new NDK(); - const tokens: NDKCashuToken[] = []; - - // Create copies and modify secrets with prefixes - const keepProofs = [...(mockResult.keep || [])].map(proof => ({ - ...proof, - secret: `keep_${proof.secret}` - })); - const sendProofs = [...(mockResult.send || [])].map(proof => ({ - ...proof, - secret: `send_${proof.secret}` - })); - - // Create modified mockResult with prefixed secrets - const modifiedMockResult = { - ...mockResult, - keep: Array.from(keepProofs), - send: Array.from(sendProofs) - }; - - if (mixProofs) { - // Interleave keep and send proofs - const allProofs = []; - const maxLength = Math.max(keepProofs.length, sendProofs.length); - - for (let i = 0; i < maxLength; i++) { - if (keepProofs[i]) allProofs.push(keepProofs[i]); - if (sendProofs[i]) allProofs.push(sendProofs[i]); - } - - const proofsPerToken = Math.ceil(allProofs.length / numTokens); - - for (let i = 0; i < numTokens && allProofs.length > 0; i++) { - const token = new NDKCashuToken(ndk); - token.id = `token${i + 1}`; - token.mint = mockResult.mint; - token.proofs = allProofs.splice(0, proofsPerToken); - tokens.push(token); - } - } else { - const keepTokens = Math.ceil(keepProofs.length / numTokens); - const sendTokens = Math.ceil(sendProofs.length / numTokens); - - for (let i = 0; i < numTokens; i++) { - if (keepProofs.length > 0) { - const keepToken = new NDKCashuToken(ndk); - keepToken.id = `keep_token${i + 1}`; - keepToken.mint = mockResult.mint; - keepToken.proofs = keepProofs.splice(0, keepTokens); - tokens.push(keepToken); - } - - if (sendProofs.length > 0) { - const sendToken = new NDKCashuToken(ndk); - sendToken.id = `send_token${i + 1}`; - sendToken.mint = mockResult.mint; - sendToken.proofs = sendProofs.splice(0, sendTokens); - tokens.push(sendToken); - } - } - } - - return { - tokens, - modifiedMockResult - }; -} - -describe('NDKCashuWallet Integration Test', () => { - let wallet: NDKCashuWallet; - let ndk: NDK; - - beforeAll(() => { - ndk = new NDK({ signer: NDKPrivateKeySigner.generate() }); - wallet = new NDKCashuWallet(ndk); - }); - - it('should add a token to the wallet and sync balance', async () => { - const token = await createMockToken([10, 20, 30]); - wallet.addToken(token); - - // Ensure token was added - expect(wallet.tokens.length).toBe(1); - expect(wallet.tokens[0].proofs.length).toBe(3); - - // Sync balance and ensure correct amount is returned - await wallet.syncBalance(); - const balance = wallet.balance(); - expect(balance?.[0].amount).toBe(60); // 10 + 20 + 30 - }); - - it('should mint nuts and correctly update the wallet state', async () => { - // Mock wallet minting nuts - jest.spyOn(wallet, 'mintNuts').mockResolvedValue({ send: [], keep: [] }); - - const result = await wallet.mintNuts([10, 20], 'sats'); - expect(result).toBeDefined(); - }); - - it('should receive a token and add it to the wallet', async () => { - const token = await createMockToken([50]); - const tokenString = JSON.stringify(token); - - await wallet.receiveToken(tokenString); - expect(wallet.tokens.length).toBeGreaterThan(0); - }); - - it('should send a payment with LN', async () => { - const paymentDetails = { - pr: 'lnbc2500n1pwyj9skpp5dj45g79qx5d...snipped_example...', - }; - - // Mock payment response - jest.spyOn(wallet, 'lnPay').mockResolvedValue({ preimage: 'some-preimage' }); - const paymentConfirmation = await wallet.lnPay(paymentDetails); - - expect(paymentConfirmation).toBeDefined(); - expect(paymentConfirmation?.preimage).toBe('some-preimage'); - }); - - async function createMockToken(amounts: number[]): Promise { - const token = new NDKCashuToken(ndk); - const proofs: Proof[] = amounts.map(amount => ({ - amount, - C: `02${Math.random().toString(16).substring(2)}`, - secret: Math.random().toString(16).substring(2), - id: Math.random().toString(16).substring(2, 10) - })); - token.proofs = proofs; - token.mint = 'https://testmint.cashu.space'; - await token.sign(); - return token; - } -}); diff --git a/ndk-wallet/src/wallets/cashu/wallet.ts b/ndk-wallet/src/wallets/cashu/wallet/index.ts similarity index 57% rename from ndk-wallet/src/wallets/cashu/wallet.ts rename to ndk-wallet/src/wallets/cashu/wallet/index.ts index 2e740edb..32bb2f18 100644 --- a/ndk-wallet/src/wallets/cashu/wallet.ts +++ b/ndk-wallet/src/wallets/cashu/wallet/index.ts @@ -6,74 +6,40 @@ import type { NDKFilter, NDKNutzap, NDKPaymentConfirmationCashu, - NDKPaymentConfirmationLN, NDKZapDetails, NDKSubscription, NDKSubscriptionOptions, NDKTag, - NostrEvent, NDKRelay, } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRelaySet, NDKUser, normalizeUrl } from "@nostr-dev-kit/ndk"; -import { NDKCashuToken, proofsTotalBalance } from "./token.js"; -import { NDKCashuDeposit } from "./deposit.js"; +import { NDKCashuToken, proofsTotalBalance } from "../token.js"; +import { NDKCashuDeposit } from "../deposit.js"; import createDebug from "debug"; -import type { MintUrl } from "./mint/utils.js"; -import type { Proof, SendResponse } from "@cashu/cashu-ts"; -import { CashuMint, CashuWallet, getDecodedToken } from "@cashu/cashu-ts"; -import { NDKWalletChange } from "./history.js"; -import { consolidateTokens } from "./validate.js"; -import { NDKWallet, NDKWalletBalance, NDKWalletEvents, NDKWalletStatus } from "../index.js"; +import type { MintUrl } from "../mint/utils.js"; +import type { CashuWallet, Proof, SendResponse } from "@cashu/cashu-ts"; +import { getDecodedToken } from "@cashu/cashu-ts"; +import { consolidateTokens } from "../validate.js"; +import { NDKWallet, NDKWalletBalance, NDKWalletEvents, NDKWalletStatus } from "../../index.js"; import { EventEmitter } from "tseep"; -import { decrypt } from "./decrypt.js"; -import { eventHandler } from "./event-handlers/index.js"; -import { NDKCashuDepositMonitor } from "./deposit-monitor.js"; -import { createToken } from "./pay/nut.js"; -import { payLn } from "./pay/ln.js"; +import { decrypt } from "../decrypt.js"; +import { eventHandler } from "../event-handlers/index.js"; +import { NDKCashuDepositMonitor } from "../deposit-monitor.js"; +import { LNPaymentResult } from "../pay/ln.js"; +import { walletForMint } from "../mint.js"; const d = createDebug("ndk-wallet:cashu:wallet"); -interface SaveProofsOptions { - nutzap?: NDKNutzap; - direction?: "in" | "out"; - amount: number; -} - -/** - * Represents a change to the wallet state - */ -export type WalletChange = { - // reserve proofs are moved into an NDKKind.CashuReserve event until we verify that the recipient has received them - reserve: Proof[], - - // destroy proofs are deleted from the wallet - destroy?: Proof[], - - // store proofs are added to the wallet - store: Proof[], - mint: MintUrl, -} - -export type WalletStateChange = { - // token ids that are to be deleted - deletedTokenIds: Set; - - // these are the Cs of the proofs that are getting deleted - deletedProofs: Set; - - // proofs that are to be moved to a reserve - reserveProofs: Proof[]; - - // proofs that are to be added to the wallet in a new token - saveProofs: Proof[]; -} - export type WalletWarning = { msg: string; event?: NDKEvent; relays?: NDKRelay[]; } +import { PaymentHandler, PaymentWithOptionalZapInfo } from "./payment.js"; +import { createInTxEvent } from "./txs.js"; +import { WalletState } from "./state.js"; + /** * This class tracks state of a NIP-60 wallet */ @@ -112,8 +78,6 @@ export class NDKCashuWallet extends EventEmitter = {}; - public walletId: string = ""; public depositMonitor = new NDKCashuDepositMonitor(); @@ -123,6 +87,9 @@ export class NDKCashuWallet extends EventEmitter acc + amount, 0); for (const mint of this.mints) { - const wallet = await this.walletForMint(mint); - if (!wallet) continue; + const wallet = await this.cashuWallet(mint); const mintProofs = await this.proofsForMint(mint); result = await wallet.send(totalAmount, mintProofs, { proofsWeHave: mintProofs, @@ -268,8 +236,6 @@ export class NDKCashuWallet extends EventEmitter>): Promise { - if (!payment.amount) throw new Error("amount is required"); - if (!payment.pr) throw new Error("pr is required"); - - const res = await payLn(this, payment.amount, payment.pr); - if (!res?.preimage) return; - - console.trace('updating state for', res.mint); - - await this.updateState({ - reserve: [], - destroy: res.send, - store: [ ...res.keep, ...res.change ], - mint: res.mint, - }); - - return { preimage: res.preimage }; + async lnPay(payment: PaymentWithOptionalZapInfo, createTxEvent = true): Promise { + return this.paymentHandler.lnPay(payment, createTxEvent); } /** @@ -513,44 +471,7 @@ export class NDKCashuWallet extends EventEmitter): Promise { - let { amount, unit } = payment; - - if (unit.startsWith("msat")) { - unit = 'sat'; - amount = amount / 1000; - } - - const createResult = await createToken( - this, - amount, - unit, - payment.mints, - payment.p2pk, - ) - if (!createResult) { - console.log("failed to pay with cashu"); - return; - } - - const historyEvent = new NDKWalletChange(this.ndk); - historyEvent.direction = "out"; - historyEvent.amount = payment.amount; - historyEvent.unit = payment.unit || this.unit; - historyEvent.mint = createResult.mint; - if (createResult.fee) historyEvent.fee = createResult.fee; - if (payment.target) { - // tag the target if there is one - historyEvent.tags.push(payment.target.tagReference()); - - if (!(payment.target instanceof NDKUser)) { - historyEvent.tags.push(["p", payment.target.pubkey]); - } - } - historyEvent.description = payment.paymentDescription; - await historyEvent.sign(); - historyEvent.publish(this.relaySet); - - return { proofs: createResult.send, mint: createResult.mint }; + return this.paymentHandler.cashuPay(payment); } /** @@ -572,172 +493,17 @@ export class NDKCashuWallet extends EventEmitter { - const allMintProofs = new Map(); - - this.tokens - .filter((t) => mint ? t.mint === mint : true) - .forEach((t) => { - t.proofs.forEach((p) => { - allMintProofs.set(p.C, t.id); - }); - }); - - return allMintProofs; - } - - /** - * Returns a map of the token ids to the token - * @param mint - * @returns - */ - private getTokensMap(mint: MintUrl) { - const map = new Map(); - - if (!this.mintTokens[mint]) { - console.trace("BUG: no entry in mintTokens for mint", mint); - return map; - } - - for (const token of this.mintTokens[mint]) { - map.set(token.id, token); - } - - return map; - } - - /** - * Calculates the new state of the wallet based on a given change. - * - * This method processes the proofs to be stored, identifies proofs to be deleted, - * and determines which tokens are affected by the change. It updates the wallet - * state by: - * - Collecting all proofs that are part of the new state. - * - Identifying all proofs that are affected by the change. - * - Removing proofs that are to be kept from the affected proofs. - * - Identifying proofs that should be deleted. - * - Processing affected tokens to determine which proofs need to be saved. - * - * @param change The change to be applied to the wallet state. - * @returns The new state of the wallet, including proofs to be saved, deleted, or reserved. - */ - public async calculateNewState(change: WalletChange): Promise { - const newState: WalletStateChange = { - deletedTokenIds: new Set(), - deletedProofs: new Set(), - reserveProofs: [], - saveProofs: [], - }; - - const newStateProofs = this.collectNewStateProofs(change.store); - const allAffectedProofs = this.getAllMintProofTokenIds(change.mint); - const tokenMap = this.getTokensMap(change.mint); - - this.processStoreProofs(change.store, allAffectedProofs, newState.saveProofs); - this.identifyDeletedProofs(allAffectedProofs, newState.deletedProofs); - - this.processAffectedTokens(allAffectedProofs, tokenMap, newState, newStateProofs); - - return newState; - } - - private collectNewStateProofs(store: Proof[]): Set { - const newStateProofs = new Set(); - store.forEach(proof => newStateProofs.add(proof.C)); - return newStateProofs; - } - - private processStoreProofs(store: Proof[], allAffectedProofs: Map, saveProofs: Proof[]) { - for (const proof of store) { - if (!allAffectedProofs.has(proof.C)) { - saveProofs.push(proof); - } else { - allAffectedProofs.delete(proof.C); - } - } - } - - private identifyDeletedProofs(allAffectedProofs: Map, deletedProofs: Set) { - allAffectedProofs.forEach((_, proofC) => { - deletedProofs.add(proofC); - }); - } - - private processAffectedTokens( - allAffectedProofs: Map, - tokenMap: Map, - newState: WalletStateChange, - newStateProofs: Set - ) { - const rolledOverProofs = new Set(); - - for (const tokenId of allAffectedProofs.values()) { - newState.deletedTokenIds.add(tokenId); - - const token = tokenMap.get(tokenId); - if (!token) { - console.log("BUG! Unable to find a token that we should have!", {tokenId}); - console.log(`tokenMap (${tokenMap.size})`, Array.from(tokenMap.entries())); - throw new Error("BUG! Unable to find a token that we should have!"); - } - - for (const proof of token.proofs) { - if (newStateProofs.has(proof.C) && !rolledOverProofs.has(proof.C)) { - newState.saveProofs.push(proof); - rolledOverProofs.add(proof.C); - } - } - } - } - - /** - * Updates the wallet state based on a send result - * @param sendResult - */ - public async updateState(change: WalletChange) { - const newState = await this.calculateNewState(change); - - // create the new token if we have to - if (newState.saveProofs.length > 0) { - const newToken = new NDKCashuToken(this.ndk); - newToken.proofs = newState.saveProofs; - console.log('publishing a new token with %d proofs', newState.saveProofs.length); - newToken.mint = change.mint; - newToken.wallet = this; - await newToken.sign(); - await newToken.publish(this.relaySet); - } - - // delete the tokens that are affected - if (newState.deletedTokenIds.size > 0) { - const deleteEvent = new NDKEvent(this.ndk, { - kind: NDKKind.EventDeletion, - tags: [ - [ "k", NDKKind.CashuToken.toString() ], - ...Array.from(newState.deletedTokenIds).map((id) => ([ "e", id ])), - ] - } as NostrEvent); - await deleteEvent.sign(); - console.log("publishing delete event", JSON.stringify(deleteEvent.rawEvent(), null, 4)); - await deleteEvent.publish(this.relaySet); - } + private wallets = new Map(); + async cashuWallet(mint: string): Promise { + if (this.wallets.has(mint)) return this.wallets.get(mint) as CashuWallet; - // create the reserve token if we have to - if (newState.reserveProofs.length > 0) { - const reserveToken = new NDKCashuToken(this.ndk); - reserveToken.proofs = newState.reserveProofs; - reserveToken.mint = change.mint; - reserveToken.wallet = this; - await reserveToken.sign(); - await reserveToken.publish(this.relaySet); - } + const w = await walletForMint(mint, this.unit); + if (!w) throw new Error("unable to load wallet for mint " + mint); + this.wallets.set(mint, w); + return w; } + // TODO: this is not efficient, we should use a set public hasProof(secret: string) { return this.tokens.some((t) => t.proofs.some((p) => p.secret === secret)); @@ -757,117 +523,57 @@ export class NDKCashuWallet extends EventEmitter { - if (this._wallets[mint]) return this._wallets[mint]; - - let unit = this.unit; - - if (unit === 'sats') { - unit = 'sat'; - } + async redeemNutzap( + nutzap: NDKNutzap, + { onRedeemed, onTxEventCreated }: { onRedeemed?: (res: Proof[]) => void, onTxEventCreated?: (event: NDKEvent) => void } + ) { + const user = this.ndk.activeUser; - try { - const wallet = new CashuWallet(new CashuMint(mint), { unit }); - console.log("[WALLET] loading mint", mint); - await wallet.loadMint(); - console.log("[WALLET] loaded mint", mint); - this._wallets[mint] = wallet; - return wallet; - } catch (e) { - return null; + if (!user) throw new Error("no active user"); + + let privkey = this.privkey; + + // if the nutzap is p2pk to the user's pubkey, check if we have the private key in memory + if (nutzap.p2pk === user.pubkey) { + if (this.ndk.signer instanceof NDKPrivateKeySigner) + privkey = (this.ndk.signer as NDKPrivateKeySigner).privateKey; + else { + throw new Error("nutzap p2pk to the active user directly and we don't have access to the private key; login with your nsec to redeem this nutzap"); + } } - } - - async redeemNutzap(nutzap: NDKEvent) { - // this.emit("nutzap:seen", nutzap); - + try { - const mint = nutzap.tagValue("u"); + const mint = nutzap.mint; + const proofs = nutzap.proofs; if (!mint) throw new Error("missing mint"); - const proofs = JSON.parse(nutzap.content); - console.log(proofs); - const _wallet = await this.walletForMint(mint); - if (!_wallet) throw new Error("unable to load wallet for mint " + mint); + const _wallet = await this.cashuWallet(mint); + const proofsWeHave = this.proofsForMint(mint); const res = await _wallet.receive( { proofs, mint }, - { - proofsWeHave: this.proofsForMint(mint), - privkey: this.privkey, - } + { proofsWeHave, privkey } ); - if (res) { - // this.emit("nutzap:redeemed", nutzap); - } + d("redeemed nutzap %o", nutzap.rawEvent()); + onRedeemed?.(res); + + const receivedAmount = proofs.reduce((acc, proof) => acc + proof.amount, 0); + const redeemedAmount = res.reduce((acc, proof) => acc + proof.amount, 0); + const fee = receivedAmount - redeemedAmount; - const tokenEvent = new NDKCashuToken(this.ndk); - tokenEvent.proofs = proofs; - tokenEvent.mint = mint; - tokenEvent.wallet = this; - await tokenEvent.sign(); - tokenEvent.publish(this.relaySet); - console.log("new token event", tokenEvent.rawEvent()); - - const historyEvent = new NDKWalletChange(this.ndk); - historyEvent.addRedeemedNutzap(nutzap); - if (this.event) historyEvent.tag(this.event); - historyEvent.tag(tokenEvent, NDKWalletChange.MARKERS.CREATED); - await historyEvent.sign(); - historyEvent.publish(this.relaySet); + const updateRes = await this.state.update({ + store: res, + mint, + }); + + const txEvent = await createInTxEvent(this, res, nutzap.unit, mint, updateRes, {nutzap, fee}); + onTxEventCreated?.(txEvent); } catch (e) { console.trace(e); // this.emit("nutzap:failed", nutzap, e); } } - /** - * Generates a new token event with proofs to be stored for this wallet - * @param proofs Proofs to be stored - * @param mint Mint URL - * @param nutzap Nutzap event if these proofs are redeemed from a nutzap - * @returns - */ - async saveProofs(proofs: Proof[], mint: MintUrl, { nutzap, direction, amount }: SaveProofsOptions) { - const tokenEvent = new NDKCashuToken(this.ndk); - tokenEvent.proofs = proofs; - tokenEvent.mint = mint; - tokenEvent.wallet = this; - await tokenEvent.sign(); - - // we can add it to the wallet here - this.addToken(tokenEvent); - - tokenEvent.publish(this.relaySet).catch((e) => { - console.error("failed to publish token", e, tokenEvent.rawEvent()); - }); - - const historyEvent = new NDKWalletChange(this.ndk); - if (this.event) historyEvent.tags.push(this.event.tagReference()); - - if (nutzap) { - historyEvent.addRedeemedNutzap(nutzap); - historyEvent.direction = "in"; - - historyEvent.amount = amount; - historyEvent.unit = this.unit; - } else { - historyEvent.direction = direction; - if (amount) historyEvent.amount = amount; - } - - historyEvent.tag(tokenEvent, NDKWalletChange.MARKERS.CREATED); - await historyEvent.sign(); - historyEvent.publish(this.relaySet); - - return tokenEvent; - } - /** * Updates the internal state to add a token, * there is no change published anywhere when calling this function. @@ -924,7 +630,7 @@ export class NDKCashuWallet extends EventEmitter = T & { + target?: NDKEvent | NDKUser; + comment?: string; + tags?: NDKTag[]; + amount?: number; + unit?: string; + recipientPubkey?: string; + paymentDescription?: string; +}; + +export class PaymentHandler { + private wallet: NDKCashuWallet; + + constructor(wallet: NDKCashuWallet) { + this.wallet = wallet; + } + + /** + * Pay a LN invoice with this wallet + */ + async lnPay( + payment: PaymentWithOptionalZapInfo, + createTxEvent = true, + ): Promise { + if (!payment.pr) throw new Error("pr is required"); + + const invoiceAmount = getBolt11Amount(payment.pr); + if (!invoiceAmount) throw new Error("invoice amount is required"); + + // if amount was passed in, we want to check that the invoice amount is not more than it + if (payment.amount && invoiceAmount > payment.amount) { + throw new Error("invoice amount is more than the amount passed in"); + } + + const res = await payLn(this.wallet, payment.pr); // msat to sat + if (!res?.preimage) return; + + const updateRes = await this.wallet.state.update(res.walletChange); + + if (createTxEvent) createOutTxEvent(this.wallet, payment, res, updateRes); + + return res; + } + + /** + * Swaps tokens to a specific amount, optionally locking to a p2pk. + */ + async cashuPay(payment: NDKZapDetails): Promise { + let { amount, unit } = payment; + + if (unit.startsWith("msat")) { + unit = 'sat'; + amount = amount / 1000; + } + + const createResult = await createToken( + this.wallet, + amount, + unit, + payment.mints, + payment.p2pk, + ) + if (!createResult) { + console.log("failed to pay with cashu"); + return; + } + + const isP2pk = (p: Proof) => p.secret.startsWith('["P2PK"'); + const isNotP2pk = (p: Proof) => !isP2pk(p); + + createResult.walletChange.reserve = createResult.send.proofs?.filter(isNotP2pk) ?? [] + this.wallet.state.update(createResult.walletChange).then((updateRes) => { + createOutTxEvent(this.wallet, payment, createResult, updateRes); + }) + + return createResult.send; + } +} diff --git a/ndk-wallet/src/wallets/cashu/wallet/state.ts b/ndk-wallet/src/wallets/cashu/wallet/state.ts new file mode 100644 index 00000000..db314b78 --- /dev/null +++ b/ndk-wallet/src/wallets/cashu/wallet/state.ts @@ -0,0 +1,305 @@ +import { NDKEvent, NDKEventId, NDKKind, normalizeUrl, NostrEvent } from "@nostr-dev-kit/ndk"; +import { NDKCashuToken } from "../token"; +import { Proof } from "@cashu/cashu-ts"; +import { MintUrl } from "../mint/utils"; +import { NDKCashuWallet } from "."; + +export type UpdateStateResult = { + /** + * Tokens that were created as the result of a state change + */ + created?: NDKCashuToken, + /** + * Tokens that were reserved as the result of a state change + */ + reserved?: NDKCashuToken, + /** + * Tokens that were deleted as the result of a state change + */ + deleted?: NDKEventId[], +} + +export type WalletChange = { + // reserve proofs are moved into an NDKKind.CashuReserve event until we verify that the recipient has received them + reserve?: Proof[], + + // destroy proofs are deleted from the wallet + destroy?: Proof[], + + // store proofs are added to the wallet + store?: Proof[], + mint: MintUrl, +} + +export type WalletStateChange = { + // token ids that are to be deleted + deletedTokenIds: Set; + + // these are the Cs of the proofs that are getting deleted + deletedProofs: Set; + + // proofs that are to be moved to a reserve + reserveProofs: Proof[]; + + // proofs that are to be added to the wallet in a new token + saveProofs: Proof[]; +} + +export class WalletState { + constructor( + private wallet: NDKCashuWallet, + public tokens: NDKCashuToken[] = [], + public usedTokenIds = new Set(), + public knownTokens = new Set() + ) {} + + /** + * Returns the tokens that are available for spending + */ + get availableTokens(): NDKCashuToken[] { + return this.tokens.filter((t) => !this.usedTokenIds.has(t.id)); + } + + /** + * Returns a map of the proof C values to the token where it was found + */ + getAllMintProofTokens(mint?: MintUrl): Map { + const allMintProofs = new Map(); + + this.tokens + .filter((t) => mint ? t.mint === mint : true) + .forEach((t) => { + t.proofs.forEach((p) => { + allMintProofs.set(p.C, t); + }); + }); + + return allMintProofs; + } + + /** + * Returns all proofs for a given mint + */ + proofsForMint(mint: MintUrl): Proof[] { + mint = normalizeUrl(mint); + + return this.tokens + .filter((t) => t.mint === mint) + .map((t) => t.proofs) + .flat(); + } + + /** + * Adds a token to the list of used tokens + * to make sure it's proofs are no longer available + */ + addUsedTokens(token: NDKCashuToken[]) { + for (const t of token) { + this.usedTokenIds.add(t.id); + } + this.wallet.emit("balance_updated"); + } + + /** + * Updates the internal state to add a token, + * there is no change published anywhere when calling this function. + */ + addToken(token: NDKCashuToken): boolean { + // check for proofs we already have + if (!token.mint) throw new Error("token " + token.encode() + " has no mint"); + + // double check we don't already have this token + if (this.knownTokens.has(token.id)) { + const stackTrace = new Error().stack; + console.debug("Refusing to add the same token twice", token.id, stackTrace); + return false; + } + + const allMintProofs = this.getAllMintProofTokens(token.mint); + + for (const proof of token.proofs) { + if (allMintProofs.has(proof.C)) { + const collidingToken = allMintProofs.get(proof.C); + + if (!collidingToken) { + console.trace("BUG: unable to find colliding token", { + token: token.id, + proof: proof.C, + }); + throw new Error("BUG: unable to find colliding token"); + } + + // keep newer token, remove old + if (token.created_at! <= collidingToken.created_at!) { + // we don't have to do anything + console.log('skipping adding requested token since we have a newer token with the same proof', { + requestedTokenId: token.id, + relay: token.onRelays.map((r) => r.url), + }) + + this.wallet.warn("Received an older token with proofs that were already known, this is likely a relay that didn't receive (or respected) a delete event", token); + + return false; + } + + // remove old token + this.removeTokenId(collidingToken.id); + } + } + + if (!this.knownTokens.has(token.id)) { + this.knownTokens.add(token.id); + this.tokens.push(token); + this.wallet.emit("balance_updated"); + } + + return true; + } + + /** + * Removes a token that has been deleted + */ + removeTokenId(id: NDKEventId) { + if (!this.knownTokens.has(id)) { + return false; + } + + this.tokens = this.tokens.filter((t) => t.id !== id); + this.wallet.emit("balance_updated"); + } + + /** + * Calculates the new state of the wallet based on a given change. + * + * This method processes the proofs to be stored, identifies proofs to be deleted, + * and determines which tokens are affected by the change. It updates the wallet + * state by: + * - Collecting all proofs that are part of the new state. + * - Identifying all proofs that are affected by the change. + * - Removing proofs that are to be kept from the affected proofs. + * - Identifying proofs that should be deleted. + * - Processing affected tokens to determine which proofs need to be saved. + * + * @param change The change to be applied to the wallet state. + * @returns The new state of the wallet, including proofs to be saved, deleted, or reserved. + */ + public async calculateNewState(change: WalletChange): Promise { + const newState: WalletStateChange = { + deletedTokenIds: new Set(), + deletedProofs: new Set(), + reserveProofs: [], + saveProofs: [], + }; + const { mint } = change; + + const proofCsToBeStored = new Set(); + let proofCsToBeDeleted = new Set(); + + // Create a set of the proofs that will be destroyed + if (change.destroy) + proofCsToBeDeleted = new Set( + change.destroy.map(proofToBeDestroyed => proofToBeDestroyed.C) + ) + + const allProofsInMint = new Set(this.proofsForMint(mint).map(proof => proof.C)); + console.log('we have %d proofs in %s', allProofsInMint.size, mint, allProofsInMint); + + // find all the new proofs we didn't know about that we need to save + for (const proofToStore of (change.store||[])) { + if (allProofsInMint.has(proofToStore.C)) continue; + console.log('new proof to store: %s', proofToStore.C.substring(0, 8)); + newState.saveProofs.push(proofToStore); + proofCsToBeStored.add(proofToStore.C); + } + console.log("we have a %d new proofs to store", newState.saveProofs.length); + + // find al the proofs that are to be destroyed + newState.deletedProofs = new Set(change.destroy?.map(proof => proof.C)); + console.log('we have %d proofs to delete', newState.deletedProofs.size); + + // find the tokens where those proofs are stored + const proofsToTokenMap = this.getAllMintProofTokens(change.mint); + for (const proofToDelete of newState.deletedProofs) { + const token = proofsToTokenMap.get(proofToDelete) + if (!token) { + console.log("BUG! Unable to find token id from known proof's C", { + proofsToTokenKeys: proofsToTokenMap.keys(), + CToDelete: proofToDelete.substring(0, 10) + }) + continue; + } + + // add to the saveProofs all the proofs that were not deleted + for (const proofInTokenToBeDeleted of token.proofs) { + if (proofCsToBeDeleted.has(proofInTokenToBeDeleted.C)) continue; + if (proofCsToBeStored.has(proofInTokenToBeDeleted.C)) continue; + console.log('moving over proof %s in token %s, which will be deleted', proofInTokenToBeDeleted.C.substring(0, 8), token.id.substring(0, 8)) + newState.saveProofs.push(proofInTokenToBeDeleted); + proofCsToBeStored.add(proofInTokenToBeDeleted.C); + } + + newState.deletedTokenIds.add(token.id) + } + + console.log('calculatedNewState output', newState); + + return newState; + } + + /** + * Updates the wallet state based on a send result + * @param sendResult + */ + public async update(change: WalletChange): Promise { + const newState = await this.calculateNewState(change); + const res: UpdateStateResult = {}; + + // create the new token if we have to + if (newState.saveProofs.length > 0) { + const newToken = new NDKCashuToken(this.wallet.ndk); + newToken.proofs = newState.saveProofs; + console.log('publishing a new token with %d proofs', newState.saveProofs.length, newState.saveProofs); + newToken.mint = change.mint; + newToken.wallet = this.wallet; + await newToken.sign(); + newToken.publish(this.wallet.relaySet); + res.created = newToken; + + // add the token to the wallet + this.addToken(newToken); + } + + // delete the tokens that are affected + if (newState.deletedTokenIds.size > 0) { + const deleteEvent = new NDKEvent(this.wallet.ndk, { + kind: NDKKind.EventDeletion, + tags: [ + [ "k", NDKKind.CashuToken.toString() ], + ...Array.from(newState.deletedTokenIds).map((id) => ([ "e", id ])), + ] + } as NostrEvent); + await deleteEvent.sign(); + console.log("publishing delete event", JSON.stringify(deleteEvent.rawEvent(), null, 4)); + deleteEvent.publish(this.wallet.relaySet); + res.deleted = Array.from(newState.deletedTokenIds); + + // remove the tokens from the wallet + for (const tokenId of newState.deletedTokenIds) { + this.removeTokenId(tokenId); + } + } + + // create the reserve token if we have to + if (newState.reserveProofs.length > 0) { + const reserveToken = new NDKCashuToken(this.wallet.ndk); + reserveToken.proofs = newState.reserveProofs; + reserveToken.mint = change.mint; + reserveToken.wallet = this.wallet; + await reserveToken.sign(); + reserveToken.publish(this.wallet.relaySet); + res.reserved = reserveToken; + } + + return res; + } +} diff --git a/ndk-wallet/src/wallets/cashu/wallet/txs.ts b/ndk-wallet/src/wallets/cashu/wallet/txs.ts new file mode 100644 index 00000000..dd082ef0 --- /dev/null +++ b/ndk-wallet/src/wallets/cashu/wallet/txs.ts @@ -0,0 +1,95 @@ +import { LnPaymentInfo, CashuPaymentInfo, NDKUser, NDKNutzap } from "@nostr-dev-kit/ndk"; +import { NDKCashuWallet, UpdateStateResult } from "."; +import { getBolt11Amount, getBolt11Description } from "../../../utils/ln"; +import { NDKWalletChange } from "../history"; +import { LNPaymentResult } from "../pay/ln"; +import { TokenCreationResult } from "../pay/nut"; +import { PaymentWithOptionalZapInfo } from "./payment"; +import { Proof } from "@cashu/cashu-ts"; +import { MintUrl } from "../mint/utils"; +import { proofsTotalBalance } from "../token"; + +export async function createOutTxEvent( + wallet: NDKCashuWallet, + paymentRequest: PaymentWithOptionalZapInfo, + paymentResult: LNPaymentResult | TokenCreationResult, + updateStateResult: UpdateStateResult, +): Promise { + let description: string | undefined = paymentRequest.paymentDescription; + let amount: number | undefined; + let unit: string | undefined; + + if ((paymentRequest as LnPaymentInfo).pr) { + amount = getBolt11Amount((paymentRequest as LnPaymentInfo).pr); + unit = "msat"; + description ??= getBolt11Description((paymentRequest as LnPaymentInfo).pr); + } else { + amount = paymentRequest.amount; + unit = paymentRequest.unit || this.wallet.unit; + } + + if (!amount) { + console.error("BUG: Unable to find amount for paymentRequest", paymentRequest); + } + + const historyEvent = new NDKWalletChange(wallet.ndk); + + if (wallet.event) historyEvent.tags.push(wallet.event.tagReference()); + historyEvent.direction = "out"; + historyEvent.amount = amount ?? 0; + historyEvent.unit = unit; + historyEvent.mint = paymentResult.walletChange.mint; + if (paymentResult.fee) historyEvent.fee = paymentResult.fee; + if (paymentRequest.target) { + // tag the target if there is one + historyEvent.tags.push(paymentRequest.target.tagReference()); + + if (!(paymentRequest.target instanceof NDKUser)) { + historyEvent.tags.push(["p", paymentRequest.target.pubkey]); + } + } + + if (updateStateResult.created) historyEvent.createdTokens = [updateStateResult.created]; + if (updateStateResult.deleted) historyEvent.destroyedTokenIds = updateStateResult.deleted; + if (updateStateResult.reserved) historyEvent.reservedTokens = [updateStateResult.reserved]; + + await historyEvent.sign(); + historyEvent.publish(wallet.relaySet); + + return historyEvent; +} + +export async function createInTxEvent( + wallet: NDKCashuWallet, + proofs: Proof[], + unit: string, + mint: MintUrl, + updateStateResult: UpdateStateResult, + { nutzap, fee, description }: { nutzap?: NDKNutzap, fee?: number, description?: string }, +): Promise { + const historyEvent = new NDKWalletChange(wallet.ndk); + + const amount = proofsTotalBalance(proofs); + + if (wallet.event) historyEvent.tags.push(wallet.event.tagReference()); + historyEvent.direction = "in"; + historyEvent.amount = amount; + historyEvent.unit = wallet.unit; + historyEvent.mint = mint; + historyEvent.description = description; + + if (nutzap) historyEvent.description ??= "redeemed nutzap"; + + if (updateStateResult.created) historyEvent.createdTokens = [updateStateResult.created]; + if (updateStateResult.deleted) historyEvent.destroyedTokenIds = updateStateResult.deleted; + if (updateStateResult.reserved) historyEvent.reservedTokens = [updateStateResult.reserved]; + + if (nutzap) historyEvent.addRedeemedNutzap(nutzap); + if (fee) historyEvent.fee = fee; + + console.log("created history event", JSON.stringify(historyEvent.rawEvent(), null, 4)); + await historyEvent.sign(); + historyEvent.publish(wallet.relaySet); + + return historyEvent; +} \ No newline at end of file diff --git a/ndk-wallet/src/wallets/index.ts b/ndk-wallet/src/wallets/index.ts index 37689bad..37f34e6c 100644 --- a/ndk-wallet/src/wallets/index.ts +++ b/ndk-wallet/src/wallets/index.ts @@ -9,7 +9,6 @@ import { NDKZapSplit, } from "@nostr-dev-kit/ndk"; import { EventEmitter } from "tseep"; -import { NutPayment } from "./cashu/pay/nut"; export type NDKWalletTypes = 'nwc' | 'nip-60' | 'webln'; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 36d38d53..472ef963 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ packages: - "ndk-cache-dexie" - "ndk-cache-redis" - "ndk-cache-nostr" + - "ndk-mobile" - "ndk-svelte" - "ndk-svelte-components" - "ndk-wallet"