diff --git a/__tests__/hooks/useSecureSign-wagmi.test.ts b/__tests__/hooks/useSecureSign-wagmi.test.ts index 616cbbc90a..15e6c89424 100644 --- a/__tests__/hooks/useSecureSign-wagmi.test.ts +++ b/__tests__/hooks/useSecureSign-wagmi.test.ts @@ -1,36 +1,41 @@ -import { renderHook, act } from '@testing-library/react'; -import { useSecureSign } from '@/hooks/useSecureSign'; -import { useAppKitAccount } from '@reown/appkit/react'; -import { useSignMessage } from 'wagmi'; -import { UserRejectedRequestError } from 'viem'; +import { renderHook, act } from "@testing-library/react"; +import { useSecureSign } from "@/hooks/useSecureSign"; +import { useAppKitAccount } from "@reown/appkit/react"; +import { useSignMessage } from "wagmi"; +import { UserRejectedRequestError } from "viem"; // Mock the hooks -jest.mock('@reown/appkit/react', () => ({ +jest.mock("@reown/appkit/react", () => ({ useAppKitAccount: jest.fn(), })); -jest.mock('wagmi', () => ({ +jest.mock("wagmi", () => ({ useSignMessage: jest.fn(), })); -const mockUseAppKitAccount = useAppKitAccount as jest.MockedFunction; -const mockUseSignMessage = useSignMessage as jest.MockedFunction; +const mockUseAppKitAccount = useAppKitAccount as jest.MockedFunction< + typeof useAppKitAccount +>; +const mockUseSignMessage = useSignMessage as jest.MockedFunction< + typeof useSignMessage +>; -describe('useSecureSign with Wagmi', () => { - const validAddress = '0x1234567890123456789012345678901234567890'; - const validSignature = '0x1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890'; +describe("useSecureSign with Wagmi", () => { + const validAddress = "0x1234567890123456789012345678901234567890"; + const validSignature = + "0x1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"; beforeEach(() => { jest.clearAllMocks(); - + // Setup default successful mocks mockUseAppKitAccount.mockReturnValue({ address: validAddress, isConnected: true, - caipAddress: '', - status: 'connected' + caipAddress: "", + status: "connected", }); - + mockUseSignMessage.mockReturnValue({ signMessage: jest.fn(), signMessageAsync: jest.fn().mockResolvedValue(validSignature), @@ -41,13 +46,13 @@ describe('useSecureSign with Wagmi', () => { isPending: false, isSuccess: false, reset: jest.fn(), - status: 'idle', + status: "idle", variables: undefined, } as any); }); - describe('Successful signing with Wagmi', () => { - it('successfully signs message using Wagmi signMessageAsync', async () => { + describe("Successful signing with Wagmi", () => { + it("successfully signs message using Wagmi signMessageAsync", async () => { const mockSignMessageAsync = jest.fn().mockResolvedValue(validSignature); mockUseSignMessage.mockReturnValue({ signMessageAsync: mockSignMessageAsync, @@ -56,17 +61,42 @@ describe('useSecureSign with Wagmi', () => { const { result } = renderHook(() => useSecureSign()); await act(async () => { - const signResult = await result.current.signMessage('test message'); + const signResult = await result.current.signMessage("test message"); expect(signResult.signature).toBe(validSignature); expect(signResult.userRejected).toBe(false); expect(signResult.error).toBeUndefined(); }); - expect(mockSignMessageAsync).toHaveBeenCalledWith({ message: 'test message' }); + expect(mockSignMessageAsync).toHaveBeenCalledWith({ + message: "test message", + }); + }); + + it("accepts contract signatures when configured", async () => { + const contractSignature = `0x${"a".repeat(200)}`; + const mockSignMessageAsync = jest + .fn() + .mockResolvedValue(contractSignature); + mockUseSignMessage.mockReturnValue({ + signMessageAsync: mockSignMessageAsync, + } as any); + + const { result } = renderHook(() => + useSecureSign({ signatureType: "contract" }) + ); + + await act(async () => { + const signResult = await result.current.signMessage("test message"); + expect(signResult.signature).toBe(contractSignature); + expect(signResult.userRejected).toBe(false); + expect(signResult.error).toBeUndefined(); + }); }); - it('handles user rejection correctly', async () => { - const mockSignMessageAsync = jest.fn().mockRejectedValue(new UserRejectedRequestError(new Error())); + it("handles user rejection correctly", async () => { + const mockSignMessageAsync = jest + .fn() + .mockRejectedValue(new UserRejectedRequestError(new Error())); mockUseSignMessage.mockReturnValue({ signMessageAsync: mockSignMessageAsync, } as any); @@ -74,7 +104,7 @@ describe('useSecureSign with Wagmi', () => { const { result } = renderHook(() => useSecureSign()); await act(async () => { - const signResult = await result.current.signMessage('test message'); + const signResult = await result.current.signMessage("test message"); expect(signResult.signature).toBeNull(); expect(signResult.userRejected).toBe(true); expect(signResult.error).toBeUndefined(); @@ -82,74 +112,82 @@ describe('useSecureSign with Wagmi', () => { }); }); - describe('Connection validation', () => { - it('fails when wallet not connected', async () => { + describe("Connection validation", () => { + it("fails when wallet not connected", async () => { mockUseAppKitAccount.mockReturnValue({ address: undefined, isConnected: false, - caipAddress: '', - status: 'disconnected' + caipAddress: "", + status: "disconnected", }); const { result } = renderHook(() => useSecureSign()); await act(async () => { - const signResult = await result.current.signMessage('test message'); - expect(signResult.error?.name).toBe('MobileSigningError'); - expect(signResult.error?.message).toBe('Wallet not connected. Please connect your wallet and try again.'); + const signResult = await result.current.signMessage("test message"); + expect(signResult.error?.name).toBe("MobileSigningError"); + expect(signResult.error?.message).toBe( + "Wallet not connected. Please connect your wallet and try again." + ); expect(signResult.signature).toBeNull(); expect(signResult.userRejected).toBe(false); }); }); - it('fails when address is missing', async () => { + it("fails when address is missing", async () => { mockUseAppKitAccount.mockReturnValue({ address: undefined, isConnected: true, - caipAddress: '', - status: 'connected' + caipAddress: "", + status: "connected", }); const { result } = renderHook(() => useSecureSign()); await act(async () => { - const signResult = await result.current.signMessage('test message'); - expect(signResult.error?.name).toBe('MobileSigningError'); - expect(signResult.error?.message).toBe('No wallet address detected. Please reconnect your wallet.'); + const signResult = await result.current.signMessage("test message"); + expect(signResult.error?.name).toBe("MobileSigningError"); + expect(signResult.error?.message).toBe( + "No wallet address detected. Please reconnect your wallet." + ); expect(signResult.signature).toBeNull(); expect(signResult.userRejected).toBe(false); }); }); }); - describe('Input validation', () => { - it('validates empty message', async () => { + describe("Input validation", () => { + it("validates empty message", async () => { const { result } = renderHook(() => useSecureSign()); await act(async () => { - const signResult = await result.current.signMessage(''); - expect(signResult.error?.name).toBe('ProviderValidationError'); - expect(signResult.error?.message).toBe('Message cannot be empty'); + const signResult = await result.current.signMessage(""); + expect(signResult.error?.name).toBe("ProviderValidationError"); + expect(signResult.error?.message).toBe("Message cannot be empty"); expect(signResult.signature).toBeNull(); expect(signResult.userRejected).toBe(false); }); }); - it('validates message length', async () => { + it("validates message length", async () => { const { result } = renderHook(() => useSecureSign()); - const longMessage = 'a'.repeat(10001); + const longMessage = "a".repeat(10001); await act(async () => { const signResult = await result.current.signMessage(longMessage); - expect(signResult.error?.name).toBe('ProviderValidationError'); - expect(signResult.error?.message).toBe('Message too long (max 10000 characters)'); + expect(signResult.error?.name).toBe("ProviderValidationError"); + expect(signResult.error?.message).toBe( + "Message too long (max 10000 characters)" + ); expect(signResult.signature).toBeNull(); expect(signResult.userRejected).toBe(false); }); }); - it('validates signature format', async () => { - const mockSignMessageAsync = jest.fn().mockResolvedValue('invalid-signature'); + it("validates signature format", async () => { + const mockSignMessageAsync = jest + .fn() + .mockResolvedValue("invalid-signature"); mockUseSignMessage.mockReturnValue({ signMessageAsync: mockSignMessageAsync, } as any); @@ -157,18 +195,20 @@ describe('useSecureSign with Wagmi', () => { const { result } = renderHook(() => useSecureSign()); await act(async () => { - const signResult = await result.current.signMessage('test message'); - expect(signResult.error?.name).toBe('ProviderValidationError'); - expect(signResult.error?.message).toBe('Invalid signature format'); + const signResult = await result.current.signMessage("test message"); + expect(signResult.error?.name).toBe("ProviderValidationError"); + expect(signResult.error?.message).toBe("Invalid signature format"); expect(signResult.signature).toBeNull(); expect(signResult.userRejected).toBe(false); }); }); }); - describe('Error handling', () => { - it('handles generic Wagmi errors', async () => { - const mockSignMessageAsync = jest.fn().mockRejectedValue(new Error('Wagmi signing failed')); + describe("Error handling", () => { + it("handles generic Wagmi errors", async () => { + const mockSignMessageAsync = jest + .fn() + .mockRejectedValue(new Error("Wagmi signing failed")); mockUseSignMessage.mockReturnValue({ signMessageAsync: mockSignMessageAsync, } as any); @@ -176,17 +216,22 @@ describe('useSecureSign with Wagmi', () => { const { result } = renderHook(() => useSecureSign()); await act(async () => { - const signResult = await result.current.signMessage('test message'); - expect(signResult.error?.name).toBe('MobileSigningError'); + const signResult = await result.current.signMessage("test message"); + expect(signResult.error?.name).toBe("MobileSigningError"); expect(signResult.signature).toBeNull(); expect(signResult.userRejected).toBe(false); }); }); - it('manages signing pending state correctly', async () => { - const mockSignMessageAsync = jest.fn().mockImplementation(() => - new Promise(resolve => setTimeout(() => resolve(validSignature), 50)) - ); + it("manages signing pending state correctly", async () => { + const mockSignMessageAsync = jest + .fn() + .mockImplementation( + () => + new Promise((resolve) => + setTimeout(() => resolve(validSignature), 50) + ) + ); mockUseSignMessage.mockReturnValue({ signMessageAsync: mockSignMessageAsync, } as any); @@ -196,11 +241,11 @@ describe('useSecureSign with Wagmi', () => { expect(result.current.isSigningPending).toBe(false); await act(async () => { - const signResult = await result.current.signMessage('test message'); + const signResult = await result.current.signMessage("test message"); expect(signResult.signature).toBe(validSignature); }); expect(result.current.isSigningPending).toBe(false); }); }); -}); \ No newline at end of file +}); diff --git a/components/auth/Auth.tsx b/components/auth/Auth.tsx index db5065bde0..e56028a1d2 100644 --- a/components/auth/Auth.tsx +++ b/components/auth/Auth.tsx @@ -10,7 +10,7 @@ import { useCallback, useRef, } from "react"; -import type { TypeOptions} from "react-toastify"; +import type { TypeOptions } from "react-toastify"; import { Slide, ToastContainer, toast } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; import { @@ -138,7 +138,9 @@ export default function Auth({ signMessage, isSigningPending, reset: resetSigning, - } = useSecureSign(); + } = useSecureSign({ + signatureType: isSafeWallet ? "contract" : "eoa", + }); const [showSignModal, setShowSignModal] = useState(false); const { profile: connectedProfile, isLoading: fetchingProfile } = useIdentity( diff --git a/components/auth/SeizeConnectContext.tsx b/components/auth/SeizeConnectContext.tsx index 43769bf416..6a883b2490 100644 --- a/components/auth/SeizeConnectContext.tsx +++ b/components/auth/SeizeConnectContext.tsx @@ -24,9 +24,11 @@ import { useAppKitAccount, useAppKitState, useDisconnect, + useWalletInfo, } from "@reown/appkit/react"; import { getAddress, isAddress } from "viem"; import { WalletErrorBoundary } from "./error-boundary"; +import { isSafeWalletInfo } from "@/utils/wallet-detection"; // Custom error types for better error handling class WalletConnectionError extends Error { @@ -345,6 +347,7 @@ export const SeizeConnectProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const account = useAppKitAccount(); + const { walletInfo } = useWalletInfo(); const { disconnect } = useDisconnect(); const { open } = useAppKit(); const state = useAppKitState(); @@ -582,9 +585,9 @@ export const SeizeConnectProvider: React.FC<{ children: React.ReactNode }> = ({ const contextValue = useMemo( (): SeizeConnectContextType => ({ address: connectedAddress, - walletName: undefined, - walletIcon: undefined, - isSafeWallet: false, + walletName: walletInfo?.name, + walletIcon: walletInfo?.icon, + isSafeWallet: isSafeWalletInfo(walletInfo), seizeConnect, seizeDisconnect, seizeDisconnectAndLogout, @@ -599,6 +602,9 @@ export const SeizeConnectProvider: React.FC<{ children: React.ReactNode }> = ({ }), [ connectedAddress, + walletInfo?.name, + walletInfo?.icon, + walletInfo?.type, seizeConnect, seizeDisconnect, seizeDisconnectAndLogout, diff --git a/hooks/useSecureSign.ts b/hooks/useSecureSign.ts index d76de3d531..19521e3885 100644 --- a/hooks/useSecureSign.ts +++ b/hooks/useSecureSign.ts @@ -67,6 +67,12 @@ interface UseSecureSignReturn { isSigningPending: boolean; reset: () => void; } + +type SignatureType = "eoa" | "contract"; + +interface UseSecureSignOptions { + signatureType?: SignatureType | undefined; +} /** * SECURITY: Validate Ethereum address format * Ensures address follows proper format and prevents injection @@ -120,26 +126,56 @@ function validateMessage(message: string): void { * SECURITY: Validate signature format * Ensures returned signature follows expected format */ -function validateSignature(signature: string): void { +const SIGNATURE_HEX_PATTERN = /^0x[a-fA-F0-9]+$/; +const MIN_EOA_SIGNATURE_HEX_LENGTH = 130; +const MAX_CONTRACT_SIGNATURE_HEX_LENGTH = 8192; + +function validateEoaSignature(signature: string): void { if (typeof signature !== "string") { throw new ProviderValidationError("Signature must be a string"); } - // Ethereum signatures should be 65 bytes (130 hex chars + 0x prefix) + // Ethereum EOA signatures should be 65 bytes (130 hex chars + 0x prefix) if (!/^0x[a-fA-F0-9]{130}$/.test(signature)) { throw new ProviderValidationError("Invalid signature format"); } } +function validateContractSignature(signature: string): void { + if (typeof signature !== "string") { + throw new ProviderValidationError("Signature must be a string"); + } + + if (!SIGNATURE_HEX_PATTERN.test(signature)) { + throw new ProviderValidationError("Invalid signature format"); + } + + const hexLength = signature.length - 2; + if (hexLength < MIN_EOA_SIGNATURE_HEX_LENGTH) { + throw new ProviderValidationError("Invalid signature format"); + } + + if (hexLength % 2 !== 0) { + throw new ProviderValidationError("Invalid signature format"); + } + + if (hexLength > MAX_CONTRACT_SIGNATURE_HEX_LENGTH) { + throw new ProviderValidationError("Invalid signature format"); + } +} + /** * Secure message signing hook with mobile wallet compatibility * Uses Wagmi's useSignMessage for proper provider management and security * This approach works with all connector types including custom AppWallet connectors */ -export const useSecureSign = (): UseSecureSignReturn => { +export const useSecureSign = ( + options?: UseSecureSignOptions +): UseSecureSignReturn => { const [isSigningPending, setIsSigningPending] = useState(false); const { address: connectedAddress, isConnected } = useAppKitAccount(); const wagmiSignMessage = useSignMessage(); + const signatureType: SignatureType = options?.signatureType ?? "eoa"; const reset = useCallback(() => { setIsSigningPending(false); @@ -157,14 +193,18 @@ export const useSecureSign = (): UseSecureSignReturn => { validateSigningContext(isConnected, connectedAddress); // Execute the signature operation using Wagmi - return await executeWagmiSignature(wagmiSignMessage, message); + return await executeWagmiSignature( + wagmiSignMessage, + message, + signatureType + ); } catch (error: unknown) { return classifySigningError(error); } finally { setIsSigningPending(false); } }, - [connectedAddress, isConnected, wagmiSignMessage] + [connectedAddress, isConnected, signatureType, wagmiSignMessage] ); return { @@ -224,7 +264,8 @@ const validateSigningContext = ( */ const executeWagmiSignature = async ( wagmiSignMessage: ReturnType, - message: string + message: string, + signatureType: SignatureType ): Promise => { try { const signature = await wagmiSignMessage.signMessageAsync({ message }); @@ -233,7 +274,11 @@ const executeWagmiSignature = async ( message = ""; // SECURITY: Validate signature format before returning - validateSignature(signature); + if (signatureType === "contract") { + validateContractSignature(signature); + } else { + validateEoaSignature(signature); + } return { signature, diff --git a/utils/wallet-detection.ts b/utils/wallet-detection.ts new file mode 100644 index 0000000000..4a02012968 --- /dev/null +++ b/utils/wallet-detection.ts @@ -0,0 +1,32 @@ +interface WalletInfoLike { + name?: string | null | undefined; + type?: string | null | undefined; +} + +const SAFE_WALLET_NAMES = new Set([ + "safe", + "gnosis safe", + "safe wallet", + "safe{wallet}", +]); + +const SAFE_WALLET_TYPES = new Set(["safe", "gnosis-safe", "gnosis_safe"]); + +const normalize = (value: string | null | undefined): string => + value?.trim().toLowerCase() ?? ""; + +export const isSafeWalletInfo = (walletInfo?: WalletInfoLike): boolean => { + if (!walletInfo) return false; + + const name = normalize(walletInfo.name); + if (name && SAFE_WALLET_NAMES.has(name)) { + return true; + } + + const type = normalize(walletInfo.type); + if (type && SAFE_WALLET_TYPES.has(type)) { + return true; + } + + return false; +};