diff --git a/components/cart-invoice-card.tsx b/components/cart-invoice-card.tsx index c4332d2c4..03925b393 100644 --- a/components/cart-invoice-card.tsx +++ b/components/cart-invoice-card.tsx @@ -61,6 +61,7 @@ import { generateKeys, getLocalStorageData, publishProofEvent, + setLocalCashuTokens, } from "@/utils/nostr/nostr-helper-functions"; import { LightningAddress } from "@getalby/lightning-tools"; import QRCode from "qrcode"; @@ -2410,7 +2411,7 @@ export default function CartInvoiceCard({ } else { proofArray = [...remainingProofs]; } - localStorage.setItem("tokens", JSON.stringify(proofArray)); + setLocalCashuTokens(proofArray); localStorage.setItem( "history", JSON.stringify([ diff --git a/components/product-invoice-card.tsx b/components/product-invoice-card.tsx index 76956aad7..4d8b823b5 100644 --- a/components/product-invoice-card.tsx +++ b/components/product-invoice-card.tsx @@ -51,6 +51,7 @@ import { getLocalStorageData, publishProofEvent, generateKeys, + setLocalCashuTokens, } from "@/utils/nostr/nostr-helper-functions"; import { LightningAddress } from "@getalby/lightning-tools"; import QRCode from "qrcode"; @@ -1941,7 +1942,7 @@ export default function ProductInvoiceCard({ } else { proofArray = [...remainingProofs]; } - localStorage.setItem("tokens", JSON.stringify(proofArray)); + setLocalCashuTokens(proofArray); localStorage.setItem( "history", JSON.stringify([ diff --git a/components/utility-components/claim-button.tsx b/components/utility-components/claim-button.tsx index 52d51506f..76572b8ce 100644 --- a/components/utility-components/claim-button.tsx +++ b/components/utility-components/claim-button.tsx @@ -16,10 +16,13 @@ import { import { useTheme } from "next-themes"; import { ProfileMapContext, ChatsContext } from "../../utils/context/context"; import { + clearPendingIncomingProofs, generateKeys, getLocalStorageData, publishProofEvent, publishWalletEvent, + setLocalCashuTokens, + stagePendingIncomingProofs, constructGiftWrappedEvent, constructMessageSeal, constructMessageGiftWrap, @@ -196,16 +199,14 @@ export default function ClaimButton({ token }: { token: string }) { setIsRedeeming(false); return; } - await publishProofEvent( - nostr!, + const pendingProofId = await stagePendingIncomingProofs( signer!, tokenMint, uniqueProofs, - "in", tokenAmount.toString() ); const tokenArray = [...tokens, ...uniqueProofs]; - localStorage.setItem("tokens", JSON.stringify(tokenArray)); + setLocalCashuTokens(tokenArray); if (!mints.includes(tokenMint)) { const updatedMints = [...mints, tokenMint]; localStorage.setItem("mints", JSON.stringify(updatedMints)); @@ -228,6 +229,17 @@ export default function ClaimButton({ token }: { token: string }) { ...history, ]) ); + const publishSucceeded = await publishProofEvent( + nostr!, + signer!, + tokenMint, + uniqueProofs, + "in", + tokenAmount.toString() + ); + if (publishSucceeded) { + clearPendingIncomingProofs([pendingProofId]); + } } else { setIsSpent(true); setIsRedeeming(false); diff --git a/components/utility-components/mint-recovery-boot.tsx b/components/utility-components/mint-recovery-boot.tsx index 5c70573e7..1596743a9 100644 --- a/components/utility-components/mint-recovery-boot.tsx +++ b/components/utility-components/mint-recovery-boot.tsx @@ -10,8 +10,11 @@ import { getPendingMintQuotes, } from "@/utils/cashu/pending-mint-operations"; import { + clearPendingIncomingProofs, getLocalStorageData, publishProofEvent, + setLocalCashuTokens, + stagePendingIncomingProofs, } from "@/utils/nostr/nostr-helper-functions"; import { NostrContext, @@ -60,8 +63,14 @@ export function MintRecoveryBoot(): null { onProofsClaimed: async (quote: PendingMintQuote, proofs: Proof[]) => { if (cancelled) return; const { tokens, history } = getLocalStorageData(); + const pendingProofId = await stagePendingIncomingProofs( + signer, + quote.mintUrl, + proofs, + quote.amount.toString() + ); const proofArray = [...tokens, ...proofs]; - window.localStorage.setItem("tokens", JSON.stringify(proofArray)); + setLocalCashuTokens(proofArray); window.localStorage.setItem( "history", JSON.stringify([ @@ -73,7 +82,7 @@ export function MintRecoveryBoot(): null { ...history, ]) ); - await publishProofEvent( + const publishSucceeded = await publishProofEvent( nostr, signer, quote.mintUrl, @@ -81,6 +90,9 @@ export function MintRecoveryBoot(): null { "in", quote.amount.toString() ); + if (publishSucceeded) { + clearPendingIncomingProofs([pendingProofId]); + } }, }); diff --git a/components/wallet/mint-button.tsx b/components/wallet/mint-button.tsx index 43933f7b2..b070647e2 100644 --- a/components/wallet/mint-button.tsx +++ b/components/wallet/mint-button.tsx @@ -22,8 +22,11 @@ import { } from "@heroui/react"; import { SHOPSTRBUTTONCLASSNAMES } from "@/utils/STATIC-VARIABLES"; import { + clearPendingIncomingProofs, getLocalStorageData, publishProofEvent, + setLocalCashuTokens, + stagePendingIncomingProofs, } from "@/utils/nostr/nostr-helper-functions"; import { Mint as CashuMint, Wallet as CashuWallet } from "@cashu/cashu-ts"; import QRCode from "qrcode"; @@ -207,8 +210,14 @@ const MintButton = () => { { maxAttempts: 5, perAttemptTimeoutMs: 15000, totalTimeoutMs: 60000 } ); if (proofs && proofs.length > 0) { + const pendingProofId = await stagePendingIncomingProofs( + signer!, + mints[0]!, + proofs, + numSats.toString() + ); const proofArray = [...tokens, ...proofs]; - localStorage.setItem("tokens", JSON.stringify(proofArray)); + setLocalCashuTokens(proofArray); localStorage.setItem( "history", JSON.stringify([ @@ -220,7 +229,8 @@ const MintButton = () => { ...history, ]) ); - await publishProofEvent( + markMintQuoteClaimed(hash); + const publishSucceeded = await publishProofEvent( nostr!, signer!, mints[0]!, @@ -228,7 +238,9 @@ const MintButton = () => { "in", numSats.toString() ); - markMintQuoteClaimed(hash); + if (publishSucceeded) { + clearPendingIncomingProofs([pendingProofId]); + } setPaymentConfirmed(true); setQrCodeUrl(null); setTimeout(() => { diff --git a/components/wallet/pay-button.tsx b/components/wallet/pay-button.tsx index ea3326a10..ffd6b80c6 100644 --- a/components/wallet/pay-button.tsx +++ b/components/wallet/pay-button.tsx @@ -20,6 +20,7 @@ import { import { getLocalStorageData, publishProofEvent, + setLocalCashuTokens, } from "@/utils/nostr/nostr-helper-functions"; import { SHOPSTRBUTTONCLASSNAMES } from "@/utils/STATIC-VARIABLES"; import { @@ -169,7 +170,7 @@ const PayButton = () => { ) || !send.some((s) => s.secret === p.secret) ) as Proof[]; const quarantineProofArray = [...remainingProofsAfterMelt, ...keep]; - localStorage.setItem("tokens", JSON.stringify(quarantineProofArray)); + setLocalCashuTokens(quarantineProofArray); throw new Error(meltOutcome.errorMessage ?? "Melt outcome ambiguous"); } const changeProofs = [...keep, ...meltOutcome.changeProofs]; @@ -190,7 +191,7 @@ const PayButton = () => { } else { proofArray = [...remainingProofs]; } - localStorage.setItem("tokens", JSON.stringify(proofArray)); + setLocalCashuTokens(proofArray); const filteredTokenAmount = filteredProofs.reduce( (acc, token: Proof) => acc + token.amount.toNumber(), 0 diff --git a/components/wallet/receive-button.tsx b/components/wallet/receive-button.tsx index ebf8fdeca..fe2300653 100644 --- a/components/wallet/receive-button.tsx +++ b/components/wallet/receive-button.tsx @@ -17,9 +17,12 @@ import { } from "@heroui/react"; import { SHOPSTRBUTTONCLASSNAMES } from "@/utils/STATIC-VARIABLES"; import { + clearPendingIncomingProofs, getLocalStorageData, publishProofEvent, publishWalletEvent, + setLocalCashuTokens, + stagePendingIncomingProofs, } from "@/utils/nostr/nostr-helper-functions"; import { Mint as CashuMint, @@ -89,8 +92,18 @@ const ReceiveButton = () => { setIsDuplicateToken(true); return; } + const transactionAmount = tokenProofs.reduce( + (acc, token: Proof) => acc + token.amount.toNumber(), + 0 + ); + const pendingProofId = await stagePendingIncomingProofs( + signer!, + tokenMint, + uniqueProofs, + transactionAmount.toString() + ); const tokenArray = [...tokens, ...uniqueProofs]; - localStorage.setItem("tokens", JSON.stringify(tokenArray)); + setLocalCashuTokens(tokenArray); if (!mints.includes(tokenMint)) { const updatedMints = [...mints, tokenMint]; localStorage.setItem("mints", JSON.stringify(updatedMints)); @@ -98,10 +111,6 @@ const ReceiveButton = () => { } setIsClaimed(true); handleToggleReceiveModal(); - const transactionAmount = tokenProofs.reduce( - (acc, token: Proof) => acc + token.amount.toNumber(), - 0 - ); localStorage.setItem( "history", JSON.stringify([ @@ -113,7 +122,7 @@ const ReceiveButton = () => { ...history, ]) ); - await publishProofEvent( + const publishSucceeded = await publishProofEvent( nostr!, signer!, tokenMint, @@ -121,6 +130,9 @@ const ReceiveButton = () => { "in", transactionAmount.toString() ); + if (publishSucceeded) { + clearPendingIncomingProofs([pendingProofId]); + } } else { setIsSpent(true); } diff --git a/components/wallet/send-button.tsx b/components/wallet/send-button.tsx index 3a80fa29c..511197f47 100644 --- a/components/wallet/send-button.tsx +++ b/components/wallet/send-button.tsx @@ -25,6 +25,7 @@ import { SHOPSTRBUTTONCLASSNAMES } from "@/utils/STATIC-VARIABLES"; import { getLocalStorageData, publishProofEvent, + setLocalCashuTokens, } from "@/utils/nostr/nostr-helper-functions"; import { Mint as CashuMint, @@ -147,7 +148,7 @@ const SendButton = () => { } else { proofArray = [...remainingProofs]; } - localStorage.setItem("tokens", JSON.stringify(proofArray)); + setLocalCashuTokens(proofArray); localStorage.setItem( "history", JSON.stringify([ diff --git a/pages/_app.tsx b/pages/_app.tsx index a3093c136..1f84124da 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -29,6 +29,7 @@ import { getLocalStorageData, getDefaultRelays, LogOut, + setLocalCashuTokens, } from "@/utils/nostr/nostr-helper-functions"; import { createNip98AuthorizationHeader } from "@/utils/nostr/nip98-auth"; import { HeroUIProvider } from "@heroui/react"; @@ -763,15 +764,15 @@ function Shopstr({ props }: { props: AppProps }) { ); } - if (walletResult?.cashuMints?.length && walletResult.cashuProofs) { + if (walletResult?.cashuProofs) { + setLocalCashuTokens(walletResult.cashuProofs); + } + + if (walletResult?.cashuMints?.length) { localStorage.setItem( "mints", JSON.stringify(walletResult.cashuMints) ); - localStorage.setItem( - "tokens", - JSON.stringify(walletResult.cashuProofs) - ); } await runTask("retrying relay publishes", async () => { diff --git a/utils/cashu/__tests__/wallet-recovery.test.ts b/utils/cashu/__tests__/wallet-recovery.test.ts index 1032dfd4e..7122fc159 100644 --- a/utils/cashu/__tests__/wallet-recovery.test.ts +++ b/utils/cashu/__tests__/wallet-recovery.test.ts @@ -6,13 +6,19 @@ import { } from "../wallet-recovery"; jest.mock("@/utils/nostr/nostr-helper-functions", () => ({ + clearPendingIncomingProofs: jest.fn(), getLocalStorageData: jest.fn(() => ({ tokens: [], history: [] })), publishProofEvent: jest.fn(), + setLocalCashuTokens: jest.fn(), + stagePendingIncomingProofs: jest.fn().mockResolvedValue("pending-proof-id"), })); const helpers = jest.requireMock("@/utils/nostr/nostr-helper-functions") as { + clearPendingIncomingProofs: jest.Mock; getLocalStorageData: jest.Mock; publishProofEvent: jest.Mock; + setLocalCashuTokens: jest.Mock; + stagePendingIncomingProofs: jest.Mock; }; const mkProof = (secret: string, amount = 10): Proof => @@ -28,11 +34,15 @@ describe("recoverProofsToBuyerWallet", () => { window.localStorage.clear(); helpers.getLocalStorageData.mockReset(); helpers.publishProofEvent.mockReset(); + helpers.setLocalCashuTokens.mockReset(); + helpers.stagePendingIncomingProofs.mockReset(); + helpers.clearPendingIncomingProofs.mockReset(); helpers.getLocalStorageData.mockReturnValue({ tokens: [], history: [] }); - helpers.publishProofEvent.mockResolvedValue(undefined); + helpers.publishProofEvent.mockResolvedValue(true); + helpers.stagePendingIncomingProofs.mockResolvedValue("pending-proof-id"); }); - it("appends proofs to localStorage tokens and writes a history entry", async () => { + it("appends proofs to the active wallet and writes a history entry", async () => { const proofs = [mkProof("s1", 4), mkProof("s2", 6)]; await recoverProofsToBuyerWallet( {} as never, @@ -42,9 +52,11 @@ describe("recoverProofsToBuyerWallet", () => { 10 ); - const tokens = JSON.parse(window.localStorage.getItem("tokens") ?? "[]"); - expect(tokens).toHaveLength(2); - expect(tokens.map((p: Proof) => p.secret)).toEqual(["s1", "s2"]); + expect(helpers.setLocalCashuTokens).toHaveBeenCalledWith(proofs); + expect(helpers.clearPendingIncomingProofs).toHaveBeenCalledWith([ + "pending-proof-id", + ]); + expect(window.localStorage.getItem("tokens")).toBeNull(); const history = JSON.parse(window.localStorage.getItem("history") ?? "[]"); expect(history[0]).toMatchObject({ type: 3, amount: 10 }); @@ -62,12 +74,14 @@ describe("recoverProofsToBuyerWallet", () => { [mkProof("new", 2)], 2 ); - const tokens = JSON.parse(window.localStorage.getItem("tokens") ?? "[]"); - expect(tokens.map((p: Proof) => p.secret)).toEqual(["existing", "new"]); + expect(helpers.setLocalCashuTokens).toHaveBeenCalledWith([ + mkProof("existing", 1), + mkProof("new", 2), + ]); }); it("does not throw when proof event publish fails", async () => { - helpers.publishProofEvent.mockRejectedValueOnce(new Error("relay down")); + helpers.publishProofEvent.mockResolvedValueOnce(false); await expect( recoverProofsToBuyerWallet( {} as never, @@ -77,8 +91,8 @@ describe("recoverProofsToBuyerWallet", () => { 5 ) ).resolves.toBeUndefined(); - const tokens = JSON.parse(window.localStorage.getItem("tokens") ?? "[]"); - expect(tokens).toHaveLength(1); + expect(helpers.setLocalCashuTokens).toHaveBeenCalledWith([mkProof("s1", 5)]); + expect(helpers.clearPendingIncomingProofs).not.toHaveBeenCalled(); }); it("no-ops on empty proof array", async () => { @@ -91,6 +105,7 @@ describe("recoverProofsToBuyerWallet", () => { ); expect(window.localStorage.getItem("tokens")).toBeNull(); expect(helpers.publishProofEvent).not.toHaveBeenCalled(); + expect(helpers.setLocalCashuTokens).not.toHaveBeenCalled(); }); }); diff --git a/utils/cashu/wallet-recovery.ts b/utils/cashu/wallet-recovery.ts index 78ed7bd68..3a9e6633c 100644 --- a/utils/cashu/wallet-recovery.ts +++ b/utils/cashu/wallet-recovery.ts @@ -1,7 +1,10 @@ import { Proof } from "@cashu/cashu-ts"; import { + clearPendingIncomingProofs, getLocalStorageData, publishProofEvent, + setLocalCashuTokens, + stagePendingIncomingProofs, } from "@/utils/nostr/nostr-helper-functions"; type Nostr = Parameters[0]; @@ -10,7 +13,7 @@ type Signer = Parameters[1]; /** * Persist freshly-minted proofs into the buyer's local wallet when the * downstream seller-DM hand-off fails. Mirrors the wallet-top-up bookkeeping - * done by the mint-button claim path: localStorage `tokens`, history entry, + * done by the mint-button claim path: local wallet tokens, history entry, * and a kind-7375 wallet event so other devices can sync. * * Idempotency: callers must only invoke this once per failed claim. The @@ -29,8 +32,14 @@ export async function recoverProofsToBuyerWallet( if (!proofs || proofs.length === 0) return; const { tokens, history } = getLocalStorageData(); + const pendingProofId = await stagePendingIncomingProofs( + signer, + mintUrl, + proofs, + amount.toString() + ); const proofArray = [...tokens, ...proofs]; - window.localStorage.setItem("tokens", JSON.stringify(proofArray)); + setLocalCashuTokens(proofArray); window.localStorage.setItem( "history", JSON.stringify([ @@ -43,11 +52,10 @@ export async function recoverProofsToBuyerWallet( ]) ); - // Best-effort wallet event publish; localStorage is the source of truth and - // sendGiftWrappedMessageEvent / publishProofEvent already cache to DB first - // so durability does not depend on relay reachability here. + // Keep an encrypted pending-proof record until the wallet event is accepted, + // so a refresh can recover these proofs even if the publish step misses. try { - await publishProofEvent( + const publishSucceeded = await publishProofEvent( nostr, signer, mintUrl, @@ -55,9 +63,12 @@ export async function recoverProofsToBuyerWallet( "in", amount.toString() ); + if (publishSucceeded) { + clearPendingIncomingProofs([pendingProofId]); + } } catch (err) { console.warn( - "[wallet-recovery] proof event publish failed; tokens are safe in localStorage:", + "[wallet-recovery] proof event publish failed; tokens are safe in the active session wallet:", err ); } diff --git a/utils/nostr/__tests__/local-storage-data.test.ts b/utils/nostr/__tests__/local-storage-data.test.ts index e62a6d1ec..c0d9204c9 100644 --- a/utils/nostr/__tests__/local-storage-data.test.ts +++ b/utils/nostr/__tests__/local-storage-data.test.ts @@ -1,13 +1,18 @@ import { + clearPendingIncomingProofs, + LogOut, getDefaultBlossomServer, getDefaultMint, getDefaultRelays, getLocalStorageData, + readPendingIncomingProofs, + setLocalCashuTokens, + stagePendingIncomingProofs, } from "../nostr-helper-functions"; describe("getLocalStorageData", () => { beforeEach(() => { - localStorage.clear(); + LogOut(); jest.restoreAllMocks(); }); @@ -70,4 +75,133 @@ describe("getLocalStorageData", () => { encryptedPrivKey: "ncryptsec1mock", }); }); + + it("keeps cashu proofs in runtime memory only", () => { + setLocalCashuTokens([ + { + id: "00d0a1b24d1c1a53", + amount: 5, + secret: "secret-1", + C: "C1", + } as any, + ]); + + const data = getLocalStorageData(); + + expect(data.tokens).toEqual([ + { + id: "00d0a1b24d1c1a53", + amount: 5, + secret: "secret-1", + C: "C1", + }, + ]); + expect(localStorage.getItem("tokens")).toBeNull(); + }); + + it("stages incoming proofs as encrypted pending records", async () => { + const signer = { + getPubKey: jest.fn().mockResolvedValue("pubkey"), + encrypt: jest.fn().mockResolvedValue("cipher-text"), + decrypt: jest.fn().mockResolvedValue( + JSON.stringify({ + mint: "https://mint.example", + proofs: [ + { + id: "00d0a1b24d1c1a53", + amount: 9, + secret: "secret-9", + C: "C9", + }, + ], + amount: "9", + }) + ), + } as any; + + const pendingId = await stagePendingIncomingProofs( + signer, + "https://mint.example", + [ + { + id: "00d0a1b24d1c1a53", + amount: 9, + secret: "secret-9", + C: "C9", + } as any, + ], + "9" + ); + + expect(localStorage.getItem("pendingIncomingProofs")).toContain( + "cipher-text" + ); + + const pendingProofs = await readPendingIncomingProofs(signer); + expect(pendingProofs).toEqual([ + { + id: pendingId, + mint: "https://mint.example", + proofs: [ + { + id: "00d0a1b24d1c1a53", + amount: 9, + secret: "secret-9", + C: "C9", + }, + ], + amount: "9", + }, + ]); + }); + + it("clears pending incoming proof records after sync", async () => { + const signer = { + getPubKey: jest.fn().mockResolvedValue("pubkey"), + encrypt: jest.fn().mockResolvedValue("cipher-text"), + decrypt: jest.fn().mockResolvedValue( + JSON.stringify({ + mint: "https://mint.example", + proofs: [], + amount: "0", + }) + ), + } as any; + + const pendingId = await stagePendingIncomingProofs( + signer, + "https://mint.example", + [], + "0" + ); + clearPendingIncomingProofs([pendingId]); + + expect(localStorage.getItem("pendingIncomingProofs")).toBeNull(); + }); + + it("removes legacy persisted cashu proofs on read", () => { + localStorage.setItem( + "tokens", + JSON.stringify([ + { + id: "00d0a1b24d1c1a53", + amount: 7, + secret: "legacy-secret", + C: "legacy-C", + }, + ]) + ); + + const data = getLocalStorageData(); + + expect(data.tokens).toEqual([ + { + id: "00d0a1b24d1c1a53", + amount: 7, + secret: "legacy-secret", + C: "legacy-C", + }, + ]); + expect(localStorage.getItem("tokens")).toBeNull(); + }); }); diff --git a/utils/nostr/fetch-service.ts b/utils/nostr/fetch-service.ts index 6b0465144..9aa4bd19a 100644 --- a/utils/nostr/fetch-service.ts +++ b/utils/nostr/fetch-service.ts @@ -12,8 +12,11 @@ import { } from "@cashu/cashu-ts"; import { ChatsMap } from "@/utils/context/context"; import { + clearPendingIncomingProofs, getLocalStorageData, deleteEvent, + publishProofEvent, + readPendingIncomingProofs, verifyNip05Identifier, } from "@/utils/nostr/nostr-helper-functions"; import { @@ -1325,6 +1328,9 @@ export const fetchCashuWallet = async ( const cashuMintSet: Set = new Set(); let cashuProofs: Proof[] = [...tokens]; // Start with existing tokens const incomingSpendingHistory: [][] = []; + const pendingIncomingProofs = signer + ? await readPendingIncomingProofs(signer) + : []; // Load wallet events from database first try { @@ -1728,6 +1734,36 @@ export const fetchCashuWallet = async ( // Final deduplication cashuProofs = getUniqueProofs(cashuProofs); + for (const pendingProof of pendingIncomingProofs) { + cashuProofs = getUniqueProofs([ + ...cashuProofs, + ...pendingProof.proofs, + ]); + if (!cashuMintSet.has(pendingProof.mint)) { + cashuMintSet.add(pendingProof.mint); + cashuMints.push(pendingProof.mint); + } + } + + const replayedPendingProofIds: string[] = []; + for (const pendingProof of pendingIncomingProofs) { + const publishSucceeded = await publishProofEvent( + nostr, + signer!, + pendingProof.mint, + pendingProof.proofs, + "in", + pendingProof.amount + ); + if (publishSucceeded) { + replayedPendingProofIds.push(pendingProof.id); + } + } + + if (replayedPendingProofIds.length > 0) { + clearPendingIncomingProofs(replayedPendingProofIds); + } + editCashuWalletContext(proofEvents, cashuMints, cashuProofs, false); resolve({ diff --git a/utils/nostr/nostr-helper-functions.ts b/utils/nostr/nostr-helper-functions.ts index adbcea590..efe4847ed 100644 --- a/utils/nostr/nostr-helper-functions.ts +++ b/utils/nostr/nostr-helper-functions.ts @@ -738,7 +738,7 @@ export async function publishProofEvent( direction: "in" | "out", amount: string, deletedEventsArray?: string[] -) { +): Promise { try { const userPubkey = await signer?.getPubKey?.(); @@ -774,8 +774,9 @@ export async function publishProofEvent( signedEvent && signedEvent.id ? signedEvent.id : "", deletedEventsArray ); + return true; } catch { - return; + return false; } } @@ -1249,6 +1250,7 @@ const LOCALSTORAGECONSTANTS = { mints: "mints", blossomServers: "blossomServers", tokens: "tokens", + pendingIncomingProofs: "pendingIncomingProofs", history: "history", wot: "wot", clientPubkey: "clientPubkey", @@ -1261,6 +1263,189 @@ const LOCALSTORAGECONSTANTS = { nwcInfo: "nwcInfo", }; +let runtimeCashuTokens: Proof[] = []; + +interface PendingIncomingProofPayload { + mint: string; + proofs: Proof[]; + amount: string; +} + +interface PendingIncomingProofRecord { + id: string; + encryptedPayload: string; + createdAt: number; +} + +function isPendingIncomingProofRecord( + value: unknown +): value is PendingIncomingProofRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const candidate = value as { + id?: unknown; + encryptedPayload?: unknown; + createdAt?: unknown; + }; + + return ( + typeof candidate.id === "string" && + typeof candidate.encryptedPayload === "string" && + typeof candidate.createdAt === "number" + ); +} + +function readPendingIncomingProofRecords(): PendingIncomingProofRecord[] { + if (typeof window === "undefined") { + return []; + } + + const records = getLocalStorageJson( + LOCALSTORAGECONSTANTS.pendingIncomingProofs, + [], + { + removeOnError: true, + validate: Array.isArray, + } + ); + + const validRecords = records.filter(isPendingIncomingProofRecord); + if (validRecords.length !== records.length) { + if (validRecords.length > 0) { + localStorage.setItem( + LOCALSTORAGECONSTANTS.pendingIncomingProofs, + JSON.stringify(validRecords) + ); + } else { + localStorage.removeItem(LOCALSTORAGECONSTANTS.pendingIncomingProofs); + } + } + + return validRecords; +} + +function writePendingIncomingProofRecords( + records: PendingIncomingProofRecord[] +): void { + if (typeof window === "undefined") { + return; + } + + if (records.length === 0) { + localStorage.removeItem(LOCALSTORAGECONSTANTS.pendingIncomingProofs); + return; + } + + localStorage.setItem( + LOCALSTORAGECONSTANTS.pendingIncomingProofs, + JSON.stringify(records) + ); +} + +function isPendingIncomingProofPayload( + value: unknown +): value is PendingIncomingProofPayload { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const candidate = value as { + mint?: unknown; + proofs?: unknown; + amount?: unknown; + }; + + return ( + typeof candidate.mint === "string" && + Array.isArray(candidate.proofs) && + typeof candidate.amount === "string" + ); +} + +export function setLocalCashuTokens(tokens: Proof[]): void { + runtimeCashuTokens = [...tokens]; + if (typeof window === "undefined") { + return; + } + localStorage.removeItem(LOCALSTORAGECONSTANTS.tokens); + window.dispatchEvent(new Event("storage")); +} + +export async function stagePendingIncomingProofs( + signer: NostrSigner, + mint: string, + proofs: Proof[], + amount: string +): Promise { + const userPubkey = await signer.getPubKey(); + const encryptedPayload = await signer.encrypt( + userPubkey, + JSON.stringify({ mint, proofs, amount }) + ); + const record: PendingIncomingProofRecord = { + id: uuidv4(), + encryptedPayload, + createdAt: Date.now(), + }; + + const records = readPendingIncomingProofRecords(); + records.push(record); + writePendingIncomingProofRecords(records); + return record.id; +} + +export function clearPendingIncomingProofs(recordIds: string[]): void { + if (recordIds.length === 0) { + return; + } + + const records = readPendingIncomingProofRecords(); + writePendingIncomingProofRecords( + records.filter((record) => !recordIds.includes(record.id)) + ); +} + +export async function readPendingIncomingProofs( + signer: NostrSigner +): Promise> { + const records = readPendingIncomingProofRecords(); + if (records.length === 0) { + return []; + } + + const userPubkey = await signer.getPubKey(); + const pendingProofs: Array = []; + + for (const record of records) { + try { + const decryptedPayload = await signer.decrypt( + userPubkey, + record.encryptedPayload + ); + const parsedPayload = JSON.parse(decryptedPayload); + if (!isPendingIncomingProofPayload(parsedPayload)) { + continue; + } + + pendingProofs.push({ + id: record.id, + mint: parsedPayload.mint, + proofs: parsedPayload.proofs, + amount: parsedPayload.amount, + }); + } catch (error) { + console.warn( + "[cashu-wallet] failed to decrypt pending incoming proofs:", + error + ); + } + } + + return pendingProofs; +} + export const setLocalStorageDataOnSignIn = ({ encryptedPrivateKey, relays, @@ -1433,7 +1618,7 @@ export const getLocalStorageData = (): LocalStorageInterface => { let writeRelays; let mints; let blossomServers; - let tokens; + let tokens = runtimeCashuTokens; let history; let wot; let clientPrivkey; @@ -1526,15 +1711,22 @@ export const getLocalStorageData = (): LocalStorageInterface => { ); } - tokens = getLocalStorageJson(LOCALSTORAGECONSTANTS.tokens, [], { - removeOnError: true, - validate: isArray, - }); - if ( - tokens.length === 0 && - !localStorage.getItem(LOCALSTORAGECONSTANTS.tokens) - ) { - localStorage.setItem(LOCALSTORAGECONSTANTS.tokens, JSON.stringify([])); + const persistedTokens = getLocalStorageJson( + LOCALSTORAGECONSTANTS.tokens, + [], + { + removeOnError: true, + validate: isArray, + } + ); + if (persistedTokens.length > 0) { + runtimeCashuTokens = [...persistedTokens]; + localStorage.removeItem(LOCALSTORAGECONSTANTS.tokens); + } + if (runtimeCashuTokens.length === 0) { + tokens = persistedTokens; + } else { + tokens = runtimeCashuTokens; } history = getLocalStorageJson( @@ -1647,6 +1839,8 @@ export const getLocalStorageData = (): LocalStorageInterface => { }; export const LogOut = () => { + runtimeCashuTokens = []; + // remove old data localStorage.removeItem("npub"); localStorage.removeItem("signIn");