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 {