UNPKG

@m3s/wallet

Version:

A flexible wallet interface supporting multiple blockchain wallet types, including EVM wallets and Web3Auth integration

518 lines 23 kB
import { Web3AuthNoModal } from "@web3auth/no-modal"; import { WALLET_ADAPTERS } from "@web3auth/base"; import { BrowserProvider, ethers } from "ethers"; import { AdapterError, NetworkHelper, WalletErrorCode } from "@m3s/shared"; import { AuthAdapter } from "@web3auth/auth-adapter"; import { EthereumPrivateKeyProvider } from "@web3auth/ethereum-provider"; import { EIP712Validator } from '../../helpers/signatures.js'; import { toBigInt, toWei } from '../../helpers/units.js'; /** * An adapter for EVM-based wallets using Web3Auth for social logins. * It implements the IEVMWallet interface directly. */ export class Web3AuthWalletAdapter { name; version; web3auth = null; config; initialized = false; decimals = 18; eventListeners = new Map(); multiChainRpcs = {}; network = null; // NEW: Add local network state constructor(args) { this.name = args.name; this.version = args.version; this.config = args; this.multiChainRpcs = args.options.multiChainRpcs || {}; } /** * Factory method to create and initialize an instance of Web3AuthWalletAdapter. */ static async create(args) { const instance = new Web3AuthWalletAdapter(args); await instance.initialize(); return instance; } // --- Core Lifecycle & Connection Methods --- async initialize() { if (this.initialized) return; const opts = this.config.options.web3authConfig; if (!opts) { throw new AdapterError("web3authConfig is missing.", { code: WalletErrorCode.MissingConfig }); } try { const privateKeyProvider = opts.privateKeyProvider || new EthereumPrivateKeyProvider({ config: { chainConfig: opts.chainConfig } }); this.web3auth = new Web3AuthNoModal({ clientId: opts.clientId, web3AuthNetwork: opts.web3AuthNetwork, chainConfig: opts.chainConfig, privateKeyProvider, }); const authAdapter = new AuthAdapter({ adapterSettings: { loginConfig: opts.loginConfig } }); this.web3auth.configureAdapter(authAdapter); await this.web3auth.init(); this.initialized = true; } catch (error) { throw new AdapterError("Web3Auth initialization failed.", { cause: error, code: WalletErrorCode.InitializationFailed }); } } async disconnect() { if (this.web3auth && this.web3auth.connected) { await this.web3auth.logout(); } this.web3auth = null; this.initialized = false; this.eventListeners.clear(); this.emitEvent('disconnect', undefined); } isConnected() { return this.initialized && !!this.web3auth?.connected; } isInitialized() { return this.initialized; } /** * Get ALL configured RPC URLs for ALL chains */ getAllChainRpcs() { return { ...this.multiChainRpcs }; } /** * Update ALL RPC configurations at once */ async updateAllChainRpcs(multiChainRpcs) { // ✅ Same validation logic as ethers if (!multiChainRpcs || typeof multiChainRpcs !== 'object') { throw new AdapterError('Invalid RPC configuration - must be an object', { code: WalletErrorCode.InvalidInput, methodName: 'updateAllChainRpcs' }); } for (const [chainId, rpcUrls] of Object.entries(multiChainRpcs)) { if (!Array.isArray(rpcUrls)) { throw new AdapterError(`Invalid RPC URLs for chain ${chainId} - must be array`, { code: WalletErrorCode.InvalidInput, methodName: 'updateAllChainRpcs' }); } if (rpcUrls.length === 0) { throw new AdapterError(`Invalid RPC URLs for chain ${chainId} - array cannot be empty`, { code: WalletErrorCode.InvalidInput, methodName: 'updateAllChainRpcs' }); } for (const url of rpcUrls) { if (typeof url !== 'string' || (!url.startsWith('http://') && !url.startsWith('https://'))) { throw new AdapterError(`Invalid RPC URL for chain ${chainId}: ${url} - must be HTTP/HTTPS URL`, { code: WalletErrorCode.InvalidInput, methodName: 'updateAllChainRpcs' }); } } } this.multiChainRpcs = { ...multiChainRpcs }; console.log(`[Web3AuthWalletAdapter] Updated all chain RPCs for ${Object.keys(multiChainRpcs).length} chains`); } async setProvider(config) { if (!this.isConnected() || !this.web3auth) { throw new AdapterError("Not connected.", { code: WalletErrorCode.WalletNotConnected }); } const newChainIdHex = config.chainId.startsWith('0x') ? config.chainId : `0x${parseInt(config.chainId, 10).toString(16)}`; // ✅ MISSING PART: Use RPC data like ethers adapter does const cid = config.chainId; const cidDecimal = parseInt(newChainIdHex, 16).toString(); const preferred = this.multiChainRpcs[cid] || this.multiChainRpcs[cidDecimal] || this.multiChainRpcs[newChainIdHex] || []; // ✅ Get proper network config with RPC preferences const networkHelper = NetworkHelper.getInstance(); await networkHelper.ensureInitialized(); let finalConfig; try { // Try to get enhanced config with preferred RPCs const enhancedConfig = await networkHelper.getNetworkConfig(cid, preferred, false); finalConfig = enhancedConfig || config; // Fall back to original config } catch (error) { console.warn(`[Web3AuthWalletAdapter] NetworkHelper failed, using original config:`, error); finalConfig = config; } // ✅ Ensure we have valid RPC URLs if (!finalConfig.rpcUrls || finalConfig.rpcUrls.length === 0) { if (preferred.length > 0) { finalConfig = { ...finalConfig, rpcUrls: preferred }; } else { throw new AdapterError(`No RPC URLs available for chain ${newChainIdHex}`, { code: WalletErrorCode.ConnectionFailed, methodName: 'setProvider' }); } } try { await this.web3auth.switchChain({ chainId: newChainIdHex }); } catch (switchError) { if (switchError.code === 4902 || switchError.message?.includes('Unrecognized chain ID') || switchError.message?.includes('Chain config has not been added')) { try { console.log(`[Web3AuthWalletAdapter] Adding chain ${newChainIdHex} to Web3Auth`); const chainToAdd = { chainId: newChainIdHex, chainNamespace: "eip155", displayName: finalConfig.displayName || finalConfig.name, rpcTarget: finalConfig.rpcUrls[0], // ✅ Use the proper RPC URL blockExplorerUrl: finalConfig.blockExplorerUrl, ticker: finalConfig.ticker || "ETH", tickerName: finalConfig.tickerName || "Ethereum", }; await this.web3auth.addChain(chainToAdd); console.log(`[Web3AuthWalletAdapter] ✅ Successfully added chain ${newChainIdHex}`); // Now try to switch again await this.web3auth.switchChain({ chainId: newChainIdHex }); console.log(`[Web3AuthWalletAdapter] ✅ Successfully switched to chain ${newChainIdHex}`); } catch (addError) { throw new AdapterError(`Failed to add or switch to chain ${newChainIdHex}: ${addError.message}`, { cause: addError, code: WalletErrorCode.ConnectionFailed, methodName: 'setProvider' }); } } else { throw new AdapterError(`Failed to switch chain ${newChainIdHex}: ${switchError.message}`, { cause: switchError, code: WalletErrorCode.ConnectionFailed, methodName: 'setProvider' }); } } this.network = null; const network = await this.getNetwork(); this.network = network; this.decimals = network.decimals || 18; this.emitEvent('chainChanged', network.chainId); } // --- Event Handling --- on(event, callback) { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, new Set()); } this.eventListeners.get(event).add(callback); } off(event, callback) { if (this.eventListeners.has(event)) { this.eventListeners.get(event).delete(callback); } } emitEvent(eventName, payload) { const listeners = this.eventListeners.get(eventName); if (listeners && listeners.size > 0) { listeners.forEach(callback => { try { callback(payload); } catch (error) { console.error(`[${this.name}] Error in ${eventName} event handler:`, error); } }); } } // --- Wallet Information & State --- async getAccounts() { if (!this.isConnected()) { const loginProvider = Object.keys(this.config.options.web3authConfig.loginConfig)[0]; if (!loginProvider) { throw new AdapterError("No login providers configured.", { code: WalletErrorCode.MissingConfig }); } await this.web3auth?.connectTo(WALLET_ADAPTERS.AUTH, { loginProvider }); if (!this.web3auth?.connected || !this.web3auth.provider) { throw new AdapterError("Failed to connect to Web3Auth.", { code: WalletErrorCode.ConnectionFailed }); } const network = await this.getNetwork(); this.network = network; this.emitEvent('connect', { chainId: network.chainId }); } const accounts = (await this.web3auth?.provider?.request({ method: "eth_accounts" })); this.emitEvent('accountsChanged', accounts); return accounts; } async getNetwork() { if (!this.isConnected() || !this.web3auth?.provider) { throw new AdapterError("Not connected.", { code: WalletErrorCode.WalletNotConnected }); } // Return cached network if available if (this.network) { return this.network; } const provider = await this.getProvider(); const network = await provider.getNetwork(); const chainId = `0x${network.chainId.toString(16)}`; // Use NetworkHelper to get rich data const networkHelper = NetworkHelper.getInstance(); await networkHelper.ensureInitialized(); const config = await networkHelper.getNetworkConfig(chainId); const finalConfig = config ?? { chainId, name: network.name, displayName: network.name, rpcUrls: [provider?.connection?.url || ''].filter(Boolean), decimals: 18, ticker: 'ETH', tickerName: 'Ethereum', }; this.network = finalConfig; // Cache the result return finalConfig; } async getBalance(account) { if (!this.isConnected()) { throw new AdapterError("Not connected.", { code: WalletErrorCode.WalletNotConnected, methodName: 'getBalance' }); } try { const provider = await this.getProvider(); const address = account || (await this.getAccounts())[0]; if (!address) { throw new AdapterError("No account available.", { code: WalletErrorCode.AccountUnavailable, methodName: 'getBalance' }); } const balanceWei = await provider.getBalance(address); const networkConfig = await this.getNetwork(); this.decimals = networkConfig.decimals || 18; return { amount: balanceWei.toString(), decimals: this.decimals, symbol: networkConfig.ticker || 'ETH', formattedAmount: ethers.formatUnits(balanceWei, this.decimals) }; } catch (error) { throw new AdapterError(`Failed to get balance: ${error.message}`, { cause: error, code: WalletErrorCode.NetworkError, methodName: 'getBalance' }); } } // --- Signing Methods --- async signMessage(message) { if (!this.isConnected()) { throw new AdapterError("Wallet not connected.", { code: WalletErrorCode.WalletNotConnected, methodName: 'signMessage' }); } try { const signer = await this.getSigner(); return await signer.signMessage(message); } catch (error) { const messageText = error.shortMessage || error.message || String(error); let code = WalletErrorCode.SigningFailed; if (messageText.toLowerCase().includes('user denied')) code = WalletErrorCode.UserRejected; throw new AdapterError(`Failed to sign message: ${messageText}`, { cause: error, code, methodName: 'signMessage' }); } } async signTransaction(tx) { if (!this.isConnected()) { throw new AdapterError("Wallet not connected.", { code: WalletErrorCode.WalletNotConnected, methodName: 'signTransaction' }); } try { const signer = await this.getSigner(); const preparedTx = await this.prepareTransactionRequest(tx); return await signer.signTransaction(preparedTx); } catch (error) { const message = error.shortMessage || error.message || String(error); throw new AdapterError(`Failed to sign transaction: ${message}`, { cause: error, code: WalletErrorCode.SignatureFailed, methodName: 'signTransaction' }); } } async signTypedData(data) { if (!this.isConnected()) { throw new AdapterError("Wallet not connected.", { code: WalletErrorCode.WalletNotConnected, methodName: 'signTypedData' }); } try { EIP712Validator.validateStructure(data); EIP712Validator.validateTypes(data.types); const network = await this.getNetwork(); EIP712Validator.validateDomain(data.domain, network.chainId.toString()); const signer = await this.getSigner(); const signature = await signer.signTypedData(data.domain, data.types, data.value); return signature; } catch (error) { if (error instanceof AdapterError) { throw error; } const message = error.shortMessage || error.message || String(error); let code = WalletErrorCode.SigningFailed; if (message.toLowerCase().includes('user denied')) code = WalletErrorCode.UserRejected; throw new AdapterError(`Failed to sign typed data: ${message}`, { cause: error, code, methodName: 'signTypedData' }); } } async verifySignature(message, signature, address) { if (!ethers.isAddress(address)) { throw new AdapterError("Invalid address format.", { code: WalletErrorCode.InvalidInput, methodName: 'verifySignature' }); } try { if (typeof message === 'object' && 'domain' in message) { return EIP712Validator.verifySignature(message, signature, address); } else { const recoveredAddress = ethers.verifyMessage(message, signature); return recoveredAddress.toLowerCase() === address.toLowerCase(); } } catch (error) { console.error(`[${this.name}] Signature verification failed:`, error); return false; } } // --- Transaction Methods --- async getNonce(type = 'pending') { const signer = await this.getSigner(); return signer.getNonce(type); } async sendTransaction(tx) { console.log('SENDING THIS TX FROM THE CLIENT ...', tx); if (!this.isConnected()) { throw new AdapterError("Wallet not connected.", { code: WalletErrorCode.WalletNotConnected, methodName: 'sendTransaction' }); } try { const signer = await this.getSigner(); const txRequest = await this.prepareTransactionRequest(tx); console.log('SENDING THIS TX FROM prepareTransactionRequest ...', txRequest); const response = await signer.sendTransaction(txRequest); return response.hash; } catch (error) { const message = error.shortMessage || error.message || String(error); let code = WalletErrorCode.TransactionFailed; if (message.toLowerCase().includes('user denied')) code = WalletErrorCode.UserRejected; throw new AdapterError(`Failed to send transaction: ${message}`, { cause: error, code, methodName: 'sendTransaction' }); } } async getTransactionReceipt(txHash) { if (!this.isConnected()) { throw new AdapterError("Wallet not connected.", { code: WalletErrorCode.WalletNotConnected, methodName: 'getTransactionReceipt' }); } const provider = await this.getProvider(); return provider.getTransactionReceipt(txHash); } async callContract(options) { if (!this.isConnected()) throw new AdapterError("Wallet not connected."); const provider = await this.getProvider(); const iface = new ethers.Interface(options.abi); // Convert arguments to strings const data = iface.encodeFunctionData(options.method, options.args || []); const rawResult = await provider.call({ to: options.contractAddress, data, }); return iface.decodeFunctionResult(options.method, rawResult); } async writeContract(options) { if (!this.isConnected()) { throw new AdapterError("Wallet not connected.", { code: WalletErrorCode.WalletNotConnected, methodName: 'writeContract' }); } const iface = new ethers.Interface(options.abi); const data = iface.encodeFunctionData(options.method, options.args || []); const tx = { to: options.contractAddress, data, value: options.value?.toString(), options: options.overrides, }; return this.sendTransaction(tx); } // --- Gas & Fee Methods --- async estimateGas(tx) { if (!this.isConnected()) { throw new AdapterError("Wallet not connected.", { code: WalletErrorCode.WalletNotConnected, methodName: 'estimateGas' }); } try { const provider = await this.getProvider(); const signer = await this.getSigner(); const fromAddress = await signer.getAddress(); // Build a minimal tx request for estimation const txRequest = { to: tx.to, value: tx.value ? toWei(tx.value, this.decimals) : undefined, data: tx.data ? (typeof tx.data === 'string' ? tx.data : ethers.hexlify(tx.data)) : undefined, from: fromAddress, }; // Estimate gas limit const gasLimit = await provider.estimateGas(txRequest); // Get fee data from provider const feeData = await provider.getFeeData(); return { gasLimit, gasPrice: feeData.gasPrice?.toString(), maxFeePerGas: feeData.maxFeePerGas?.toString(), maxPriorityFeePerGas: feeData.maxPriorityFeePerGas?.toString(), }; } catch (error) { throw new AdapterError(`Failed to estimate gas: ${error.message}`, { cause: error, code: WalletErrorCode.GasEstimationFailed, methodName: 'estimateGas' }); } } async getGasPrice() { if (!this.isConnected()) { throw new AdapterError("Wallet not connected.", { code: WalletErrorCode.WalletNotConnected, methodName: 'getGasPrice' }); } try { const provider = await this.getProvider(); const feeData = await provider.getFeeData(); if (feeData.gasPrice) { return feeData.gasPrice; } else if (feeData.maxFeePerGas) { return feeData.maxFeePerGas; } else { throw new AdapterError("Gas price not available from any source."); } } catch (error) { throw new AdapterError(`Failed to get gas price: ${error.message}`, { cause: error, code: WalletErrorCode.GasEstimationFailed, methodName: 'getGasPrice' }); } } // --- Protected Helper Methods --- async getProvider() { if (!this.web3auth?.provider) { throw new AdapterError("Provider not available from Web3Auth.", { code: WalletErrorCode.ProviderNotFound }); } return new BrowserProvider(this.web3auth.provider); } async getSigner() { const provider = await this.getProvider(); const accounts = await this.getAccounts(); if (accounts.length === 0) { throw new AdapterError("No accounts available to create signer.", { code: WalletErrorCode.AccountUnavailable }); } return provider.getSigner(accounts[0]); } async prepareTransactionRequest(tx) { const signer = await this.getSigner(); // const provider = await this.getProvider(); const txRequest = { to: tx.to, value: tx.value ? toWei(tx.value, this.decimals) : undefined, data: tx.data ? (typeof tx.data === 'string' ? tx.data : ethers.hexlify(tx.data)) : undefined, nonce: tx.options?.nonce, chainId: tx.options?.chainId ? toBigInt(tx.options.chainId) : undefined, }; // Set nonce if not provided if (txRequest.nonce === undefined) { txRequest.nonce = await signer.getNonce('pending'); } // Clean up undefined values Object.keys(txRequest).forEach(key => txRequest[key] === undefined && delete txRequest[key]); return txRequest; } } //# sourceMappingURL=web3authWallet.js.map