diff --git a/src/components/SendTokenDialog.vue b/src/components/SendTokenDialog.vue index c90f9fa52..31afc22db 100644 --- a/src/components/SendTokenDialog.vue +++ b/src/components/SendTokenDialog.vue @@ -77,6 +77,14 @@ style="max-width: 600px" > + + {{ paymentRequestMintWarning }} + @@ -395,6 +403,38 @@ export default defineComponent({ this.isValidPubkey(this.sendData.p2pkPubkey) ); }, + // NUT-18's `m` field is mandatory today (cashu-ts `PaymentRequest.mints` + // is a plain `string[]`). This constraint helper wraps that so when the + // spec change at https://github.com/cashubtc/nuts/pull/381 lands we can + // also return `{ kind: "preferred", allowed }` without touching callers: + // the button disable + warning logic just keys off `kind`. + paymentRequestMintConstraint(): { + kind: "mandatory"; + allowed: string[]; + } | null { + const allowed = + this.sendData.paymentRequest?.mints?.filter((m): m is string => !!m) ?? + []; + if (allowed.length === 0) return null; + return { kind: "mandatory", allowed }; + }, + selectedMintViolatesRequest(): boolean { + const c = this.paymentRequestMintConstraint; + if (!c) return false; + if (!this.activeMintUrl) return true; + return !c.allowed.includes(this.activeMintUrl); + }, + paymentRequestMintWarning(): string { + // Only block (and warn) for mandatory constraints. Preferred-mint + // copy can be added later as a separate i18n key without reshaping + // this method. + const c = this.paymentRequestMintConstraint; + if (!c || c.kind !== "mandatory") return ""; + if (!this.selectedMintViolatesRequest) return ""; + return this.$t( + "SendTokenDialog.errors.mint_not_allowed_by_request" + ) as string; + }, paymentRequestButtonDisabled(): boolean { if (!this.sendData.paymentRequest) { return true; @@ -409,10 +449,24 @@ export default defineComponent({ if (this.globalMutexLock) { return true; } + const c = this.paymentRequestMintConstraint; + if (c && c.kind === "mandatory" && this.selectedMintViolatesRequest) { + return true; + } return false; }, }, watch: { + activeMintUrl: function (newUrl, oldUrl) { + // When the user switches mint inside the Pay-PaymentRequest sheet, any + // proofs we already prepared belong to the previous mint (and embed + // that mint URL in the serialized token). Drop them so the next pay + // attempt rebuilds from the newly-selected mint. + if (!this.showSendTokens) return; + if (!this.sendData.paymentRequest) return; + if (!newUrl || !oldUrl || newUrl === oldUrl) return; + useSendTokensStore().invalidatePreparedPaymentRequestToken(); + }, showSendTokens: function (val) { if (val) { this.$nextTick(() => { diff --git a/src/i18n/cs-CZ/index.ts b/src/i18n/cs-CZ/index.ts index ef6aa2d26..3f3f64bf0 100644 --- a/src/i18n/cs-CZ/index.ts +++ b/src/i18n/cs-CZ/index.ts @@ -1050,6 +1050,8 @@ export default { errors: { amount_required: "Nejprve zadejte částku.", serialization_failed: "Nepodařilo se připravit ecash token.", + mint_not_allowed_by_request: + "Vybraný mint není tímto platebním požadavkem přijímán.", }, }, diff --git a/src/i18n/en-US/index.ts b/src/i18n/en-US/index.ts index d81129da5..e7cecaba9 100644 --- a/src/i18n/en-US/index.ts +++ b/src/i18n/en-US/index.ts @@ -1040,6 +1040,8 @@ export default { errors: { amount_required: "Enter an amount first.", serialization_failed: "Could not prepare ecash token.", + mint_not_allowed_by_request: + "The selected mint is not accepted by this payment request.", }, }, SendPaymentRequest: { diff --git a/src/i18n/pt-BR/index.ts b/src/i18n/pt-BR/index.ts index 265bfce50..2656a369b 100644 --- a/src/i18n/pt-BR/index.ts +++ b/src/i18n/pt-BR/index.ts @@ -1047,6 +1047,8 @@ export default { errors: { amount_required: "Insira um valor primeiro.", serialization_failed: "Não foi possível preparar o token ecash.", + mint_not_allowed_by_request: + "O mint selecionado não é aceito por esta solicitação de pagamento.", }, }, SendPaymentRequest: { diff --git a/src/stores/proofs.ts b/src/stores/proofs.ts index ff95e963f..dcbfa8c05 100644 --- a/src/stores/proofs.ts +++ b/src/stores/proofs.ts @@ -39,8 +39,13 @@ export const useProofsStore = defineStore("proofs", { }, }); - // Function to update activeProofs - const updateActiveProofs = async () => { + // Filter the live in-memory proofs ref instead of re-querying cashuDb. + // The liveQuery above keeps `proofs.value` in sync with the table, so + // doing this synchronously lets activeProofs update in the same tick + // as activeMintUrl/activeUnit — otherwise downstream getters like + // activeBalance briefly read the old mint's proofs and the UI flashes + // an "insufficient balance" warning before the async fetch resolves. + const updateActiveProofs = () => { const mintStore = useMintsStore(); const currentMint = mintStore.mints.find( (m) => m.url === mintStore.activeMintUrl @@ -58,15 +63,10 @@ export const useProofsStore = defineStore("proofs", { return; } - const keysetIds = unitKeysets.map((k) => k.id); - const activeProofs = await cashuDb.proofs - .where("id") - .anyOf(keysetIds) - .toArray() - .then((proofs) => { - return coerceWalletProofs(proofs).filter((p) => !p.reserved); - }); - mintStore.activeProofs = activeProofs; + const keysetIds = new Set(unitKeysets.map((k) => k.id)); + mintStore.activeProofs = proofs.value.filter( + (p) => keysetIds.has(p.id) && !p.reserved + ); }; return { diff --git a/src/stores/sendTokensStore.ts b/src/stores/sendTokensStore.ts index 9a0249e45..afd27d416 100644 --- a/src/stores/sendTokensStore.ts +++ b/src/stores/sendTokensStore.ts @@ -37,5 +37,18 @@ export const useSendTokensStore = defineStore("sendTokensStore", { this.sendData.paymentRequest = undefined; this.sendData.historyToken = undefined; }, + // Drops any token/proofs that were prepared for the current PaymentRequest + // so the next pay attempt rebuilds them from the currently-active mint. + // No-op when no PR is active (regular send flows keep their tokensBase64 + // until the dialog closes). + invalidatePreparedPaymentRequestToken(): boolean { + if (!this.sendData.paymentRequest) return false; + if (!this.sendData.tokensBase64) return false; + this.sendData.tokens = ""; + this.sendData.tokensBase64 = ""; + this.sendData.historyToken = undefined; + this.sendData.historyAmount = null; + return true; + }, }, }); diff --git a/test/vitest/__tests__/sendTokensStore.test.ts b/test/vitest/__tests__/sendTokensStore.test.ts new file mode 100644 index 000000000..0c6867ef8 --- /dev/null +++ b/test/vitest/__tests__/sendTokensStore.test.ts @@ -0,0 +1,62 @@ +import { test, describe, expect, beforeEach } from "vitest"; +import { setActivePinia, createPinia } from "pinia"; +import { useSendTokensStore } from "src/stores/sendTokensStore"; + +beforeEach(() => { + setActivePinia(createPinia()); +}); + +describe("sendTokensStore.invalidatePreparedPaymentRequestToken", () => { + test("no-op when there is no active payment request", () => { + const store = useSendTokensStore(); + store.sendData.tokensBase64 = "cashuB-xxxxxx"; + store.sendData.tokens = "stale-proofs"; + store.sendData.historyToken = { id: "h1" } as any; + store.sendData.historyAmount = -42; + + const changed = store.invalidatePreparedPaymentRequestToken(); + + expect(changed).toBe(false); + // Regular send flows must keep their cached token until the dialog closes. + expect(store.sendData.tokensBase64).toBe("cashuB-xxxxxx"); + expect(store.sendData.tokens).toBe("stale-proofs"); + expect(store.sendData.historyToken).toEqual({ id: "h1" }); + expect(store.sendData.historyAmount).toBe(-42); + }); + + test("no-op when there is no cached token to invalidate", () => { + const store = useSendTokensStore(); + store.sendData.paymentRequest = { id: "pr1" } as any; + + const changed = store.invalidatePreparedPaymentRequestToken(); + + expect(changed).toBe(false); + expect(store.sendData.paymentRequest).toEqual({ id: "pr1" }); + }); + + test("clears cached PR token state so the next pay attempt rebuilds", () => { + const store = useSendTokensStore(); + store.sendData.paymentRequest = { + id: "pr1", + mints: ["https://trusted.example"], + } as any; + store.sendData.tokensBase64 = "cashuB-from-wrong-mint"; + store.sendData.tokens = "stale-proofs"; + store.sendData.historyToken = { id: "h1" } as any; + store.sendData.historyAmount = -42; + + const changed = store.invalidatePreparedPaymentRequestToken(); + + expect(changed).toBe(true); + expect(store.sendData.tokensBase64).toBe(""); + expect(store.sendData.tokens).toBe(""); + expect(store.sendData.historyToken).toBeUndefined(); + expect(store.sendData.historyAmount).toBeNull(); + // The payment request itself must stay intact – only the prepared + // proofs/token are invalidated. + expect(store.sendData.paymentRequest).toEqual({ + id: "pr1", + mints: ["https://trusted.example"], + }); + }); +});