Skip to content

Commit 28ad1e5

Browse files
authored
Merge pull request #298 from dev-fatima-24/issue-#222
Frontend — Wallet session persistence: reconnect on page reload without re-prompting
2 parents 5a42252 + 6f2c6e7 commit 28ad1e5

3 files changed

Lines changed: 180 additions & 15 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Wallet Security and Persistence
2+
3+
## Storing Public Keys vs Private Keys
4+
In the interest of user experience, our application persists the wallet session using `localStorage` to attempt silent auto-reconnects on page reloads.
5+
6+
**Important Security Implications:**
7+
1. **Never Store Secrets:** We only persist the `walletType` and the `publicKey` (Stellar Address) in localStorage.
8+
2. **Never** store seed phrases, private keys, or signed transactions in local storage or sessionStorage, as these are vulnerable to XSS (Cross-Site Scripting) attacks.
9+
3. **Public Nature of Addresses:** A public key is not secret. Storing it in localStorage merely indicates which account the user was last trying to connect with.
10+
4. **Validation on Reconnect:** During silent reconnect (`app mount`), the app queries the wallet extension or kit for the active address. We explicitly validate that the returned address matches the persisted public key. If there is a mismatch (e.g., the user switched accounts in their wallet extension), the session is cleared to prevent impersonation bugs.
11+
12+
By enforcing these constraints, we ensure a smooth UX without compromising the user's private keys or allowing incorrect application states.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import React from 'react';
2+
import { render, act, waitFor } from '@testing-library/react';
3+
import { StellarWalletsKit } from '@creit.tech/stellar-wallets-kit';
4+
import { WalletProvider, useWalletContext } from '../context/WalletContext';
5+
import { toast } from '@/components/ui/use-toast';
6+
7+
// Mock dependencies
8+
jest.mock('@creit.tech/stellar-wallets-kit', () => ({
9+
StellarWalletsKit: {
10+
init: jest.fn(),
11+
on: jest.fn(),
12+
setWallet: jest.fn(),
13+
getAddress: jest.fn(),
14+
getNetwork: jest.fn(),
15+
setNetwork: jest.fn(),
16+
disconnect: jest.fn(),
17+
signTransaction: jest.fn(),
18+
},
19+
Networks: { PUBLIC: 'public', TESTNET: 'testnet', FUTURENET: 'futurenet' },
20+
KitEventType: { STATE_UPDATED: 'state_updated', DISCONNECT: 'disconnect' },
21+
}));
22+
23+
jest.mock('@creit.tech/stellar-wallets-kit/modules/freighter', () => ({
24+
FreighterModule: jest.fn(),
25+
FREIGHTER_ID: 'freighter',
26+
}));
27+
28+
jest.mock('@creit.tech/stellar-wallets-kit/modules/xbull', () => ({
29+
xBullModule: jest.fn(),
30+
XBULL_ID: 'xbull',
31+
}));
32+
33+
jest.mock('@/components/ui/use-toast', () => ({
34+
toast: jest.fn(),
35+
}));
36+
37+
const LS_WALLET_SESSION = 'niffyinsur-wallet-session-v1';
38+
39+
const TestComponent = () => {
40+
const { address, activeWalletId, connectionStatus } = useWalletContext();
41+
return (
42+
<div>
43+
<div data-testid="address">{address || 'none'}</div>
44+
<div data-testid="wallet-id">{activeWalletId || 'none'}</div>
45+
<div data-testid="status">{connectionStatus}</div>
46+
</div>
47+
);
48+
};
49+
50+
describe('WalletContext Auto-Reconnect', () => {
51+
beforeEach(() => {
52+
localStorage.clear();
53+
jest.clearAllMocks();
54+
(StellarWalletsKit.getNetwork as jest.Mock).mockResolvedValue({ network: 'testnet' });
55+
});
56+
57+
it('silently reconnects when valid session exists', async () => {
58+
const session = { walletId: 'freighter', publicKey: 'GBXYZ...' };
59+
localStorage.setItem(LS_WALLET_SESSION, JSON.stringify(session));
60+
61+
(StellarWalletsKit.getAddress as jest.Mock).mockResolvedValue({ address: 'GBXYZ...' });
62+
63+
const { getByTestId } = render(
64+
<WalletProvider>
65+
<TestComponent />
66+
</WalletProvider>
67+
);
68+
69+
await waitFor(() => {
70+
expect(getByTestId('status').textContent).toBe('connected');
71+
expect(getByTestId('address').textContent).toBe('GBXYZ...');
72+
expect(getByTestId('wallet-id').textContent).toBe('freighter');
73+
});
74+
75+
expect(StellarWalletsKit.setWallet).toHaveBeenCalledWith('freighter');
76+
});
77+
78+
it('clears session when public keys mismatch', async () => {
79+
// Session is for GBXYZ, but adapter returns GB123
80+
const session = { walletId: 'xbull', publicKey: 'GBXYZ...' };
81+
localStorage.setItem(LS_WALLET_SESSION, JSON.stringify(session));
82+
83+
(StellarWalletsKit.getAddress as jest.Mock).mockResolvedValue({ address: 'GB123...' });
84+
85+
const { getByTestId } = render(
86+
<WalletProvider>
87+
<TestComponent />
88+
</WalletProvider>
89+
);
90+
91+
await waitFor(() => {
92+
expect(localStorage.getItem(LS_WALLET_SESSION)).toBeNull();
93+
expect(getByTestId('status').textContent).toBe('disconnected');
94+
});
95+
});
96+
97+
it('shows non-blocking banner (toast) when reconnect fails', async () => {
98+
const session = { walletId: 'freighter', publicKey: 'GBXYZ...' };
99+
localStorage.setItem(LS_WALLET_SESSION, JSON.stringify(session));
100+
101+
(StellarWalletsKit.getAddress as jest.Mock).mockRejectedValue(new Error('Locked'));
102+
103+
render(
104+
<WalletProvider>
105+
<TestComponent />
106+
</WalletProvider>
107+
);
108+
109+
await waitFor(() => {
110+
expect(toast).toHaveBeenCalledWith(expect.objectContaining({
111+
title: 'Reconnect failed',
112+
variant: 'default',
113+
}));
114+
});
115+
});
116+
});

