From 7a2bb73a8d75393ffb315d2d801fe53f5eec75ce Mon Sep 17 00:00:00 2001 From: cathyzaks Date: Wed, 21 Oct 2026 13:56:29 +0100 Subject: [PATCH] Version localStorage keys and consolidate duplicate preference stores --- .../components/OnboardingWalkthrough.test.tsx | 21 ++++++ .../src/components/OnboardingWalkthrough.tsx | 17 ++++- .../src/components/VaultDashboard.test.tsx | 4 +- frontend/src/components/VaultDashboard.tsx | 12 +++- frontend/src/context/AuthContext.test.tsx | 2 +- frontend/src/hooks/usePreferences.test.ts | 45 +++++++++++++ frontend/src/hooks/usePreferences.ts | 45 +++++++------ frontend/src/hooks/useSlippage.test.ts | 20 +++++- frontend/src/hooks/useSlippage.ts | 46 +++++++------ .../src/hooks/useWalletConnection.test.tsx | 2 +- frontend/src/lib/sessionCleanup.test.ts | 27 ++++++++ frontend/src/lib/sessionCleanup.ts | 10 +++ frontend/src/lib/versionedStorage.test.ts | 46 +++++++++++++ frontend/src/lib/versionedStorage.ts | 64 +++++++++++++++++++ frontend/src/lib/walletSession.test.ts | 2 +- frontend/src/lib/walletSession.ts | 21 ++++-- 16 files changed, 330 insertions(+), 54 deletions(-) create mode 100644 frontend/src/components/OnboardingWalkthrough.test.tsx create mode 100644 frontend/src/hooks/usePreferences.test.ts create mode 100644 frontend/src/lib/versionedStorage.test.ts create mode 100644 frontend/src/lib/versionedStorage.ts diff --git a/frontend/src/components/OnboardingWalkthrough.test.tsx b/frontend/src/components/OnboardingWalkthrough.test.tsx new file mode 100644 index 000000000..5e30fe139 --- /dev/null +++ b/frontend/src/components/OnboardingWalkthrough.test.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import OnboardingWalkthrough from "./OnboardingWalkthrough"; + +describe("OnboardingWalkthrough storage", () => { + beforeEach(() => localStorage.clear()); + + it("shows when unseen and persists dismissal as a v1 envelope", () => { + const { container } = render(); + expect(container.firstChild).not.toBeNull(); + fireEvent.click(screen.getAllByRole("button")[0]); + expect(localStorage.getItem("hasSeenWalkthrough")).toBe('{"version":1,"data":true}'); + }); + + it("stays hidden for a legacy raw 'true' value and migrates it", () => { + localStorage.setItem("hasSeenWalkthrough", "true"); + const { container } = render(); + expect(container.firstChild).toBeNull(); + expect(localStorage.getItem("hasSeenWalkthrough")).toBe('{"version":1,"data":true}'); + }); +}); diff --git a/frontend/src/components/OnboardingWalkthrough.tsx b/frontend/src/components/OnboardingWalkthrough.tsx index 7c450e99b..99a7b8561 100644 --- a/frontend/src/components/OnboardingWalkthrough.tsx +++ b/frontend/src/components/OnboardingWalkthrough.tsx @@ -1,13 +1,26 @@ import React, { useState } from "react"; import { X, ChevronRight, ChevronLeft } from "lucide-react"; import { useTranslation } from "../i18n"; +import { readVersionedItem, writeVersionedItem } from "../lib/versionedStorage"; const LOCAL_STORAGE_KEY = "hasSeenWalkthrough"; +const STORAGE_VERSION = 1; + +// Legacy format: the string "true"; any pre-envelope value counted as seen. +const toSeen = (value: unknown): boolean | null => + typeof value === "boolean" ? value : value === "true" || value === true ? true : null; + +const hasSeenWalkthrough = (): boolean => + readVersionedItem(LOCAL_STORAGE_KEY, { + version: STORAGE_VERSION, + parseData: toSeen, + migrateLegacy: () => true, + }) === true; const OnboardingWalkthrough: React.FC = () => { const { t } = useTranslation(); const [isVisible, setIsVisible] = useState( - () => !localStorage.getItem(LOCAL_STORAGE_KEY), + () => !hasSeenWalkthrough(), ); const [currentStep, setCurrentStep] = useState(0); @@ -19,7 +32,7 @@ const OnboardingWalkthrough: React.FC = () => { ]; const handleDismiss = () => { - localStorage.setItem(LOCAL_STORAGE_KEY, "true"); + writeVersionedItem(LOCAL_STORAGE_KEY, STORAGE_VERSION, true); setIsVisible(false); }; diff --git a/frontend/src/components/VaultDashboard.test.tsx b/frontend/src/components/VaultDashboard.test.tsx index 213e4e69c..892ff79eb 100644 --- a/frontend/src/components/VaultDashboard.test.tsx +++ b/frontend/src/components/VaultDashboard.test.tsx @@ -296,7 +296,9 @@ describe("VaultDashboard", () => { await waitFor(() => { expect(confetti).toHaveBeenCalled(); }); - expect(localStorage.getItem("yieldvault:first-deposit:GFIRSTDEPOSITWALLET000000000000000000000000000000")).toBe("true"); + expect(localStorage.getItem("yieldvault:first-deposit:GFIRSTDEPOSITWALLET000000000000000000000000000000")).toBe( + JSON.stringify({ version: 1, data: true }), + ); }); it("shows a View on Explorer link after a successful deposit that returns a transaction hash", async () => { diff --git a/frontend/src/components/VaultDashboard.tsx b/frontend/src/components/VaultDashboard.tsx index 9a1e88973..a857ffce2 100644 --- a/frontend/src/components/VaultDashboard.tsx +++ b/frontend/src/components/VaultDashboard.tsx @@ -71,8 +71,10 @@ import { t } from "../i18n"; import { useTransactionHistory } from "../hooks/useTransactionData"; import TransactionTimeline, { type TxTimelineStatus } from "./TransactionTimeline"; import Badge from "./Badge"; +import { readVersionedItem, writeVersionedItem } from "../lib/versionedStorage"; const FIRST_DEPOSIT_PREFIX = "yieldvault:first-deposit:"; +const FIRST_DEPOSIT_VERSION = 1; const MAX_TRANSACTION_RETRY_ATTEMPTS = 3; function prefersReducedMotion(): boolean { @@ -676,9 +678,15 @@ const VaultDashboard: React.FC = ({ try { const depositKey = `${FIRST_DEPOSIT_PREFIX}${walletAddress}`; - const alreadyCelebrated = localStorage.getItem(depositKey) === "true"; + const alreadyCelebrated = + readVersionedItem(depositKey, { + version: FIRST_DEPOSIT_VERSION, + parseData: (data) => (data === true ? true : null), + // Legacy format: the string "true". + migrateLegacy: (legacy) => (legacy === true ? true : null), + }) === true; if (!alreadyCelebrated) { - localStorage.setItem(depositKey, "true"); + writeVersionedItem(depositKey, FIRST_DEPOSIT_VERSION, true); runDepositConfetti(); } } catch (storageErr) { diff --git a/frontend/src/context/AuthContext.test.tsx b/frontend/src/context/AuthContext.test.tsx index f73a3b740..510ecd59d 100644 --- a/frontend/src/context/AuthContext.test.tsx +++ b/frontend/src/context/AuthContext.test.tsx @@ -122,7 +122,7 @@ describe("AuthContext", () => { actions!.renew(); }); - const newStart = parseInt(localStorage.getItem("wallet_session_start")!, 10); + const newStart = JSON.parse(localStorage.getItem("wallet_session_start")!).data; expect(newStart).toBeGreaterThanOrEqual(now); expect(screen.getByTestId("state").textContent).toBe("idle"); }); diff --git a/frontend/src/hooks/usePreferences.test.ts b/frontend/src/hooks/usePreferences.test.ts new file mode 100644 index 000000000..467ccfc7c --- /dev/null +++ b/frontend/src/hooks/usePreferences.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { usePreferences } from "./usePreferences"; + +const KEY = "yieldvault-preferences:GABC"; + +describe("usePreferences storage", () => { + beforeEach(() => localStorage.clear()); + + it("migrates the legacy unversioned object into a v1 envelope", () => { + localStorage.setItem( + KEY, + JSON.stringify({ theme: "dark", notifications: { priceAlerts: true } }), + ); + + const { result } = renderHook(() => usePreferences("GABC")); + + expect(result.current.preferences.theme).toBe("dark"); + expect(result.current.preferences.notifications.priceAlerts).toBe(true); + expect(result.current.preferences.notifications.depositAlerts).toBe(true); + const stored = JSON.parse(localStorage.getItem(KEY)!); + expect(stored.version).toBe(1); + expect(stored.data.theme).toBe("dark"); + }); + + it("writes updates as a versioned envelope", () => { + const { result } = renderHook(() => usePreferences("GABC")); + act(() => result.current.setTheme("light")); + expect(JSON.parse(localStorage.getItem(KEY)!)).toMatchObject({ + version: 1, + data: { theme: "light" }, + }); + }); + + it("falls back to defaults for corrupt or unknown-version data", () => { + localStorage.setItem(KEY, "not json"); + expect(renderHook(() => usePreferences("GABC")).result.current.preferences.theme).toBe( + "system", + ); + localStorage.setItem(KEY, JSON.stringify({ version: 99, data: { theme: "dark" } })); + expect(renderHook(() => usePreferences("GABC")).result.current.preferences.theme).toBe( + "system", + ); + }); +}); diff --git a/frontend/src/hooks/usePreferences.ts b/frontend/src/hooks/usePreferences.ts index c8bedecfa..9907cef9b 100644 --- a/frontend/src/hooks/usePreferences.ts +++ b/frontend/src/hooks/usePreferences.ts @@ -1,4 +1,5 @@ import { useState, useCallback, useEffect } from 'react'; +import { readVersionedItem, writeVersionedItem } from '../lib/versionedStorage'; export type Theme = 'light' | 'dark' | 'system'; export type Locale = 'en-US' | 'en-GB' | 'de-DE' | 'fr-FR' | 'ja-JP' | 'zh-CN'; @@ -51,30 +52,34 @@ function getStorageKey(walletAddress?: string | null): string { return `${STORAGE_KEY_PREFIX}:${walletScope}`; } +export const PREFERENCES_STORAGE_VERSION = 1; + +function normalizePreferences(value: unknown): UserPreferences | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const parsed = value as Partial; + return { + ...DEFAULT_PREFERENCES, + ...parsed, + notifications: { + ...DEFAULT_PREFERENCES.notifications, + ...(parsed.notifications ?? {}), + }, + }; +} + function loadPreferences(walletAddress?: string | null): UserPreferences { - try { - const raw = localStorage.getItem(getStorageKey(walletAddress)); - if (!raw) return DEFAULT_PREFERENCES; - const parsed = JSON.parse(raw) as Partial; - return { - ...DEFAULT_PREFERENCES, - ...parsed, - notifications: { - ...DEFAULT_PREFERENCES.notifications, - ...(parsed.notifications ?? {}), - }, - }; - } catch { - return DEFAULT_PREFERENCES; - } + return ( + readVersionedItem(getStorageKey(walletAddress), { + version: PREFERENCES_STORAGE_VERSION, + parseData: normalizePreferences, + // Legacy format: the bare preferences object (no envelope). + migrateLegacy: normalizePreferences, + }) ?? DEFAULT_PREFERENCES + ); } function savePreferences(prefs: UserPreferences, walletAddress?: string | null): void { - try { - localStorage.setItem(getStorageKey(walletAddress), JSON.stringify(prefs)); - } catch { - // storage quota exceeded or unavailable — silently ignore - } + writeVersionedItem(getStorageKey(walletAddress), PREFERENCES_STORAGE_VERSION, prefs); } export function usePreferences(walletAddress?: string | null) { diff --git a/frontend/src/hooks/useSlippage.test.ts b/frontend/src/hooks/useSlippage.test.ts index 2ac582116..31ecc0193 100644 --- a/frontend/src/hooks/useSlippage.test.ts +++ b/frontend/src/hooks/useSlippage.test.ts @@ -10,6 +10,8 @@ import { const STORAGE_KEY = "yv_slippage_percent"; const LEGACY_STORAGE_KEY = "yv_slippage_bps"; +const env = (data: number) => JSON.stringify({ version: 1, data }); + describe("useSlippage", () => { beforeEach(() => { localStorage.clear(); @@ -28,7 +30,7 @@ describe("useSlippage", () => { }); expect(result.current.slippage).toBe(1.5); - expect(localStorage.getItem(STORAGE_KEY)).toBe("1.5"); + expect(localStorage.getItem(STORAGE_KEY)).toBe(env(1.5)); }); it("migrates a value stored under the legacy misnamed key", () => { @@ -37,7 +39,7 @@ describe("useSlippage", () => { const { result } = renderHook(() => useSlippage()); expect(result.current.slippage).toBe(0.75); - expect(localStorage.getItem(STORAGE_KEY)).toBe("0.75"); + expect(localStorage.getItem(STORAGE_KEY)).toBe(env(0.75)); expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull(); }); @@ -50,6 +52,20 @@ describe("useSlippage", () => { expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull(); }); + it("migrates an unversioned value under the current key into an envelope", () => { + localStorage.setItem(STORAGE_KEY, "1.5"); + const { result } = renderHook(() => useSlippage()); + expect(result.current.slippage).toBe(1.5); + expect(localStorage.getItem(STORAGE_KEY)).toBe(env(1.5)); + }); + + it("reads a versioned envelope and rejects an out-of-range envelope", () => { + localStorage.setItem(STORAGE_KEY, env(2)); + expect(renderHook(() => useSlippage()).result.current.slippage).toBe(2); + localStorage.setItem(STORAGE_KEY, env(75)); + expect(renderHook(() => useSlippage()).result.current.slippage).toBe(SLIPPAGE_DEFAULT_PERCENT); + }); + it("falls back to the default when the stored value is out of range", () => { localStorage.setItem(STORAGE_KEY, "75"); diff --git a/frontend/src/hooks/useSlippage.ts b/frontend/src/hooks/useSlippage.ts index 0d06c2bd5..54b9cfa20 100644 --- a/frontend/src/hooks/useSlippage.ts +++ b/frontend/src/hooks/useSlippage.ts @@ -1,4 +1,5 @@ import { useState, useCallback } from "react"; +import { readVersionedItem, writeVersionedItem } from "../lib/versionedStorage"; import { SLIPPAGE_DEFAULT_PERCENT, SLIPPAGE_HIGH_THRESHOLD_PERCENT, @@ -20,24 +21,35 @@ const STORAGE_KEY = "yv_slippage_percent"; const LEGACY_STORAGE_KEY = "yv_slippage_bps"; const PRESETS = [0.1, 0.5, 1] as const; +const STORAGE_VERSION = 1; + +function toValidSlippage(value: unknown): number | null { + const n = typeof value === "string" ? parseFloat(value) : value; + return typeof n === "number" && isValidSlippagePercent(n) ? n : null; +} + function readStored(): number { try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw === null) { - // Migrate any value saved under the old (misnamed) key. - const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); - if (legacy !== null) { - const legacyValue = parseFloat(legacy); - localStorage.removeItem(LEGACY_STORAGE_KEY); - if (isValidSlippagePercent(legacyValue)) { - localStorage.setItem(STORAGE_KEY, legacy); - return legacyValue; - } + const stored = readVersionedItem(STORAGE_KEY, { + version: STORAGE_VERSION, + parseData: toValidSlippage, + // Pre-envelope format: the percent as a bare number string. + migrateLegacy: toValidSlippage, + }); + if (stored !== null) return stored; + if (localStorage.getItem(STORAGE_KEY) !== null) return SLIPPAGE_DEFAULT_PERCENT; + + // Migrate any value saved under the old (misnamed) key. + const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); + if (legacy !== null) { + localStorage.removeItem(LEGACY_STORAGE_KEY); + const legacyValue = toValidSlippage(legacy); + if (legacyValue !== null) { + writeVersionedItem(STORAGE_KEY, STORAGE_VERSION, legacyValue); + return legacyValue; } - return SLIPPAGE_DEFAULT_PERCENT; } - const n = parseFloat(raw); - return isValidSlippagePercent(n) ? n : SLIPPAGE_DEFAULT_PERCENT; + return SLIPPAGE_DEFAULT_PERCENT; } catch { return SLIPPAGE_DEFAULT_PERCENT; } @@ -53,11 +65,7 @@ export function useSlippage() { const setSlippage = useCallback((value: number) => { const bounded = clampSlippagePercent(value); setSlippageState(bounded); - try { - localStorage.setItem(STORAGE_KEY, String(bounded)); - } catch { - // storage unavailable – state still updates in memory - } + writeVersionedItem(STORAGE_KEY, STORAGE_VERSION, bounded); }, []); const minReceived = useCallback( diff --git a/frontend/src/hooks/useWalletConnection.test.tsx b/frontend/src/hooks/useWalletConnection.test.tsx index 6ff2bddf6..8559b4ddb 100644 --- a/frontend/src/hooks/useWalletConnection.test.tsx +++ b/frontend/src/hooks/useWalletConnection.test.tsx @@ -135,7 +135,7 @@ describe("performConnect error mapping (issue #1625)", () => { expect(sessionStart).not.toBeNull(); expect(sessionStart as number).toBeGreaterThanOrEqual(before); expect(localStorage.getItem(WALLET_SESSION_START_KEY)).toBe( - String(sessionStart), + JSON.stringify({ version: 1, data: sessionStart }), ); }); }); diff --git a/frontend/src/lib/sessionCleanup.test.ts b/frontend/src/lib/sessionCleanup.test.ts index 10a883d8b..c8a3cf84b 100644 --- a/frontend/src/lib/sessionCleanup.test.ts +++ b/frontend/src/lib/sessionCleanup.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getWalletSessionStart, startWalletSession } from "./walletSession"; import { clearWalletSessionState, SESSION_STORAGE_KEYS, LOCAL_STORAGE_KEYS } from "./sessionCleanup"; describe("clearWalletSessionState", () => { @@ -34,4 +35,30 @@ describe("clearWalletSessionState", () => { "{\"theme\":\"dark\"}", ); }); + + it("clears the versioned wallet_session_start envelope and the legacy raw value", () => { + startWalletSession(1234567890); + expect(getWalletSessionStart()).toBe(1234567890); + clearWalletSessionState(queryClient); + expect(localStorage.getItem("wallet_session_start")).toBeNull(); + + localStorage.setItem("wallet_session_start", "1234567890"); + clearWalletSessionState(queryClient); + expect(localStorage.getItem("wallet_session_start")).toBeNull(); + }); + + it("keeps versioned preference and one-time-UX keys across cleanup", () => { + const kept: Record = { + "yieldvault-preferences:guest": '{"version":1,"data":{}}', + "yieldvault:user-preferences:guest": '{"version":1,"data":{}}', + yv_slippage_percent: '{"version":1,"data":0.5}', + hasSeenWalkthrough: '{"version":1,"data":true}', + "yieldvault:first-deposit:GABC": '{"version":1,"data":true}', + }; + for (const [k, v] of Object.entries(kept)) localStorage.setItem(k, v); + + clearWalletSessionState(queryClient); + + for (const [k, v] of Object.entries(kept)) expect(localStorage.getItem(k)).toBe(v); + }); }); diff --git a/frontend/src/lib/sessionCleanup.ts b/frontend/src/lib/sessionCleanup.ts index 022da817e..2d8d61ee6 100644 --- a/frontend/src/lib/sessionCleanup.ts +++ b/frontend/src/lib/sessionCleanup.ts @@ -11,6 +11,16 @@ export const SESSION_STORAGE_KEYS = [ "yv.wallet.address", ] as const; +/** + * Wallet-session keys cleared on disconnect/expiry. `wallet_session_start` is a + * versioned envelope (see `lib/versionedStorage`). + * + * Deliberately NOT cleared: user preference and one-time-UX keys, which are + * versioned envelopes that must survive a session — `yieldvault-preferences:*`, + * `yieldvault:user-preferences:*`, `yv_slippage_percent`, `hasSeenWalkthrough` + * and `yieldvault:first-deposit:*` (wallet-scoped). The legacy + * `yv_slippage_bps` key is migrated and removed by `useSlippage`. + */ export const LOCAL_STORAGE_KEYS = [ "wallet_session_start", ] as const; diff --git a/frontend/src/lib/versionedStorage.test.ts b/frontend/src/lib/versionedStorage.test.ts new file mode 100644 index 000000000..650c6e7e7 --- /dev/null +++ b/frontend/src/lib/versionedStorage.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { readVersionedItem, writeVersionedItem } from "./versionedStorage"; + +const opts = { + version: 2, + parseData: (d: unknown) => (typeof d === "number" ? d : null), + migrateLegacy: (l: unknown) => (typeof l === "string" ? Number(l) : null), +}; + +describe("versionedStorage", () => { + beforeEach(() => localStorage.clear()); + + it("round-trips an envelope", () => { + writeVersionedItem("k", 2, 7); + expect(localStorage.getItem("k")).toBe('{"version":2,"data":7}'); + expect(readVersionedItem("k", opts)).toBe(7); + }); + + it("returns null when missing", () => { + expect(readVersionedItem("k", opts)).toBeNull(); + }); + + it("migrates a legacy raw value and rewrites it as an envelope", () => { + localStorage.setItem("k", "5"); + expect(readVersionedItem("k", { ...opts, migrateLegacy: (l) => Number(l) })).toBe(5); + expect(localStorage.getItem("k")).toBe('{"version":2,"data":5}'); + }); + + it("migrates a legacy non-JSON string", () => { + localStorage.setItem("k", "abc"); + expect( + readVersionedItem("k", { ...opts, migrateLegacy: (l) => (l === "abc" ? 1 : null) }), + ).toBe(1); + }); + + it("does not rewrite when legacy migration fails", () => { + localStorage.setItem("k", "{}"); + expect(readVersionedItem("k", { ...opts, migrateLegacy: () => null })).toBeNull(); + expect(localStorage.getItem("k")).toBe("{}"); + }); + + it("ignores envelopes from another version", () => { + localStorage.setItem("k", '{"version":9,"data":1}'); + expect(readVersionedItem("k", opts)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/versionedStorage.ts b/frontend/src/lib/versionedStorage.ts new file mode 100644 index 000000000..8b1867164 --- /dev/null +++ b/frontend/src/lib/versionedStorage.ts @@ -0,0 +1,64 @@ +/** + * Versioned localStorage envelope: `{ "version": , "data": }`. + * + * Values written before versioning existed (raw strings, bare JSON) are + * detected on read, passed to a caller-supplied `migrateLegacy`, and rewritten + * as an envelope so future shape changes can be migrated explicitly. + */ + +export interface VersionedEnvelope { + version: number; + data: T; +} + +export interface ReadVersionedOptions { + /** Current schema version of the stored payload. */ + version: number; + /** Validate/normalise a payload stored at the current version; null = invalid. */ + parseData: (data: unknown) => T | null; + /** Convert a pre-envelope value (parsed JSON, or the raw string) to `T`; null = unusable. */ + migrateLegacy: (legacy: unknown) => T | null; +} + +export function isVersionedEnvelope(value: unknown): value is VersionedEnvelope { + return ( + typeof value === "object" && + value !== null && + typeof (value as { version?: unknown }).version === "number" && + "data" in value + ); +} + +export function writeVersionedItem(key: string, version: number, data: T): void { + try { + localStorage.setItem(key, JSON.stringify({ version, data } satisfies VersionedEnvelope)); + } catch { + // storage quota exceeded or unavailable — callers keep in-memory state + } +} + +export function readVersionedItem(key: string, options: ReadVersionedOptions): T | null { + let raw: string | null; + try { + raw = localStorage.getItem(key); + } catch { + return null; + } + if (raw === null) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = raw; + } + + if (isVersionedEnvelope(parsed)) { + // Envelopes from a different version cannot be interpreted safely. + return parsed.version === options.version ? options.parseData(parsed.data) : null; + } + + const migrated = options.migrateLegacy(parsed); + if (migrated !== null) writeVersionedItem(key, options.version, migrated); + return migrated; +} diff --git a/frontend/src/lib/walletSession.test.ts b/frontend/src/lib/walletSession.test.ts index 27ed787f5..0c536379b 100644 --- a/frontend/src/lib/walletSession.test.ts +++ b/frontend/src/lib/walletSession.test.ts @@ -127,7 +127,7 @@ describe("walletSession start timestamp helpers", () => { it("persists the session start through startWalletSession", () => { startWalletSession(1_700_000_000_000); expect(localStorage.getItem(WALLET_SESSION_START_KEY)).toBe( - "1700000000000", + '{"version":1,"data":1700000000000}', ); expect(getWalletSessionStart()).toBe(1_700_000_000_000); }); diff --git a/frontend/src/lib/walletSession.ts b/frontend/src/lib/walletSession.ts index 08f96c001..6165944c8 100644 --- a/frontend/src/lib/walletSession.ts +++ b/frontend/src/lib/walletSession.ts @@ -1,3 +1,5 @@ +import { readVersionedItem, writeVersionedItem } from "./versionedStorage"; + /** Session flag: user chose Disconnect; skip Freighter auto-reconnect until they connect again. */ export const WALLET_MANUAL_DISCONNECT_KEY = "yieldvault_wallet_manual_disconnect"; @@ -23,10 +25,19 @@ export const WALLET_SESSION_START_KEY = "wallet_session_start"; /** Read the persisted session start, or `null` when there is no valid one. */ export function getWalletSessionStart(): number | null { if (typeof window === "undefined") return null; - const raw = localStorage.getItem(WALLET_SESSION_START_KEY); - if (!raw) return null; - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) ? parsed : null; + return readVersionedItem(WALLET_SESSION_START_KEY, { + version: WALLET_SESSION_START_VERSION, + parseData: toTimestamp, + // Legacy format: the epoch-ms timestamp as a bare string. + migrateLegacy: toTimestamp, + }); +} + +const WALLET_SESSION_START_VERSION = 1; + +function toTimestamp(value: unknown): number | null { + const n = typeof value === "string" ? Number.parseInt(value, 10) : value; + return typeof n === "number" && Number.isFinite(n) ? n : null; } /** @@ -36,7 +47,7 @@ export function getWalletSessionStart(): number | null { */ export function startWalletSession(now: number = Date.now()): void { if (typeof window === "undefined") return; - localStorage.setItem(WALLET_SESSION_START_KEY, String(now)); + writeVersionedItem(WALLET_SESSION_START_KEY, WALLET_SESSION_START_VERSION, now); } /** Persisted last-used wallet provider across sessions. */