From 058d55aa500712de3dde3853d8de607858bf298f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 27 Oct 2024 02:17:05 +0200 Subject: [PATCH] Dev cashu ts fee coinselect (#242) * working * fees work for melt * restore * fees work * update * restore * add more * adjust * restore all * clean up logging * cleanup --- src/components/ChooseMint.vue | 1 - src/components/InvoiceDetailDialog.vue | 27 +- src/components/MintSettings.vue | 3 +- src/components/RestoreView.vue | 235 +++++++++++++++++ src/components/SendTokenDialog.vue | 75 +++--- src/components/SettingsView.vue | 193 ++++++++------ src/pages/Restore.vue | 17 ++ src/router/routes.js | 5 + src/stores/mints.ts | 44 +++- src/stores/proofs.ts | 6 +- src/stores/restore.ts | 119 +++++++++ src/stores/settings.ts | 1 + src/stores/tokens.ts | 12 +- src/stores/wallet.ts | 348 +++++++++---------------- 14 files changed, 729 insertions(+), 357 deletions(-) create mode 100644 src/components/RestoreView.vue create mode 100644 src/pages/Restore.vue create mode 100644 src/stores/restore.ts diff --git a/src/components/ChooseMint.vue b/src/components/ChooseMint.vue index 68bb7776..57b1520e 100644 --- a/src/components/ChooseMint.vue +++ b/src/components/ChooseMint.vue @@ -92,7 +92,6 @@ export default defineComponent({ }, watch: { chosenMint: async function () { - console.log("Mint chosen ", this.chosenMint); await this.activateMintUrl(this.chosenMint.url); }, }, diff --git a/src/components/InvoiceDetailDialog.vue b/src/components/InvoiceDetailDialog.vue index 9d96ce6e..0467236e 100644 --- a/src/components/InvoiceDetailDialog.vue +++ b/src/components/InvoiceDetailDialog.vue @@ -52,11 +52,11 @@ : 'Create Invoice' " :loading="globalMutexLock" - > - + > + Close diff --git a/src/components/RestoreView.vue b/src/components/RestoreView.vue new file mode 100644 index 00000000..c10120a6 --- /dev/null +++ b/src/components/RestoreView.vue @@ -0,0 +1,235 @@ + + + diff --git a/src/components/SendTokenDialog.vue b/src/components/SendTokenDialog.vue index 9b8bc787..0161ea26 100644 --- a/src/components/SendTokenDialog.vue +++ b/src/components/SendTokenDialog.vue @@ -139,8 +139,7 @@ - +
+ :color="!isV4Token ? 'primary' : 'grey'" + :label="isV4Token ? 'V4' : 'V3'" + class="q-my-sm q-mx-md cursor-pointer" + @click="toggleTokenEncoding" + :outline="isV4Token" + />
@@ -356,7 +355,11 @@ import { Buffer } from "buffer"; import { useCameraStore } from "src/stores/camera"; import { useP2PKStore } from "src/stores/p2pk"; import TokenInformation from "components/TokenInformation.vue"; -import { getDecodedToken, getEncodedTokenV4, getEncodedToken } from "@cashu/cashu-ts"; +import { + getDecodedToken, + getEncodedTokenV4, + getEncodedToken, +} from "@cashu/cashu-ts"; import { mapActions, mapState, mapWritableState } from "pinia"; import ChooseMint from "components/ChooseMint.vue"; @@ -403,15 +406,23 @@ export default defineComponent({ ]), ...mapWritableState(useSendTokensStore, ["sendData"]), ...mapWritableState(useCameraStore, ["camera", "hasCamera"]), - ...mapState(useUiStore, ["tickerShort", "canPasteFromClipboard", "globalMutexLock"]), + ...mapState(useUiStore, [ + "tickerShort", + "canPasteFromClipboard", + "globalMutexLock", + ]), ...mapState(useMintsStore, [ "activeProofs", "activeUnit", "activeUnitLabel", + "activeUnitCurrencyMultiplyer", "activeMintUrl", "activeMintBalance", ]), - ...mapState(useSettingsStore, ["checkSentTokens"]), + ...mapState(useSettingsStore, [ + "checkSentTokens", + "includeFeesInSendAmount", + ]), ...mapState(useWorkersStore, ["tokenWorkerRunning"]), // TOKEN METHODS sumProofs: function () { @@ -450,12 +461,19 @@ export default defineComponent({ let spendableProofs = this.spendableProofs(this.activeProofs); let selectedProofs = this.coinSelect( spendableProofs, - this.sendData.amount + this.sendData.amount * this.activeUnitCurrencyMultiplyer, + this.includeFeesInSendAmount ); + const feesToAdd = this.includeFeesInSendAmount + ? this.getFeesForProofs(selectedProofs) + : 0; const sumSelectedProofs = selectedProofs .flat() .reduce((sum, el) => (sum += el.amount), 0); - return sumSelectedProofs == this.sendData.amount; + return ( + sumSelectedProofs == + this.sendData.amount * this.activeUnitCurrencyMultiplyer + feesToAdd + ); }, }, watch: { @@ -499,10 +517,11 @@ export default defineComponent({ "clearAllWorkers", ]), ...mapActions(useWalletStore, [ - "splitToSend", + "send", "sendToLock", "coinSelect", "spendableProofs", + "getFeesForProofs", ]), ...mapActions(useProofsStore, ["serializeProofs"]), ...mapActions(useTokensStore, [ @@ -596,17 +615,13 @@ export default defineComponent({ // if it starts with 'cashuB', it is a v4 token if (this.sendData.tokensBase64.startsWith("cashuA")) { try { - this.sendData.tokensBase64 = getEncodedTokenV4(decodedToken) + this.sendData.tokensBase64 = getEncodedTokenV4(decodedToken); } catch { console.log("### Could not encode token to V4"); - this.sendData.tokensBase64 = getEncodedToken( - decodedToken - ); + this.sendData.tokensBase64 = getEncodedToken(decodedToken); } } else { - this.sendData.tokensBase64 = getEncodedToken( - decodedToken - ); + this.sendData.tokensBase64 = getEncodedToken(decodedToken); } }, deleteThisToken: function () { @@ -630,7 +645,6 @@ export default defineComponent({ ); // update UI this.sendData.tokens = sendProofs; - console.log("### this.sendData.tokens", this.sendData.tokens); this.sendData.tokensBase64 = this.serializeProofs(sendProofs); this.addPendingToken({ @@ -649,7 +663,7 @@ export default defineComponent({ }, sendTokens: async function () { /* - calls splitToSend, displays token and kicks off the spendableWorker + calls send, displays token and kicks off the spendableWorker */ if ( this.sendData.p2pkPubkey && @@ -660,25 +674,22 @@ export default defineComponent({ } try { - let sendAmount = this.sendData.amount; - // if unit is USD, multiply by 100 - if (this.activeUnit === "usd" || this.activeUnit == "eur") { - sendAmount = sendAmount * 100; - } + let sendAmount = + this.sendData.amount * this.activeUnitCurrencyMultiplyer; // keep firstProofs, send scndProofs and delete them (invalidate=true) - let { _, sendProofs } = await this.splitToSend( + let { _, sendProofs } = await this.send( this.activeProofs, sendAmount, - true + true, + this.includeFeesInSendAmount ); // update UI this.sendData.tokens = sendProofs; - console.log("### this.sendData.tokens", this.sendData.tokens); this.sendData.tokensBase64 = this.serializeProofs(sendProofs); this.addPendingToken({ - amount: -this.sendData.amount, + amount: -sendAmount, serializedProofs: this.sendData.tokensBase64, unit: this.activeUnit, mint: this.activeMintUrl, diff --git a/src/components/SettingsView.vue b/src/components/SettingsView.vue index ead93e9b..a52307a2 100644 --- a/src/components/SettingsView.vue +++ b/src/components/SettingsView.vue @@ -10,12 +10,7 @@ > Your seed phrase can restore your wallet. Keep it safe and - private. Warning: this wallet does not support seed phrase - recovery yet. Use a different Cashu wallet or - this tool - to recover from seed phrase. + private.
@@ -51,80 +46,6 @@
- -
- - - - P2PK - Generate a key pair to receive P2PK-locked ecash. Warning: This - feature is experimental. Only use with small amounts. If you lose - your private keys, nobody will be able to unlock the ecash locked - to it anymore. - - - - Generate key - - -
- - - - - - - {{ key.publicKey }} - - - - - - - - - -
@@ -562,6 +483,116 @@
+ +
+ + + + P2PK + Generate a key pair to receive P2PK-locked ecash. Warning: + This feature is experimental. Only use with small amounts. If + you lose your private keys, nobody will be able to unlock the + ecash locked to it anymore. + + + + Generate key + + +
+ + + + + + + {{ key.publicKey }} + + + + + + + + + + + +
+ + + + Restore ecash + The restore wizard lets you recover lost ecash from a + mnemonic seed phrase. + + + + Restore + + +
+
@@ -950,6 +981,7 @@ import { useP2PKStore } from "src/stores/p2pk"; import { useNWCStore } from "src/stores/nwc"; import { useWorkersStore } from "src/stores/workers"; import { useProofsStore } from "src/stores/proofs"; +import { useRestoreStore } from "src/stores/restore"; export default defineComponent({ name: "SettingsView", @@ -1097,6 +1129,7 @@ export default defineComponent({ ...mapActions(useWorkersStore, ["invoiceCheckWorker"]), ...mapActions(useProofsStore, ["serializeProofs"]), ...mapActions(useNPCStore, ["generateNPCConnection"]), + ...mapActions(useRestoreStore, ["restoreMint"]), generateNewMnemonic: async function () { this.newMnemonic(); await this.initSigner(); diff --git a/src/pages/Restore.vue b/src/pages/Restore.vue new file mode 100644 index 00000000..51f2c4d7 --- /dev/null +++ b/src/pages/Restore.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/router/routes.js b/src/router/routes.js index 55298121..93169b14 100644 --- a/src/router/routes.js +++ b/src/router/routes.js @@ -11,6 +11,11 @@ const routes = [ component: () => import("layouts/FullscreenLayout.vue"), children: [{ path: "", component: () => import("src/pages/Settings.vue") }], }, + { + path: "/restore", + component: () => import("layouts/FullscreenLayout.vue"), + children: [{ path: "", component: () => import("src/pages/Restore.vue") }], + }, { path: "/already-running", component: () => import("layouts/FullscreenLayout.vue"), diff --git a/src/stores/mints.ts b/src/stores/mints.ts index 8eade977..90623b3f 100644 --- a/src/stores/mints.ts +++ b/src/stores/mints.ts @@ -2,7 +2,7 @@ import { defineStore } from "pinia"; import { useLocalStorage } from "@vueuse/core"; import { useWorkersStore } from "./workers"; import { notifyApiError, notifyError, notifySuccess } from "src/js/notify"; -import { CashuMint, MintKeys, MintAllKeysets, Proof, SerializedBlindedSignature, MintKeyset } from "@cashu/cashu-ts"; +import { CashuMint, MintKeys, MintAllKeysets, MintActiveKeys, Proof, SerializedBlindedSignature, MintKeyset, GetInfoResponse } from "@cashu/cashu-ts"; import { useUiStore } from "./ui"; export type Mint = { url: string; @@ -48,7 +48,7 @@ export class MintClass { } unitKeysets(unit: string): MintKeyset[] { - return this.mint.keysets.filter((k) => k.unit === unit && k.active); + return this.mint.keysets.filter((k) => k.unit === unit); } unitProofs(unit: string) { @@ -116,6 +116,23 @@ export const useMintsStore = defineStore("mints", { ).reduce((sum, p) => sum + p.amount, 0); return balance }, + activeKeysets({ activeMintUrl, activeUnit }): MintKeyset[] { + const unitKeysets = this.mints.find((m) => m.url === activeMintUrl)?.keysets?.filter((k) => k.unit === activeUnit); + if (!unitKeysets) { + return []; + } + return unitKeysets; + }, + activeKeys({ activeMintUrl, activeUnit }): MintKeys[] { + const unitKeys = this.mints.find((m) => m.url === activeMintUrl)?.keys?.filter((k) => k.unit === activeUnit); + if (!unitKeys) { + return []; + } + return unitKeys; + }, + activeInfo({ activeMintUrl }): GetInfoResponse { + return this.mints.find((m) => m.url === activeMintUrl)?.info; + }, activeUnitLabel({ activeUnit }): string { if (activeUnit == "sat") { return "SAT"; @@ -128,7 +145,16 @@ export const useMintsStore = defineStore("mints", { } else { return activeUnit; } - } + }, + activeUnitCurrencyMultiplyer({ activeUnit }): number { + if (activeUnit == "usd") { + return 100; + } else if (activeUnit == "eur") { + return 100; + } else { + return 1; + } + }, }, actions: { activeMint() { @@ -157,12 +183,9 @@ export const useMintsStore = defineStore("mints", { proofsToWalletProofs(proofs: Proof[]): WalletProof[] { return proofs.map((p) => { return { - amount: p.amount, - secret: p.secret, - C: p.C, + ...p, reserved: false, - id: p.id, - }; + } as WalletProof; }); }, addProofs(proofs: Proof[]) { @@ -268,6 +291,8 @@ export const useMintsStore = defineStore("mints", { } }, activateUnit: async function (unit: string, verbose = false) { + const uIStore = useUiStore(); + await uIStore.lockMutex(); const mint = this.mints.find((m) => m.url === this.activeMintUrl); if (!mint) { notifyError("No active mint", "Unit activation failed"); @@ -279,6 +304,9 @@ export const useMintsStore = defineStore("mints", { } else { notifyError("Unit not supported by mint", "Unit activation failed"); } + await uIStore.unlockMutex(); + const worker = useWorkersStore(); + worker.clearAllWorkers(); }, activateMint: async function (mint: Mint, verbose = false, force = false) { const workers = useWorkersStore(); diff --git a/src/stores/proofs.ts b/src/stores/proofs.ts index c0cc9b17..2476a920 100644 --- a/src/stores/proofs.ts +++ b/src/stores/proofs.ts @@ -25,7 +25,7 @@ export const useProofsStore = defineStore("proofs", { getUnreservedProofs: function (proofs: WalletProof[]) { return proofs.filter((p) => !p.reserved); }, - serializeProofs: function (proofs: Proof[]) { + serializeProofs: function (proofs: Proof[]): string { const mintStore = useMintsStore(); // unique keyset IDs of proofs let uniqueIds = [...new Set(proofs.map((p) => p.id))]; @@ -34,14 +34,14 @@ export const useProofsStore = defineStore("proofs", { m.keysets.filter((k) => uniqueIds.includes(k.id)) ); if (keysets.length === 0) { - return null; + throw new Error("No keysets found for proofs"); } // mints that have any of the keyset.id let mints = mintStore.mints.filter((m) => m.keysets.some((k) => uniqueIds.includes(k.id)) ); if (mints.length === 0) { - return null; + throw new Error("No mints found for proofs"); } // unit of keysets let unit = keysets[0].unit; diff --git a/src/stores/restore.ts b/src/stores/restore.ts new file mode 100644 index 00000000..a18b5d5d --- /dev/null +++ b/src/stores/restore.ts @@ -0,0 +1,119 @@ +import { defineStore } from "pinia"; +import { useLocalStorage } from "@vueuse/core"; +import { generateSecretKey, getPublicKey } from 'nostr-tools' +import { bytesToHex } from '@noble/hashes/utils' // already an installed dependency +import { useWalletStore } from "./wallet"; +import { CashuMint, CashuWallet, Proof } from "@cashu/cashu-ts"; +import { useMintsStore } from "./mints"; +import { notify, notifyError, notifySuccess } from "src/js/notify"; +import { useUiStore } from "./ui"; + +const BATCH_SIZE = 100; +const MAX_GAP = 2; + +export const useRestoreStore = defineStore("restore", { + state: () => ({ + showRestoreDialog: useLocalStorage("cashu.restore.showRestoreDialog", false), + restoringState: false, + restoringMint: "", + mnemonicToRestore: useLocalStorage("cashu.restore.mnemonicToRestore", ""), + restoreProgress: 0, + restoreCounter: 0, + restoreStatus: "", + }), + getters: { + + }, + actions: { + restoreMint: async function (url: string) { + this.restoringState = true; + this.restoringMint = url; + this.restoreProgress = 0; + this.restoreCounter = 0; + this.restoreStatus = ""; + try { + await this._restoreMint(url); + } catch (error) { + notifyError(`Error restoring mint: ${error}`); + } finally { + this.restoringState = false; + this.restoringMint = ""; + this.restoreProgress = 0; + } + }, + _restoreMint: async function (url: string) { + if (this.mnemonicToRestore.length === 0) { + notifyError("Please enter a mnemonic"); + return; + } + this.restoreProgress = 0; + const mintStore = useMintsStore(); + await mintStore.activateMintUrl(url); + + const mnemonic = this.mnemonicToRestore; + this.restoreStatus = `Preparing restore process...`; + const mint = new CashuMint(url); + const keysets = (await mint.getKeySets()).keysets; + let restoredSomething = false; + + // Calculate total steps for progress calculation + let totalSteps = keysets.length * MAX_GAP * 2; + let currentStep = 1; + + for (const keyset of keysets) { + console.log(`Restoring keyset ${keyset.id} with unit ${keyset.unit}`); + const wallet = new CashuWallet(mint, { mnemonicOrSeed: mnemonic, unit: keyset.unit }); + let start = 0; + let emptyBatchCount = 0; + let restoreProofs: Proof[] = []; + + while (emptyBatchCount < MAX_GAP) { + console.log(`Restoring proofs ${start} to ${start + BATCH_SIZE}`); + const proofs = (await wallet.restore(start, BATCH_SIZE, { keysetId: keyset.id })).proofs; + if (proofs.length === 0) { + console.log(`No proofs found for keyset ${keyset.id}`); + emptyBatchCount++; + } else { + console.log(`> Restored ${proofs.length} proofs with sum ${proofs.reduce((s, p) => s + p.amount, 0)}`); + restoreProofs = restoreProofs.concat(proofs); + emptyBatchCount = 0; + totalSteps += MAX_GAP * 2; + this.restoreCounter += proofs.length; + } + this.restoreStatus = `Restored ${this.restoreCounter} proofs for keyset ${keyset.id}`; + start += BATCH_SIZE; + + currentStep++; + this.restoreProgress = currentStep / totalSteps; + + } + + let restoredProofs: Proof[] = []; + for (let i = 0; i < restoreProofs.length; i += BATCH_SIZE) { + this.restoreStatus = `Checking proofs ${i} to ${i + BATCH_SIZE} for keyset ${keyset.id}`; + const checkRestoreProofs = restoreProofs.slice(i, i + BATCH_SIZE); + const spentProofs = await wallet.checkProofsSpent(checkRestoreProofs); + const spentProofsSecrets = spentProofs.map((p) => p.secret); + const unspentProofs = checkRestoreProofs.filter((p) => !spentProofsSecrets.includes(p.secret)); + if (unspentProofs.length > 0) { + console.log(`Found ${unspentProofs.length} unspent proofs with sum ${unspentProofs.reduce((s, p) => s + p.amount, 0)}`); + } + const newProofs = unspentProofs.filter((p) => !mintStore.proofs.some((pr) => pr.secret === p.secret)); + mintStore.addProofs(newProofs); + restoredProofs = restoredProofs.concat(newProofs); + currentStep++; + this.restoreProgress = currentStep / totalSteps; + } + const restoredAmount = restoredProofs.reduce((s, p) => s + p.amount, 0); + const restoredAmountStr = useUiStore().formatCurrency(restoredAmount, keyset.unit); + if (restoredAmount > 0) { + notifySuccess(`Restored ${restoredAmountStr}`); + restoredSomething = true; + } + } + if (!restoredSomething) { + notify("No proofs found to restore"); + } + }, + }, +}); diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 2ebd7ebc..3fce78b9 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -9,6 +9,7 @@ export const useSettingsStore = defineStore("settings", { getBitcoinPrice: useLocalStorage("cashu.settings.getBitcoinPrice", false), checkSentTokens: useLocalStorage("cashu.settings.checkSentTokens", true), defaultNostrRelays: useLocalStorage("cashu.settings.defaultNostrRelays", defaultNostrRelays), + includeFeesInSendAmount: useLocalStorage("cashu.settings.includeFeesInSendAmount", false), } } }); diff --git a/src/stores/tokens.ts b/src/stores/tokens.ts index 5f6e351c..49f73694 100644 --- a/src/stores/tokens.ts +++ b/src/stores/tokens.ts @@ -14,6 +14,7 @@ type HistoryToken = { token: string; mint: string; unit: string; + fee?: number; }; export const useTokensStore = defineStore("tokens", { @@ -30,11 +31,13 @@ export const useTokensStore = defineStore("tokens", { serializedProofs, mint, unit, + fee, }: { amount: number; serializedProofs: string; mint: string; unit: string; + fee?: number; }) { this.historyTokens.push({ status: "paid", @@ -43,6 +46,7 @@ export const useTokensStore = defineStore("tokens", { token: serializedProofs, mint, unit, + fee, } as HistoryToken); }, addPendingToken({ @@ -50,11 +54,13 @@ export const useTokensStore = defineStore("tokens", { serializedProofs, mint, unit, + fee, }: { amount: number; serializedProofs: string; mint: string; unit: string; + fee?: number; }) { this.historyTokens.push({ status: "pending", @@ -63,9 +69,10 @@ export const useTokensStore = defineStore("tokens", { token: serializedProofs, mint, unit, + fee, }); }, - editHistoryToken(tokenToEdit: string, options?: { newAmount?: number; addAmount?: number, newStatus?: "paid" | "pending", newToken?: string, }): HistoryToken | undefined { + editHistoryToken(tokenToEdit: string, options?: { newAmount?: number; addAmount?: number, newStatus?: "paid" | "pending", newToken?: string, newFee?: number }): HistoryToken | undefined { const index = this.historyTokens.findIndex((t) => t.token === tokenToEdit); if (index >= 0) { if (options) { @@ -86,6 +93,9 @@ export const useTokensStore = defineStore("tokens", { if (options.newStatus) { this.historyTokens[index].status = options.newStatus; } + if (options.newFee) { + this.historyTokens[index].fee = options.newFee; + } } return this.historyTokens[index]; diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts index 2afd88a0..05f74feb 100644 --- a/src/stores/wallet.ts +++ b/src/stores/wallet.ts @@ -18,7 +18,8 @@ import * as bolt11Decoder from "light-bolt11-decoder"; import { bech32 } from "bech32"; import axios from "axios"; import { date } from "quasar"; -import { splitAmount } from "@cashu/cashu-ts/dist/lib/es5/utils"; +import { getKeepAmounts, splitAmount } from "@cashu/cashu-ts/dist/lib/es5/utils"; +import { KeepAlive } from "vue"; // HACK: this is a workaround so that the catch block in the melt function does not throw an error when the user exits the app // before the payment is completed. This is necessary because the catch block in the melt function would otherwise remove all @@ -111,7 +112,7 @@ export const useWalletStore = defineStore("wallet", { this.mnemonic = generateNewMnemonic(); } const mnemonic: string = this.mnemonic; - const wallet = new CashuWallet(mint, { mnemonicOrSeed: mnemonic, unit: mints.activeUnit }); + const wallet = new CashuWallet(mint, { keys: mints.activeKeys, keysets: mints.activeKeysets, mintInfo: mints.activeInfo, mnemonicOrSeed: mnemonic, unit: mints.activeUnit }); return wallet; }, seed(): Uint8Array { @@ -140,11 +141,9 @@ export const useWalletStore = defineStore("wallet", { const keysetCounter = this.keysetCounters.find((c) => c.id === id); if (keysetCounter) { keysetCounter.counter += by; - console.log("### increaseKeysetCounter", keysetCounter); } else { const newCounter = { id, counter: by } as KeysetCounter; this.keysetCounters.push(newCounter); - console.log("### new keyset counter", keysetCounter); } }, getKeyset(): string { @@ -176,9 +175,9 @@ export const useWalletStore = defineStore("wallet", { } const keyset_id = sortedKeysets[0].id; const keys = mintStore.activeMint().mint.keys.find((k) => k.id === keyset_id); - if (keys) { - this.wallet.keys = keys; - } + // if (keys) { + // this.wallet.keys = keys; + // } return keyset_id; }, /** @@ -200,53 +199,6 @@ export const useWalletStore = defineStore("wallet", { } return chunks; }, - outputAmountSelect: function (amount: number, target = 3) { - // This function produces an amount split for outputs based on the current coins we have. - // Its objective is to fill up the wallet so that it reaches `target` coins of each amount. - // The coins we currently have are are this.activeProofs - const mintStore = useMintsStore(); - const amountsWeHave = mintStore.activeProofs.map((p) => p.amount); - /* - # NOTE: Do not assume 2^n here. This is not a general case. - */ - // calculate until 2^64 - const allPossibleAmounts = Array.from({ length: 64 }, (_, i) => 2 ** i); - const amountsWeWantLL = allPossibleAmounts.map((a) => { - const count = Math.max(0, target - amountsWeHave.filter((x) => x === a).length); - return Array(count).fill(a); - }); - const amountsWeWant = amountsWeWantLL.flat().sort((a, b) => a - b); - - let amounts: number[] = []; - while (amounts.reduce((s, t) => (s += t), 0) < amount && amountsWeWant.length) { - if (amounts.reduce((s, t) => (s += t), 0) + amountsWeWant[0] > amount) { - break; - } - amounts.push(amountsWeWant.shift() as number); - } - const remainingAmount = amount - amounts.reduce((s, t) => (s += t), 0); - if (remainingAmount > 0) { - console.log("remaining amount", remainingAmount) - // amount_split is the optimal 2^n split: splitAmount - amounts = amounts.concat(this.splitAmount(remainingAmount)); - } - if (amounts.reduce((s, t) => (s += t), 0) != amount) { - throw new Error(`Amounts do not sum to ${amount}.`); - } - - // make array of AmountPreference types which have a unique `amount` and its `count` - const amountsWithCount: AmountPreference[] = []; - amounts.forEach((a) => { - const existing = amountsWithCount.find((ac) => ac.amount === a); - if (existing) { - existing.count += 1; - } else { - amountsWithCount.push({ amount: a, count: 1 }); - } - }); - - return amountsWithCount; - }, coinSelectSpendBase64: function (proofs: WalletProof[], amount: number): WalletProof[] { const base64Proofs = proofs.filter(p => !p.id.startsWith("00")) if (base64Proofs.length > 0) { @@ -265,55 +217,16 @@ export const useWalletStore = defineStore("wallet", { } return []; }, - coinSelect: function (proofs: WalletProof[], amount: number) { + coinSelect: function (proofs: WalletProof[], amount: number, includeFees: boolean = false): WalletProof[] { if (proofs.reduce((s, t) => (s += t.amount), 0) < amount) { // there are not enough proofs to pay the amount return []; } - - // override: if there are proofs with a base64 id, use them - const base64Proofs = this.coinSelectSpendBase64(proofs, amount); - if (base64Proofs.length > 0 && base64Proofs.reduce((s, t) => (s += t.amount), 0) >= amount) { - return base64Proofs; - } - - // sort proofs by amount ascending - proofs = proofs.slice().sort((a, b) => a.amount - b.amount); - // remember next bigger proof as a fallback - const nextBigger = proofs.find((p) => p.amount > amount); - - // go through smaller proofs until sum is bigger than amount - const smallerProofs = proofs.filter((p) => p.amount <= amount); - // sort by amount descending - smallerProofs.sort((a, b) => b.amount - a.amount); - - let selectedProofs: WalletProof[] = []; - - if (smallerProofs.length == 0 && nextBigger) { - // if there are no smaller proofs, take the next bigger proof as a fallback - return [nextBigger]; - } else if (smallerProofs.length == 0 && !nextBigger) { - // no proofs available - return []; - } - - // recursively select the largest proof of smallerProofs, subtract the amount from the remainder - // and call coinSelect again with the remainder and the rest of the smallerProofs (without the largest proof) - let remainder = amount; - selectedProofs = [smallerProofs[0]]; - remainder -= smallerProofs[0].amount; - if (remainder > 0) { - selectedProofs = selectedProofs.concat(this.coinSelect(smallerProofs.slice(1), remainder)); - } - let sum = selectedProofs.reduce((s, t) => (s += t.amount), 0); - - // if sum of selectedProofs is smaller than amount, take next bigger proof instead as a fallback - if (sum < amount && nextBigger) { - selectedProofs = [nextBigger]; - } - - // console.log("### selected amounts", "sum", selectedProofs.reduce((s, t) => (s += t.amount), 0), selectedProofs.map(p => p.amount)); - return selectedProofs + const { send: selectedProofs, keep: _ } = this.wallet.selectProofsToSend(proofs, amount, includeFees); + const selectedWalletProofs = selectedProofs.map((p) => { + return { ...p, reserved: false } as WalletProof; + }); + return selectedWalletProofs; }, spendableProofs: function (proofs: WalletProof[], amount: number) { const uIStore = useUiStore(); @@ -331,18 +244,21 @@ export const useWalletStore = defineStore("wallet", { } return spendableProofs; }, + getFeesForProofs: function (proofs: Proof[]): number { + return this.wallet.getFeesForProofs(proofs); + }, sendToLock: async function (proofs: WalletProof[], amount: number, receiverPubkey: string) { const spendableProofs = this.spendableProofs(proofs, amount); - const proofsToSplit = this.coinSelect(spendableProofs, amount); - const { returnChange: keepProofs, send: sendProofs } = await this.wallet.send(amount, proofsToSplit, { pubkey: receiverPubkey }) + const proofsToSend = this.coinSelect(spendableProofs, amount); + const { keep: keepProofs, send: sendProofs } = await this.wallet.send(amount, proofsToSend, { pubkey: receiverPubkey }) const mintStore = useMintsStore(); // note: we do not store sendProofs in the proofs store but // expect from the caller to store it in the history mintStore.addProofs(keepProofs); - mintStore.removeProofs(proofsToSplit); + mintStore.removeProofs(proofsToSend); return { keepProofs, sendProofs }; }, - splitToSend: async function (proofs: WalletProof[], amount: number, invalidate: boolean = false): Promise<{ keepProofs: Proof[], sendProofs: Proof[] }> { + send: async function (proofs: WalletProof[], amount: number, invalidate: boolean = false, includeFees: boolean = false): Promise<{ keepProofs: Proof[], sendProofs: Proof[] }> { /* splits proofs so the user can keep firstProofs, send scndProofs. then sets scndProofs as reserved. @@ -352,36 +268,32 @@ export const useWalletStore = defineStore("wallet", { const mintStore = useMintsStore(); const proofsStore = useProofsStore() const uIStore = useUiStore(); - let proofsToSplit: WalletProof[] = []; + let proofsToSend: WalletProof[] = []; const keysetId = this.getKeyset() await uIStore.lockMutex(); try { const spendableProofs = this.spendableProofs(proofs, amount); - proofsToSplit = this.coinSelect(spendableProofs, amount); - const totalAmount = proofsToSplit.reduce((s, t) => (s += t.amount), 0); - proofsStore.setReserved(proofsToSplit, true); + + proofsToSend = this.coinSelect(spendableProofs, amount, includeFees); + const totalAmount = proofsToSend.reduce((s, t) => (s += t.amount), 0); + const fees = includeFees ? this.wallet.getFeesForProofs(proofsToSend) : 0; + const targetAmount = amount + fees; + let keepProofs: Proof[] = []; let sendProofs: Proof[] = []; - if (totalAmount != amount) { + + if (totalAmount != targetAmount) { const counter = this.keysetCounter(keysetId); - ({ returnChange: keepProofs, send: sendProofs } = await this.wallet.send(amount, proofsToSplit, { counter })); + proofsToSend = this.coinSelect(spendableProofs, targetAmount, true); + ({ keep: keepProofs, send: sendProofs } = await this.wallet.send(targetAmount, proofsToSend, { counter, proofsWeHave: spendableProofs })); this.increaseKeysetCounter(keysetId, keepProofs.length + sendProofs.length); - - mintStore.removeProofs(proofsToSplit); - - mintStore.addProofs(keepProofs); mintStore.addProofs(sendProofs); - } else if (totalAmount == amount) { + mintStore.removeProofs(proofsToSend); + } else if (totalAmount == targetAmount) { + keepProofs = []; - sendProofs = proofsToSplit.map((p) => { - return { - amount: p.amount, - secret: p.secret, - C: p.C, - id: p.id, - }; - }); + sendProofs = proofsToSend } else { throw new Error("could not split proofs."); } @@ -389,10 +301,10 @@ export const useWalletStore = defineStore("wallet", { if (invalidate) { mintStore.removeProofs(sendProofs); } - + proofsStore.setReserved(sendProofs, true); return { keepProofs, sendProofs }; } catch (error: any) { - proofsStore.setReserved(proofsToSplit, false); + proofsStore.setReserved(proofsToSend, false); console.error(error); notifyApiError(error); this.handleOutputsHaveAlreadyBeenSignedError(keysetId, error); @@ -415,7 +327,6 @@ export const useWalletStore = defineStore("wallet", { const p2pkStore = useP2PKStore(); receiveStore.showReceiveTokens = false; - console.log("### receive tokens", receiveStore.receiveData.tokensBase64); if (receiveStore.receiveData.tokensBase64.length == 0) { throw new Error("no tokens provided."); @@ -435,33 +346,36 @@ export const useWalletStore = defineStore("wallet", { // redeem const keysetId = this.getKeyset() const counter = this.keysetCounter(keysetId) - const preference = this.outputAmountSelect(amount); + // const preference = this.outputAmountSelect(amount); const privkey = receiveStore.receiveData.p2pkPrivateKey; - const decodedToken = getDecodedToken(receiveStore.receiveData.tokensBase64); - let tokenCts: Token + // const decodedToken = getDecodedToken(receiveStore.receiveData.tokensBase64); + // let tokenCts: Token let proofs: Proof[] try { - proofs = await this.wallet.receive(receiveStore.receiveData.tokensBase64, { counter, preference, privkey }) + proofs = await this.wallet.receive(receiveStore.receiveData.tokensBase64, { counter, privkey, proofsWeHave: mintStore.activeProofs }) this.increaseKeysetCounter(keysetId, proofs.length); } catch (error: any) { console.error(error); this.handleOutputsHaveAlreadyBeenSignedError(keysetId, error); throw new Error("Error receiving tokens: " + error); } - // const proofs: Proof[] = tokenCts.token.map(t => t.proofs).flat(); p2pkStore.setPrivateKeyUsed(privkey); - mintStore.removeProofs(proofs); - // gather all token.token[i].proofs mintStore.addProofs(proofs); + const receivedAdmount = proofs.reduce((s, t) => (s += t.amount), 0); + // if token is already in history, set to paid, else add to history - if (tokenStore.historyTokens.find((t) => t.token === receiveStore.receiveData.tokensBase64)) { + if (tokenStore.historyTokens.find((t) => t.token === receiveStore.receiveData.tokensBase64 && t.amount == receivedAdmount)) { tokenStore.setTokenPaid(receiveStore.receiveData.tokensBase64); } else { + // if this is a self-sent token, we will find an outgoing token with the inverse amount + if (tokenStore.historyTokens.find((t) => t.token === receiveStore.receiveData.tokensBase64 && t.amount == -receivedAdmount)) { + tokenStore.setTokenPaid(receiveStore.receiveData.tokensBase64); + } tokenStore.addPaidToken({ - amount, + amount: receivedAdmount, serializedProofs: receiveStore.receiveData.tokensBase64, unit: mintStore.activeUnit, mint: mintStore.activeMintUrl, @@ -470,7 +384,7 @@ export const useWalletStore = defineStore("wallet", { if (!!window.navigator.vibrate) navigator.vibrate(200); - notifySuccess("Received " + uIStore.formatCurrency(amount, mintStore.activeUnit)); + notifySuccess("Received " + uIStore.formatCurrency(receivedAdmount, mintStore.activeUnit)); } catch (error: any) { console.error(error); notifyApiError(error); @@ -521,6 +435,16 @@ export const useWalletStore = defineStore("wallet", { uIStore.unlockMutex(); } }, + getMintQuoteState: async function (quote: string, mint: CashuMint): Promise { + // stateless function to check the state of a mint quote + const resp = await mint.checkMintQuote(quote); + return resp.state; + }, + getMeltQuoteState: async function (quote: string, mint: CashuMint): Promise { + // stateless function to check the state of a melt quote + const resp = await mint.checkMeltQuote(quote); + return resp.state; + }, mint: async function (amount: number, hash: string, verbose: boolean = true) { const proofsStore = useProofsStore(); const mintStore = useMintsStore(); @@ -540,9 +464,7 @@ export const useWalletStore = defineStore("wallet", { throw new Error("invoice not paid yet."); } const counter = this.keysetCounter(keysetId) - const preference = this.outputAmountSelect(amount); - console.log("### preference", preference); - const { proofs } = await this.wallet.mintTokens(amount, hash, { keysetId, counter, preference: preference }) + const { proofs } = await this.wallet.mintProofs(amount, hash, { keysetId, counter, proofsWeHave: mintStore.activeProofs }) this.increaseKeysetCounter(keysetId, proofs.length); // const proofs = await this.mintApi(split, hash, verbose); @@ -599,7 +521,6 @@ export const useWalletStore = defineStore("wallet", { const data = await mintStore.activeMint().api.createMeltQuote(payload); mintStore.assertMintError(data); this.payInvoiceData.meltQuote.response = data; - console.log("#### meltQuote", payload, " response:", data); this.payInvoiceData.blocking = false; return data; } catch (error: any) { @@ -639,35 +560,30 @@ export const useWalletStore = defineStore("wallet", { throw new Error("no quote found."); } const amount = quote.amount + quote.fee_reserve; - - console.log( - "#### amount invoice", - amount_invoice, - "amount with fees", - amount - ); let countChangeOutputs = 0; const keysetId = this.getKeyset(); let keysetCounterIncrease = 0; - // get right amount of proofs to send - const { keepProofs, sendProofs } = await this.splitToSend( - mintStore.activeMint().unitProofs(mintStore.activeUnit), - amount - ); - if (sendProofs.length == 0) { - throw new Error("could not split proofs."); - } + // start melt - await uIStore.lockMutex(); + let sendProofs: Proof[] = []; try { - // proof management - const serializedSendProofs = proofsStore.serializeProofs(sendProofs); - if (serializedSendProofs == null) { - throw new Error("could not serialize proofs."); + const { keepProofs: keepProofs, sendProofs: _sendProofs } = await this.send(mintStore.activeProofs, amount, false, true) + sendProofs = _sendProofs; + if (sendProofs.length == 0) { + throw new Error("could not split proofs."); } - proofsStore.setReserved(sendProofs, true); - await this.addOutgoingPendingInvoiceToHistory(quote, serializedSendProofs); + } catch (error: any) { + console.error(error); + notifyApiError(error, "Payment failed"); + throw error; + } + + + await uIStore.lockMutex(); + try { + + await this.addOutgoingPendingInvoiceToHistory(quote, sendProofs); // NUT-08 blank outputs for change const counter = this.keysetCounter(keysetId); @@ -684,9 +600,9 @@ export const useWalletStore = defineStore("wallet", { // NOTE: if the user exits the app while we're in the API call, JS will emit an error that we would catch below! // We have to handle that case in the catch block below - const data = await this.wallet.payLnInvoice(invoice, sendProofs, quote, { keysetId, counter }) + const data = await this.wallet.meltProofs(quote, sendProofs, { keysetId, counter }) - if (data.isPaid != true) { + if (data.quote.state != MeltQuoteState.PAID) { throw new Error("Invoice not paid."); } let amount_paid = amount - proofsStore.sumProofs(data.change) @@ -708,7 +624,7 @@ export const useWalletStore = defineStore("wallet", { tokenStore.addPaidToken({ amount: -amount_paid, - serializedProofs: serializedSendProofs, + serializedProofs: proofsStore.serializeProofs(sendProofs), unit: mintStore.activeUnit, mint: mintStore.activeMintUrl, }); @@ -770,8 +686,6 @@ export const useWalletStore = defineStore("wallet", { }; try { const spentProofs = await this.wallet.checkProofsSpent(proofs); - // const data = await mintStore.activeMint().api.check(payload); - // mintStore.assertMintError(data); if (spentProofs.length) { mintStore.removeProofs(spentProofs); @@ -860,15 +774,22 @@ export const useWalletStore = defineStore("wallet", { checkInvoice: async function (quote: string, verbose = true) { const uIStore = useUiStore(); const mintStore = useMintsStore(); - console.log("### checkInvoice.quote", quote); const invoice = this.invoiceHistory.find((i) => i.quote === quote); if (!invoice) { throw new Error("invoice not found"); } try { + // check the state first + const state = await this.getMintQuoteState(invoice.quote, new CashuMint(invoice.mint)); + if (state != MintQuoteState.PAID) { + console.log("### mintQuote not paid yet"); + if (verbose) { + notify("Invoice still pending"); + } + throw new Error("invoice not paid yet."); + } // activate the mint await mintStore.activateMintUrl(invoice.mint, false, false, invoice.unit); - const proofs = await this.mint(invoice.amount, invoice.quote, verbose); if (!!window.navigator.vibrate) navigator.vibrate(200); notifySuccess("Received " + uIStore.formatCurrency(invoice.amount, mintStore.activeUnit) + " via Lightning"); @@ -888,60 +809,46 @@ export const useWalletStore = defineStore("wallet", { if (!invoice) { throw new Error("invoice not found"); } + + let proofs: Proof[] = []; + if (invoice.token) { + const tokenJson = token.decode(invoice.token); + if (tokenJson == undefined) { + throw new Error("no tokens provided."); + } + proofs = token.getProofs(tokenJson); + if (proofs.length == 0) { + throw new Error("no proofs found."); + } + } + try { - await mintStore.activateMintUrl(invoice.mint, false, false, invoice.unit); // this is an outgoing invoice, we first do a getMintQuote to check if the invoice is paid - const mintQuote = await mintStore.activeMint().api.checkMeltQuote(quote); - console.log("### mintQuote", mintQuote); - if (mintQuote.state != MeltQuoteState.PAID) { + const mint = new CashuMint(invoice.mint); + const mintQuote = await mint.checkMeltQuote(quote); + if (mintQuote.state == MeltQuoteState.PENDING) { console.log("### mintQuote not paid yet"); - if (invoice.token) { - const tokenJson = token.decode(invoice.token); - if (tokenJson == undefined) { - throw new Error("no tokens provided."); - } - let proofs = token.getProofs(tokenJson); - if (proofs.length == 0) { - throw new Error("no proofs found."); - } - const states = await this.getProofState(proofs); - // if all proofs are CheckStateEnum.PENDING, we notify that the invoice is still pending - if (states.every((s) => s.state === CheckStateEnum.PENDING)) { - if (verbose) { - notify("Invoice still pending"); - } - throw new Error("invoice not paid yet."); - } - // if all proofs are CheckStateEnum.UNSPENT, we assume that the payment failed and we unset the proofs as reserved - // and remove the invoice from the history - if (states.every((s) => s.state === CheckStateEnum.UNSPENT)) { - useProofsStore().setReserved(proofs, false); - this.removeOutgoingInvoiceFromHistory(quote); - notifyWarning("Lightning payment failed"); - } - // - } else { - throw new Error("no token in invoice."); + if (verbose) { + notify("Invoice still pending"); } - } else { + throw new Error("invoice not paid yet."); + } + else if (mintQuote.state == MeltQuoteState.UNPAID) { + // we assume that the payment failed and we unset the proofs as reserved + useProofsStore().setReserved(proofs, false); + this.removeOutgoingInvoiceFromHistory(quote); + notifyWarning("Lightning payment failed"); + } else if (mintQuote.state == MeltQuoteState.PAID) { // if the invoice is paid, we check if all proofs are spent and if so, we invalidate them and set the invoice state in the history to "paid" - if (invoice.token) { - const tokenJson = token.decode(invoice.token); - if (tokenJson == undefined) { - throw new Error("no tokens provided."); - } - let proofs = token.getProofs(tokenJson); - if (proofs.length == 0) { - throw new Error("no proofs found."); - } - const spentProofs = await this.checkProofsSpendable(proofs, true); - if (spentProofs != undefined && spentProofs.length == proofs.length) { - if (!!window.navigator.vibrate) navigator.vibrate(200); - notifySuccess("Sent " + uIStore.formatCurrency(useProofsStore().sumProofs(spentProofs), mintStore.activeUnit)); - } - // set invoice in history to paid - this.setInvoicePaid(quote); + await mintStore.activateMintUrl(invoice.mint, false, false, invoice.unit); + const spentProofs = await this.checkProofsSpendable(proofs, true); + if (spentProofs != undefined && spentProofs.length == proofs.length) { + mintStore.removeProofs(proofs); + if (!!window.navigator.vibrate) navigator.vibrate(200); + notifySuccess("Sent " + uIStore.formatCurrency(useProofsStore().sumProofs(proofs), mintStore.activeUnit)); } + // set invoice in history to paid + this.setInvoicePaid(quote); } } catch (error: any) { if (verbose) { @@ -952,7 +859,10 @@ export const useWalletStore = defineStore("wallet", { } }, ////////////// UI HELPERS ////////////// - addOutgoingPendingInvoiceToHistory: function (quote: MeltQuoteResponse, serlializedToken?: string) { + addOutgoingPendingInvoiceToHistory: function (quote: MeltQuoteResponse, sendProofs: Proof[]) { + const proofsStore = useProofsStore(); + const serlializedToken = proofsStore.serializeProofs(sendProofs); + proofsStore.setReserved(sendProofs, true); const mintStore = useMintsStore(); this.invoiceHistory.push({ amount: -(quote.amount + quote.fee_reserve), @@ -963,6 +873,7 @@ export const useWalletStore = defineStore("wallet", { status: "pending", mint: mintStore.activeMintUrl, token: serlializedToken, + unit: mintStore.activeUnit, }); }, removeOutgoingInvoiceFromHistory: function (quote: string) { @@ -1196,7 +1107,6 @@ export const useWalletStore = defineStore("wallet", { const satPrice = 1 / (priceUsd / 1e8); const usdAmount = amount; amount = Math.floor(usdAmount * satPrice); - console.log(`converted amount: ${amount}`); } var { data } = await axios.get( `${this.payInvoiceData.lnurlpay.callback}?amount=${amount * 1000}` @@ -1206,8 +1116,6 @@ export const useWalletStore = defineStore("wallet", { notifyError(data.reason, "LNURL Error"); return; } - console.log(data.pr); - console.log(`callback: ${this.payInvoiceData.lnurlpay.callback}`); await this.decodeRequest(data.pr); } },