Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions frontend/src/components/OnboardingWalkthrough.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<OnboardingWalkthrough />);
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(<OnboardingWalkthrough />);
expect(container.firstChild).toBeNull();
expect(localStorage.getItem("hasSeenWalkthrough")).toBe('{"version":1,"data":true}');
});
});
17 changes: 15 additions & 2 deletions frontend/src/components/OnboardingWalkthrough.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>(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);

Expand All @@ -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);
};

Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/VaultDashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/components/VaultDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -676,9 +678,15 @@ const VaultDashboard: React.FC<VaultDashboardProps> = ({

try {
const depositKey = `${FIRST_DEPOSIT_PREFIX}${walletAddress}`;
const alreadyCelebrated = localStorage.getItem(depositKey) === "true";
const alreadyCelebrated =
readVersionedItem<boolean>(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) {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/context/AuthContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/hooks/usePreferences.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
45 changes: 25 additions & 20 deletions frontend/src/hooks/usePreferences.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<UserPreferences>;
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<UserPreferences>;
return {
...DEFAULT_PREFERENCES,
...parsed,
notifications: {
...DEFAULT_PREFERENCES.notifications,
...(parsed.notifications ?? {}),
},
};
} catch {
return DEFAULT_PREFERENCES;
}
return (
readVersionedItem<UserPreferences>(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) {
Expand Down
20 changes: 18 additions & 2 deletions frontend/src/hooks/useSlippage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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", () => {
Expand All @@ -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();
});

Expand All @@ -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");

Expand Down
46 changes: 27 additions & 19 deletions frontend/src/hooks/useSlippage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useCallback } from "react";
import { readVersionedItem, writeVersionedItem } from "../lib/versionedStorage";
import {
SLIPPAGE_DEFAULT_PERCENT,
SLIPPAGE_HIGH_THRESHOLD_PERCENT,
Expand All @@ -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<number>(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;
}
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/hooks/useWalletConnection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
);
});
});
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/lib/sessionCleanup.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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<string, string> = {
"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);
});
});
10 changes: 10 additions & 0 deletions frontend/src/lib/sessionCleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading