diff --git a/context/WalletContext.tsx b/context/WalletContext.tsx index 7f47718a..6cdf1a23 100644 --- a/context/WalletContext.tsx +++ b/context/WalletContext.tsx @@ -3,6 +3,7 @@ import React, { ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { ALBEDO_ID, FREIGHTER_ID, LOBSTR_ID, RABET_ID, XBULL_ID } from "@creit.tech/stellar-wallets-kit"; import { getKit } from "@/constants/wallet-kits.constant"; +import { clearIntentsForWallet } from "@/lib/transaction/intent"; export type WalletErrorKind = "unauthenticated" | "forbidden" | "user_rejected" | "wallet_locked" | "identity_changed" | "validation" | "network" | "conflict" | "unknown"; export type WalletOperationKind = "connect" | "disconnect" | "reconcile"; @@ -94,8 +95,12 @@ export function WalletProvider({ children }: { children: ReactNode }) { const identityRef = useRef({ address: null as string | null, generation: 0 }); const updateIdentity = useCallback((nextAddress: string | null, nextName: string | null) => { + const previousAddress = identityRef.current.address; const nextGeneration = identityRef.current.generation + 1; identityRef.current = { address: nextAddress, generation: nextGeneration }; + // Leaving an identity behind (disconnect or account switch) invalidates any + // signed intents persisted for that address. + if (previousAddress && previousAddress !== nextAddress) clearIntentsForWallet(previousAddress); setAddress(nextAddress); setName(nextName); setConnected(Boolean(nextAddress && nextName)); @@ -161,7 +166,9 @@ export function WalletProvider({ children }: { children: ReactNode }) { try { await getKit().disconnect(); if (!isOperationCurrent(operation)) return conflictFailure(operation.id); + const disconnectedAddress = identityRef.current.address; updateIdentity(null, null); + if (disconnectedAddress) clearIntentsForWallet(disconnectedAddress); return { success: true, operationId: operation.id }; } catch (error: unknown) { if (!isOperationCurrent(operation)) return conflictFailure(operation.id); @@ -195,6 +202,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { const result = await kit.getAddress(); if (!mounted || !isOperationCurrent(operation)) return; if (result.address !== persisted.address || !isValidStellarAddress(result.address)) { + clearIntentsForWallet(persisted.address); localStorage.removeItem(WALLET_STORAGE_KEY); setOperationError({ success: false, error: "The active wallet account changed. Connect it again to continue.", errorKind: "identity_changed", operationId: operation.id }); return; diff --git a/context/__tests__/WalletContext.test.tsx b/context/__tests__/WalletContext.test.tsx index 919cd8d7..e3119ef9 100644 --- a/context/__tests__/WalletContext.test.tsx +++ b/context/__tests__/WalletContext.test.tsx @@ -1,6 +1,7 @@ import React from "react"; import { act, renderHook, waitFor } from "@testing-library/react"; import { WalletProvider, useWalletContext, walletStateValidation } from "@/context/WalletContext"; +import { upsertIntent, listIntents } from "@/lib/transaction/intent"; jest.mock("@creit.tech/stellar-wallets-kit", () => ({ ALBEDO_ID: "albedo", @@ -80,6 +81,44 @@ describe("WalletProvider operation coordinator", () => { expect(hook.result.current.identityGeneration).toBeGreaterThan(connectedGeneration); }); + it("purges persisted intents for the wallet on disconnect", async () => { + mockGetAddress.mockResolvedValue({ address: ADDRESS_A }); + mockDisconnect.mockResolvedValue(undefined); + const hook = await renderWallet(); + await act(async () => { await hook.result.current.connectWallet("freighter"); }); + upsertIntent({ key: `${ADDRESS_A}:h`, walletAddress: ADDRESS_A, xdrHash: "h", status: "signed", signedXdr: "signed-envelope" }); + expect(listIntents()).toHaveLength(1); + + await act(async () => { await hook.result.current.disconnectWallet(); }); + + expect(listIntents()).toHaveLength(0); + expect(localStorage.getItem("predictify:intents:v1") ?? "").not.toContain(ADDRESS_A); + }); + + it("purges the previous wallet intents when the active identity changes", async () => { + mockGetAddress.mockResolvedValueOnce({ address: ADDRESS_A }); + const hook = await renderWallet(); + await act(async () => { await hook.result.current.connectWallet("freighter"); }); + upsertIntent({ key: `${ADDRESS_A}:h`, walletAddress: ADDRESS_A, xdrHash: "h", status: "signed", signedXdr: "s" }); + + mockGetAddress.mockResolvedValueOnce({ address: ADDRESS_B }); + await act(async () => { await hook.result.current.connectWallet("lobstr"); }); + + expect(hook.result.current.address).toBe(ADDRESS_B); + expect(listIntents().some((i) => i.walletAddress === ADDRESS_A)).toBe(false); + }); + + it("purges intents for a persisted wallet that fails reconciliation", async () => { + localStorage.setItem("predictify_wallet_state", JSON.stringify({ address: ADDRESS_A, name: "Freighter", connected: true })); + upsertIntent({ key: `${ADDRESS_A}:h`, walletAddress: ADDRESS_A, xdrHash: "h", status: "signed", signedXdr: "s" }); + mockGetAddress.mockResolvedValue({ address: ADDRESS_B }); + + const hook = await renderWallet(); + + expect(hook.result.current.operationError?.errorKind).toBe("identity_changed"); + expect(listIntents().some((i) => i.walletAddress === ADDRESS_A)).toBe(false); + }); + it("classifies a rejected provider request without logging the raw error", async () => { const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => undefined); mockGetAddress.mockRejectedValue(new Error(`User rejected ${ADDRESS_A}`)); diff --git a/lib/transaction/__tests__/intent.test.ts b/lib/transaction/__tests__/intent.test.ts index d9dd2b53..948c031f 100644 --- a/lib/transaction/__tests__/intent.test.ts +++ b/lib/transaction/__tests__/intent.test.ts @@ -1,4 +1,32 @@ -import { computeXdrHash, upsertIntent, getIntent, removeIntent, listIntents, clearAllIntents } from '../intent'; +import { + computeXdrHash, + upsertIntent, + getIntent, + removeIntent, + listIntents, + clearAllIntents, + clearIntentsForWallet, +} from '../intent'; + +const STORAGE_KEY = 'predictify:intents:v1'; +const TTL_MS = 24 * 60 * 60 * 1000; + +function readRaw(): Record { + return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record; +} + +function buildRecord(overrides: Record = {}) { + const time = Date.now(); + return { + key: 'k', + walletAddress: 'GABC', + xdrHash: 'hash', + status: 'built', + createdAt: time, + updatedAt: time, + ...overrides, + }; +} describe('intent store', () => { beforeEach(() => { @@ -28,4 +56,80 @@ describe('intent store', () => { removeIntent(key); expect(getIntent(key)).toBeUndefined(); }); + + it('discards records with a missing or wrongly typed status', () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + 'missing-status': buildRecord({ key: 'missing-status', status: undefined }), + 'typed-status': buildRecord({ key: 'typed-status', status: 42 }), + 'unknown-status': buildRecord({ key: 'unknown-status', status: 'processing' }), + valid: buildRecord({ key: 'valid' }), + })); + + expect(listIntents().map((i) => i.key)).toEqual(['valid']); + expect(getIntent('missing-status')).toBeUndefined(); + + const raw = readRaw(); + expect(raw['missing-status']).toBeUndefined(); + expect(raw['typed-status']).toBeUndefined(); + expect(raw['unknown-status']).toBeUndefined(); + expect(raw.valid).toBeDefined(); + }); + + it('discards records with a missing or wrongly typed walletAddress', () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + 'missing-wallet': buildRecord({ key: 'missing-wallet', walletAddress: undefined }), + 'typed-wallet': buildRecord({ key: 'typed-wallet', walletAddress: 7 }), + valid: buildRecord({ key: 'valid' }), + })); + + expect(listIntents().map((i) => i.key)).toEqual(['valid']); + const raw = readRaw(); + expect(raw['missing-wallet']).toBeUndefined(); + expect(raw['typed-wallet']).toBeUndefined(); + }); + + it('prunes expired intents on the first read and persists the pruning', () => { + const stale = Date.now() - TTL_MS - 1; + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + fresh: buildRecord({ key: 'fresh' }), + stale: buildRecord({ key: 'stale', updatedAt: stale }), + })); + + expect(listIntents().map((i) => i.key)).toEqual(['fresh']); + expect(readRaw().stale).toBeUndefined(); + expect(getIntent('stale')).toBeUndefined(); + }); + + it('drops signedXdr as soon as a submissionHash exists', () => { + const key = 'submit:1'; + upsertIntent({ key, walletAddress: 'GABC', xdrHash: 'h', status: 'signed', signedXdr: 'signed-envelope' }); + expect(getIntent(key)?.signedXdr).toBe('signed-envelope'); + + upsertIntent({ key, status: 'submitted', submissionHash: 'tx-hash' }); + const submitted = getIntent(key); + expect(submitted?.submissionHash).toBe('tx-hash'); + expect(submitted?.signedXdr).toBeUndefined(); + }); + + it('strips a persisted signedXdr when a submissionHash is already present', () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + legacy: buildRecord({ key: 'legacy', signedXdr: 'leftover-envelope', submissionHash: 'tx-hash' }), + })); + + const record = getIntent('legacy'); + expect(record?.signedXdr).toBeUndefined(); + expect((readRaw().legacy as Record).signedXdr).toBeUndefined(); + }); + + it('clears only the intents belonging to the given wallet', () => { + upsertIntent({ key: 'a1', walletAddress: 'GAAA', xdrHash: 'h1', status: 'built' }); + upsertIntent({ key: 'a2', walletAddress: 'GAAA', xdrHash: 'h2', status: 'signed', signedXdr: 's' }); + upsertIntent({ key: 'b1', walletAddress: 'GBBB', xdrHash: 'h3', status: 'built' }); + + clearIntentsForWallet('GAAA'); + + expect(getIntent('a1')).toBeUndefined(); + expect(getIntent('a2')).toBeUndefined(); + expect(getIntent('b1')).toBeDefined(); + }); }); diff --git a/lib/transaction/intent.ts b/lib/transaction/intent.ts index 6d709080..4a448763 100644 --- a/lib/transaction/intent.ts +++ b/lib/transaction/intent.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + export type IntentStatus = | 'built' | 'signed' @@ -22,15 +24,68 @@ export interface IntentRecord { const STORAGE_KEY = 'predictify:intents:v1'; const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const INTENT_STATUSES = ['built', 'signed', 'submitted', 'confirming', 'success', 'failed'] as const; + +const intentRecordSchema = z.object({ + key: z.string(), + walletAddress: z.string(), + xdrHash: z.string(), + status: z.enum(INTENT_STATUSES), + builtXdr: z.string().optional(), + signedXdr: z.string().optional(), + submissionHash: z.string().optional(), + error: z.string().optional(), + createdAt: z.number().finite(), + updatedAt: z.number().finite(), +}); + function now() { return Date.now(); } +interface SanitizedStore { + map: Record; + changed: boolean; +} + +// Validate every persisted record and drop malformed or expired entries so a +// tampered localStorage payload can never reach consumers such as useTransaction. +function sanitizeStore(value: unknown): SanitizedStore { + const map: Record = {}; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { map, changed: true }; + } + let changed = false; + const current = now(); + for (const [key, entry] of Object.entries(value as Record)) { + const parsed = intentRecordSchema.safeParse(entry); + if (!parsed.success) { + changed = true; + continue; + } + const record = parsed.data; + if (current - record.updatedAt > DEFAULT_TTL_MS) { + changed = true; + continue; + } + // A signed envelope must not outlive the submission it belongs to. + if (record.submissionHash && record.signedXdr !== undefined) { + delete record.signedXdr; + changed = true; + } + map[key] = record; + } + return { map, changed }; +} + function safeGetStorage(): Record { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return {}; - return JSON.parse(raw) as Record; + const parsed = JSON.parse(raw); + const { map, changed } = sanitizeStore(parsed); + if (changed) safeSetStorage(map); + return map; } catch (err) { console.debug('intent: failed to read storage', err); return {}; @@ -64,40 +119,40 @@ export async function computeXdrHash(xdr: string): Promise { export function getIntent(key: string): IntentRecord | undefined { const map = safeGetStorage(); - const item = map[key]; - if (!item) return undefined; - // expire stale entries - if (now() - item.updatedAt > DEFAULT_TTL_MS) { - removeIntent(key); - return undefined; - } - return item; + return map[key]; } export function listIntents(): IntentRecord[] { const map = safeGetStorage(); - return Object.values(map).filter((i) => now() - i.updatedAt <= DEFAULT_TTL_MS); + return Object.values(map); } export function upsertIntent(partial: Partial & { key: string; walletAddress?: string; xdrHash?: string; }) { const map = safeGetStorage(); const existing = map[partial.key]; const time = now(); + const submissionHash = partial.submissionHash ?? existing?.submissionHash; const merged: IntentRecord = { key: partial.key, walletAddress: partial.walletAddress ?? existing?.walletAddress ?? '', xdrHash: partial.xdrHash ?? existing?.xdrHash ?? '', - status: (partial as any).status ?? existing?.status ?? 'built', + status: partial.status ?? existing?.status ?? 'built', builtXdr: partial.builtXdr ?? existing?.builtXdr, - signedXdr: partial.signedXdr ?? existing?.signedXdr, - submissionHash: partial.submissionHash ?? existing?.submissionHash, + // Once a submission hash exists the signed envelope is no longer required. + signedXdr: submissionHash ? undefined : (partial.signedXdr ?? existing?.signedXdr), + submissionHash, error: partial.error ?? existing?.error, createdAt: existing?.createdAt ?? time, updatedAt: time, }; - map[partial.key] = merged; + const parsed = intentRecordSchema.safeParse(merged); + if (!parsed.success) { + console.debug('intent: refused to persist an invalid record', parsed.error.issues); + return existing ?? merged; + } + map[partial.key] = parsed.data; safeSetStorage(map); - return merged; + return parsed.data; } export function removeIntent(key: string) { @@ -108,6 +163,19 @@ export function removeIntent(key: string) { } } +export function clearIntentsForWallet(walletAddress: string) { + if (!walletAddress) return; + const map = safeGetStorage(); + let changed = false; + for (const key of Object.keys(map)) { + if (map[key].walletAddress === walletAddress) { + delete map[key]; + changed = true; + } + } + if (changed) safeSetStorage(map); +} + export function clearAllIntents() { try { localStorage.removeItem(STORAGE_KEY);