From 62eb28e8c8dd5ee2590bc2260be835f81c258462 Mon Sep 17 00:00:00 2001 From: mikey Date: Wed, 15 Jul 2026 05:45:19 +0100 Subject: [PATCH 1/3] fix(wallet): ensure keyset freshness before building outputs Mints with automatic keyset rotation (cashubtc/nutshell#1058) can deactivate their active keyset mid-session. Building outputs on a stale keyset then fails at the mint. Resolve this proactively rather than reacting to the error. Centralize the fix in retryOnceOnSignedOutputs, the wrapper every signature-requesting operation already flows through: add an ensureKeysetsCurrent helper (keys-only fetchMintKeys refresh, then active-keyset resolution and wallet construction from the freshly updated store) and make the wrapper its sole caller. The wrapper now hands each operation a fresh wallet + keyset id, so every signing path (send, redeem, bolt11, bolt12, onchain, melt, sendToLock) builds outputs on the keyset that is active after the refresh. - fetchMintKeys (keys-only) is used instead of updateMintInfoAndKeys, which fetches /v1/info and can trigger a MOTD redirect mid-payment. - bolt12 and onchain previously never refreshed at all (they omitted the update flag); they now refresh via the wrapper. - Drop the redundant, un-awaited second handleOutputsHaveAlreadyBeenSignedError calls in the bolt12/onchain catch blocks; the wrapper already handles that path. --- src/stores/wallet.ts | 83 +++++++++++++++++++++++-------------- src/stores/walletBolt11.ts | 22 +++++----- src/stores/walletBolt12.ts | 11 +++-- src/stores/walletMelt.ts | 35 +++++++++------- src/stores/walletOnchain.ts | 11 +++-- 5 files changed, 92 insertions(+), 70 deletions(-) diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts index 93b510339..3629bbf6e 100644 --- a/src/stores/wallet.ts +++ b/src/stores/wallet.ts @@ -356,6 +356,23 @@ export const useWalletStore = defineStore("wallet", { } return this.createWalletInstance(storedMint, url, unit); }, + // Refreshes the mint's keysets and resolves the active keyset + // after the refresh, so any keyset rotation on the mint is + // picked up before outputs are built. + ensureKeysetsCurrent: async function ( + mintUrl: string, + unit: string + ): Promise<{ wallet: Wallet; keysetId: string }> { + const mints = useMintsStore(); + const storedMint = mints.mints.find((m) => m.url === mintUrl); + if (!storedMint) { + throw new Error("mint not found"); + } + await mints.fetchMintKeys(storedMint); + const wallet = this.mintWalletSync(mintUrl, unit); + const keysetId = this.getKeyset(mintUrl, unit); + return { wallet, keysetId }; + }, getOrCreateCounterSource(): CounterSource { if (!this.sharedCounterSource) { const initial = Object.fromEntries( @@ -424,12 +441,17 @@ export const useWalletStore = defineStore("wallet", { this.mnemonic = generateMnemonic(wordlist); }, retryOnceOnSignedOutputs: async function ( - keysetId: string, - operation: () => Promise, + mintUrl: string, + unit: string, + operation: (wallet: Wallet, keysetId: string) => Promise, notifyUser = true ): Promise { + const { wallet, keysetId } = await this.ensureKeysetsCurrent( + mintUrl, + unit + ); try { - return await operation(); + return await operation(wallet, keysetId); } catch (error: any) { const handled = await this.handleOutputsHaveAlreadyBeenSignedError( keysetId, @@ -441,7 +463,7 @@ export const useWalletStore = defineStore("wallet", { } // Counter source is shared — the bump from handleOutputsHaveAlreadyBeenSignedError // is already visible to the wallet, so just retry. - return await operation(); + return await operation(wallet, keysetId); } }, getKeyset( @@ -610,12 +632,17 @@ export const useWalletStore = defineStore("wallet", { amount, true ); - const keysetId = this.getKeyset(wallet.mint.mintUrl, wallet.unit); - const { keep: keepProofs, send: sendProofs } = await wallet.ops - .send(amount, toProofs(proofsToSend)) - .keyset(keysetId) - .asP2PK(p2pkOptions) - .run(); + const { keep: keepProofs, send: sendProofs } = + await this.retryOnceOnSignedOutputs( + wallet.mint.mintUrl, + wallet.unit, + async (lockWallet: Wallet, keysetId: string) => + lockWallet.ops + .send(amount, toProofs(proofsToSend)) + .keyset(keysetId) + .asP2PK(p2pkOptions) + .run() + ); const proofsStore = useProofsStore(); await proofsStore.removeProofs(proofsToSend); // note: we do not store sendProofs in the proofs store but @@ -636,7 +663,6 @@ export const useWalletStore = defineStore("wallet", { // or removes them if `invalidate` is true (caller takes ownership). const proofsStore = useProofsStore(); const uIStore = useUiStore(); - const keysetId = this.getKeyset(wallet.mint.mintUrl, wallet.unit); await uIStore.lockMutex(); try { const spendableProofs: Proof[] = toProofs( @@ -664,17 +690,12 @@ export const useWalletStore = defineStore("wallet", { sendProofs = exactMatch; } else { // we need to swap! - // get a new wallet with potentially updated keysets / info - const swapWallet = await this.mintWallet( - wallet.mint.mintUrl, - wallet.unit, - true // update keysets - ); // includeFees=true inflates send outputs so sendProofs sum to // amount + fees(sendProofs): required for melt and includeFees sends. const swapResult = await this.retryOnceOnSignedOutputs( - keysetId, - async () => + wallet.mint.mintUrl, + wallet.unit, + async (swapWallet: Wallet, keysetId: string) => swapWallet.ops .send(amount, spendableProofs) .asDeterministic() @@ -751,11 +772,6 @@ export const useWalletStore = defineStore("wallet", { mint: mintInToken, fee: fee, }; - const mintWallet = await this.mintWallet( - historyToken.mint, - historyToken.unit, - true - ); const mint = mintStore.mints.find((m) => m.url === historyToken.mint); if (!mint) { throw new Error("mint not found"); @@ -763,17 +779,20 @@ export const useWalletStore = defineStore("wallet", { await uIStore.lockMutex(); try { // redeem - const keysetId = this.getKeyset(historyToken.mint, historyToken.unit); const privkey = receiveStore.receiveData.p2pkPrivateKey; let proofs: Proof[]; try { - proofs = await this.retryOnceOnSignedOutputs(keysetId, async () => - mintWallet.ops - .receive(receiveStore.receiveData.tokensBase64) - .asDeterministic() - .privkey(privkey) - .proofsWeHave(mintStore.mintUnitProofs(mint, historyToken.unit)) - .run() + proofs = await this.retryOnceOnSignedOutputs( + historyToken.mint, + historyToken.unit, + + async (mintWallet: Wallet) => + mintWallet.ops + .receive(receiveStore.receiveData.tokensBase64) + .asDeterministic() + .privkey(privkey) + .proofsWeHave(mintStore.mintUnitProofs(mint, historyToken.unit)) + .run() ); await proofsStore.addProofs(proofs); } catch (error: any) { diff --git a/src/stores/walletBolt11.ts b/src/stores/walletBolt11.ts index 4980e41fb..320c024f2 100644 --- a/src/stores/walletBolt11.ts +++ b/src/stores/walletBolt11.ts @@ -97,8 +97,7 @@ export async function mintBolt11( const proofsStore = useProofsStore(); const mintStore = useMintsStore(); const uIStore = useUiStore(); - const keysetId = this.getKeyset(invoice.mint, invoice.unit); - const mintWallet = await this.mintWallet(invoice.mint, invoice.unit, true); + const mintWallet = this.mintWalletSync(invoice.mint, invoice.unit); const mint = mintStore.mints.find((m: any) => m.url === invoice.mint); if (!mint) { throw new Error("mint not found"); @@ -134,14 +133,17 @@ export async function mintBolt11( throw new Error("unknown state."); } // MintQuoteState must be PAID - const proofs = await this.retryOnceOnSignedOutputs(keysetId, async () => - mintWallet.ops - .mintBolt11(invoice.amount, invoice.quote) - .keyset(keysetId) - .asDeterministic() - .proofsWeHave(mintStore.mintUnitProofs(mint, invoice.unit)) - .privkey(invoice.privKey as string) - .run() + const proofs = await this.retryOnceOnSignedOutputs( + invoice.mint, + invoice.unit, + async (wallet: Wallet, keysetId: string) => + wallet.ops + .mintBolt11(invoice.amount, invoice.quote) + .keyset(keysetId) + .asDeterministic() + .proofsWeHave(mintStore.mintUnitProofs(mint, invoice.unit)) + .privkey(invoice.privKey as string) + .run() ); await proofsStore.addProofs(proofs); diff --git a/src/stores/walletBolt12.ts b/src/stores/walletBolt12.ts index 224a387cb..0d54bbdd0 100644 --- a/src/stores/walletBolt12.ts +++ b/src/stores/walletBolt12.ts @@ -113,8 +113,7 @@ export async function checkOfferAndMintBolt12( ); if (!invoice) throw new Error("offer not found"); - const mintWallet = await this.mintWallet(invoice.mint, invoice.unit); - const keysetId = this.getKeyset(invoice.mint, invoice.unit); + const mintWallet = this.mintWalletSync(invoice.mint, invoice.unit); const mint = mintStore.mints.find((m: any) => m.url === invoice.mint); if (!mint) throw new Error("mint not found"); @@ -133,9 +132,10 @@ export async function checkOfferAndMintBolt12( } const proofs = await this.retryOnceOnSignedOutputs( - keysetId, - async () => - mintWallet.ops + invoice.mint, + invoice.unit, + async (wallet: Wallet, keysetId: string) => + wallet.ops .mintBolt12(delta, updated) .keyset(keysetId) .asDeterministic() @@ -202,7 +202,6 @@ export async function checkOfferAndMintBolt12( console.error(error); } if (verbose) notifyApiError(error); - this.handleOutputsHaveAlreadyBeenSignedError(keysetId, error, verbose); throw error; } finally { uIStore.unlockMutex(); diff --git a/src/stores/walletMelt.ts b/src/stores/walletMelt.ts index e440d64a9..f649c0b69 100644 --- a/src/stores/walletMelt.ts +++ b/src/stores/walletMelt.ts @@ -148,7 +148,6 @@ export async function meltGeneric( const uIStore = useUiStore(); this.payInvoiceData.paying = true; const amount = quote.amount + quote.fee_reserve; - const keysetId = this.getKeyset(mintWallet.mint.mintUrl, mintWallet.unit); let sendProofs: ProofLike[] = []; try { @@ -187,21 +186,25 @@ export async function meltGeneric( let data; let paidMeltQuote: AppMeltQuote | null = null; try { - data = await this.retryOnceOnSignedOutputs(keysetId, async () => { - const preparedQuote = toMeltQuote(quote); - const preview = await mintWallet.prepareMelt( - method, - preparedQuote, - sendProofs, - { keysetId } - ); - await this.setMeltChangeOutputData(quote.quote, preview.outputData); - return await mintWallet.completeMelt( - preview, - undefined, - completeMeltOptions - ); - }); + data = await this.retryOnceOnSignedOutputs( + mintWallet.mint.mintUrl, + mintWallet.unit, + async (wallet: Wallet, keysetId: string) => { + const preparedQuote = toMeltQuote(quote); + const preview = await wallet.prepareMelt( + method, + preparedQuote, + sendProofs, + { keysetId } + ); + await this.setMeltChangeOutputData(quote.quote, preview.outputData); + return await wallet.completeMelt( + preview, + undefined, + completeMeltOptions + ); + } + ); paidMeltQuote = normalizeMeltQuote(data.quote); await this.updateOutgoingInvoiceInHistory(paidMeltQuote); if (data.outputData?.length) { diff --git a/src/stores/walletOnchain.ts b/src/stores/walletOnchain.ts index 4058fb85f..66ae4f39c 100644 --- a/src/stores/walletOnchain.ts +++ b/src/stores/walletOnchain.ts @@ -115,8 +115,7 @@ export async function checkOnchainAndMint( ); if (!invoice) throw new Error("on-chain quote not found"); - const mintWallet = await this.mintWallet(invoice.mint, invoice.unit); - const keysetId = this.getKeyset(invoice.mint, invoice.unit); + const mintWallet = this.mintWalletSync(invoice.mint, invoice.unit); const mint = mintStore.mints.find((m: any) => m.url === invoice.mint); if (!mint) throw new Error("mint not found"); if (!invoice.network) { @@ -160,9 +159,10 @@ export async function checkOnchainAndMint( } const proofs = await this.retryOnceOnSignedOutputs( - keysetId, - async () => - mintWallet.ops + invoice.mint, + invoice.unit, + async (wallet: Wallet, keysetId: string) => + wallet.ops .mintOnchain(delta, updated) .keyset(keysetId) .asDeterministic() @@ -231,7 +231,6 @@ export async function checkOnchainAndMint( notifyApiError(error); } } - this.handleOutputsHaveAlreadyBeenSignedError(keysetId, error, verbose); throw error; } finally { uIStore.unlockMutex(); From 1570b4b482de827ea80bf624df0b396e70bb6987 Mon Sep 17 00:00:00 2001 From: mikey Date: Wed, 15 Jul 2026 05:45:19 +0100 Subject: [PATCH 2/3] test(wallet): cover keyset freshness funnel and per-path routing - ensureKeysetsCurrent: refreshes keys before resolving the keyset, returns the newly-active id and a wallet built after the refresh, never calls updateMintInfoAndKeys, and throws on an unknown mint. - retryOnceOnSignedOutputs: calls ensureKeysetsCurrent once and hands its wallet/keyset id to the operation; retries once on "outputs have already been signed" with the same keyset (no second refresh); rethrows unrelated errors; propagates a second signed-outputs failure. - Post-rotation routing: send, redeem, bolt11, bolt12, onchain, melt, and sendToLock each build on the keyset that becomes active during the refresh. --- src/stores/__tests__/wallet.test.js | 449 +++++++++++++++++- .../__tests__/p2pkPaymentRequest.test.ts | 8 + 2 files changed, 453 insertions(+), 4 deletions(-) diff --git a/src/stores/__tests__/wallet.test.js b/src/stores/__tests__/wallet.test.js index 1c6f498cd..259c65792 100644 --- a/src/stores/__tests__/wallet.test.js +++ b/src/stores/__tests__/wallet.test.js @@ -83,6 +83,7 @@ const h = vi.hoisted(() => { ), mintUnitProofs: vi.fn(() => []), updateMintInfoAndKeys: vi.fn(async () => {}), + fetchMintKeys: vi.fn(async () => {}), }; const priceStore = { bitcoinPrice: 100_000, @@ -665,7 +666,7 @@ describe("wallet store", () => { }); vi.spyOn(wallet, "getKeyset").mockReturnValue("00bb"); - vi.spyOn(wallet, "mintWallet").mockResolvedValue(swapWallet); + vi.spyOn(wallet, "mintWalletSync").mockReturnValue(swapWallet); await wallet.send(proofs, swapWallet, 100, false, true); @@ -677,6 +678,191 @@ describe("wallet store", () => { expect(includeFeesSpy).toHaveBeenCalledWith(true); }); + describe("ensureKeysetsCurrent (the freshness guarantee)", () => { + // Make fetchMintKeys rotate the store's active keyset, exactly as a mint + // that rotated mid-session would look after a refresh. + function rotateOnFetch() { + h.mintsStore.fetchMintKeys.mockImplementationOnce(async (mint) => { + mint.keysets = [ + { id: "00aa", unit: "sat", active: false }, + { id: "00cc", unit: "sat", active: true }, + ]; + }); + } + + it("(a) refreshes the mint's keys before resolving the active keyset", async () => { + const wallet = useWalletStore(); + rotateOnFetch(); + const getKeysetSpy = vi.spyOn(wallet, "getKeyset"); + + await wallet.ensureKeysetsCurrent("https://mint-a.example", "sat"); + + expect(h.mintsStore.fetchMintKeys).toHaveBeenCalledTimes(1); + expect( + h.mintsStore.fetchMintKeys.mock.invocationCallOrder[0] + ).toBeLessThan(getKeysetSpy.mock.invocationCallOrder[0]); + }); + + it("(b) returns the newly-active keyset id and a wallet built after the refresh", async () => { + const wallet = useWalletStore(); + rotateOnFetch(); + const mintWalletSyncSpy = vi.spyOn(wallet, "mintWalletSync"); + + const { wallet: signingWallet, keysetId } = + await wallet.ensureKeysetsCurrent("https://mint-a.example", "sat"); + + // resolves the keyset that became active during the refresh + expect(keysetId).toBe("00cc"); + // the signing wallet is the one built AFTER the refresh + expect(signingWallet).toBe(mintWalletSyncSpy.mock.results[0].value); + expect( + h.mintsStore.fetchMintKeys.mock.invocationCallOrder[0] + ).toBeLessThan(mintWalletSyncSpy.mock.invocationCallOrder[0]); + }); + + it("(c) never triggers the mint-info / MOTD refresh path", async () => { + const wallet = useWalletStore(); + rotateOnFetch(); + + await wallet.ensureKeysetsCurrent("https://mint-a.example", "sat"); + + expect(h.mintsStore.updateMintInfoAndKeys).not.toHaveBeenCalled(); + }); + + it("(d) throws when the mint is unknown", async () => { + const wallet = useWalletStore(); + + await expect( + wallet.ensureKeysetsCurrent("https://nope.example", "sat") + ).rejects.toThrow("mint not found"); + expect(h.mintsStore.fetchMintKeys).not.toHaveBeenCalled(); + }); + }); + + describe("retryOnceOnSignedOutputs (the freshness funnel)", () => { + // Make fetchMintKeys rotate the store's active keyset, exactly as a mint + // that rotated mid-session would look after a refresh. + function rotateOnFetch() { + h.mintsStore.fetchMintKeys.mockImplementationOnce(async (mint) => { + mint.keysets = [ + { id: "00aa", unit: "sat", active: false }, + { id: "00cc", unit: "sat", active: true }, + ]; + }); + } + + it("(e) calls ensureKeysetsCurrent once and hands its wallet/keysetId to the operation", async () => { + const wallet = useWalletStore(); + const sentinelWallet = { sentinel: true }; + const ensureSpy = vi + .spyOn(wallet, "ensureKeysetsCurrent") + .mockResolvedValue({ wallet: sentinelWallet, keysetId: "00cc" }); + const operation = vi.fn(async () => "ok"); + + const result = await wallet.retryOnceOnSignedOutputs( + "https://mint-a.example", + "sat", + operation + ); + + expect(result).toBe("ok"); + expect(ensureSpy).toHaveBeenCalledTimes(1); + expect(ensureSpy).toHaveBeenCalledWith("https://mint-a.example", "sat"); + expect(operation).toHaveBeenCalledWith(sentinelWallet, "00cc"); + }); + + it("(f) retries once with the SAME keyset after a signed-outputs error, without a second refresh", async () => { + const wallet = useWalletStore(); + wallet.keysetCounters = [{ id: "00aa", counter: 1 }]; + const ensureSpy = vi.spyOn(wallet, "ensureKeysetsCurrent"); + const operation = vi + .fn() + .mockRejectedValueOnce(new Error("outputs have already been signed")) + .mockResolvedValueOnce("second-try"); + + const result = await wallet.retryOnceOnSignedOutputs( + "https://mint-a.example", + "sat", + operation + ); + + expect(result).toBe("second-try"); + // freshness runs exactly once, even though the operation ran twice + expect(ensureSpy).toHaveBeenCalledTimes(1); + expect(operation).toHaveBeenCalledTimes(2); + // same keyset id on both attempts; only the counter was bumped + expect(operation.mock.calls[0][1]).toBe("00aa"); + expect(operation.mock.calls[1][1]).toBe("00aa"); + expect(wallet.keysetCounter("00aa")).toBe(11); + }); + + it("(f) rethrows an unrelated error without retrying", async () => { + const wallet = useWalletStore(); + const operation = vi.fn().mockRejectedValue(new Error("mint offline")); + + await expect( + wallet.retryOnceOnSignedOutputs( + "https://mint-a.example", + "sat", + operation + ) + ).rejects.toThrow("mint offline"); + expect(operation).toHaveBeenCalledTimes(1); + }); + + it("(f) propagates a second signed-outputs failure after the single retry", async () => { + const wallet = useWalletStore(); + const operation = vi + .fn() + .mockRejectedValue(new Error("outputs have already been signed")); + + await expect( + wallet.retryOnceOnSignedOutputs( + "https://mint-a.example", + "sat", + operation + ) + ).rejects.toThrow("outputs have already been signed"); + expect(operation).toHaveBeenCalledTimes(2); + }); + + it("routes send()'s swap through the wrapper onto the rotated keyset", async () => { + const wallet = useWalletStore(); + const proofs = [ + { id: "00aa", amount: 128, reserved: false, secret: "s1" }, + ]; + const builder = { + asDeterministic: vi.fn(), + keyset: vi.fn(), + proofsWeHave: vi.fn(), + includeFees: vi.fn(), + run: vi.fn(async () => ({ keep: [], send: [] })), + }; + builder.asDeterministic.mockReturnValue(builder); + builder.keyset.mockReturnValue(builder); + builder.proofsWeHave.mockReturnValue(builder); + builder.includeFees.mockReturnValue(builder); + + const swapWallet = { + mint: { mintUrl: "https://mint-a.example" }, + unit: "sat", + // no exact match -> force the swap branch + selectProofsToSend: vi.fn((p, _a, _f, exact) => + exact ? { send: [], keep: p } : { send: p, keep: [] } + ), + ops: { send: vi.fn(() => builder) }, + }; + + rotateOnFetch(); + vi.spyOn(wallet, "mintWalletSync").mockReturnValue(swapWallet); + + await wallet.send(proofs, swapWallet, 100, false, true); + + expect(h.mintsStore.fetchMintKeys).toHaveBeenCalledTimes(1); + expect(builder.keyset).toHaveBeenCalledWith("00cc"); + }); + }); + it("accounts for signed-output errors", async () => { const wallet = useWalletStore(); wallet.keysetCounters = [{ id: "00aa", counter: 1 }]; @@ -968,7 +1154,7 @@ describe("wallet store", () => { checkMintQuoteBolt12: vi.fn(async () => quoteStates.shift()), ops: { mintBolt12 }, }; - vi.spyOn(wallet, "mintWallet").mockResolvedValue(mintWallet); + vi.spyOn(wallet, "mintWalletSync").mockReturnValue(mintWallet); await Promise.all([ wallet.checkOfferAndMintBolt12("offer-q", false, false), @@ -1030,7 +1216,7 @@ describe("wallet store", () => { checkMintQuoteOnchain: vi.fn(async () => quoteStates.shift()), ops: { mintOnchain }, }; - vi.spyOn(wallet, "mintWallet").mockResolvedValue(mintWallet); + vi.spyOn(wallet, "mintWalletSync").mockReturnValue(mintWallet); await wallet.checkOnchainAndMint(parentQuote, false, false); @@ -1381,6 +1567,7 @@ describe("wallet store", () => { prepareMelt, completeMelt, }; + vi.spyOn(wallet, "mintWalletSync").mockReturnValue(mintWallet); vi.spyOn(wallet, "send").mockResolvedValue({ keepProofs: [], sendProofs: proofs, @@ -1452,6 +1639,8 @@ describe("wallet store", () => { prepareMelt, completeMelt, }; + // meltGeneric now gets its signing wallet from retryOnceOnSignedOutputs + vi.spyOn(wallet, "mintWalletSync").mockReturnValue(mintWallet); vi.spyOn(wallet, "send").mockResolvedValue({ keepProofs: [], sendProofs: proofs, @@ -1566,6 +1755,7 @@ describe("wallet store", () => { prepareMelt, completeMelt, }; + vi.spyOn(wallet, "mintWalletSync").mockReturnValue(mintWallet); vi.spyOn(wallet, "send").mockResolvedValue({ keepProofs: [], sendProofs: proofs, @@ -1754,7 +1944,7 @@ describe("wallet store", () => { return proofs.reduce((sum, p) => sum + p.amount, 0); }); - vi.spyOn(wallet, "mintWallet").mockResolvedValue({ + vi.spyOn(wallet, "mintWalletSync").mockReturnValue({ ops: { receive: vi.fn(() => ({ asDeterministic: vi.fn(() => ({ @@ -1778,4 +1968,255 @@ describe("wallet store", () => { expect.objectContaining({ amount: 500, fee: 0, unit: "sat" }) ); }); + + describe("signing paths route through the wrapper (post-rotation)", () => { + // Every signing path must build outputs on the keyset that is active + // after the refresh, not the one cached before it. + function rotateOnFetch() { + h.mintsStore.fetchMintKeys.mockImplementationOnce(async (mint) => { + mint.keysets = [ + { id: "00aa", unit: "sat", active: false }, + { id: "00cc", unit: "sat", active: true }, + ]; + }); + } + + function mintBuilder() { + const builder = { + keyset: vi.fn(), + asDeterministic: vi.fn(), + proofsWeHave: vi.fn(), + privkey: vi.fn(), + run: vi.fn(async () => [{ id: "00cc", amount: 100, secret: "s1" }]), + }; + builder.keyset.mockReturnValue(builder); + builder.asDeterministic.mockReturnValue(builder); + builder.proofsWeHave.mockReturnValue(builder); + builder.privkey.mockReturnValue(builder); + return builder; + } + + it("redeem receives on a wallet built after the refresh", async () => { + const wallet = useWalletStore(); + h.receiveTokensStore.receiveData.tokensBase64 = "cashuB500"; + h.tokenModule.decodeFull.mockResolvedValue({}); + h.tokenModule.getProofs.mockReturnValue([ + { id: "00aa", amount: 500, secret: "s-in" }, + ]); + h.tokenModule.getMint.mockReturnValue("https://mint-a.example"); + h.tokenModule.getUnit.mockReturnValue("sat"); + + rotateOnFetch(); + const mintWalletSyncSpy = vi + .spyOn(wallet, "mintWalletSync") + .mockReturnValue({ + ops: { + receive: vi.fn(() => ({ + asDeterministic: vi.fn(() => ({ + privkey: vi.fn(() => ({ + proofsWeHave: vi.fn(() => ({ + run: vi.fn(async () => [ + { id: "00cc", amount: 500, secret: "s-out" }, + ]), + })), + })), + })), + })), + }, + }); + + await wallet.redeem(); + + // the receive wallet is constructed only after the keys refresh, so + // cts implicit keyset selection sees the rotated keyset + expect(h.mintsStore.fetchMintKeys).toHaveBeenCalledTimes(1); + expect( + h.mintsStore.fetchMintKeys.mock.invocationCallOrder[0] + ).toBeLessThan(mintWalletSyncSpy.mock.invocationCallOrder[0]); + }); + + it("mintBolt11 mints on the rotated keyset", async () => { + const wallet = useWalletStore(); + const invoice = { + quote: "q-1", + amount: 100, + mint: "https://mint-a.example", + unit: "sat", + privKey: "privkey", + }; + wallet.invoiceHistory = [invoice]; + const builder = mintBuilder(); + + rotateOnFetch(); + vi.spyOn(wallet, "mintWalletSync").mockReturnValue({ + checkMintQuoteBolt11: vi.fn(async () => ({ + state: "PAID", + quote: "q-1", + amount: 100, + })), + ops: { mintBolt11: vi.fn(() => builder) }, + }); + + await wallet.mintBolt11(invoice, false); + + expect(h.mintsStore.fetchMintKeys).toHaveBeenCalledTimes(1); + expect(builder.keyset).toHaveBeenCalledWith("00cc"); + }); + + it("checkOfferAndMintBolt12 mints on the rotated keyset", async () => { + const wallet = useWalletStore(); + wallet.invoiceHistory = [ + { + quote: "offer-q", + amount: 0, + request: "lno1offer", + memo: "memo", + date: "old", + status: "pending", + mint: "https://mint-a.example", + unit: "sat", + privKey: "privkey", + type: PaymentMethod.Bolt12, + }, + ]; + const builder = mintBuilder(); + const quoteStates = [ + { quote: "offer-q", amount_paid: 100, amount_issued: 0 }, + { quote: "offer-q", amount_paid: 100, amount_issued: 100 }, + ]; + + rotateOnFetch(); + vi.spyOn(wallet, "mintWalletSync").mockReturnValue({ + checkMintQuoteBolt12: vi.fn(async () => quoteStates.shift()), + ops: { mintBolt12: vi.fn(() => builder) }, + }); + + await wallet.checkOfferAndMintBolt12("offer-q", false, false); + + expect(h.mintsStore.fetchMintKeys).toHaveBeenCalledTimes(1); + expect(builder.keyset).toHaveBeenCalledWith("00cc"); + }); + + it("checkOnchainAndMint mints on the rotated keyset", async () => { + const wallet = useWalletStore(); + wallet.invoiceHistory = [ + { + quote: "onchain-q", + amount: 100, + request: "bc1qexample", + memo: "memo", + date: "old", + status: "pending", + mint: "https://mint-a.example", + unit: "sat", + privKey: "privkey", + network: "mainnet", + type: PaymentMethod.Onchain, + }, + ]; + const builder = mintBuilder(); + const quoteStates = [ + { quote: "onchain-q", amount_paid: 100, amount_issued: 0 }, + { quote: "onchain-q", amount_paid: 100, amount_issued: 100 }, + ]; + + rotateOnFetch(); + vi.spyOn(wallet, "mintWalletSync").mockReturnValue({ + checkMintQuoteOnchain: vi.fn(async () => quoteStates.shift()), + ops: { mintOnchain: vi.fn(() => builder) }, + }); + + await wallet.checkOnchainAndMint("onchain-q", false, false); + + expect(h.mintsStore.fetchMintKeys).toHaveBeenCalledTimes(1); + expect(builder.keyset).toHaveBeenCalledWith("00cc"); + }); + + it("melt prepares change outputs on the rotated keyset", async () => { + const wallet = useWalletStore(); + wallet.invoiceHistory = []; + wallet.payInvoiceData.input.request = "lnbc123"; + const proofs = [{ id: "00aa", amount: 105, secret: "s1" }]; + const quote = { + quote: "bolt11-melt-q", + amount: 100, + fee_reserve: 5, + state: "PENDING", + expiry: 0, + request: "lnbc123", + payment_preimage: null, + }; + const prepareMelt = vi.fn(async () => ({ + method: "bolt11", + inputs: proofs, + outputData: [], + keysetId: "00cc", + quote, + })); + const mintWallet = { + mint: { mintUrl: "https://mint-a.example" }, + unit: "sat", + prepareMelt, + completeMelt: vi.fn(async () => ({ + quote: { ...quote, state: "PAID" }, + change: [], + outputData: [], + })), + }; + + rotateOnFetch(); + vi.spyOn(wallet, "mintWalletSync").mockReturnValue(mintWallet); + vi.spyOn(wallet, "send").mockResolvedValue({ + keepProofs: [], + sendProofs: proofs, + }); + + await wallet.meltGeneric( + proofs, + quote, + mintWallet, + true, + vi.fn(), + PaymentMethod.Bolt11 + ); + + expect(h.mintsStore.fetchMintKeys).toHaveBeenCalledTimes(1); + expect(prepareMelt).toHaveBeenCalledWith( + PaymentMethod.Bolt11, + expect.objectContaining({ quote: "bolt11-melt-q" }), + proofs, + { keysetId: "00cc" } + ); + }); + + it("sendToLock locks to the rotated keyset", async () => { + const wallet = useWalletStore(); + const proofs = [ + { id: "00aa", amount: 10, reserved: false, secret: "s1" }, + ]; + const builder = { + keyset: vi.fn(), + asP2PK: vi.fn(), + run: vi.fn(async () => ({ keep: [], send: [] })), + }; + builder.keyset.mockReturnValue(builder); + builder.asP2PK.mockReturnValue(builder); + + const lockWallet = { + mint: { mintUrl: "https://mint-a.example" }, + unit: "sat", + selectProofsToSend: vi.fn(() => ({ send: proofs, keep: [] })), + ops: { send: vi.fn(() => builder) }, + }; + + rotateOnFetch(); + vi.spyOn(wallet, "mintWalletSync").mockReturnValue(lockWallet); + + await wallet.sendToLock(proofs, lockWallet, 10, "02pubkey"); + + expect(h.mintsStore.fetchMintKeys).toHaveBeenCalledTimes(1); + expect(builder.keyset).toHaveBeenCalledWith("00cc"); + expect(builder.asP2PK).toHaveBeenCalledWith({ pubkey: "02pubkey" }); + }); + }); }); diff --git a/test/vitest/__tests__/p2pkPaymentRequest.test.ts b/test/vitest/__tests__/p2pkPaymentRequest.test.ts index 4891f9126..77d4a9ac8 100644 --- a/test/vitest/__tests__/p2pkPaymentRequest.test.ts +++ b/test/vitest/__tests__/p2pkPaymentRequest.test.ts @@ -10,6 +10,7 @@ vi.mock("vue-i18n", async (importOriginal) => ({ import { useWalletStore } from "src/stores/wallet"; import { useProofsStore } from "src/stores/proofs"; +import { useMintsStore } from "src/stores/mints"; const PUBKEY = "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2"; @@ -95,6 +96,13 @@ describe("walletStore.sendToLock pubkey normalization", () => { wallet.getKeyset = vi.fn().mockReturnValue("ks1"); proofs.removeProofs = vi.fn().mockResolvedValue(undefined); proofs.addProofs = vi.fn().mockResolvedValue(undefined); + // sendToLock routes through retryOnceOnSignedOutputs, which refreshes the + // mint's keys and rebuilds the signing wallet from the store. Stub those so + // the wrapper resolves to this mockWallet without touching the network. + const mints = useMintsStore(); + mints.mints = [{ url: "https://mint.test", keys: [], keysets: [] }] as any; + mints.fetchMintKeys = vi.fn().mockResolvedValue(undefined); + wallet.mintWalletSync = vi.fn().mockReturnValue(mockWallet); return { wallet, mockWallet, asP2PK, sendProofs, walletProofs }; }; From 39242f5489d71ffa74e9dcb3ce6a2b071d0db0de Mon Sep 17 00:00:00 2001 From: mikey Date: Thu, 16 Jul 2026 10:30:32 +0100 Subject: [PATCH 3/3] test(wallet): make retryOnceOnSignedOutputs test labels unique Three cases in the funnel describe block shared the "(f)" prefix, which makes failures hard to tell apart in Vitest output. Relabel them (f)/(g)/(h) so each case is uniquely identifiable. --- src/stores/__tests__/wallet.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stores/__tests__/wallet.test.js b/src/stores/__tests__/wallet.test.js index 259c65792..1d104786c 100644 --- a/src/stores/__tests__/wallet.test.js +++ b/src/stores/__tests__/wallet.test.js @@ -796,7 +796,7 @@ describe("wallet store", () => { expect(wallet.keysetCounter("00aa")).toBe(11); }); - it("(f) rethrows an unrelated error without retrying", async () => { + it("(g) rethrows an unrelated error without retrying", async () => { const wallet = useWalletStore(); const operation = vi.fn().mockRejectedValue(new Error("mint offline")); @@ -810,7 +810,7 @@ describe("wallet store", () => { expect(operation).toHaveBeenCalledTimes(1); }); - it("(f) propagates a second signed-outputs failure after the single retry", async () => { + it("(h) propagates a second signed-outputs failure after the single retry", async () => { const wallet = useWalletStore(); const operation = vi .fn()