diff --git a/context/WalletContext.tsx b/context/WalletContext.tsx index b242e247..7f47718a 100644 --- a/context/WalletContext.tsx +++ b/context/WalletContext.tsx @@ -1,80 +1,245 @@ "use client"; -import React, { createContext, useContext, useState, useEffect, ReactNode } from "react"; +import React, { ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { ALBEDO_ID, FREIGHTER_ID, LOBSTR_ID, RABET_ID, XBULL_ID } from "@creit.tech/stellar-wallets-kit"; +import { getKit } from "@/constants/wallet-kits.constant"; + +export type WalletErrorKind = "unauthenticated" | "forbidden" | "user_rejected" | "wallet_locked" | "identity_changed" | "validation" | "network" | "conflict" | "unknown"; +export type WalletOperationKind = "connect" | "disconnect" | "reconcile"; +export type WalletOperationStatus = "idle" | "connecting" | "disconnecting" | "reconciling"; + +export interface WalletOperationFailure { + success: false; + error: string; + errorKind: WalletErrorKind; + operationId?: number; +} + +export interface WalletConnectSuccess { success: true; address: string; operationId: number } +export interface WalletDisconnectSuccess { success: true; operationId: number } +export type WalletConnectResult = WalletConnectSuccess | WalletOperationFailure; +export type WalletDisconnectResult = WalletDisconnectSuccess | WalletOperationFailure; interface WalletContextType { address: string | null; name: string | null; connected: boolean; - connect: (address: string, name: string) => void; - disconnect: () => void; isLoading: boolean; + operationStatus: WalletOperationStatus; + activeOperationId: number | null; + operationError: WalletOperationFailure | null; + identityGeneration: number; + connectWallet: (walletId: string) => Promise; + disconnectWallet: () => Promise; + clearOperationError: () => void; + isIdentityCurrent: (address: string, generation: number) => boolean; } +interface PersistedWalletState { address: string; name: string; connected: true } +interface ActiveOperation { id: number; kind: WalletOperationKind } + const WalletContext = createContext(undefined); +const WALLET_STORAGE_KEY = "predictify_wallet_state"; + +const WALLET_NAMES: Record = { + [FREIGHTER_ID]: "Freighter", + [LOBSTR_ID]: "LOBSTR", + [XBULL_ID]: "XBull", + [ALBEDO_ID]: "Albedo", + [RABET_ID]: "Rabet", +}; + +const WALLET_IDS_BY_NAME = Object.fromEntries( + Object.entries(WALLET_NAMES).map(([id, name]) => [name.toLowerCase(), id]), +) as Record; + +function isValidStellarAddress(value: unknown): value is string { + return typeof value === "string" && /^G[A-Z2-7]{55}$/.test(value); +} + +function parsePersistedWalletState(raw: string | null): PersistedWalletState | null { + if (!raw) return null; + try { + const value = JSON.parse(raw) as Partial; + if (value.connected !== true || !isValidStellarAddress(value.address) || typeof value.name !== "string" || !WALLET_IDS_BY_NAME[value.name.toLowerCase()]) return null; + return { address: value.address, name: value.name, connected: true }; + } catch { + return null; + } +} + +export function classifyWalletError(error: unknown): WalletOperationFailure { + const message = error instanceof Error ? error.message.toLowerCase() : ""; + if (/reject|denied|cancel|cancelled|user aborted/.test(message)) return { success: false, error: "The wallet request was cancelled.", errorKind: "user_rejected" }; + if (/lock|unlock|logged out/.test(message)) return { success: false, error: "Unlock your wallet and try again.", errorKind: "wallet_locked" }; + if (/network|offline|timeout|timed out|fetch|connection/.test(message)) return { success: false, error: "The wallet could not be reached. Check your connection and try again.", errorKind: "network" }; + return { success: false, error: "The wallet operation could not be completed. Try again.", errorKind: "unknown" }; +} -const WALLET_STORAGE_KEY = 'predictify_wallet_state'; +function conflictFailure(operationId?: number): WalletOperationFailure { + return { success: false, error: "Another wallet operation is already in progress.", errorKind: "conflict", operationId }; +} export function WalletProvider({ children }: { children: ReactNode }) { const [address, setAddress] = useState(null); const [name, setName] = useState(null); const [connected, setConnected] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [operationStatus, setOperationStatus] = useState("idle"); + const [activeOperationId, setActiveOperationId] = useState(null); + const [operationError, setOperationError] = useState(null); + const [identityGeneration, setIdentityGeneration] = useState(0); + const activeOperationRef = useRef(null); + const nextOperationIdRef = useRef(0); + const identityRef = useRef({ address: null as string | null, generation: 0 }); - // Load wallet state from localStorage on mount - useEffect(() => { + const updateIdentity = useCallback((nextAddress: string | null, nextName: string | null) => { + const nextGeneration = identityRef.current.generation + 1; + identityRef.current = { address: nextAddress, generation: nextGeneration }; + setAddress(nextAddress); + setName(nextName); + setConnected(Boolean(nextAddress && nextName)); + setIdentityGeneration(nextGeneration); + }, []); + + const startOperation = useCallback((kind: WalletOperationKind, preemptConnect = false) => { + const active = activeOperationRef.current; + if (active && !(preemptConnect && active.kind === "connect")) return null; + // A new generation makes every provider callback from a preempted operation stale. + const operation = { id: ++nextOperationIdRef.current, kind }; + activeOperationRef.current = operation; + setActiveOperationId(operation.id); + setOperationStatus(kind === "connect" ? "connecting" : kind === "disconnect" ? "disconnecting" : "reconciling"); + setOperationError(null); + return operation; + }, []); + + const isOperationCurrent = useCallback((operation: ActiveOperation) => activeOperationRef.current?.id === operation.id, []); + const finishOperation = useCallback((operation: ActiveOperation) => { + if (activeOperationRef.current?.id !== operation.id) return; + activeOperationRef.current = null; + setActiveOperationId(null); + setOperationStatus("idle"); + }, []); + + const connectWallet = useCallback(async (walletId: string): Promise => { + const walletName = WALLET_NAMES[walletId]; + if (!walletName) { + const failure: WalletOperationFailure = { success: false, error: "This wallet provider is not supported.", errorKind: "validation" }; + setOperationError(failure); + return failure; + } + const operation = startOperation("connect"); + if (!operation) return conflictFailure(activeOperationRef.current?.id); try { - const savedState = localStorage.getItem(WALLET_STORAGE_KEY); - if (savedState) { - const { address, name, connected } = JSON.parse(savedState); - if (connected && address && name) { - setAddress(address); - setName(name); - setConnected(true); - console.log('Wallet state restored from localStorage:', { name, address: address.slice(0, 6) + '...' }); - } + const kit = getKit(); + kit.setWallet(walletId); + const result = await kit.getAddress(); + // Results only commit while their operation ID is still current. + if (!isOperationCurrent(operation)) return conflictFailure(operation.id); + if (!isValidStellarAddress(result.address)) { + const failure: WalletOperationFailure = { success: false, error: "The wallet returned an invalid Stellar address.", errorKind: "validation", operationId: operation.id }; + setOperationError(failure); + return failure; } - } catch (error) { - console.error('Error loading wallet state:', error); + updateIdentity(result.address, walletName); + return { success: true, address: result.address, operationId: operation.id }; + } catch (error: unknown) { + if (!isOperationCurrent(operation)) return conflictFailure(operation.id); + const failure = { ...classifyWalletError(error), operationId: operation.id }; + setOperationError(failure); + return failure; + } finally { + finishOperation(operation); + } + }, [finishOperation, isOperationCurrent, startOperation, updateIdentity]); + + const disconnectWallet = useCallback(async (): Promise => { + // Disconnect is a safety boundary and preempts any in-flight connect. + const operation = startOperation("disconnect", true); + if (!operation) return conflictFailure(activeOperationRef.current?.id); + try { + await getKit().disconnect(); + if (!isOperationCurrent(operation)) return conflictFailure(operation.id); + updateIdentity(null, null); + return { success: true, operationId: operation.id }; + } catch (error: unknown) { + if (!isOperationCurrent(operation)) return conflictFailure(operation.id); + const failure = { ...classifyWalletError(error), operationId: operation.id }; + setOperationError(failure); + return failure; } finally { + finishOperation(operation); + } + }, [finishOperation, isOperationCurrent, startOperation, updateIdentity]); + + useEffect(() => { + let mounted = true; + const persisted = parsePersistedWalletState(localStorage.getItem(WALLET_STORAGE_KEY)); + if (!persisted) { + localStorage.removeItem(WALLET_STORAGE_KEY); setIsLoading(false); + return () => { mounted = false; }; } - }, []); - // Save wallet state to localStorage whenever it changes + // Stored account data is metadata, never proof of a live wallet session. + const operation = startOperation("reconcile"); + if (!operation) { + setIsLoading(false); + return () => { mounted = false; }; + } + const reconcile = async () => { + try { + const kit = getKit(); + kit.setWallet(WALLET_IDS_BY_NAME[persisted.name.toLowerCase()]); + const result = await kit.getAddress(); + if (!mounted || !isOperationCurrent(operation)) return; + if (result.address !== persisted.address || !isValidStellarAddress(result.address)) { + localStorage.removeItem(WALLET_STORAGE_KEY); + setOperationError({ success: false, error: "The active wallet account changed. Connect it again to continue.", errorKind: "identity_changed", operationId: operation.id }); + return; + } + updateIdentity(persisted.address, persisted.name); + } catch { + if (mounted && isOperationCurrent(operation)) { + localStorage.removeItem(WALLET_STORAGE_KEY); + setOperationError({ success: false, error: "Reconnect your wallet to continue.", errorKind: "wallet_locked", operationId: operation.id }); + } + } finally { + if (mounted) { + finishOperation(operation); + setIsLoading(false); + } + } + }; + void reconcile(); + return () => { + mounted = false; + if (activeOperationRef.current?.id === operation.id) activeOperationRef.current = null; + }; + }, [finishOperation, isOperationCurrent, startOperation, updateIdentity]); + useEffect(() => { - if (!isLoading) { - const state = { address, name, connected }; - localStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(state)); + if (isLoading) return; + try { + if (connected && address && name) localStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify({ address, name, connected: true })); + else localStorage.removeItem(WALLET_STORAGE_KEY); + } catch { + // Storage failure cannot change the verified in-memory identity. } - }, [address, name, connected, isLoading]); - - const connect = (address: string, name: string) => { - setAddress(address); - setName(name); - setConnected(true); - }; - - const disconnect = () => { - setAddress(null); - setName(null); - setConnected(false); - localStorage.removeItem(WALLET_STORAGE_KEY); - }; - - return ( - - {children} - - ); + }, [address, connected, isLoading, name]); + + const isIdentityCurrent = useCallback((expectedAddress: string, generation: number) => identityRef.current.address === expectedAddress && identityRef.current.generation === generation, []); + const clearOperationError = useCallback(() => setOperationError(null), []); + const value = useMemo(() => ({ address, name, connected, isLoading, operationStatus, activeOperationId, operationError, identityGeneration, connectWallet, disconnectWallet, clearOperationError, isIdentityCurrent }), [activeOperationId, address, clearOperationError, connectWallet, connected, disconnectWallet, identityGeneration, isIdentityCurrent, isLoading, name, operationError, operationStatus]); + + return {children}; } export function useWalletContext() { const context = useContext(WalletContext); - if (context === undefined) { - throw new Error("useWalletContext must be used within a WalletProvider"); - } + if (context === undefined) throw new Error("useWalletContext must be used within a WalletProvider"); return context; } + +export const walletStateValidation = { isValidStellarAddress, parsePersistedWalletState }; diff --git a/context/__tests__/WalletContext.test.tsx b/context/__tests__/WalletContext.test.tsx new file mode 100644 index 00000000..919cd8d7 --- /dev/null +++ b/context/__tests__/WalletContext.test.tsx @@ -0,0 +1,209 @@ +import React from "react"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { WalletProvider, useWalletContext, walletStateValidation } from "@/context/WalletContext"; + +jest.mock("@creit.tech/stellar-wallets-kit", () => ({ + ALBEDO_ID: "albedo", + FREIGHTER_ID: "freighter", + LOBSTR_ID: "lobstr", + RABET_ID: "rabet", + XBULL_ID: "xbull", +})); + +const mockSetWallet = jest.fn(); +const mockGetAddress = jest.fn(); +const mockDisconnect = jest.fn(); + +jest.mock("@/constants/wallet-kits.constant", () => ({ + getKit: () => ({ + setWallet: mockSetWallet, + getAddress: mockGetAddress, + disconnect: mockDisconnect, + }), +})); + +const ADDRESS_A = `G${"A".repeat(55)}`; +const ADDRESS_B = `G${"B".repeat(55)}`; + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +describe("WalletProvider operation coordinator", () => { + beforeEach(() => { + localStorage.clear(); + mockSetWallet.mockReset(); + mockGetAddress.mockReset(); + mockDisconnect.mockReset(); + }); + + async function renderWallet() { + const hook = renderHook(() => useWalletContext(), { wrapper }); + await waitFor(() => expect(hook.result.current.isLoading).toBe(false)); + return hook; + } + + it("connects once and preserves the public state shape", async () => { + mockGetAddress.mockResolvedValue({ address: ADDRESS_A }); + const hook = await renderWallet(); + let result: Awaited> | undefined; + await act(async () => { result = await hook.result.current.connectWallet("freighter"); }); + + expect(result).toMatchObject({ success: true, address: ADDRESS_A }); + expect(mockSetWallet).toHaveBeenCalledWith("freighter"); + expect(hook.result.current).toEqual(expect.objectContaining({ + address: ADDRESS_A, + name: "Freighter", + connected: true, + connectWallet: expect.any(Function), + disconnectWallet: expect.any(Function), + })); + }); + + it("disconnects successfully and invalidates the identity generation", async () => { + mockGetAddress.mockResolvedValue({ address: ADDRESS_A }); + mockDisconnect.mockResolvedValue(undefined); + const hook = await renderWallet(); + await act(async () => { await hook.result.current.connectWallet("freighter"); }); + const connectedGeneration = hook.result.current.identityGeneration; + await act(async () => { await hook.result.current.disconnectWallet(); }); + + expect(mockDisconnect).toHaveBeenCalledTimes(1); + expect(hook.result.current.connected).toBe(false); + expect(hook.result.current.identityGeneration).toBeGreaterThan(connectedGeneration); + }); + + it("classifies a rejected provider request without logging the raw error", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => undefined); + mockGetAddress.mockRejectedValue(new Error(`User rejected ${ADDRESS_A}`)); + const hook = await renderWallet(); + let result: unknown; + await act(async () => { result = await hook.result.current.connectWallet("freighter"); }); + + expect(result).toMatchObject({ success: false, errorKind: "user_rejected", error: "The wallet request was cancelled." }); + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it("rejects an unknown provider before invoking the wallet kit", async () => { + const hook = await renderWallet(); + let result: unknown; + await act(async () => { result = await hook.result.current.connectWallet("not-a-wallet"); }); + expect(result).toMatchObject({ success: false, errorKind: "validation" }); + expect(mockSetWallet).not.toHaveBeenCalled(); + expect(mockGetAddress).not.toHaveBeenCalled(); + }); + + it("removes malformed persisted state instead of trusting it", async () => { + localStorage.setItem("predictify_wallet_state", JSON.stringify({ address: "bad", name: "Freighter", connected: true })); + const hook = await renderWallet(); + expect(hook.result.current.connected).toBe(false); + expect(localStorage.getItem("predictify_wallet_state")).toBeNull(); + expect(mockGetAddress).not.toHaveBeenCalled(); + }); + + it("allows only one concurrent provider attempt globally", async () => { + const pending = deferred<{ address: string }>(); + mockGetAddress.mockReturnValueOnce(pending.promise); + const hook = await renderWallet(); + let first!: ReturnType; + let secondResult: unknown; + act(() => { first = hook.result.current.connectWallet("freighter"); }); + await act(async () => { secondResult = await hook.result.current.connectWallet("lobstr"); }); + + expect(secondResult).toMatchObject({ success: false, errorKind: "conflict" }); + expect(mockGetAddress).toHaveBeenCalledTimes(1); + await act(async () => { pending.resolve({ address: ADDRESS_A }); await first; }); + }); + + it("deduplicates disconnect attempts", async () => { + mockGetAddress.mockResolvedValue({ address: ADDRESS_A }); + const pending = deferred(); + mockDisconnect.mockReturnValue(pending.promise); + const hook = await renderWallet(); + await act(async () => { await hook.result.current.connectWallet("freighter"); }); + let first!: ReturnType; + let secondResult: unknown; + act(() => { first = hook.result.current.disconnectWallet(); }); + await act(async () => { secondResult = await hook.result.current.disconnectWallet(); }); + + expect(secondResult).toMatchObject({ success: false, errorKind: "conflict" }); + expect(mockDisconnect).toHaveBeenCalledTimes(1); + await act(async () => { pending.resolve(); await first; }); + }); + + it("prevents a stale connect from committing after disconnect", async () => { + const pendingConnect = deferred<{ address: string }>(); + mockGetAddress.mockReturnValue(pendingConnect.promise); + mockDisconnect.mockResolvedValue(undefined); + const hook = await renderWallet(); + let connectPromise!: ReturnType; + act(() => { connectPromise = hook.result.current.connectWallet("freighter"); }); + await act(async () => { await hook.result.current.disconnectWallet(); }); + let staleResult: unknown; + await act(async () => { pendingConnect.resolve({ address: ADDRESS_A }); staleResult = await connectPromise; }); + + expect(staleResult).toMatchObject({ success: false, errorKind: "conflict" }); + expect(hook.result.current.connected).toBe(false); + }); + + it("rejects a stale result after a newer connect operation", async () => { + const firstConnect = deferred<{ address: string }>(); + mockGetAddress.mockReturnValueOnce(firstConnect.promise).mockResolvedValueOnce({ address: ADDRESS_B }); + mockDisconnect.mockResolvedValue(undefined); + const hook = await renderWallet(); + let stalePromise!: ReturnType; + act(() => { stalePromise = hook.result.current.connectWallet("freighter"); }); + await act(async () => { await hook.result.current.disconnectWallet(); }); + await act(async () => { await hook.result.current.connectWallet("lobstr"); }); + await act(async () => { firstConnect.resolve({ address: ADDRESS_A }); await stalePromise; }); + + expect(hook.result.current.address).toBe(ADDRESS_B); + expect(hook.result.current.name).toBe("LOBSTR"); + }); + + it("requires live reconciliation and rejects a changed account identity", async () => { + localStorage.setItem("predictify_wallet_state", JSON.stringify({ address: ADDRESS_A, name: "Freighter", connected: true })); + mockGetAddress.mockResolvedValue({ address: ADDRESS_B }); + const hook = await renderWallet(); + expect(hook.result.current.connected).toBe(false); + expect(hook.result.current.operationError?.errorKind).toBe("identity_changed"); + expect(localStorage.getItem("predictify_wallet_state")).toBeNull(); + }); + + it("permits an explicit new attempt after failure", async () => { + mockGetAddress.mockRejectedValueOnce(new Error("wallet locked")).mockResolvedValueOnce({ address: ADDRESS_A }); + const hook = await renderWallet(); + await act(async () => { await hook.result.current.connectWallet("freighter"); }); + expect(hook.result.current.operationError?.errorKind).toBe("wallet_locked"); + await act(async () => { await hook.result.current.connectWallet("freighter"); }); + expect(hook.result.current.connected).toBe(true); + expect(mockGetAddress).toHaveBeenCalledTimes(2); + }); + + it("keeps verified state recoverable when disconnect partially fails", async () => { + mockGetAddress.mockResolvedValue({ address: ADDRESS_A }); + mockDisconnect.mockRejectedValue(new Error("network offline")); + const hook = await renderWallet(); + await act(async () => { await hook.result.current.connectWallet("freighter"); }); + let result: unknown; + await act(async () => { result = await hook.result.current.disconnectWallet(); }); + + expect(result).toMatchObject({ success: false, errorKind: "network" }); + expect(hook.result.current.connected).toBe(true); + expect(hook.result.current.operationStatus).toBe("idle"); + }); +}); + +describe("wallet state validation", () => { + it("accepts only structurally valid Stellar account IDs", () => { + expect(walletStateValidation.isValidStellarAddress(ADDRESS_A)).toBe(true); + expect(walletStateValidation.isValidStellarAddress("G123")).toBe(false); + }); +}); diff --git a/hooks/__tests__/useTransaction.hook.test.tsx b/hooks/__tests__/useTransaction.hook.test.tsx index 6dfdac87..84d0ded1 100644 --- a/hooks/__tests__/useTransaction.hook.test.tsx +++ b/hooks/__tests__/useTransaction.hook.test.tsx @@ -3,11 +3,13 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' const mockSignTransaction = jest.fn(); const mockToast = jest.fn(); +let mockIdentityGeneration = 0; jest.mock('@/hooks/useWallet.hook', () => ({ useWallet: () => ({ signTransaction: mockSignTransaction, isConnected: true, + identityGeneration: mockIdentityGeneration, }), })); @@ -25,6 +27,8 @@ function TestComponent({ buildXdr }: { buildXdr: () => Promise | string transactionError, failureType, executeTransaction, + retryTransaction, + canRetry, } = useTransaction(); const latest = useRef({ status, transactionHash, transactionError, failureType }); @@ -37,10 +41,12 @@ function TestComponent({ buildXdr }: { buildXdr: () => Promise | string return (
+
{status}
{transactionHash ?? ''}
{transactionError ?? ''}
{failureType ?? ''}
+
{String(canRetry)}
); } @@ -50,6 +56,7 @@ describe('useTransaction hook', () => { jest.restoreAllMocks(); mockSignTransaction.mockReset(); mockToast.mockReset(); + mockIdentityGeneration = 0; }); it('completes a transaction lifecycle successfully', async () => { @@ -120,4 +127,17 @@ describe('useTransaction hook', () => { delete (global as any).fetch; } }); + + it('invalidates privileged retry material when the wallet identity changes', async () => { + mockSignTransaction.mockResolvedValue({ success: false, error: 'User rejected request' }); + const { rerender } = render( 'sensitive-xdr'} />); + fireEvent.click(screen.getByRole('button', { name: /submit/i })); + await waitFor(() => expect(screen.getByTestId('canRetry')).toHaveTextContent('true')); + + mockIdentityGeneration = 1; + rerender( 'sensitive-xdr'} />); + + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('idle')); + expect(screen.getByTestId('canRetry')).toHaveTextContent('false'); + }); }); diff --git a/hooks/useTransaction.hook.ts b/hooks/useTransaction.hook.ts index c52bcbee..bc94d096 100644 --- a/hooks/useTransaction.hook.ts +++ b/hooks/useTransaction.hook.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useWallet } from '@/hooks/useWallet.hook'; import { toast } from '@/hooks/use-toast'; import { @@ -55,7 +55,7 @@ function isUserRejectedError(message: string) { } export const useTransaction = (): UseTransactionResult => { - const { signTransaction, isConnected } = useWallet(); + const { signTransaction, isConnected, identityGeneration } = useWallet(); const [status, setStatus] = useState('idle'); const [transactionHash, setTransactionHash] = useState(null); const [transactionError, setTransactionError] = useState(null); @@ -64,6 +64,7 @@ export const useTransaction = (): UseTransactionResult => { const lastBuildXdrRef = useRef<(() => Promise | string) | null>(null); const lastSignedXdrRef = useRef(null); const lastSubmittedHashRef = useRef(null); + const retryIdentityGenerationRef = useRef(identityGeneration); const resetTransaction = useCallback(() => { setStatus('idle'); @@ -73,7 +74,17 @@ export const useTransaction = (): UseTransactionResult => { lastBuildXdrRef.current = null; lastSignedXdrRef.current = null; lastSubmittedHashRef.current = null; - }, []); + retryIdentityGenerationRef.current = identityGeneration; + }, [identityGeneration]); + + const previousIdentityGenerationRef = useRef(identityGeneration); + useEffect(() => { + if (previousIdentityGenerationRef.current !== identityGeneration) { + // Signed payloads and retry callbacks are privileged to the identity that created them. + resetTransaction(); + previousIdentityGenerationRef.current = identityGeneration; + } + }, [identityGeneration, resetTransaction]); const executeTransaction = useCallback( async (buildXdr: () => Promise | string) => { @@ -84,6 +95,7 @@ export const useTransaction = (): UseTransactionResult => { lastBuildXdrRef.current = buildXdr; lastSignedXdrRef.current = null; lastSubmittedHashRef.current = null; + retryIdentityGenerationRef.current = identityGeneration; if (!isConnected) { const error = 'Connect a wallet before submitting a transaction.'; @@ -200,10 +212,14 @@ export const useTransaction = (): UseTransactionResult => { return { success: false, error: rawMessage, failureType: 'requestFailed' as TransactionFailureType }; } }, - [isConnected, signTransaction], + [identityGeneration, isConnected, signTransaction], ); const retryTransaction = useCallback(async () => { + if (retryIdentityGenerationRef.current !== identityGeneration) { + resetTransaction(); + return { success: false, error: 'Wallet identity changed. Start a new transaction.', failureType: 'requestFailed' as TransactionFailureType }; + } if (status !== 'failed' || !failureType) { return { success: false, error: 'No failed transaction to retry', failureType: 'requestFailed' as TransactionFailureType }; } @@ -338,7 +354,7 @@ export const useTransaction = (): UseTransactionResult => { }); return { success: false, error: rawMessage, failureType: 'requestFailed' as TransactionFailureType }; } - }, [status, failureType, executeTransaction]); + }, [executeTransaction, failureType, identityGeneration, resetTransaction, status]); const canRetry = status === 'failed' && ( lastBuildXdrRef.current !== null || diff --git a/hooks/useWallet.hook.ts b/hooks/useWallet.hook.ts index 00a28039..055b781d 100644 --- a/hooks/useWallet.hook.ts +++ b/hooks/useWallet.hook.ts @@ -1,68 +1,5 @@ -// import { ISupportedWallet } from "@creit.tech/stellar-wallets-kit"; -// import { kit } from "../constants/wallet-kit.constant"; -// import { useGlobalAuthenticationStore } from "@/core/store/data"; -// import { useRouter } from "next/navigation"; - -// export const useWallet = () => { -// const router = useRouter(); -// const { connectWalletStore, disconnectWalletStore } = -// useGlobalAuthenticationStore(); - -// const connectWallet = async () => { -// await kit.openModal({ -// modalTitle: "Connect to your favorite wallet", -// onWalletSelected: async (option: ISupportedWallet) => { -// kit.setWallet(option.id); - -// const { address } = await kit.getAddress(); -// const { name } = option; - -// connectWalletStore(address, name); -// }, -// }); -// }; - -// const disconnectWallet = async () => { -// await kit.disconnect(); -// disconnectWalletStore(); -// router.push("/"); -// }; - -// const handleConnect = async () => { -// try { -// await connectWallet(); -// } catch (error) { -// console.error("Error connecting wallet:", error); -// } -// }; - -// const handleDisconnect = async () => { -// try { -// if (disconnectWallet) { -// await disconnectWallet(); -// } -// } catch (error) { -// console.error("Error disconnecting wallet:", error); -// } -// }; - -// return { -// connectWallet, -// disconnectWallet, -// handleConnect, -// handleDisconnect, -// }; -// }; - import { useWalletContext } from "@/context/WalletContext"; -import { - FREIGHTER_ID, - LOBSTR_ID, - XBULL_ID, - ALBEDO_ID, - RABET_ID, - WalletNetwork, -} from "@creit.tech/stellar-wallets-kit"; +import { WalletNetwork } from "@creit.tech/stellar-wallets-kit"; import { useState } from "react"; import { getKit } from "../constants/wallet-kits.constant"; import { getClientConfig } from "@/lib/config"; @@ -70,102 +7,48 @@ import { normalizeContractError } from "@/lib/stellar/contract-error-normalizer" export const useWallet = () => { const walletState = useWalletContext(); - const [isConnecting, setIsConnecting] = useState(false); - const [error, setError] = useState(null); - - const connectWallet = async (walletId: string) => { - try { - setIsConnecting(true); - setError(null); - - const kit = getKit(); - kit.setWallet(walletId); - - const { address } = await kit.getAddress(); - - const walletName = - walletId === FREIGHTER_ID - ? "Freighter" - : walletId === LOBSTR_ID - ? "LOBSTR" - : walletId === XBULL_ID - ? "XBull" - : walletId === ALBEDO_ID - ? "Albedo" - : walletId === RABET_ID - ? "Rabet" - : "Unknown Wallet"; - - walletState.connect(address, walletName); - - return { success: true, address }; - } catch (error: unknown) { - const rawMessage = (error as Error)?.message || "Error connecting wallet"; - // Use normalized message for user-facing error state; log raw for diagnostics - const normalized = normalizeContractError(rawMessage); - const userMessage = normalized.description; - setError(userMessage); - console.error("Error connecting wallet:", error); - return { success: false, error: userMessage }; - } finally { - setIsConnecting(false); - } - }; - - const disconnectWallet = async () => { - try { - setError(null); - const kit = getKit(); - await kit.disconnect(); - walletState.disconnect(); - return { success: true }; - } catch (error: unknown) { - const rawMessage = (error as Error)?.message || "Error disconnecting wallet"; - const normalized = normalizeContractError(rawMessage); - const userMessage = normalized.description; - setError(userMessage); - console.error("Error disconnecting wallet:", error); - return { success: false, error: userMessage }; - } - }; + const [signingError, setSigningError] = useState(null); const signTransaction = async (xdr: string) => { + if (!walletState.address) return { success: false, error: "No wallet connected", errorKind: "validation" as const }; + setSigningError(null); + const expectedAddress = walletState.address; + const expectedGeneration = walletState.identityGeneration; try { - setError(null); - if (!walletState.address) { - throw new Error("No wallet connected"); - } - - const kit = getKit(); const clientConfig = getClientConfig(); - const networkPassphrase = - clientConfig.stellar.network === 'mainnet' - ? WalletNetwork.PUBLIC - : WalletNetwork.TESTNET; - - const { signedTxXdr } = await kit.signTransaction(xdr, { - address: walletState.address, - networkPassphrase, - }); - + const networkPassphrase = clientConfig.stellar.network === "mainnet" ? WalletNetwork.PUBLIC : WalletNetwork.TESTNET; + const { signedTxXdr } = await getKit().signTransaction(xdr, { address: expectedAddress, networkPassphrase }); + if (!walletState.isIdentityCurrent(expectedAddress, expectedGeneration)) { + return { success: false, error: "The active wallet account changed. Review the transaction and try again.", errorKind: "identity_changed" as const }; + } return { success: true, signedTxXdr }; } catch (error: unknown) { const rawMessage = (error as Error)?.message || "Error signing transaction"; - // Use normalized message for user-facing error state; log raw for diagnostics const normalized = normalizeContractError(rawMessage); const userMessage = normalized.description; - setError(userMessage); - console.error("Error signing transaction:", error); - return { success: false, error: userMessage }; + setSigningError(userMessage); + return { success: false, error: userMessage, errorKind: "unknown" as const }; } }; + const clearError = () => { + setSigningError(null); + walletState.clearOperationError(); + }; + return { - connectWallet, - disconnectWallet, + connectWallet: walletState.connectWallet, + disconnectWallet: walletState.disconnectWallet, signTransaction, - isConnecting, - error, + isConnecting: walletState.operationStatus === "connecting" || walletState.operationStatus === "reconciling", + isDisconnecting: walletState.operationStatus === "disconnecting", + isOperationPending: walletState.operationStatus !== "idle", + operationStatus: walletState.operationStatus, + activeOperationId: walletState.activeOperationId, + error: walletState.operationError?.error ?? signingError, + errorKind: walletState.operationError?.errorKind ?? null, + clearError, + identityGeneration: walletState.identityGeneration, isConnected: walletState.connected, walletAddress: walletState.address, walletName: walletState.name, diff --git a/src/legacy-pages/LinkedAccounts.tsx b/src/legacy-pages/LinkedAccounts.tsx index 5f2f9e05..5476a3c6 100644 --- a/src/legacy-pages/LinkedAccounts.tsx +++ b/src/legacy-pages/LinkedAccounts.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Check, + AlertCircle, Copy, ExternalLink, Link2, @@ -15,6 +16,7 @@ import Image from "next/image" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { LiveRegion } from "@/components/ui/live-region" import { useWalletContext } from "@/context/WalletContext" import { useWallet } from "@/hooks/useWallet.hook" @@ -57,13 +59,14 @@ function getExplorerUrl(address: string, network: string): string { */ export default function LinkedAccounts() { const { address, name, connected } = useWalletContext() - const { disconnectWallet, connectWallet, isConnecting } = useWallet() + const { disconnectWallet, connectWallet, isConnecting, isDisconnecting, isOperationPending } = useWallet() const { balance, isLoading: balanceLoading } = useStellarBalance( connected ? address : null, ) const [scrolled, setScrolled] = useState(false) const [copied, setCopied] = useState(false) + const [operationMessage, setOperationMessage] = useState(null) const headerRef = useRef(null) const config = getClientConfig() @@ -93,12 +96,16 @@ export default function LinkedAccounts() { }, [address]) const handleDisconnect = useCallback(async () => { - await disconnectWallet() + setOperationMessage(null) + const result = await disconnectWallet() + if (!result.success) setOperationMessage(result.error) }, [disconnectWallet]) const handleConnect = useCallback( async (walletId: string) => { - await connectWallet(walletId) + setOperationMessage(null) + const result = await connectWallet(walletId) + if (!result.success) setOperationMessage(result.error) }, [connectWallet], ) @@ -142,6 +149,16 @@ export default function LinkedAccounts() { + {operationMessage && ( + + + )} + {/* ── Connected Wallet Card ───────────────────────────────────── */} {connected && address ? ( @@ -237,6 +254,7 @@ export default function LinkedAccounts() { size="sm" className="h-8 gap-1.5 px-3 text-xs text-destructive hover:text-destructive" onClick={handleDisconnect} + disabled={isOperationPending} aria-label={`Disconnect ${name} wallet from card`} >