From 3d2f829ee1ff61aecdb9b80e977e532aeea12cf9 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 1 Sep 2024 00:55:18 +0200 Subject: [PATCH] Global mutex + output already signed healer + tokenv4 base64 keyset id fix (#224) * global mutex wip * fix outputs already signed healer * fix mutex and animate * fix animations * update cashu-ts-rc3 self healing on mint * async mutex lock * remove unneded code * fix counter for pay invoice and mutex lock on mint activation * counter for send --- package-lock.json | 8 +-- package.json | 2 +- src/components/BalanceView.vue | 22 +++++- src/components/InvoiceDetailDialog.vue | 14 ++-- src/components/MintSettings.vue | 4 ++ src/components/PayInvoiceDialog.vue | 16 +++-- src/components/ReceiveTokenDialog.vue | 2 +- src/components/SendTokenDialog.vue | 17 +++-- src/components/TokenInformation.vue | 4 +- src/stores/mints.ts | 7 +- src/stores/proofs.ts | 8 ++- src/stores/ui.ts | 21 ++++++ src/stores/wallet.ts | 92 ++++++++++++++++++-------- 13 files changed, 160 insertions(+), 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index a1f598ea..facbe65c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@capacitor/clipboard": "^6.0.0", "@capacitor/core": "^6.0.0", "@capacitor/ios": "^6.0.0", - "@cashu/cashu-ts": "^1.1.0-2", + "@cashu/cashu-ts": "^1.1.0-3", "@cashu/crypto": "^0.2.7", "@chenfengyuan/vue-qrcode": "^2.0.0", "@gandlaf21/bc-ur": "^1.1.12", @@ -2001,9 +2001,9 @@ } }, "node_modules/@cashu/cashu-ts": { - "version": "1.1.0-2", - "resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-1.1.0-2.tgz", - "integrity": "sha512-qWcr6tF1W7KOJs+9x6s+YKsJ5qoUm/Ae2GYL4D19aLZUY5Pnz7qs0kwtsTCvf5My8tS5QnH+ZipRjLjNG4X43A==", + "version": "1.1.0-3", + "resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-1.1.0-3.tgz", + "integrity": "sha512-xxraCuHeBvU5syL+Hhr3aKSrIVpA1TXZgNWUIH7hhB/5v8ObfO0MoLHpvW1x+4lR2kTWt3WRzXHIqgJDZQyxmA==", "license": "MIT", "dependencies": { "@cashu/crypto": "^0.2.7", diff --git a/package.json b/package.json index a0ba9a07..26aa758d 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@capacitor/clipboard": "^6.0.0", "@capacitor/core": "^6.0.0", "@capacitor/ios": "^6.0.0", - "@cashu/cashu-ts": "^1.1.0-2", + "@cashu/cashu-ts": "^1.1.0-3", "@cashu/crypto": "^0.2.7", "@chenfengyuan/vue-qrcode": "^2.0.0", "@gandlaf21/bc-ur": "^1.1.12", diff --git a/src/components/BalanceView.vue b/src/components/BalanceView.vue index 68230c60..903f189e 100644 --- a/src/components/BalanceView.vue +++ b/src/components/BalanceView.vue @@ -2,8 +2,25 @@
-
- +
+
+ + + +
+
+ + + +
+ :loading="globalMutexLock" + > + + Close @@ -173,7 +175,7 @@ export default defineComponent({ ...mapState(useWalletStore, ["invoiceData"]), ...mapState(useMintsStore, ["activeUnit", "activeUnitLabel"]), ...mapState(useWorkersStore, ["invoiceWorkerRunning"]), - ...mapWritableState(useUiStore, ["showInvoiceDetails", "tickerShort"]), + ...mapWritableState(useUiStore, ["showInvoiceDetails", "tickerShort", "globalMutexLock"]), displayUnit: function () { let display = this.formatCurrency( this.invoiceData.amount, diff --git a/src/components/MintSettings.vue b/src/components/MintSettings.vue index 062a5c0b..f51e875d 100644 --- a/src/components/MintSettings.vue +++ b/src/components/MintSettings.vue @@ -660,6 +660,10 @@ export default defineComponent({ } }, sanitizeMintUrlAndShowAddDialog: function () { + // if no protocol is given, add https + if (!this.addMintData.url.match(/^[a-zA-Z]+:\/\//)) { + this.addMintData.url = "https://" + this.addMintData.url; + } if (!this.validateMintUrl(this.addMintData.url)) { notifyError("Invalid URL"); return; diff --git a/src/components/PayInvoiceDialog.vue b/src/components/PayInvoiceDialog.vue index 87fbbb1d..b1c47260 100644 --- a/src/components/PayInvoiceDialog.vue +++ b/src/components/PayInvoiceDialog.vue @@ -55,12 +55,14 @@ payInvoiceData.blocking || payInvoiceData.meltQuote.error != '' " @click="melt" - :label="!payInvoiceData.blocking ? 'Pay' : 'Processing...'" - > + :label="payInvoiceData.meltQuote.error != '' ? 'Error' : !payInvoiceData.blocking ? 'Pay' : 'Processing...'" + :loading="globalMutexLock && !payInvoiceData.blocking" + class="q-px-lg" + > + + Close
@@ -248,7 +250,7 @@ export default defineComponent({ }, }, computed: { - ...mapState(useUiStore, ["tickerShort"]), + ...mapState(useUiStore, ["tickerShort", "globalMutexLock"]), ...mapWritableState(useCameraStore, ["camera", "hasCamera"]), ...mapState(useWalletStore, ["payInvoiceData"]), ...mapState(useMintsStore, [ diff --git a/src/components/ReceiveTokenDialog.vue b/src/components/ReceiveTokenDialog.vue index 85ce1500..18da2d54 100644 --- a/src/components/ReceiveTokenDialog.vue +++ b/src/components/ReceiveTokenDialog.vue @@ -59,7 +59,7 @@ />
-
+
SendSend + +
- + {{ displayUnit }} - + {{ tokenMintUrl }} diff --git a/src/stores/mints.ts b/src/stores/mints.ts index 223ec875..edd86953 100644 --- a/src/stores/mints.ts +++ b/src/stores/mints.ts @@ -3,7 +3,7 @@ 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 { useUiStore } from "./ui"; export type Mint = { url: string; keys: MintKeys[]; @@ -282,6 +282,7 @@ export const useMintsStore = defineStore("mints", { }, activateMint: async function (mint: Mint, verbose = false, force = false) { const workers = useWorkersStore(); + const uIStore = useUiStore(); if (mint.url === this.activeMintUrl && !force) { // return here because this function is called repeatedly by the // invoice check and token spendable check workers and would otherwise @@ -293,6 +294,7 @@ export const useMintsStore = defineStore("mints", { // create new mint.api instance because we can't store it in local storage let previousUrl = this.activeMintUrl; + await uIStore.lockMutex(); try { this.activeMintUrl = mint.url; console.log("### this.activeMintUrl", this.activeMintUrl); @@ -316,6 +318,8 @@ export const useMintsStore = defineStore("mints", { } await notifyError(err_msg, "Mint activation failed"); throw error; + } finally { + await uIStore.unlockMutex(); } }, fetchMintInfo: async function (mint: Mint) { @@ -332,6 +336,7 @@ export const useMintsStore = defineStore("mints", { } }, fetchMintKeys: async function (mint: Mint) { + try { const mintClass = new MintClass(mint); const keysets = await this.fetchMintKeysets(mint); diff --git a/src/stores/proofs.ts b/src/stores/proofs.ts index 6ac43443..c0cc9b17 100644 --- a/src/stores/proofs.ts +++ b/src/stores/proofs.ts @@ -49,7 +49,13 @@ export const useProofsStore = defineStore("proofs", { token: [{ proofs: proofs, mint: mints[0].url }], unit: unit, } as Token; - return getEncodedTokenV4(token); + try { + return getEncodedTokenV4(token); + } catch (e) { + console.log("Could not encode TokenV4, defaulting to TokenV3", e); + return getEncodedToken(token); + } + // // what we put into the JSON // let mintsJson = mints.map((m) => [{ url: m.url, ids: m.keysets }][0]); diff --git a/src/stores/ui.ts b/src/stores/ui.ts index 7fb58e8d..cf02305f 100644 --- a/src/stores/ui.ts +++ b/src/stores/ui.ts @@ -1,6 +1,7 @@ import { defineStore } from "pinia"; import { useMintsStore } from "./mints"; import { useLocalStorage } from "@vueuse/core"; +import { notifyApiError, notifyError, notifySuccess, notifyWarning, notify } from "../js/notify"; const unitTickerShortMap = { sat: "sats", @@ -19,8 +20,28 @@ export const useUiStore = defineStore("ui", { tab: useLocalStorage("cashu.ui.tab", "history" as string), expandHistory: useLocalStorage("cashu.ui.expandHistory", true as boolean), + globalMutexLock: false, }), actions: { + async lockMutex() { + const nRetries = 10; + const retryInterval = 500; + let retries = 0; + + while (this.globalMutexLock) { + if (retries >= nRetries) { + notify("Please try again.") + throw new Error("Failed to acquire global mutex lock"); + } + retries++; + await new Promise(resolve => setTimeout(resolve, retryInterval)); + } + + this.globalMutexLock = true; + }, + unlockMutex() { + this.globalMutexLock = false; + }, setTab(tab: string) { this.tab = tab; }, diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts index 0c49a065..fd5cd674 100644 --- a/src/stores/wallet.ts +++ b/src/stores/wallet.ts @@ -140,8 +140,11 @@ export const useWalletStore = defineStore("wallet", { const keysetCounter = this.keysetCounters.find((c) => c.id === id); if (keysetCounter) { keysetCounter.counter += by; + console.log("### increaseKeysetCounter", keysetCounter); } else { - this.keysetCounters.push({ id, counter: by }); + const newCounter = { id, counter: by } as KeysetCounter; + this.keysetCounters.push(newCounter); + console.log("### new keyset counter", keysetCounter); } }, getKeyset(): string { @@ -300,7 +303,7 @@ export const useWalletStore = defineStore("wallet", { mintStore.removeProofs(proofsToSplit); return { keepProofs, sendProofs }; }, - splitToSend: async function (proofs: WalletProof[], amount: number, invalidate: boolean = false) { + splitToSend: async function (proofs: WalletProof[], amount: number, invalidate: boolean = false): Promise<{ keepProofs: Proof[], sendProofs: Proof[] }> { /* splits proofs so the user can keep firstProofs, send scndProofs. then sets scndProofs as reserved. @@ -309,8 +312,10 @@ export const useWalletStore = defineStore("wallet", { */ const mintStore = useMintsStore(); const proofsStore = useProofsStore() + const uIStore = useUiStore(); let proofsToSplit: WalletProof[] = []; - + const keysetId = this.getKeyset() + await uIStore.lockMutex(); try { const spendableProofs = this.spendableProofs(proofs, amount); proofsToSplit = this.coinSelect(spendableProofs, amount); @@ -319,14 +324,12 @@ export const useWalletStore = defineStore("wallet", { let keepProofs: Proof[] = []; let sendProofs: Proof[] = []; if (totalAmount != amount) { - const keysetId = this.getKeyset() const counter = this.keysetCounter(keysetId); - const { returnChange: _keepProofs, send: _sendProofs } = await this.wallet.send(amount, proofsToSplit, { counter }) + ({ returnChange: keepProofs, send: sendProofs } = await this.wallet.send(amount, proofsToSplit, { counter })); this.increaseKeysetCounter(keysetId, keepProofs.length + sendProofs.length); mintStore.removeProofs(proofsToSplit); - keepProofs = _keepProofs; - sendProofs = _sendProofs; + mintStore.addProofs(keepProofs); mintStore.addProofs(sendProofs); @@ -353,7 +356,10 @@ export const useWalletStore = defineStore("wallet", { proofsStore.setReserved(proofsToSplit, false); console.error(error); notifyApiError(error); + this.handleOutputsHaveAlreadyBeenSignedError(keysetId, error); throw error; + } finally { + uIStore.unlockMutex(); } }, /** @@ -385,6 +391,7 @@ export const useWalletStore = defineStore("wallet", { await mintStore.activateMintUrl(token.getMint(tokenJson), false, false, tokenJson.unit); const amount = proofs.reduce((s, t) => (s += t.amount), 0); + await uIStore.lockMutex(); try { // redeem const keysetId = this.getKeyset() @@ -399,7 +406,8 @@ export const useWalletStore = defineStore("wallet", { this.increaseKeysetCounter(keysetId, proofs.length); } catch (error: any) { console.error(error); - throw new Error("Error receiving tokens"); + this.handleOutputsHaveAlreadyBeenSignedError(keysetId, error); + throw new Error("Error receiving tokens: " + error); } // const proofs: Proof[] = tokenCts.token.map(t => t.proofs).flat(); @@ -428,6 +436,8 @@ export const useWalletStore = defineStore("wallet", { console.error(error); notifyApiError(error); throw error; + } finally { + uIStore.unlockMutex(); } // } }, @@ -441,9 +451,12 @@ export const useWalletStore = defineStore("wallet", { */ requestMint: async function (amount?: number) { const mintStore = useMintsStore(); + const uIStore = useUiStore(); + if (amount) { this.invoiceData.amount = amount; } + await uIStore.lockMutex(); try { // create MintQuotePayload(this.invoiceData.amount) payload const payload: MintQuotePayload = { @@ -465,13 +478,17 @@ export const useWalletStore = defineStore("wallet", { } catch (error: any) { console.error(error); notifyApiError(error, "Could not request mint"); + } finally { + uIStore.unlockMutex(); } }, mint: async function (amount: number, hash: string, verbose: boolean = true) { const proofsStore = useProofsStore(); const mintStore = useMintsStore(); const tokenStore = useTokensStore(); - + const uIStore = useUiStore(); + const keysetId = this.getKeyset() + await uIStore.lockMutex(); try { // first we check if the mint quote is paid const mintQuote = await mintStore.activeMint().api.checkMintQuote(hash); @@ -483,8 +500,6 @@ export const useWalletStore = defineStore("wallet", { } throw new Error("invoice not paid yet."); } - // const split = splitAmount(amount); - const keysetId = this.getKeyset() const counter = this.keysetCounter(keysetId) const preference = this.outputAmountSelect(amount); console.log("### preference", preference); @@ -516,17 +531,22 @@ export const useWalletStore = defineStore("wallet", { if (verbose) { notifyApiError(error); } + this.handleOutputsHaveAlreadyBeenSignedError(keysetId, error); throw error; + } finally { + uIStore.unlockMutex(); } }, // get a melt quote meltQuote: async function () { + const uIStore = useUiStore(); // throw an error if this.payInvoiceData.blocking is true if (this.payInvoiceData.blocking) { throw new Error("already processing an melt quote."); } this.payInvoiceData.blocking = true; this.payInvoiceData.meltQuote.error = ""; + await uIStore.lockMutex(); try { const mintStore = useMintsStore(); if (this.payInvoiceData.input.request == "") { @@ -537,7 +557,7 @@ export const useWalletStore = defineStore("wallet", { request: this.payInvoiceData.input.request, }; this.payInvoiceData.meltQuote.payload = payload; - const data = await mintStore.activeMint().api.meltQuote(payload); + const data = await mintStore.activeMint().api.createMeltQuote(payload); mintStore.assertMintError(data); this.payInvoiceData.meltQuote.response = data; console.log("#### meltQuote", payload, " response:", data); @@ -549,6 +569,8 @@ export const useWalletStore = defineStore("wallet", { console.error(error); notifyApiError(error); throw error; + } finally { + uIStore.unlockMutex(); } }, melt: async function () { @@ -585,17 +607,22 @@ export const useWalletStore = defineStore("wallet", { "amount with fees", amount ); - this.payInvoiceData.blocking = true; - let sendProofs: Proof[] = []; 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(); try { - const { keepProofs, sendProofs: _sendProofs } = await this.splitToSend( - mintStore.activeMint().unitProofs(mintStore.activeUnit), - amount - ); - sendProofs = _sendProofs; - // update UI + // proof management const serializedSendProofs = proofsStore.serializeProofs(sendProofs); proofsStore.setReserved(sendProofs, true); await this.addOutgoingPendingInvoiceToHistory(quote, serializedSendProofs || undefined) @@ -605,13 +632,12 @@ export const useWalletStore = defineStore("wallet", { // QUIRK: we increase the keyset counter by sendProofs and the maximum number of possible change outputs // this way, in case the user exits the app before payLnInvoice is completed, the returned change outputs won't cause a "outputs already signed" error - // if the payment succeeds, we decrease the counter by the difference of the maximum number of possible change outputs and the actual - // number of change outputs - // if the payment fails, we decrease the counter to the original value + // if the payment fails, we decrease the counter again this.increaseKeysetCounter(keysetId, sendProofs.length); if (quote.fee_reserve > 0) { countChangeOutputs = Math.ceil(Math.log2(quote.fee_reserve)) || 1; this.increaseKeysetCounter(keysetId, countChangeOutputs); + keysetCounterIncrease += countChangeOutputs; } // NOTE: if the user exits the app while we're in the API call, JS will emit an error that we would catch below! @@ -636,7 +662,6 @@ export const useWalletStore = defineStore("wallet", { "## Received change: " + proofsStore.sumProofs(changeProofs) ); mintStore.addProofs(changeProofs); - this.increaseKeysetCounter(keysetId, -countChangeOutputs + changeProofs.length) } if (serializedSendProofs != null) { tokenStore.addPaidToken({ @@ -655,14 +680,17 @@ export const useWalletStore = defineStore("wallet", { // do not handle the error if the user exits the app return; } + // roll back proof management and keyset counter proofsStore.setReserved(sendProofs, false); - this.increaseKeysetCounter(keysetId, -(countChangeOutputs + sendProofs.length)); + this.increaseKeysetCounter(keysetId, -keysetCounterIncrease); this.removeOutgoingInvoiceFromHistory(quote.quote) + console.error(error); + this.handleOutputsHaveAlreadyBeenSignedError(keysetId, error); notifyApiError(error, "Payment failed"); throw error; } finally { - this.payInvoiceData.blocking = false; + uIStore.unlockMutex(); } }, getProofState: async function (proofs: Proof[]) { @@ -813,7 +841,7 @@ export const useWalletStore = defineStore("wallet", { 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.getMeltQuote(quote); + const mintQuote = await mintStore.activeMint().api.checkMeltQuote(quote); console.log("### mintQuote", mintQuote); if (!mintQuote.paid) { console.log("### mintQuote not paid yet"); @@ -1138,6 +1166,14 @@ export const useWalletStore = defineStore("wallet", { this.mnemonic = generateNewMnemonic(); } return this.mnemonic - } + }, + handleOutputsHaveAlreadyBeenSignedError: function (keysetId: string, error: any) { + if (error.message.includes("outputs have already been signed")) { + this.increaseKeysetCounter(keysetId, 10); + notify("Please try again."); + return true; + } + return false; + }, }, });