UNPKG

@nostr-dev-kit/ndk-wallet

Version:
1,588 lines (1,556 loc) 91.6 kB
// src/nutzap-monitor/index.ts import { NDKKind as NDKKind5, NDKNutzap as NDKNutzap2, NDKPrivateKeySigner as NDKPrivateKeySigner2, NDKSubscriptionCacheUsage as NDKSubscriptionCacheUsage2, cashuPubkeyToNostrPubkey as cashuPubkeyToNostrPubkey2, proofP2pk as proofP2pk2 } from "@nostr-dev-kit/ndk"; import { NdkNutzapStatus } from "@nostr-dev-kit/ndk"; import { EventEmitter as EventEmitter4 } from "tseep"; // src/wallets/cashu/wallet/index.ts import { getDecodedToken } from "@cashu/cashu-ts"; import { NDKEvent as NDKEvent3, NDKKind as NDKKind4, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; // src/wallets/index.ts import { EventEmitter } from "tseep"; // src/wallets/cashu/mint.ts import { CashuMint, CashuWallet } from "@cashu/cashu-ts"; var mintWallets = /* @__PURE__ */ new Map(); var mintWalletPromises = /* @__PURE__ */ new Map(); function mintKey(mint, unit, pk) { if (unit === "sats") { unit = "sat"; } if (pk) { const pkStr = new TextDecoder().decode(pk); return `${mint}-${unit}-${pkStr}`; } return `${mint}-${unit}`; } async function walletForMint(mint, { pk, timeout = 5e3, mintInfo, mintKeys, onMintInfoNeeded, onMintInfoLoaded, onMintKeysNeeded, onMintKeysLoaded } = {}) { mintInfo ??= await onMintInfoNeeded?.(mint); mintKeys ??= await onMintKeysNeeded?.(mint); if (!mintInfo && onMintInfoLoaded) { mintInfo = await CashuMint.getInfo(mint); onMintInfoLoaded?.(mint, mintInfo); } const unit = "sat"; const key = mintKey(mint, unit, pk); if (mintWallets.has(key)) { return mintWallets.get(key); } if (mintWalletPromises.has(key)) { return mintWalletPromises.get(key); } if (!mintInfo) { if (onMintInfoNeeded) { mintInfo = await onMintInfoNeeded(mint); } if (!mintInfo && onMintInfoLoaded) { mintInfo = await CashuMint.getInfo(mint); onMintInfoLoaded(mint, mintInfo); } } if (!mintKeys && onMintKeysNeeded) { mintKeys = await onMintKeysNeeded(mint); } const wallet = new CashuWallet(new CashuMint(mint), { unit, bip39seed: pk, mintInfo, keys: mintKeys }); 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]); mintWallets.set(key, wallet); mintWalletPromises.delete(key); if (wallet.keys) { onMintKeysLoaded?.(mint, wallet.keys); } resolve(wallet); } catch (e) { console.error("[WALLET] error loading mint", mint, e.message); mintWalletPromises.delete(key); resolve(null); } }); mintWalletPromises.set(key, loadPromise); return loadPromise; } // src/wallets/mint.ts async function getCashuWallet(mint) { if (this.cashuWallets.has(mint)) return this.cashuWallets.get(mint); const w = await walletForMint(mint, { onMintInfoNeeded: this.onMintInfoNeeded, onMintInfoLoaded: this.onMintInfoLoaded, onMintKeysNeeded: this.onMintKeysNeeded, onMintKeysLoaded: this.onMintKeysLoaded }); if (!w) throw new Error(`unable to load wallet for mint ${mint}`); this.cashuWallets.set(mint, w); return w; } // src/wallets/index.ts var NDKWalletStatus = /* @__PURE__ */ ((NDKWalletStatus2) => { NDKWalletStatus2["INITIAL"] = "initial"; NDKWalletStatus2["LOADING"] = "loading"; NDKWalletStatus2["READY"] = "ready"; NDKWalletStatus2["FAILED"] = "failed"; return NDKWalletStatus2; })(NDKWalletStatus || {}); var NDKWallet = class extends EventEmitter { cashuWallets = /* @__PURE__ */ new Map(); onMintInfoNeeded; onMintInfoLoaded; onMintKeysNeeded; onMintKeysLoaded; getCashuWallet = getCashuWallet.bind(this); ndk; constructor(ndk) { super(); this.ndk = ndk; } status = "initial" /* INITIAL */; get type() { throw new Error("Not implemented"); } /** * An ID of this wallet */ walletId = "unknown"; /** * Get the balance of this wallet */ get balance() { throw new Error("Not implemented"); } /** * Redeem a set of nutzaps into an NWC wallet. * * This function gets an invoice from the NWC wallet until the total amount of the nutzaps is enough to pay for the invoice * when accounting for fees. * * @param cashuWallet - The cashu wallet to redeem the nutzaps into * @param nutzapIds - The IDs of the nutzaps to redeem * @param proofs - The proofs to redeem * @param privkey - The private key needed to redeem p2pk proofs. */ redeemNutzaps(_nutzaps, _privkey, _opts) { throw new Error("Not implemented"); } }; // src/wallets/cashu/deposit-monitor.ts import { EventEmitter as EventEmitter2 } from "tseep"; var NDKCashuDepositMonitor = class extends EventEmitter2 { deposits = /* @__PURE__ */ new Map(); addDeposit(deposit) { const { quoteId } = deposit; if (!quoteId) throw new Error("deposit has no quote ID"); if (this.deposits.has(quoteId)) return false; deposit.once("success", (_token) => { this.removeDeposit(quoteId); }); this.deposits.set(quoteId, deposit); this.emit("change"); return true; } removeDeposit(quoteId) { this.deposits.delete(quoteId); this.emit("change"); } }; // src/wallets/cashu/deposit.ts import createDebug from "debug"; import { EventEmitter as EventEmitter3 } from "tseep"; // src/wallets/cashu/quote.ts import { NDKKind } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk"; // src/utils/ln.ts import { decode as decodeBolt11 } from "light-bolt11-decoder"; function getBolt11ExpiresAt(bolt11) { const decoded = decodeBolt11(bolt11); const expiry = decoded.expiry; const timestamp = decoded.sections.find((section) => section.name === "timestamp").value; if (typeof expiry === "number" && typeof timestamp === "number") { return expiry + timestamp; } return void 0; } function getBolt11Amount(bolt11) { const decoded = decodeBolt11(bolt11); const section = decoded.sections.find((section2) => section2.name === "amount"); const val = section?.value; return Number(val); } function getBolt11Description(bolt11) { const decoded = decodeBolt11(bolt11); const section = decoded.sections.find((section2) => section2.name === "description"); const val = section?.value; return val; } // src/wallets/cashu/quote.ts var NDKCashuQuote = class _NDKCashuQuote extends NDKEvent { quoteId; mint; amount; unit; _wallet; static kind = NDKKind.CashuQuote; constructor(ndk, event) { super(ndk, event); this.kind ??= NDKKind.CashuQuote; } static async from(event) { const quote = new _NDKCashuQuote(event.ndk, event); const original = event; try { await quote.decrypt(); } catch { quote.content = original.content; } try { const content = JSON.parse(quote.content); quote.quoteId = content.quoteId; quote.mint = content.mint; quote.amount = content.amount; quote.unit = content.unit; } catch (_e) { return; } return quote; } set wallet(wallet) { this._wallet = wallet; } set invoice(invoice) { const bolt11Expiry = getBolt11ExpiresAt(invoice); if (bolt11Expiry) this.tags.push(["expiration", bolt11Expiry.toString()]); } async save() { if (!this.ndk) throw new Error("NDK is required"); this.content = JSON.stringify({ quoteId: this.quoteId, mint: this.mint, amount: this.amount, unit: this.unit }); await this.encrypt(this.ndk.activeUser, void 0, "nip44"); await this.sign(); await this.publish(this._wallet?.relaySet); } }; // src/wallets/cashu/wallet/txs.ts import { NDKCashuWalletTx, NDKUser, proofsTotalBalance } from "@nostr-dev-kit/ndk"; async function createOutTxEvent(ndk, paymentRequest, paymentResult, relaySet, { nutzaps } = {}) { let description = paymentRequest.paymentDescription; let amount; if (paymentRequest.pr) { amount = getBolt11Amount(paymentRequest.pr); description ??= getBolt11Description(paymentRequest.pr); if (amount) amount /= 1e3; } else { amount = paymentRequest.amount; } if (!amount) { console.error("BUG: Unable to find amount for paymentRequest", paymentRequest); } const txEvent = new NDKCashuWalletTx(ndk); txEvent.direction = "out"; txEvent.amount = amount ?? 0; txEvent.mint = paymentResult.mint; txEvent.description = description; if (paymentResult.fee) txEvent.fee = paymentResult.fee; if (paymentRequest.target) { txEvent.tags.push(paymentRequest.target.tagReference()); if (!(paymentRequest.target instanceof NDKUser)) { txEvent.tags.push(["p", paymentRequest.target.pubkey]); } } if (nutzaps) { txEvent.description ??= "nutzap redeem"; for (const nutzap of nutzaps) txEvent.addRedeemedNutzap(nutzap); } if (paymentResult.stateUpdate?.created) txEvent.createdTokens = [paymentResult.stateUpdate.created]; if (paymentResult.stateUpdate?.deleted) txEvent.destroyedTokenIds = paymentResult.stateUpdate.deleted; if (paymentResult.stateUpdate?.reserved) txEvent.reservedTokens = [paymentResult.stateUpdate.reserved]; await txEvent.sign(); txEvent.publish(relaySet); return txEvent; } async function createInTxEvent(ndk, proofs, mint, updateStateResult, { nutzaps, fee, description }, relaySet) { const txEvent = new NDKCashuWalletTx(ndk); const amount = proofsTotalBalance(proofs); txEvent.direction = "in"; txEvent.amount = amount; txEvent.mint = mint; txEvent.description = description; if (updateStateResult.created) txEvent.createdTokens = [updateStateResult.created]; if (updateStateResult.deleted) txEvent.destroyedTokenIds = updateStateResult.deleted; if (updateStateResult.reserved) txEvent.reservedTokens = [updateStateResult.reserved]; if (nutzaps) for (const nutzap of nutzaps) txEvent.addRedeemedNutzap(nutzap); if (fee) txEvent.fee = fee; await txEvent.sign(); txEvent.publish(relaySet); return txEvent; } // src/wallets/cashu/deposit.ts var d = createDebug("ndk-wallet:cashu:deposit"); function randomMint(wallet) { const mints = wallet.mints; const mint = mints[Math.floor(Math.random() * mints.length)]; return mint; } var NDKCashuDeposit = class _NDKCashuDeposit extends EventEmitter3 { mint; amount; quoteId; wallet; checkTimeout; checkIntervalLength = 2500; finalized = false; quoteEvent; constructor(wallet, amount, mint) { super(); this.wallet = wallet; this.mint = mint || randomMint(wallet); this.amount = amount; } static fromQuoteEvent(wallet, quote) { if (!quote.amount) throw new Error("quote has no amount"); if (!quote.mint) throw new Error("quote has no mint"); const deposit = new _NDKCashuDeposit(wallet, quote.amount, quote.mint); deposit.quoteId = quote.quoteId; return deposit; } /** * Creates a quote ID and start monitoring for payment. * * Once a payment is received, the deposit will emit a "success" event. * * @param pollTime - time in milliseconds between checks * @returns */ async start(pollTime = 2500) { const cashuWallet = await this.wallet.getCashuWallet(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; this.wallet.depositMonitor.addDeposit(this); setTimeout(this.check.bind(this, pollTime), pollTime); this.createQuoteEvent(quote.quote, quote.request).then((event) => this.quoteEvent = event); return quote.request; } /** * This generates a 7374 event containing the quote ID * with an optional expiration set to the bolt11 expiry (if there is one) */ async createQuoteEvent(quoteId, bolt11) { const { ndk } = this.wallet; const quoteEvent = new NDKCashuQuote(ndk); quoteEvent.quoteId = quoteId; quoteEvent.mint = this.mint; quoteEvent.amount = this.amount; quoteEvent.wallet = this.wallet; quoteEvent.invoice = bolt11; try { await quoteEvent.save(); d("saved quote on event %s", quoteEvent.rawEvent()); } catch (e) { d("error saving quote on event %s", e.relayErrors); } return quoteEvent; } async runCheck() { if (!this.finalized) await this.finalize(); if (!this.finalized) this.delayCheck(); } delayCheck() { setTimeout(() => { this.runCheck(); this.checkIntervalLength += 500; }, this.checkIntervalLength); } /** * Check if the deposit has been finalized. * @param timeout A timeout in milliseconds to wait before giving up. */ async check(timeout) { this.runCheck(); if (timeout) { setTimeout(() => { clearTimeout(this.checkTimeout); }, timeout); } } async finalize() { if (!this.quoteId) throw new Error("No quoteId set."); let proofs; try { d("Checking for minting status of %s", this.quoteId); const cashuWallet = await this.wallet.getCashuWallet(this.mint); const proofsWeHave = await this.wallet.state.getProofs({ mint: this.mint }); proofs = await cashuWallet.mintProofs(this.amount, this.quoteId, { proofsWeHave }); if (proofs.length === 0) return; } catch (e) { 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 += 5e3; return; } d(e.message); return; } try { this.finalized = true; const updateRes = await this.wallet.state.update( { store: proofs, mint: this.mint }, "Deposit" ); const tokenEvent = updateRes.created; if (!tokenEvent) throw new Error("no token event created"); createInTxEvent( this.wallet.ndk, proofs, this.mint, updateRes, { description: "Deposit" }, this.wallet.relaySet ); this.emit("success", tokenEvent); this.destroyQuoteEvent(); } catch (e) { this.emit("error", e.message); console.error(e); } } async destroyQuoteEvent() { if (!this.quoteEvent) return; const deleteEvent = await this.quoteEvent.delete(void 0, false); deleteEvent.publish(this.wallet.relaySet); } }; // src/wallets/cashu/event-handlers/index.ts import { NDKKind as NDKKind2 } from "@nostr-dev-kit/ndk"; // src/wallets/cashu/event-handlers/deletion.ts async function handleEventDeletion(event) { const deletedIds = event.getMatchingTags("e").map((tag) => tag[1]); for (const deletedId of deletedIds) { this.state.removeTokenId(deletedId); } } // src/wallets/cashu/event-handlers/quote.ts async function handleQuote(event) { const quote = await NDKCashuQuote.from(event); if (!quote) return; const deposit = NDKCashuDeposit.fromQuoteEvent(this, quote); if (this.depositMonitor.addDeposit(deposit)) { deposit.finalize(); } } // src/wallets/cashu/event-handlers/token.ts import { NDKCashuToken } from "@nostr-dev-kit/ndk"; var _cumulativeTime = 0; var _cumulativeCalls = 0; async function handleToken(event) { if (this.state.tokens.has(event.id)) return; const startTime = Date.now(); const token = await NDKCashuToken.from(event); if (!token) { _cumulativeTime += Date.now() - startTime; _cumulativeCalls++; return; } _cumulativeTime += Date.now() - startTime; _cumulativeCalls++; for (const deletedTokenId of token.deletedTokens) { this.state.removeTokenId(deletedTokenId); } this.state.addToken(token); } setInterval(() => { }, 5e3); // src/wallets/cashu/event-handlers/index.ts var handlers = { [NDKKind2.CashuToken]: handleToken, [NDKKind2.CashuQuote]: handleQuote, [NDKKind2.EventDeletion]: handleEventDeletion }; var balanceUpdateTimer = null; async function eventHandler(event) { const handler = handlers[event.kind]; if (handler) { if (balanceUpdateTimer) clearTimeout(balanceUpdateTimer); await handler.call(this, event); balanceUpdateTimer = setTimeout(() => { this.emit("balance_updated"); }, 100); } } async function eventDupHandler(_event, _relay, _timeSinceFirstSeen, _sub, _fromCache) { } // src/wallets/cashu/validate.ts import { CheckStateEnum } from "@cashu/cashu-ts"; import createDebug2 from "debug"; var d2 = createDebug2("ndk-wallet:cashu:validate"); async function consolidateTokens() { d2("checking %d tokens for spent proofs", this.state.tokens.size); const mints = new Set( this.state.getMintsProofs({ validStates: /* @__PURE__ */ new Set(["available", "reserved", "deleted"]) }).keys() ); d2("found %d mints", mints.size); mints.forEach((mint) => { consolidateMintTokens(mint, this); }); } async function consolidateMintTokens(mint, wallet, allProofs, onResult, onFailure) { allProofs ??= wallet.state.getProofs({ mint, includeDeleted: true, onlyAvailable: false }); const _wallet = await walletForMint(mint); if (!_wallet) { return; } let proofStates = []; try { proofStates = await _wallet.checkProofsStates(allProofs); } catch (e) { onFailure?.(e.message); return; } const spentProofs = []; const unspentProofs = []; const pendingProofs = []; allProofs.forEach((proof, index) => { const { state } = proofStates[index]; if (state === CheckStateEnum.SPENT) { spentProofs.push(proof); } else if (state === CheckStateEnum.UNSPENT) { unspentProofs.push(proof); } else { pendingProofs.push(proof); } }); const walletChange = { mint, store: unspentProofs, destroy: spentProofs }; onResult?.(walletChange); const _totalSpentProofs = spentProofs.reduce((acc, proof) => acc + proof.amount, 0); if (walletChange.destroy?.length === 0) return; walletChange.store?.push(...pendingProofs); const totalPendingProofs = pendingProofs.reduce((acc, proof) => acc + proof.amount, 0); wallet.state.reserveProofs(pendingProofs, totalPendingProofs); return wallet.state.update(walletChange, "Consolidate"); } // src/wallets/cashu/pay/ln.ts import { MeltQuoteState } from "@cashu/cashu-ts"; // src/wallets/cashu/wallet/fee.ts function calculateFee(intendedAmount, providedProofs, returnedProofs) { const totalProvided = providedProofs.reduce((acc, p) => acc + p.amount, 0); const totalReturned = returnedProofs.reduce((acc, p) => acc + p.amount, 0); const totalFee = totalProvided - intendedAmount - totalReturned; if (totalFee < 0) { throw new Error("Invalid fee calculation: received more proofs than sent to mint"); } return totalFee; } // src/wallets/cashu/wallet/effect.ts async function withProofReserve(wallet, cashuWallet, mint, amountWithFees, amountWithoutFees, cb) { cashuWallet ??= await wallet.getCashuWallet(mint); const availableMintProofs = wallet.state.getProofs({ mint, onlyAvailable: true }); const proofs = cashuWallet.selectProofsToSend(availableMintProofs, amountWithFees); const fetchedAmount = proofs.send.reduce((a, b) => a + b.amount, 0); if (fetchedAmount < amountWithFees) return null; wallet.state.reserveProofs(proofs.send, amountWithFees); let cbResult = null; let proofsChange = null; let updateRes = null; try { cbResult = await cb(proofs.send, availableMintProofs); if (!cbResult) return null; proofsChange = { mint, store: cbResult.change, destroy: proofs.send }; updateRes = await wallet.state.update(proofsChange); } catch (e) { wallet.state.unreserveProofs(proofs.send, amountWithFees, "available"); throw e; } if (!cbResult) return null; return { result: cbResult.result, proofsChange, stateUpdate: updateRes, mint, fee: calculateFee(amountWithoutFees, proofs.send, cbResult.change) }; } // src/wallets/cashu/pay/ln.ts async function payLn(wallet, pr, { amount, unit } = {}) { let invoiceAmount = getBolt11Amount(pr); if (!invoiceAmount) throw new Error("invoice amount is required"); invoiceAmount = invoiceAmount / 1e3; if (amount && unit) { if (unit === "msat") { amount = amount / 1e3; } } const eligibleMints = wallet.getMintsWithBalance(invoiceAmount + 3); if (!eligibleMints.length) { return null; } for (const mint of eligibleMints) { try { const result = await executePayment(mint, pr, amount ?? invoiceAmount, wallet); if (result) { if (amount) { result.fee = calculateFee( amount, result.proofsChange?.destroy ?? [], result.proofsChange?.store ?? [] ); } return result; } } catch (error) { wallet.warn(`Failed to execute payment with min ${mint}: ${error}`); } } return null; } async function executePayment(mint, pr, amountWithoutFees, wallet) { const cashuWallet = await wallet.getCashuWallet(mint); try { const meltQuote = await cashuWallet.createMeltQuote(pr); const amountToSend = meltQuote.amount + meltQuote.fee_reserve; const result = await withProofReserve( wallet, cashuWallet, mint, amountToSend, amountWithoutFees, async (proofsToUse, _allOurProofs) => { const meltResult = await cashuWallet.meltProofs(meltQuote, proofsToUse); if (meltResult.quote.state === MeltQuoteState.PAID) { return { result: { preimage: meltResult.quote.payment_preimage ?? "" }, change: meltResult.change }; } return null; } ); return result; } catch (e) { if (e instanceof Error) { if (e.message.match(/already spent/i)) { setTimeout(() => { consolidateMintTokens(mint, wallet); }, 2500); } else { throw e; } } return null; } } // src/wallets/cashu/pay/nut.ts import { normalizeUrl } from "@nostr-dev-kit/ndk"; // src/utils/cashu.ts function ensureIsCashuPubkey(pubkey) { if (!pubkey) return; let _pubkey = pubkey; if (_pubkey.length === 64) _pubkey = `02${_pubkey}`; if (_pubkey.length !== 66) throw new Error("Invalid pubkey"); return _pubkey; } async function mintProofs(wallet, quote, amount, mint, p2pk) { const mintTokenAttempt = (resolve, reject, attempt) => { const pubkey = ensureIsCashuPubkey(p2pk); wallet.mintProofs(amount, quote.quote, { pubkey }).then((mintProofs2) => { console.debug("minted tokens", mintProofs2); resolve({ proofs: mintProofs2, mint }); }).catch((e) => { attempt++; if (attempt <= 3) { console.error("error minting tokens", e); setTimeout(() => mintTokenAttempt(resolve, reject, attempt), attempt * 1500); } else { reject(e); } }); }; return new Promise((resolve, reject) => { mintTokenAttempt(resolve, reject, 0); }); } // src/wallets/cashu/pay/nut.ts async function createToken(wallet, amount, recipientMints, p2pk) { p2pk = ensureIsCashuPubkey(p2pk); const myMintsWithEnoughBalance = wallet.getMintsWithBalance(amount); const hasRecipientMints = recipientMints && recipientMints.length > 0; const mintsInCommon = hasRecipientMints ? findMintsInCommon([recipientMints, myMintsWithEnoughBalance]) : myMintsWithEnoughBalance; for (const mint of mintsInCommon) { try { const res = await createTokenInMint(wallet, mint, amount, p2pk); if (res) { return res; } } catch (_e) { } } if (hasRecipientMints) { return await createTokenWithMintTransfer(wallet, amount, recipientMints, p2pk); } return null; } async function createTokenInMint(wallet, mint, amount, p2pk) { const cashuWallet = await wallet.getCashuWallet(mint); try { const result = await withProofReserve( wallet, cashuWallet, mint, amount, amount, async (proofsToUse, allOurProofs) => { const sendResult = await cashuWallet.send(amount, proofsToUse, { pubkey: p2pk, proofsWeHave: allOurProofs }); return { result: { proofs: sendResult.send, mint }, change: sendResult.keep, mint }; } ); return result; } catch (_e) { } return null; } async function createTokenWithMintTransfer(wallet, amount, recipientMints, p2pk) { const generateQuote = async () => { const generateQuoteFromSomeMint = async (mint3) => { const targetMintWallet3 = await walletForMint(mint3); if (!targetMintWallet3) throw new Error(`unable to load wallet for mint ${mint3}`); const quote3 = await targetMintWallet3.createMintQuote(amount); return { quote: quote3, mint: mint3, targetMintWallet: targetMintWallet3 }; }; const quotesPromises = recipientMints.map(generateQuoteFromSomeMint); const { quote: quote2, mint: mint2, targetMintWallet: targetMintWallet2 } = await Promise.any(quotesPromises); if (!quote2) { throw new Error("failed to get quote from any mint"); } return { quote: quote2, mint: mint2, targetMintWallet: targetMintWallet2 }; }; const { quote, mint: targetMint, targetMintWallet } = await generateQuote(); if (!quote) { return null; } const invoiceAmount = getBolt11Amount(quote.request); if (!invoiceAmount) throw new Error("invoice amount is required"); const invoiceAmountInSat = invoiceAmount / 1e3; 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, { amount }); if (!payLNResult) { return null; } const { proofs, mint } = await mintProofs(targetMintWallet, quote, amount, targetMint, p2pk); return { ...payLNResult, result: { proofs, mint }, fee: payLNResult.fee }; } function findMintsInCommon(mintCollections) { const mintCounts = /* @__PURE__ */ new Map(); for (const mints of mintCollections) { for (const mint of mints) { const normalizedMint = normalizeUrl(mint); if (!mintCounts.has(normalizedMint)) { mintCounts.set(normalizedMint, 1); } else { mintCounts.set(normalizedMint, mintCounts.get(normalizedMint) + 1); } } } const commonMints = []; for (const [mint, count] of mintCounts.entries()) { if (count === mintCollections.length) { commonMints.push(mint); } } return commonMints; } // src/wallets/cashu/wallet/payment.ts var PaymentHandler = class { wallet; constructor(wallet) { this.wallet = wallet; } /** * Pay a LN invoice with this wallet. This will used cashu proofs to pay a bolt11. */ async lnPay(payment, createTxEvent = true) { if (!payment.pr) throw new Error("pr is required"); const invoiceAmount = getBolt11Amount(payment.pr); if (!invoiceAmount) throw new Error("invoice amount is required"); 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, { amount: payment.amount, unit: payment.unit }); if (!res?.result?.preimage) return; if (createTxEvent) { createOutTxEvent(this.wallet.ndk, payment, res, this.wallet.relaySet); } return res.result; } /** * Swaps tokens to a specific amount, optionally locking to a p2pk. */ async cashuPay(payment) { const satPayment = { ...payment }; if (satPayment.unit?.startsWith("msat")) { satPayment.amount = satPayment.amount / 1e3; satPayment.unit = "sat"; } let createResult = await createToken(this.wallet, satPayment.amount, payment.mints, payment.p2pk); if (!createResult) { if (payment.allowIntramintFallback) { createResult = await createToken(this.wallet, satPayment.amount, void 0, payment.p2pk); } if (!createResult) { return; } } createOutTxEvent(this.wallet.ndk, satPayment, createResult, this.wallet.relaySet); return createResult.result; } }; // src/wallets/cashu/wallet/state/balance.ts function getBalance(opts) { const proofs = this.getProofEntries(opts); return proofs.reduce((sum, proof) => sum + proof.proof.amount, 0); } function getMintsBalances({ onlyAvailable } = { onlyAvailable: true }) { const balances = {}; const proofs = this.getProofEntries({ onlyAvailable }); for (const proof of proofs) { if (!proof.mint) continue; balances[proof.mint] ??= 0; balances[proof.mint] += proof.proof.amount; } return balances; } // src/wallets/cashu/wallet/state/proofs.ts function addProof(proofEntry) { this.proofs.set(proofEntry.proof.C, proofEntry); this.journal.push({ memo: "Added proof", timestamp: Date.now(), metadata: { type: "proof", id: proofEntry.proof.C, amount: proofEntry.proof.amount, mint: proofEntry.mint } }); } function reserveProofs(proofs, amount) { for (const proof of proofs) { this.updateProof(proof, { state: "reserved" }); } this.reserveAmounts.push(amount); } function unreserveProofs(proofs, amount, newState) { for (const proof of proofs) { this.updateProof(proof, { state: newState }); } const index = this.reserveAmounts.indexOf(amount); if (index !== -1) { this.reserveAmounts.splice(index, 1); } else { throw new Error(`BUG: Amount ${amount} not found in reserveAmounts`); } } function getProofEntries(opts = {}) { const proofs = /* @__PURE__ */ new Map(); const validStates = /* @__PURE__ */ new Set(["available"]); let { mint, onlyAvailable, includeDeleted } = opts; onlyAvailable ??= true; if (!onlyAvailable) validStates.add("reserved"); if (includeDeleted) validStates.add("deleted"); for (const proofEntry of this.proofs.values()) { if (mint && proofEntry.mint !== mint) continue; if (!validStates.has(proofEntry.state)) continue; if (!proofEntry.proof) continue; proofs.set(proofEntry.proof.C, proofEntry); } return Array.from(proofs.values()); } function updateProof(proof, state) { const proofC = proof.C; const currentState = this.proofs.get(proofC); if (!currentState) throw new Error("Proof not found"); const newState = { ...currentState, ...state }; this.proofs.set(proofC, newState); this.journal.push({ memo: `Updated proof state: ${JSON.stringify(state)}`, timestamp: Date.now(), metadata: { type: "proof", id: proofC, amount: proof.amount, mint: currentState.mint } }); } // src/wallets/cashu/wallet/state/token.ts function addToken(token) { if (!token.mint) throw new Error("BUG: Token has no mint"); const currentEntry = this.tokens.get(token.id); const state = currentEntry?.state ?? "available"; this.tokens.set(token.id, { token, state }); let _added = 0; let _invalid = 0; for (const proof of token.proofs) { const val = maybeAssociateProofWithToken(this, proof, token, state); if (val === false) { _invalid++; } else { _added++; } } } function maybeAssociateProofWithToken(walletState, proof, token, state) { const proofC = proof.C; const proofEntry = walletState.proofs.get(proofC); if (!proofEntry) { walletState.addProof({ mint: token.mint, state, tokenId: token.id, timestamp: token.created_at, proof }); return true; } if (proofEntry.tokenId) { if (proofEntry.tokenId === token.id) { return null; } const existingTokenEntry = walletState.tokens.get(proofEntry.tokenId); if (!existingTokenEntry) { throw new Error( `BUG: Token id ${proofEntry.tokenId} not found, was expected to be associated with proof ${proofC}` ); } const existingToken = existingTokenEntry.token; if (existingToken) { if (existingToken.created_at && (!token.created_at || token.created_at < existingToken.created_at)) { return false; } } walletState.updateProof(proof, { tokenId: token.id, state }); return true; } walletState.updateProof(proof, { tokenId: token.id, state }); return true; } function removeTokenId(tokenId) { const currentEntry = this.tokens.get(tokenId) || {}; this.tokens.set(tokenId, { ...currentEntry, state: "deleted" }); for (const proofEntry of this.proofs.values()) { const { proof } = proofEntry; if (proofEntry.tokenId === tokenId) { if (!proof) { throw new Error("BUG: Proof entry has no proof"); } this.updateProof(proof, { state: "deleted" }); } } } // src/wallets/cashu/wallet/state/update.ts import { NDKCashuToken as NDKCashuToken2, NDKEvent as NDKEvent2, NDKKind as NDKKind3 } from "@nostr-dev-kit/ndk"; async function update(stateChange, _memo) { updateInternalState(this, stateChange); this.wallet.emit("balance_updated"); return updateExternalState(this, stateChange); } function updateInternalState(walletState, stateChange) { if (stateChange.store && stateChange.store.length > 0) { for (const proof of stateChange.store) { walletState.addProof({ mint: stateChange.mint, state: "available", proof, timestamp: Date.now() }); } } if (stateChange.destroy && stateChange.destroy.length > 0) { for (const proof of stateChange.destroy) { walletState.updateProof(proof, { state: "deleted" }); } } if (stateChange.reserve && stateChange.reserve.length > 0) { throw new Error("BUG: Proofs should not be reserved via update"); } } async function updateExternalState(walletState, stateChange) { const newState = calculateNewState(walletState, stateChange); if (newState.deletedTokenIds.size > 0) { const deleteEvent = new NDKEvent2(walletState.wallet.ndk, { kind: NDKKind3.EventDeletion, tags: [ ["k", NDKKind3.CashuToken.toString()], ...Array.from(newState.deletedTokenIds).map((id) => ["e", id]) ] }); await deleteEvent.sign(); publishWithRetry(walletState, deleteEvent, walletState.wallet.relaySet); for (const tokenId of newState.deletedTokenIds) { walletState.removeTokenId(tokenId); } } const res = {}; if (newState.saveProofs.length > 0) { const newToken = await createTokenEvent(walletState, stateChange.mint, newState); res.created = newToken; } return res; } async function publishWithRetry(walletState, event, relaySet, retryTimeout = 10 * 1e3) { let publishResult; publishResult = await event.publish(relaySet); let type; if (event.kind === NDKKind3.EventDeletion) type = "deletion"; if (event.kind === NDKKind3.CashuToken) type = "token"; if (event.kind === NDKKind3.CashuWallet) type = "wallet"; const journalEntryMetadata = { type, id: event.id, relayUrl: relaySet?.relayUrls.join(",") }; if (publishResult) { walletState.journal.push({ memo: `Publish kind:${event.kind} succeesfully`, timestamp: Date.now(), metadata: journalEntryMetadata }); return publishResult; } walletState.journal.push({ memo: "Publish failed", timestamp: Date.now(), metadata: journalEntryMetadata }); setTimeout(() => { publishWithRetry(walletState, event, relaySet, retryTimeout); }, retryTimeout); } async function createTokenEvent(walletState, mint, newState) { const newToken = new NDKCashuToken2(walletState.wallet.ndk); newToken.mint = mint; newToken.proofs = newState.saveProofs; await newToken.toNostrEvent(); walletState.addToken(newToken); newToken.deletedTokens = Array.from(newState.deletedTokenIds); await newToken.sign(); walletState.addToken(newToken); publishWithRetry(walletState, newToken, walletState.wallet.relaySet); return newToken; } function calculateNewState(walletState, stateChange) { const destroyProofs = /* @__PURE__ */ new Set(); for (const proof of stateChange.destroy || []) destroyProofs.add(proof.C); const proofsToStore = /* @__PURE__ */ new Map(); let tokensToDelete; for (const proof of stateChange.store || []) proofsToStore.set(proof.C, proof); tokensToDelete = getAffectedTokens(walletState, stateChange); for (const token of tokensToDelete.values()) { for (const proof of token.proofs) { if (destroyProofs.has(proof.C)) continue; proofsToStore.set(proof.C, proof); } } return { deletedTokenIds: new Set(tokensToDelete.keys()), deletedProofs: destroyProofs, reserveProofs: [], saveProofs: Array.from(proofsToStore.values()) }; } function getAffectedTokens(walletState, stateChange) { const tokens = /* @__PURE__ */ new Map(); for (const proof of stateChange.destroy || []) { const proofEntry = walletState.proofs.get(proof.C); if (!proofEntry) { continue; } const tokenId = proofEntry.tokenId; if (!tokenId) { continue; } const tokenEntry = walletState.tokens.get(tokenId); if (!tokenEntry?.token) { continue; } tokens.set(tokenId, tokenEntry.token); } return tokens; } // src/wallets/cashu/wallet/state/index.ts var WalletState = class { constructor(wallet, reservedProofCs = /* @__PURE__ */ new Set()) { this.wallet = wallet; this.reservedProofCs = reservedProofCs; } /** * the amounts that are intended to be reserved * this is the net amount we are trying to pay out, * excluding fees and coin sizes * e.g. we might want to pay 5 sats, have 2 sats in fees * and we're using 2 inputs that add up to 8, the reserve amount is 5 * while the reserve proofs add up to 8 */ reserveAmounts = []; /** * Source of truth of the proofs this wallet has/had. */ proofs = /* @__PURE__ */ new Map(); /** * The tokens that are known to this wallet. */ tokens = /* @__PURE__ */ new Map(); journal = []; /** This is a debugging function that dumps the state of the wallet */ dump() { const res = { proofs: Array.from(this.proofs.values()), balances: this.getMintsBalance(), totalBalance: this.getBalance(), tokens: Array.from(this.tokens.values()) }; return res; } /*************************** * Tokens ***************************/ addToken = addToken.bind(this); removeTokenId = removeTokenId.bind(this); /*************************** * Proof management ***************************/ addProof = addProof.bind(this); /** * Reserves a number of selected proofs and a specific amount. * * The amount and total of the proofs don't need to match. We * might want to use 5 sats and have 2 proofs of 4 sats each. * In that case, the reserve amount is 5, while the reserve proofs * add up to 8. */ reserveProofs = reserveProofs.bind(this); /** * Unreserves a number of selected proofs and a specific amount. */ unreserveProofs = unreserveProofs.bind(this); /** * Returns all proof entries, optionally filtered by mint and state */ getProofEntries = getProofEntries.bind(this); /** * Updates information about a proof */ updateProof = updateProof.bind(this); /** * Returns all proofs, optionally filtered by mint and state * @param opts.mint - optional mint to filter by * @param opts.onlyAvailable - only include available proofs @default true * @param opts.includeDeleted - include deleted proofs @default false */ getProofs(opts) { return this.getProofEntries(opts).map((entry) => entry.proof); } getTokens(opts = { onlyAvailable: true }) { const proofEntries = this.getProofEntries(opts); const tokens = /* @__PURE__ */ new Map(); for (const proofEntry of proofEntries) { const tokenId = proofEntry.tokenId ?? null; const current = tokens.get(tokenId) ?? { tokenId, mint: proofEntry.mint, proofEntries: [] }; current.token ??= tokenId ? this.tokens.get(tokenId)?.token : void 0; current.proofEntries.push(proofEntry); tokens.set(tokenId, current); } return tokens; } /** * Gets a list of proofs for each mint * @returns */ getMintsProofs({ validStates = /* @__PURE__ */ new Set(["available"]) } = {}) { const mints = /* @__PURE__ */ new Map(); for (const entry of this.proofs.values()) { if (!entry.mint || !entry.proof) continue; if (!validStates.has(entry.state)) continue; const current = mints.get(entry.mint) || []; current.push(entry.proof); mints.set(entry.mint, current); } return mints; } /*************************** * Balance ***************************/ /** * Returns the balance of the wallet, optionally filtered by mint and state * * @params opts.mint - optional mint to filter by * @params opts.onlyAvailable - only include available proofs @default true */ getBalance = getBalance.bind(this); /** * Returns the balances of the different mints * * @params opts.onlyAvailable - only include available proofs @default true */ getMintsBalance = getMintsBalances.bind(this); /*************************** * State update ***************************/ update = update.bind(this); }; // src/wallets/cashu/wallet/index.ts var NDKCashuWallet = class _NDKCashuWallet extends NDKWallet { get type() { return "nip-60"; } _p2pk; sub; status = "initial" /* INITIAL */; static kind = NDKKind4.CashuWallet; static kinds = [NDKKind4.CashuWallet]; mints = []; privkeys = /* @__PURE__ */ new Map(); signer; walletId = "nip-60"; depositMonitor = new NDKCashuDepositMonitor(); /** * Warnings that have been raised */ warnings = []; paymentHandler; state; relaySet; constructor(ndk) { super(ndk); this.ndk = ndk; this.paymentHandler = new PaymentHandler(this); this.state = new WalletState(this); } /** * Generates a backup event for this wallet */ async backup(publish = true) { if (this.privkeys.size === 0) throw new Error("no privkey to backup"); const backup = new NDKCashuWalletBackup(this.ndk); const privkeys = []; for (const [_pubkey, signer] of this.privkeys.entries()) { privkeys.push(signer.privateKey); } backup.privkeys = privkeys; backup.mints = this.mints; if (publish) backup.save(this.relaySet); return backup; } consolidateTokens = consolidateTokens.bind(this); /** * Generates nuts that can be used to send to someone. * * Note that this function does not send anything, it just generates a specific amount of proofs. * @param amounts * @returns */ async mintNuts(amounts) { let result; const totalAmount = amounts.reduce((acc, amount) => acc + amount, 0); for (const mint of this.mints) { const wallet = await this.getCashuWallet(mint); const mintProofs2 = await this.state.getProofs({ mint }); result = await wallet.send(totalAmount, mintProofs2, { proofsWeHave: mintProofs2, includeFees: true, outputAmounts: { sendAmounts: amounts } }); if (result.send.length > 0) { const change = { store: result?.keep ?? [], destroy: result.send, mint }; const updateRes = await this.state.update(change); createOutTxEvent( this.ndk, { paymentDescription: "minted nuts", amount: amounts.reduce((acc, amount) => acc + amount, 0) }, { result: { proofs: result.send, mint }, proofsChange: change, stateUpdate: updateRes, mint, fee: 0 }, this.relaySet ); this.emit("balance_updated"); return result; } } } /** * Loads a wallet information from an event * @param event */ async loadFromEvent(event) { const _event = new NDKEvent3(event.ndk, event.rawEvent()); await _event.decrypt(); const content = JSON.parse(_event.content); for (const tag of content) { if (tag[0] === "mint") { this.mints.push(tag[1]); } else if (tag[0] === "privkey") { await this.addPrivkey(tag[1]); } } await this.getP2pk(); } static async from(event) { if (!event.ndk) throw new Error("no ndk instance on event"); const wallet = new _NDKCashuWallet(event.ndk); await wallet.loadFromEvent(event); return wallet; } /** * Starts monitoring the wallet. * * Use `since` to start syncing state from a specific timestamp. This should be * used by storing at the app level a time in which we know we were able to communicate * with the relays, for example, by saving the time the wallet has emitted a "ready" event. */ start(opts) { const activeUser = this.ndk?.activeUser; if (this.status === "ready" /* READY */) return Promise.resolve(); this.status = "loading" /* LOADING */; const pubkey = opts?.pubkey ?? activeUser?.pubkey; if (!pubkey) throw new Error("no pubkey"); const filters = [ { kinds: [NDKKind4.CashuToken], authors: [pubkey] }, { kinds: [NDKKind4.CashuQuote], authors: [pubkey] }, { kinds: [NDKKind4.EventDeletion], authors: [pubkey], "#k": [NDKKind4.CashuToken.toString()] } ]; if (opts?.since) { filters[0].since = opts.since; filters[1].since = opts.since; filters[2].since = opts.since; } const subOpts = opts ?? {}; subOpts.subId ??= "cashu-wallet-state"; return new Promise((resolve) => { this.sub = this.ndk.subscribe(filters, { ...subOpts, relaySet: this.relaySet }, false); this.sub.on("event:dup", eventDupHandler.bind(this)); this.sub.on("event", (event) => { eventHandler.call(this, event); }); this.sub.on("eose", () => { this.emit("ready"); this.status = "ready" /* READY */; resolve(); }); this.sub.start(); }); } stop() { this.sub?.stop(); this.status = "initial" /* INITIAL */; } /** * Returns the p2pk of this wallet or generates a new one if we don't have one */ async getP2pk() { if (this._p2pk) return this._p2pk; if (this.privkeys.size === 0) { const signer = NDKPrivateKeySigner.generate(); await this.addPrivkey(signer.privateKey); } return this.p2pk; } /** * If this wallet has access to more than one privkey, this will return all of them. */ get p2pks() { return Array.from(this.privkeys.keys()); } async addPrivkey(privkey) { const signer = new NDKPrivateKeySigner(privkey); const user = await signer.user(); this.privkeys.set(user.pubkey, signer); this._p2pk ??= user.pubkey; return this._p2pk; } get p2pk() { if (!this._p2pk) throw new Error("p2pk not set"); return this._p2pk; } set p2pk(pubkey) { if (this.privkeys.has(pubkey)) { this.signer = this.privkeys.get(pubkey); this.p2pk = pubkey; } else { throw new Error(`privkey for ${pubkey} not found`); } } /** * Generates the payload for a wallet event */ walletPayload() { const privkeys = Array.from(this.privkeys.values()).map((signer) => signer.privateKey); return payloadForEvent(privkeys, this.mints); } async publish() { const event = new NDKEvent3(this.ndk, { content: JSON.stringify(this.walletPayload()), kind: NDKKind4.CashuWallet }); const user = await this.ndk?.signer?.user(); await event.encrypt(user, void 0, "nip44"); return event.publish(this.relaySet); } /** * Prepares a deposit * @param amount * @param mint * * @example * const wallet = new NDKCashuWallet(...); * const deposit = wallet.deposit(1000, "https://mint.example.com", "sats"); * deposit.on("success", (token) => { * }); * deposit.on("error", (error) => { * }); * * // start monitoring the deposit * deposit.start(); */ deposit(amount, mint) { const deposit = new NDKCashuDeposit(this, amount, mint); deposit.on("success", (token) => { this.state.addToken(token); }); return deposit; } /** * Receives a token and adds it to the wallet * @param token * @returns the token event that was created */ async receiveToken(token, description) { const { mint } = getDecodedToken(token); const wallet = await this.getCashuWallet(mint); const proofs = await wallet.receive(token); const updateRes = await this.state.update({ store: proofs, mint }); const tokenEvent = updateRes.created; createInTxEvent(this.ndk, proofs, mint, updateRes, { description }, this.relaySet); return tokenEvent; } /** * Pay a LN invoice with this wallet */ async lnPay(payment, createTxEvent = true) { return this.paymentHandler.lnPay(payment, createTxEvent); } /** * Swaps tokens to a specific amount, optionally locking to a p2pk. * * This function has side effects: * - It swaps tokens at the mint * - It updates the wallet state (deletes affected tokens, might create new ones) * - It creates a wallet transaction event * * This function returns the proofs that need to be sent to the recipient. * @param amount */ async cashuPay(payment) { return this.paymentHandler.cashuPay(payment); } wallets = /* @__PURE__ */ new Map(); async redeemNutzaps(nutzaps, privkey, { mint, proofs, cashuWallet }) { if (cashuWallet) { mint ??= cashuWallet.mint.mintUrl; } else { if (!mint) throw new Error("mint not set"); cashuWallet = await this.getCashuWa