@openzeppelin/contracts-ui-builder-adapter-evm
Version:
EVM Adapter for Contracts UI Builder
544 lines (507 loc) • 22.2 kB
text/typescript
/**
* Private Wagmi implementation for EVM wallet connection
*
* This file contains the internal implementation of Wagmi and Viem for wallet connection.
* It's encapsulated within the EVM adapter and not exposed to the rest of the application.
*/
import { injected, metaMask, safe, walletConnect } from '@wagmi/connectors';
import {
connect,
createConfig,
disconnect,
getAccount,
getPublicClient as getWagmiCorePublicClient,
getWalletClient as getWagmiWalletClient,
switchChain,
watchAccount,
type Config,
type GetAccountReturnType,
type CreateConnectorFn as WagmiCreateConnectorFn,
} from '@wagmi/core';
import { http, PublicClient, WalletClient, type Chain } from 'viem';
import type { Connector, UiKitConfiguration } from '@openzeppelin/contracts-ui-builder-types';
import { appConfigService, logger } from '@openzeppelin/contracts-ui-builder-utils';
import { evmNetworks } from '../../networks';
import { getWagmiConfigForRainbowKit } from '../rainbowkit';
import { type WagmiConfigChains } from '../types';
const LOG_SYSTEM = 'WagmiWalletImplementation'; // Define LOG_SYSTEM here
/**
* Generates the supported chains for Wagmi from the EVM network configurations.
* Only includes networks that have a viemChain property (ensuring wagmi compatibility).
* This ensures that wagmi only supports networks that are defined in mainnet.ts and testnet.ts.
*/
const getSupportedChainsFromNetworks = (): readonly Chain[] => {
const chains = evmNetworks
.filter((network) => network.viemChain) // Only include networks with viemChain
.map((network) => network.viemChain!)
.filter((chain, index, self) => self.findIndex((c) => c.id === chain.id) === index); // Remove duplicates
logger.info(
LOG_SYSTEM,
`Generated supported chains from network configurations: ${chains.length} chains`,
chains.map((c) => ({ id: c.id, name: c.name }))
);
return chains;
};
/**
* Generates the mapping from Viem chain IDs to application network IDs.
* This mapping is auto-generated from the EVM network configurations.
*/
const getChainIdToNetworkIdMapping = (): Record<number, string> => {
const mapping = evmNetworks
.filter((network) => network.viemChain) // Only include networks with viemChain
.reduce(
(acc, network) => {
acc[network.chainId] = network.id;
return acc;
},
{} as Record<number, string>
);
logger.info(
LOG_SYSTEM,
'Generated chain ID to network ID mapping from network configurations:',
mapping
);
return mapping;
};
/**
* The supported chains for Wagmi, dynamically generated from network configurations.
* This ensures consistency between adapter networks and wagmi-supported networks.
*/
const defaultSupportedChains: readonly Chain[] = getSupportedChainsFromNetworks();
/**
* Auto-generated mapping from Viem chain IDs to application network IDs.
* This mapping is essential for AppConfigService to look up RPC URL overrides.
* It's automatically synchronized with the networks defined in mainnet.ts and testnet.ts.
*/
const viemChainIdToAppNetworkId: Record<number, string> = getChainIdToNetworkIdMapping();
/**
* Class responsible for encapsulating Wagmi core logic for wallet interactions.
* This class should not be used directly by UI components. The EvmAdapter
* exposes a standardized interface for wallet operations.
* It manages Wagmi Config instances and provides methods for wallet actions.
*/
export class WagmiWalletImplementation {
private defaultInstanceConfig: Config | null = null;
private activeWagmiConfig: Config | null = null; // To be set by EvmUiKitManager
private unsubscribe?: ReturnType<typeof watchAccount>;
private initialized: boolean = false;
private walletConnectProjectId?: string;
/**
* Constructs the WagmiWalletImplementation.
* Configuration for Wagmi is deferred until actually needed or set externally.
* @param walletConnectProjectIdFromAppConfig - Optional WalletConnect Project ID from global app configuration.
* @param initialUiKitConfig - Optional initial UI kit configuration, primarily for logging the anticipated kit.
*/
constructor(
walletConnectProjectIdFromAppConfig?: string,
initialUiKitConfig?: UiKitConfiguration
) {
this.walletConnectProjectId = walletConnectProjectIdFromAppConfig;
logger.info(
LOG_SYSTEM,
'Constructor called. Initial anticipated kitName:',
initialUiKitConfig?.kitName
);
this.initialized = true;
logger.info(
LOG_SYSTEM,
'WagmiWalletImplementation instance initialized (Wagmi config creation deferred).'
);
// No config created here by default anymore.
}
/**
* Sets the externally determined, currently active WagmiConfig instance.
* This is typically called by EvmUiKitManager after it has resolved the appropriate
* config for the selected UI kit (e.g., RainbowKit's config or a default custom config).
* @param config - The Wagmi Config object to set as active, or null to clear it.
*/
public setActiveWagmiConfig(config: Config | null): void {
logger.info(
LOG_SYSTEM,
'setActiveWagmiConfig called with config:',
config ? 'Valid Config' : 'Null'
);
this.activeWagmiConfig = config;
// If the activeWagmiConfig instance has changed and there was an existing direct subscription
// via onWalletConnectionChange, that subscription was bound to the *previous* config instance.
// It might now be stale or not receive updates reflecting the new config context.
// Consumers relying on onWalletConnectionChange for live updates across fundamental config changes
// (e.g., switching UI kits or major network group changes affecting the config instance)
// may need to re-invoke onWalletConnectionChange to get a new subscription bound to the new config.
// UI components should primarily rely on useWalletState and derived hooks for reactivity,
// which will naturally update with the new adapter/config context.
if (this.unsubscribe) {
logger.warn(
LOG_SYSTEM,
'setActiveWagmiConfig: Active WagmiConfig instance has changed. Existing direct watchAccount subscription (via onWalletConnectionChange) may be stale and operating on an old config instance.'
);
}
}
/**
* Creates a default WagmiConfig instance on demand.
* This configuration includes standard connectors (injected, MetaMask, Safe)
* and WalletConnect if a project ID is available.
* Used as a fallback or for 'custom' UI kit mode.
* @returns A Wagmi Config object.
*/
private createDefaultConfig(): Config {
const baseConnectors: WagmiCreateConnectorFn[] = [injected(), metaMask(), safe()];
if (this.walletConnectProjectId?.trim()) {
baseConnectors.push(walletConnect({ projectId: this.walletConnectProjectId }));
logger.info(LOG_SYSTEM, 'WalletConnect connector added to DEFAULT config.');
} else {
logger.warn(
LOG_SYSTEM,
'WalletConnect Project ID not provided; WC connector unavailable for DEFAULT config.'
);
}
const transportsConfig = defaultSupportedChains.reduce(
(acc, chainDefinition) => {
let rpcUrlToUse: string | undefined = chainDefinition.rpcUrls.default?.http?.[0];
const appNetworkIdString = viemChainIdToAppNetworkId[chainDefinition.id];
if (appNetworkIdString) {
const rpcOverrideSetting = appConfigService.getRpcEndpointOverride(appNetworkIdString);
let httpRpcOverride: string | undefined;
if (typeof rpcOverrideSetting === 'string') {
httpRpcOverride = rpcOverrideSetting;
} else if (typeof rpcOverrideSetting === 'object') {
// Handle both RpcEndpointConfig and UserRpcProviderConfig
if ('http' in rpcOverrideSetting && rpcOverrideSetting.http) {
httpRpcOverride = rpcOverrideSetting.http;
} else if ('url' in rpcOverrideSetting && rpcOverrideSetting.url) {
httpRpcOverride = rpcOverrideSetting.url;
}
}
if (httpRpcOverride) {
logger.info(
LOG_SYSTEM,
`Using overridden RPC for chain ${chainDefinition.name} (default config): ${httpRpcOverride}`
);
rpcUrlToUse = httpRpcOverride;
}
}
acc[chainDefinition.id] = http(rpcUrlToUse);
return acc;
},
{} as Record<number, ReturnType<typeof http>>
);
try {
const defaultConfig = createConfig({
chains: defaultSupportedChains as unknown as WagmiConfigChains,
connectors: baseConnectors,
transports: transportsConfig,
});
logger.info(LOG_SYSTEM, 'Default Wagmi config created successfully on demand.');
return defaultConfig;
} catch (error) {
logger.error(LOG_SYSTEM, 'Error creating default Wagmi config on demand:', error);
return createConfig({
chains: [defaultSupportedChains[0]] as unknown as WagmiConfigChains,
connectors: [injected()],
transports: { [defaultSupportedChains[0].id]: http() },
});
}
}
/**
* Wrapper function to convert AppConfigService RPC overrides to the format expected by RainbowKit.
* @param networkId - The network ID to get RPC override for
* @returns RPC configuration in the format expected by RainbowKit
*/
private getRpcOverrideForRainbowKit(
networkId: string
): string | { http?: string; ws?: string } | undefined {
const rpcOverrideSetting = appConfigService.getRpcEndpointOverride(networkId);
if (typeof rpcOverrideSetting === 'string') {
return rpcOverrideSetting;
} else if (typeof rpcOverrideSetting === 'object' && rpcOverrideSetting !== null) {
// Check for UserRpcProviderConfig first
if ('url' in rpcOverrideSetting && typeof rpcOverrideSetting.url === 'string') {
// It's a UserRpcProviderConfig - convert url to http
return {
http: rpcOverrideSetting.url,
};
} else if ('http' in rpcOverrideSetting || 'ws' in rpcOverrideSetting) {
// It's an RpcEndpointConfig
const config = rpcOverrideSetting as { http?: string; ws?: string };
return {
http: config.http,
ws: config.ws,
};
}
}
return undefined;
}
/**
* Retrieves or creates the WagmiConfig specifically for RainbowKit.
* This delegates to `getWagmiConfigForRainbowKit` service which handles caching
* and uses RainbowKit's `getDefaultConfig`.
* @param currentAdapterUiKitConfig - The fully resolved UI kit configuration for the adapter.
* @returns A Promise resolving to the RainbowKit-specific Wagmi Config object, or null if creation fails or not RainbowKit.
*/
public async getConfigForRainbowKit(
currentAdapterUiKitConfig: UiKitConfiguration
): Promise<Config | null> {
if (!this.initialized) {
logger.error(
LOG_SYSTEM,
'getConfigForRainbowKit called before implementation initialization.'
);
return null;
}
if (currentAdapterUiKitConfig?.kitName !== 'rainbowkit') {
logger.warn(
LOG_SYSTEM,
'getConfigForRainbowKit called, but kitName is not rainbowkit. Returning null.'
);
return null;
}
logger.info(
LOG_SYSTEM,
'getConfigForRainbowKit: Kit is RainbowKit. Proceeding to create/get config. CurrentAdapterUiKitConfig:',
currentAdapterUiKitConfig
);
const rainbowKitWagmiConfig = await getWagmiConfigForRainbowKit(
currentAdapterUiKitConfig,
defaultSupportedChains as WagmiConfigChains,
viemChainIdToAppNetworkId,
this.getRpcOverrideForRainbowKit.bind(this)
);
if (rainbowKitWagmiConfig) {
logger.info(LOG_SYSTEM, 'Returning RainbowKit-specific Wagmi config for provider.');
return rainbowKitWagmiConfig;
}
logger.warn(LOG_SYSTEM, 'RainbowKit specific Wagmi config creation failed.');
return null;
}
/**
* Determines and returns the WagmiConfig to be used by EvmUiKitManager during its configuration process.
* If RainbowKit is specified in the passed uiKitConfig, it attempts to get its specific config.
* Otherwise, it falls back to creating/returning a default instance config.
* @param uiKitConfig - The fully resolved UiKitConfiguration that the manager is currently processing.
* @returns A Promise resolving to the determined Wagmi Config object.
*/
public async getActiveConfigForManager(uiKitConfig: UiKitConfiguration): Promise<Config> {
if (!this.initialized) {
logger.error(
LOG_SYSTEM,
'getActiveConfigForManager called before initialization! Creating fallback.'
);
return createConfig({
chains: [defaultSupportedChains[0]] as unknown as WagmiConfigChains,
transports: { [defaultSupportedChains[0].id]: http() },
});
}
if (uiKitConfig?.kitName === 'rainbowkit') {
const rkConfig = await this.getConfigForRainbowKit(uiKitConfig);
if (rkConfig) return rkConfig;
logger.warn(
LOG_SYSTEM,
'getActiveConfigForManager: RainbowKit config failed, falling back to default.'
);
}
if (!this.defaultInstanceConfig) {
this.defaultInstanceConfig = this.createDefaultConfig();
}
return this.defaultInstanceConfig;
}
/**
* @deprecated Prefer using methods that rely on the externally set `activeWagmiConfig`
* or methods that determine contextually appropriate config like `getActiveConfigForManager` (for manager use)
* or ensure `activeWagmiConfig` is set before calling wagmi actions.
* This method returns the internally cached default config or the active one if set.
* @returns The current default or active Wagmi Config object.
*/
public getConfig(): Config {
logger.warn(
LOG_SYSTEM,
'getConfig() is deprecated. Internal calls should use activeWagmiConfig if set, or ensure default is created.'
);
if (this.activeWagmiConfig) return this.activeWagmiConfig;
if (!this.defaultInstanceConfig) {
this.defaultInstanceConfig = this.createDefaultConfig();
}
return this.defaultInstanceConfig!;
}
/**
* Gets the current wallet connection status (isConnected, address, chainId, etc.).
* This is a synchronous operation and uses the `activeWagmiConfig` if set by `EvmUiKitManager`,
* otherwise falls back to the default instance config (created on demand).
* For UI reactivity to connection changes, `onWalletConnectionChange` or derived hooks are preferred.
* @returns The current account status from Wagmi.
*/
public getWalletConnectionStatus(): GetAccountReturnType {
logger.debug(LOG_SYSTEM, 'getWalletConnectionStatus called.');
const configToUse =
this.activeWagmiConfig ||
this.defaultInstanceConfig ||
(this.defaultInstanceConfig = this.createDefaultConfig());
if (!configToUse) {
logger.error(LOG_SYSTEM, 'No config available for getWalletConnectionStatus!');
// Return a valid GetAccountReturnType for a disconnected state
return {
isConnected: false,
isConnecting: false,
isDisconnected: true,
isReconnecting: false,
status: 'disconnected',
address: undefined,
addresses: undefined,
chainId: undefined,
chain: undefined,
connector: undefined,
};
}
return getAccount(configToUse);
}
/**
* Subscribes to account and connection status changes from Wagmi.
* The subscription is bound to the `activeWagmiConfig` if available at the time of call,
* otherwise to the default instance config. If `activeWagmiConfig` changes later,
* this specific subscription might become stale (see warning in `setActiveWagmiConfig`).
* @param callback - Function to call when connection status changes.
* @returns A function to unsubscribe from the changes.
*/
public onWalletConnectionChange(
callback: (status: GetAccountReturnType, prevStatus: GetAccountReturnType) => void
): () => void {
if (!this.initialized) {
logger.warn(LOG_SYSTEM, 'onWalletConnectionChange called before initialization. No-op.');
return () => {};
}
if (this.unsubscribe) {
this.unsubscribe();
logger.debug(LOG_SYSTEM, 'Previous watchAccount unsubscribed.');
}
const configToUse =
this.activeWagmiConfig ||
this.defaultInstanceConfig ||
(this.defaultInstanceConfig = this.createDefaultConfig());
if (!configToUse) {
logger.error(
LOG_SYSTEM,
'No config available for onWalletConnectionChange! Subscription not set.'
);
return () => {};
}
this.unsubscribe = watchAccount(configToUse, { onChange: callback });
logger.info(
LOG_SYSTEM,
'watchAccount subscription established/re-established using config:',
configToUse === this.activeWagmiConfig ? 'activeExternal' : 'defaultInstance'
);
return this.unsubscribe;
}
// Methods that perform actions should use the most current activeWagmiConfig
/**
* Gets the Viem Wallet Client for the currently connected account and chain, using the active Wagmi config.
* @returns A Promise resolving to the Viem WalletClient or null if not connected or config not active.
*/
public async getWalletClient(): Promise<WalletClient | null> {
if (!this.initialized || !this.activeWagmiConfig) {
logger.warn(
LOG_SYSTEM,
'getWalletClient: Not initialized or no activeWagmiConfig. Returning null.'
);
return null;
}
const accountStatus = getAccount(this.activeWagmiConfig);
if (!accountStatus.isConnected || !accountStatus.chainId || !accountStatus.address) return null;
return getWagmiWalletClient(this.activeWagmiConfig, {
chainId: accountStatus.chainId,
account: accountStatus.address,
});
}
/**
* Gets the Viem Public Client for the currently connected chain, using the active Wagmi config.
* Note: Direct public client retrieval from WagmiConfig is complex in v2. This is a placeholder.
* Prefer using Wagmi actions like readContract, simulateContract which use the public client internally.
* @returns A Promise resolving to the Viem PublicClient or null.
*/
public async getPublicClient(): Promise<PublicClient | null> {
if (!this.initialized || !this.activeWagmiConfig) {
logger.warn(
LOG_SYSTEM,
'getPublicClient: Not initialized or no activeWagmiConfig. Returning null.'
);
return null;
}
const accountStatus = getAccount(this.activeWagmiConfig); // Get current chain from the active config
const currentChainId = accountStatus.chainId;
if (!currentChainId) {
logger.warn(
LOG_SYSTEM,
'getPublicClient: No connected chainId available from accountStatus. Returning null.'
);
return null;
}
try {
// Use the getPublicClient action from wagmi/core
// It requires the config and optionally a chainId. If no chainId, it uses the config's primary/first chain.
// It's better to be explicit with the current chainId.
const publicClient = getWagmiCorePublicClient(this.activeWagmiConfig, {
chainId: currentChainId,
});
if (publicClient) {
logger.info(
LOG_SYSTEM,
`getPublicClient: Successfully retrieved public client for chainId ${currentChainId}.`
);
return publicClient;
}
logger.warn(
LOG_SYSTEM,
`getPublicClient: getWagmiCorePublicClient returned undefined/null for chainId ${currentChainId}.`
);
return null;
} catch (error) {
logger.error(LOG_SYSTEM, 'Error getting public client from wagmi/core:', error);
return null;
}
}
/**
* Gets the list of available wallet connectors from the active Wagmi config.
* @returns A Promise resolving to an array of available connectors.
*/
public async getAvailableConnectors(): Promise<Connector[]> {
if (!this.initialized || !this.activeWagmiConfig) return [];
return this.activeWagmiConfig.connectors.map((co) => ({ id: co.uid, name: co.name }));
}
/**
* Initiates the connection process for a specific connector using the active Wagmi config.
* @param connectorId - The ID of the connector to use.
* @returns A Promise with connection result including address and chainId if successful.
*/
public async connect(
connectorId: string
): Promise<{ connected: boolean; address?: string; chainId?: number; error?: string }> {
if (!this.initialized || !this.activeWagmiConfig)
throw new Error('Wallet not initialized or no active config');
const connectorToUse = this.activeWagmiConfig.connectors.find(
(cn) => cn.id === connectorId || cn.uid === connectorId
);
if (!connectorToUse) throw new Error(`Connector ${connectorId} not found`);
const res = await connect(this.activeWagmiConfig, { connector: connectorToUse });
return { connected: true, address: res.accounts[0], chainId: res.chainId };
}
/**
* Disconnects the currently connected wallet using the active Wagmi config.
* @returns A Promise with disconnection result.
*/
public async disconnect(): Promise<{ disconnected: boolean; error?: string }> {
if (!this.initialized || !this.activeWagmiConfig)
return { disconnected: false, error: 'Wallet not initialized or no active config' };
await disconnect(this.activeWagmiConfig);
return { disconnected: true };
}
/**
* Prompts the user to switch to the specified network using the active Wagmi config.
* @param chainId - The target chain ID to switch to.
* @returns A Promise that resolves if the switch is successful, or rejects with an error.
*/
public async switchNetwork(chainId: number): Promise<void> {
if (!this.initialized || !this.activeWagmiConfig)
throw new Error('Wallet not initialized or no active config');
await switchChain(this.activeWagmiConfig, { chainId });
}
// ... (rest of class, ensure all wagmi/core actions use this.activeWagmiConfig if available and appropriate)
}