diff --git a/.changeset/polite-foxes-wonder.md b/.changeset/polite-foxes-wonder.md new file mode 100644 index 000000000..8a2782a5f --- /dev/null +++ b/.changeset/polite-foxes-wonder.md @@ -0,0 +1,5 @@ +--- +"@fuel-connectors/solana-connector": minor +--- + +feat: Added signature validation for the Solana Connector. diff --git a/packages/react/src/icons/CopyIcon.tsx b/packages/react/src/icons/CopyIcon.tsx index b772ee010..ead34a47e 100644 --- a/packages/react/src/icons/CopyIcon.tsx +++ b/packages/react/src/icons/CopyIcon.tsx @@ -9,9 +9,9 @@ export function CopyIcon({ size, ...props }: SvgIconProps) { viewBox="0 0 24 24" fill="none" stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" {...props} > Copy Icon diff --git a/packages/react/src/icons/ErrorIcon.tsx b/packages/react/src/icons/ErrorIcon.tsx new file mode 100644 index 000000000..5b8abe26b --- /dev/null +++ b/packages/react/src/icons/ErrorIcon.tsx @@ -0,0 +1,24 @@ +import type { SvgIconProps } from '../types'; + +export function ErrorIcon({ size = 24, theme = 'light' }: SvgIconProps) { + const color = theme === 'light' ? '#f25a68' : '#ff6b7a'; + + return ( + + + + ); +} diff --git a/packages/react/src/providers/FuelUIProvider.tsx b/packages/react/src/providers/FuelUIProvider.tsx index 70d2f0ea5..58c4a22db 100644 --- a/packages/react/src/providers/FuelUIProvider.tsx +++ b/packages/react/src/providers/FuelUIProvider.tsx @@ -30,6 +30,7 @@ export enum Routes { Connecting = 'CONNECTING', PredicateExternalDisclaimer = 'PREDICATE_EXTERNAL_DISCLAIMER', PredicateAddressDisclaimer = 'PREDICATE_ADDRESS_DISCLAIMER', + SignatureError = 'SIGNATURE_ERROR', } export type FuelUIContextType = { @@ -42,9 +43,7 @@ export type FuelUIContextType = { isConnecting: boolean; isError: boolean; connect: () => void; - cancel: (params?: { - clean?: boolean; - }) => void; + cancel: (params?: { clean?: boolean }) => void; setError: (error: Error | null) => void; error: Error | null; dialog: { diff --git a/packages/react/src/ui/Connect/components/Connector/Connecting.tsx b/packages/react/src/ui/Connect/components/Connector/Connecting.tsx index 2dd6758a2..5b569d967 100644 --- a/packages/react/src/ui/Connect/components/Connector/Connecting.tsx +++ b/packages/react/src/ui/Connect/components/Connector/Connecting.tsx @@ -101,6 +101,14 @@ export function Connecting({ className }: ConnectorProps) { }; }, [fuel]); + useEffect(() => { + if (error) { + if (error.message.includes('Failed to sign message')) { + setRoute(Routes.SignatureError); + } + } + }, [error, setRoute]); + if (!connector) return null; return ( diff --git a/packages/react/src/ui/Connect/components/SignatureError/SignatureError.tsx b/packages/react/src/ui/Connect/components/SignatureError/SignatureError.tsx new file mode 100644 index 000000000..9200a5a7a --- /dev/null +++ b/packages/react/src/ui/Connect/components/SignatureError/SignatureError.tsx @@ -0,0 +1,65 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import { ErrorIcon } from '../../../../icons/ErrorIcon'; +import { useConnectUI } from '../../../../providers/FuelUIProvider'; +import { + BackIcon, + CloseIcon, + DialogHeader, + DialogMain, + DialogTitle, + Divider, +} from '../../styles'; +import { + ConnectorButton, + ConnectorButtonPrimary, + ConnectorContent, + ConnectorDescription, + ConnectorImage, + ConnectorTitle, +} from '../Connector/styles'; +import { DialogContent } from '../Core/DialogContent'; +import { DialogFuel } from '../Core/DialogFuel'; + +type SignatureErrorProps = { + theme: 'dark' | 'light'; +}; + +export function SignatureError({ theme }: SignatureErrorProps) { + const { cancel } = useConnectUI(); + + return ( + cancel()}> + + + + Signature Error + + cancel()} /> + + + + +
+ + + + + Failed to Sign Message + + If you are using a Ledger device, please check the + troubleshooting guide below. + + + + View Troubleshooting Guide + + cancel()}>Close +
+
+
+
+ ); +} diff --git a/packages/react/src/ui/Connect/index.tsx b/packages/react/src/ui/Connect/index.tsx index efe383974..7ee907b6d 100644 --- a/packages/react/src/ui/Connect/index.tsx +++ b/packages/react/src/ui/Connect/index.tsx @@ -17,8 +17,11 @@ import { DialogContent } from './components/Core/DialogContent'; import { DialogFuel } from './components/Core/DialogFuel'; import { PredicateAddressDisclaimer } from './components/PredicateAddressDisclaimer/PredicateAddressDisclaimer'; import { PredicateExternalDisclaimer } from './components/PredicateExternalDisclaimer/PredicateExternalDisclaimer'; +import { SignatureError } from './components/SignatureError/SignatureError'; const ConnectRoutes = ({ route }: { route: Routes }) => { + const { theme } = useConnectUI(); + switch (route) { case Routes.List: return ; @@ -30,6 +33,8 @@ const ConnectRoutes = ({ route }: { route: Routes }) => { return ; case Routes.Connecting: return ; + case Routes.SignatureError: + return ; default: return null; } diff --git a/packages/solana-connector/src/SolanaConnector.ts b/packages/solana-connector/src/SolanaConnector.ts index 3cf44e2d4..1793ecab9 100644 --- a/packages/solana-connector/src/SolanaConnector.ts +++ b/packages/solana-connector/src/SolanaConnector.ts @@ -18,18 +18,28 @@ import { type ConnectorMetadata, FuelConnectorEventTypes, Provider as FuelProvider, + LocalStorage, + type StorageAbstract, type TransactionRequestLike, hexlify, toUtf8Bytes, } from 'fuels'; -import { HAS_WINDOW, SOLANA_ICON } from './constants'; +import nacl from 'tweetnacl'; +import { HAS_WINDOW, SOLANA_ICON, WINDOW } from './constants'; import { PREDICATE_VERSIONS } from './generated/predicates'; import type { SolanaConfig } from './types'; +import { SolanaConnectorEvents } from './types'; import { createSolanaConfig, createSolanaWeb3ModalInstance } from './web3Modal'; +const SIGNATURE_VALIDATION_KEY = (address: string) => + `SIGNATURE_VALIDATION_${address}`; + export class SolanaConnector extends PredicateConnector { name = 'Solana Wallets'; - events = FuelConnectorEventTypes; + events = { + ...FuelConnectorEventTypes, + ...SolanaConnectorEvents, + }; metadata: ConnectorMetadata = { image: SOLANA_ICON, install: { @@ -44,16 +54,30 @@ export class SolanaConnector extends PredicateConnector { private web3Modal!: Web3Modal; private config: SolanaConfig = {}; private svmAddress: string | null = null; + private storage: StorageAbstract; + + private isPollingSignatureRequestActive = false; constructor(config: SolanaConfig) { super(); + this.storage = + config.storage || new LocalStorage(WINDOW?.localStorage as Storage); this.customPredicate = config.predicateConfig || null; if (HAS_WINDOW) { this.configProviders(config); } + + this.on(this.events.currentConnector, async () => { + const address = this.web3Modal?.getAddress(); + if (!address) { + return; + } + }); } private async _emitDisconnect() { + console.log('!!! _emitDisconnect CALLED !!! - Resetting state.'); + this.isPollingSignatureRequestActive = false; this.svmAddress = null; await this.setupPredicate(); this.emit(this.events.connection, false); @@ -61,13 +85,25 @@ export class SolanaConnector extends PredicateConnector { this.emit(this.events.currentAccount, null); } + private _emitSignatureError(_error: unknown) { + console.log( + '!!! _emitSignatureError CALLED !!! - Will call _emitDisconnect.', + ); + this.isPollingSignatureRequestActive = false; + this.emit(SolanaConnectorEvents.ERROR, new Error('Failed to sign message')); + this.web3Modal.disconnect(); + this._emitDisconnect(); + } + private async _emitConnected() { + const address = this.web3Modal?.getAddress(); + const predicate = this.predicateAccount?.getPredicateAddress(address || ''); await this.setupPredicate(); - const address = this.web3Modal.getAddress(); - if (!address || !this.predicateAccount) return; + if (!address || !this.predicateAccount) { + return; + } this.svmAddress = address; this.emit(this.events.connection, true); - const predicate = this.predicateAccount.getPredicateAddress(address); this.emit(this.events.currentAccount, predicate); const accounts = await this.walletAccounts(); const _accounts = this.predicateAccount?.getPredicateAddresses(accounts); @@ -103,7 +139,6 @@ export class SolanaConnector extends PredicateConnector { if (!address || address.startsWith('0x')) { return; } - this._emitConnected(); break; } case 'DISCONNECT_SUCCESS': { @@ -116,14 +151,30 @@ export class SolanaConnector extends PredicateConnector { // Poll for account changes due a problem with the event listener not firing on account changes const interval = setInterval(async () => { - if (!this.web3Modal) { + if (!this.web3Modal || this.isPollingSignatureRequestActive) { return; } const address = this.web3Modal.getAddress(); if (address && address !== this.svmAddress) { - this._emitConnected(); - } - if (!address && this.svmAddress) { + const hasValidation = await this.accountHasValidation(address); + if (hasValidation) { + this.svmAddress = address; + this._emitConnected(); + } else { + const currentStorage = await this.storage.getItem( + SIGNATURE_VALIDATION_KEY(address), + ); + if (currentStorage !== 'pending' && currentStorage !== 'true') { + await this.storage.setItem( + SIGNATURE_VALIDATION_KEY(address), + 'pending', + ); + this.emit(this.events.currentConnector, { + metadata: { pendingSignature: true }, + }); + } + } + } else if (!address && this.svmAddress) { this._emitDisconnect(); } }, 300); @@ -194,17 +245,109 @@ export class SolanaConnector extends PredicateConnector { } public async connect(): Promise { - this.createModal(); + if (!this.web3Modal) { + this.createModal(); + } + const currentAddress = this.web3Modal.getAddress(); + if (currentAddress) { + const storageValue = await this.storage.getItem( + SIGNATURE_VALIDATION_KEY(currentAddress), + ); + if (storageValue === 'pending') { + this.isPollingSignatureRequestActive = true; + try { + const provider = this.web3Modal.getWalletProvider() as SolanaProvider; + if (!provider) throw new Error('Connect(Pending): No provider'); + const message = `Sign this message to verify the connected account: ${currentAddress}`; + const messageBytes = new TextEncoder().encode(message); + const signedMessage = await provider.signMessage(messageBytes); + const signature = + 'signature' in signedMessage + ? signedMessage.signature + : signedMessage; + const publicKey = provider.publicKey; + if (!publicKey) throw new Error('Connect(Pending): No public key'); + const isValid = nacl.sign.detached.verify( + messageBytes, + signature, + publicKey.toBytes(), + ); + + if (isValid) { + await this.storage.setItem( + SIGNATURE_VALIDATION_KEY(currentAddress), + 'true', + ); + this._emitConnected(); + this.isPollingSignatureRequestActive = false; + return true; + } + await this.storage.removeItem( + SIGNATURE_VALIDATION_KEY(currentAddress), + ); // Clean up storage + this._emitSignatureError( + new Error('Invalid signature provided during connect.'), + ); + this.isPollingSignatureRequestActive = false; + return false; + } catch (error) { + this._emitSignatureError(error); + this.isPollingSignatureRequestActive = false; + return false; + } + } else if (storageValue === 'true') { + this._emitConnected(); + return true; + } + } + + this.web3Modal.open(); + return new Promise((resolve) => { - this.web3Modal.open(); const unsub = this.web3Modal.subscribeEvents(async (event) => { + console.log( + '[Connect Promise] Modal event received:', + event.data.event, + ); switch (event.data.event) { case 'CONNECT_SUCCESS': { - resolve(true); + const provider = + this.web3Modal.getWalletProvider() as SolanaProvider; + const address = provider?.publicKey?.toString(); + if (!address || !provider || !provider.publicKey) { + resolve(false); + unsub(); + break; + } + + try { + const hasValidation = await this.accountHasValidation(address); + if (hasValidation) { + this._emitConnected(); + resolve(true); + } else { + await this.storage.setItem( + SIGNATURE_VALIDATION_KEY(address), + 'pending', + ); + this.emit(this.events.currentConnector, { + metadata: { pendingSignature: true }, + }); + resolve(false); + } + } catch (error) { + this._emitSignatureError(error); + resolve(false); + } finally { + unsub(); + } + break; + } + case 'MODAL_CLOSE': { + resolve(false); unsub(); break; } - case 'MODAL_CLOSE': case 'CONNECT_ERROR': { resolve(false); unsub(); @@ -216,9 +359,12 @@ export class SolanaConnector extends PredicateConnector { } public async disconnect(): Promise { + console.log( + '!!! public disconnect() CALLED !!! - Will call _emitDisconnect.', + ); this.web3Modal.disconnect(); this._emitDisconnect(); - return this.isConnected(); + return !(await this.isConnected()); } private encodeTxId(txId: string): Uint8Array { @@ -297,4 +443,32 @@ export class SolanaConnector extends PredicateConnector { return predicateAddresses; } + + private async accountHasValidation( + account: string | undefined, + ): Promise { + if (!account) { + return false; + } + try { + const storageKey = SIGNATURE_VALIDATION_KEY(account); + const isValidated = await this.storage.getItem(storageKey); + return isValidated === 'true'; + } catch (err) { + console.error( + `[Validation Check] Error checking storage for ${account}:`, + err, + ); + return false; + } + } + + public async isConnected(): Promise { + const address = this.web3Modal?.getAddress(); + if (!address) { + return false; + } + + return await this.accountHasValidation(address); + } } diff --git a/packages/solana-connector/src/constants.ts b/packages/solana-connector/src/constants.ts index 37afb36b7..0a3e34d40 100644 --- a/packages/solana-connector/src/constants.ts +++ b/packages/solana-connector/src/constants.ts @@ -42,3 +42,4 @@ export const DEFAULT_CHAINS = [ ]; export const HAS_WINDOW = typeof window !== 'undefined'; +export const WINDOW = HAS_WINDOW ? window : null; diff --git a/packages/solana-connector/src/types.ts b/packages/solana-connector/src/types.ts index c71400f5e..6c779481c 100644 --- a/packages/solana-connector/src/types.ts +++ b/packages/solana-connector/src/types.ts @@ -1,6 +1,10 @@ import type { PredicateConfig } from '@fuel-connectors/common'; import type { ProviderType } from '@web3modal/solana/dist/types/src/utils/scaffold'; -import type { Provider as FuelProvider } from 'fuels'; +import type { Provider as FuelProvider, StorageAbstract } from 'fuels'; + +export enum SolanaConnectorEvents { + ERROR = 'error', +} export type SolanaConfig = { fuelProvider?: FuelProvider | Promise; @@ -8,6 +12,7 @@ export type SolanaConfig = { predicateConfig?: PredicateConfig; solanaConfig?: ProviderType; chainId?: number; + storage?: StorageAbstract; }; export interface GetAccounts {