frontend/src/features/wallet/context/WalletContext.tsx

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ const WalletContext = createContext<WalletContextValue | null>(null)
4747

4848
const LS_WALLET_KEY = 'niffyinsure:lastWalletId'
4949
const LS_NETWORK_KEY = 'niffyinsure:appNetwork'
50+
const LS_WALLET_SESSION = 'niffyinsur-wallet-session-v1'
51+
52+
interface WalletSession {
53+
walletId: WalletId;
54+
publicKey: string;
55+
}
5056

5157
function kitNetworkFor(app: AppNetwork): Networks {
5258
if (app === 'mainnet') return Networks.PUBLIC
@@ -94,6 +100,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
94100
setActiveWalletId(null)
95101
setWalletNetwork(null)
96102
setWalletNetworkResolution({ status: 'idle' })
103+
localStorage.removeItem(LS_WALLET_SESSION)
97104
}
98105
} catch {
99106
setAddress(null)
@@ -106,28 +113,48 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
106113
setActiveWalletId(null)
107114
setWalletNetwork(null)
108115
setWalletNetworkResolution({ status: 'idle' })
116+
localStorage.removeItem(LS_WALLET_SESSION)
109117
})
110118

111-
// Auto-reconnect last wallet
112-
const lastWallet = localStorage.getItem(LS_WALLET_KEY) as WalletId | null
113-
if (lastWallet) {
114-
reconnect(lastWallet)
119+
// Auto-reconnect last wallet (Silent reconnect on app mount)
120+
const sessionRaw = localStorage.getItem(LS_WALLET_SESSION)
121+
if (sessionRaw) {
122+
try {
123+
const session = JSON.parse(sessionRaw) as WalletSession
124+
reconnect(session)
125+
} catch {
126+
localStorage.removeItem(LS_WALLET_SESSION)
127+
}
115128
}
116129
// eslint-disable-next-line react-hooks/exhaustive-deps
117130
}, [])
118131

119-
async function reconnect(walletId: WalletId) {
132+
async function reconnect(session: WalletSession) {
120133
try {
121-
StellarWalletsKit.setWallet(walletId)
134+
StellarWalletsKit.setWallet(session.walletId)
122135
const { address: addr } = await StellarWalletsKit.getAddress()
136+
123137
if (addr) {
138+
// Validate reconnected public key matches stored value (Requirement: clear if mismatched)
139+
if (addr !== session.publicKey) {
140+
console.warn('Wallet address mismatch during reconnect. Clearing session.')
141+
localStorage.removeItem(LS_WALLET_SESSION)
142+
return
143+
}
144+
124145
setAddress(addr)
125-
setActiveWalletId(walletId)
146+
setActiveWalletId(session.walletId)
126147
setConnectionStatus('connected')
127148
await refreshWalletNetwork()
128149
}
129-
} catch {
130-
// Silent — wallet may not be unlocked yet
150+
} catch (err) {
151+
// Failed to reconnect (extension locked or unavailable)
152+
// Requirement: show a non-blocking banner if it fails.
153+
toast({
154+
title: 'Reconnect failed',
155+
description: 'Unable to auto-reconnect to your wallet. Please unlock your extension or connect manually.',
156+
variant: 'default', // non-blocking (not 'destructive' if we want it subtle)
157+
})
131158
}
132159
}
133160

@@ -148,11 +175,21 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
148175
try {
149176
StellarWalletsKit.setWallet(walletId)
150177
const { address: addr } = await StellarWalletsKit.getAddress()
151-
setAddress(addr ?? null)
152-
setActiveWalletId(walletId)
153-
setConnectionStatus('connected')
154-
localStorage.setItem(LS_WALLET_KEY, walletId)
155-
await refreshWalletNetwork()
178+
179+
if (addr) {
180+
setAddress(addr)
181+
setActiveWalletId(walletId)
182+
setConnectionStatus('connected')
183+
184+
// Save session data (Requirement: {walletType, publicKey})
185+
// SECURITY NOTE: We only store the public key. Never store private keys or seed phrases in localStorage.
186+
localStorage.setItem(LS_WALLET_SESSION, JSON.stringify({
187+
walletId,
188+
publicKey: addr
189+
}))
190+
191+
await refreshWalletNetwork()
192+
}
156193
} catch (err: unknown) {
157194
setWalletNetworkResolution({ status: 'idle' })
158195
setConnectionStatus('error')
@@ -173,7 +210,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
173210
setActiveWalletId(null)
174211
setWalletNetwork(null)
175212
setWalletNetworkResolution({ status: 'idle' })
176-
localStorage.removeItem(LS_WALLET_KEY)
213+
localStorage.removeItem(LS_WALLET_SESSION)
177214
}, [])
178215

179216
const signTransaction = useCallback(async (xdr: string): Promise<string> => {

0 commit comments

Comments
 (0)