Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/components/SendTokenDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@
style="max-width: 600px"
>
<ChooseMint />
<q-banner
v-if="paymentRequestMintWarning"
dense
rounded
class="bg-red-1 text-red-9 q-mt-sm"
>
{{ paymentRequestMintWarning }}
</q-banner>
</div>
</div>

Expand Down Expand Up @@ -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;
Expand All @@ -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(() => {
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/cs-CZ/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
},

Expand Down
2 changes: 2 additions & 0 deletions src/i18n/en-US/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/pt-BR/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
22 changes: 11 additions & 11 deletions src/stores/proofs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions src/stores/sendTokensStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
},
});
62 changes: 62 additions & 0 deletions test/vitest/__tests__/sendTokensStore.test.ts
Original file line number Diff line number Diff line change
@@ -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"],
});
});
});
Loading