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. */