forked from StellarSend/frontend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathWalletContext.tsx
More file actions
279 lines (241 loc) · 9.85 KB
/
Copy pathWalletContext.tsx
File metadata and controls
279 lines (241 loc) · 9.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useRef,
} from 'react'
import {
getPublicKey,
isConnected,
isAllowed,
getUserInfo,
setAllowed,
} from '@stellar/freighter-api'
import type { Network, WalletState, AccountInfo } from '@/types'
import { DEFAULT_SETTINGS } from '@/types'
import { fetchAccountFromHorizon } from '@/lib/api'
// Freighter's own popup lets the user switch accounts entirely outside this
// app. Poll for that drift on an interval distinct from (and shorter than)
// the account/balance refresh interval below, which is configurable by the
// user and defaults to 30s - too slow to catch a mid-session account switch
// before a stale publicKey gets used to build/sign a transaction.
export const WALLET_POLL_INTERVAL_MS = 3_000
// ─── State & Actions ─────────────────────────────────────────────────────────
type WalletAction =
| { type: 'SET_CONNECTING' }
| { type: 'SET_CONNECTED'; publicKey: string }
| { type: 'SET_DISCONNECTED' }
| { type: 'SET_ERROR'; error: string }
| { type: 'SET_ACCOUNT'; account: AccountInfo | null }
| { type: 'SET_FREIGHTER_INSTALLED'; installed: boolean }
| { type: 'SET_NETWORK'; network: Network }
| { type: 'WALLET_CHANGED'; error: string }
function walletReducer(state: WalletState, action: WalletAction): WalletState {
switch (action.type) {
case 'SET_CONNECTING':
return { ...state, status: 'connecting', error: null }
case 'SET_CONNECTED':
return { ...state, status: 'connected', publicKey: action.publicKey, error: null }
case 'SET_DISCONNECTED':
return { ...state, status: 'disconnected', publicKey: null, account: null, error: null }
case 'SET_ERROR':
return { ...state, status: 'error', error: action.error }
case 'SET_ACCOUNT':
return { ...state, account: action.account }
case 'SET_FREIGHTER_INSTALLED':
return { ...state, isFreighterInstalled: action.installed }
case 'SET_NETWORK':
return { ...state, network: action.network }
case 'WALLET_CHANGED':
// Freighter's active account changed underneath us - the publicKey
// we've been holding is stale, so clear it rather than risk building
// or signing a transaction against the wrong account.
return {
...state,
status: 'error',
publicKey: null,
account: null,
error: action.error,
}
default:
return state
}
}
const initialState: WalletState = {
status: 'disconnected',
publicKey: null,
network: DEFAULT_SETTINGS.network,
account: null,
isFreighterInstalled: false,
error: null,
}
// ─── Context ──────────────────────────────────────────────────────────────────
interface WalletContextValue {
wallet: WalletState
connect: () => Promise<void>
disconnect: () => void
refreshAccount: () => Promise<void>
setNetwork: (network: Network) => void
signTransaction: (xdr: string) => Promise<string>
}
const WalletContext = createContext<WalletContextValue | null>(null)
// ─── Provider ─────────────────────────────────────────────────────────────────
export function WalletProvider({ children }: { children: React.ReactNode }) {
const [wallet, dispatch] = useReducer(walletReducer, initialState)
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Mirrors wallet.publicKey for use inside setInterval callbacks below,
// which close over stale state if they read `wallet` directly instead of
// a ref kept current via this effect.
const publicKeyRef = useRef<string | null>(null)
useEffect(() => {
publicKeyRef.current = wallet.publicKey
}, [wallet.publicKey])
// Load persisted network preference
useEffect(() => {
const saved = localStorage.getItem('stellarsend_network') as Network | null
if (saved === 'testnet' || saved === 'mainnet') {
dispatch({ type: 'SET_NETWORK', network: saved })
}
}, [])
// Detect Freighter on mount
useEffect(() => {
const detect = async () => {
try {
const connected = await isConnected()
dispatch({ type: 'SET_FREIGHTER_INSTALLED', installed: true })
if (connected) {
const allowed = await isAllowed()
if (allowed) {
const pubKey = await getPublicKey()
if (pubKey) {
dispatch({ type: 'SET_CONNECTED', publicKey: pubKey })
}
}
}
} catch {
dispatch({ type: 'SET_FREIGHTER_INSTALLED', installed: false })
}
}
detect()
}, [])
// Auto-refresh account info when connected
const refreshAccount = useCallback(async () => {
if (!wallet.publicKey) return
try {
const account = await fetchAccountFromHorizon(wallet.publicKey, wallet.network)
dispatch({ type: 'SET_ACCOUNT', account })
} catch (err) {
console.warn('Failed to refresh account:', err)
}
}, [wallet.publicKey, wallet.network])
useEffect(() => {
if (wallet.status !== 'connected') return
refreshAccount()
const interval = parseInt(
localStorage.getItem('stellarsend_refresh_interval') || '30',
10,
)
refreshTimerRef.current = setInterval(refreshAccount, interval * 1_000)
return () => {
if (refreshTimerRef.current) clearInterval(refreshTimerRef.current)
}
// wallet.network is included explicitly (not just implied via
// refreshAccount's own identity change) so a network switch while
// connected always triggers an immediate refetch on this exact line,
// not just as a side effect of how refreshAccount happens to memoize.
}, [wallet.status, wallet.network, refreshAccount])
// Freighter exposes no account/network-change event (see WALLET_POLL_INTERVAL_MS
// above), so reconcile by polling getPublicKey() while connected. A mismatch means
// the user switched accounts inside Freighter's own UI; force a reconnect rather
// than continuing to sign with the stale key.
useEffect(() => {
if (wallet.status !== 'connected') return
const checkForAccountChange = async () => {
try {
const currentKey = await getPublicKey()
if (currentKey && publicKeyRef.current && currentKey !== publicKeyRef.current) {
dispatch({
type: 'WALLET_CHANGED',
error: 'Freighter account changed. Please reconnect to continue.',
})
}
} catch {
// Freighter may be locked or briefly unreachable; ignore transient failures.
}
}
const pollTimer = setInterval(checkForAccountChange, WALLET_POLL_INTERVAL_MS)
return () => clearInterval(pollTimer)
}, [wallet.status])
const connect = useCallback(async () => {
dispatch({ type: 'SET_CONNECTING' })
try {
const connected = await isConnected()
if (!connected) {
dispatch({
type: 'SET_ERROR',
error: 'Freighter wallet is not installed. Please install it from freighter.app',
})
return
}
await setAllowed()
const pubKey = await getPublicKey()
if (!pubKey) throw new Error('Could not retrieve public key from Freighter')
dispatch({ type: 'SET_CONNECTED', publicKey: pubKey })
// Fetch user info (optional, may fail)
try {
await getUserInfo()
} catch {
/* non-fatal */
}
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to connect wallet'
dispatch({ type: 'SET_ERROR', error: message })
}
}, [])
const disconnect = useCallback(() => {
if (refreshTimerRef.current) clearInterval(refreshTimerRef.current)
dispatch({ type: 'SET_DISCONNECTED' })
}, [])
const setNetwork = useCallback((network: Network) => {
dispatch({ type: 'SET_NETWORK', network })
// Clear the stale account synchronously so the UI never shows the
// previous network's balances under the new network's label, even for
// one render - the refresh-account effect above (keyed on wallet.network)
// will refetch fresh data for the new network right after.
dispatch({ type: 'SET_ACCOUNT', account: null })
localStorage.setItem('stellarsend_network', network)
}, [])
const signTransaction = useCallback(
async (xdr: string): Promise<string> => {
if (!wallet.publicKey) throw new Error('Wallet not connected')
// Dynamic import to avoid SSR issues
const { signTransaction: freighterSign } = await import('@stellar/freighter-api')
const networkPassphrase =
wallet.network === 'testnet'
? 'Test SDF Network ; September 2015'
: 'Public Global Stellar Network ; September 2015'
const result = await freighterSign(xdr, {
networkPassphrase,
accountToSign: wallet.publicKey,
})
return result
},
[wallet.publicKey, wallet.network],
)
const value = useMemo<WalletContextValue>(
() => ({ wallet, connect, disconnect, refreshAccount, setNetwork, signTransaction }),
[wallet, connect, disconnect, refreshAccount, setNetwork, signTransaction],
)
return <WalletContext.Provider value={value}>{children}</WalletContext.Provider>
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
// eslint-disable-next-line react-refresh/only-export-components
export function useWalletContext(): WalletContextValue {
const ctx = useContext(WalletContext)
if (!ctx) throw new Error('useWalletContext must be used inside WalletProvider')
return ctx
}