@m3s/wallet
Version:
A flexible wallet interface supporting multiple blockchain wallet types, including EVM wallets and Web3Auth integration
444 lines • 19 kB
JavaScript
import { ethers, Wallet as EthersWallet, JsonRpcProvider } from 'ethers';
import { AdapterError, NetworkHelper, WalletErrorCode } from '@m3s/shared';
import { WalletEvent } from '../../types/index.js';
import { EIP712Validator } from '../../helpers/signatures.js';
import { toBigInt, toWei } from '../../helpers/units.js';
/**
* An adapter for EVM-based wallets using a private key, powered by ethers.js.
* It implements the IEVMWallet interface directly, without a base class.
*/
export class EvmWalletAdapter {
name;
version;
wallet;
provider;
config;
_connected = false;
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 EvmWalletAdapter.
*/
static async create(args) {
const instance = new EvmWalletAdapter(args);
await instance.initialize();
return instance;
}
// --- Core Lifecycle & Connection Methods ---
async initialize() {
if (this.initialized)
return;
const { privateKey, provider: providerConfig } = this.config.options;
if (!privateKey) {
throw new AdapterError("privateKey is required in options.", {
code: WalletErrorCode.MissingConfig,
methodName: 'initialize'
});
}
this.wallet = new EthersWallet(privateKey);
if (providerConfig) {
await this.setProvider(providerConfig);
}
this.initialized = true;
}
async disconnect() {
this.provider = undefined;
this._connected = false;
this.initialized = false;
this.eventListeners.clear();
this.emitEvent('disconnect', undefined);
}
isConnected() {
return this._connected && !!this.provider && !!this.wallet;
}
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) {
// ✅ Fix: Add proper validation that actually throws
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)) {
// ✅ Fix: Check if rpcUrls is actually an array
if (!Array.isArray(rpcUrls)) {
throw new AdapterError(`Invalid RPC URLs for chain ${chainId} - must be array`, {
code: WalletErrorCode.InvalidInput,
methodName: 'updateAllChainRpcs'
});
}
// ✅ Fix: Check for empty arrays
if (rpcUrls.length === 0) {
throw new AdapterError(`Invalid RPC URLs for chain ${chainId} - array cannot be empty`, {
code: WalletErrorCode.InvalidInput,
methodName: 'updateAllChainRpcs'
});
}
// ✅ Fix: Validate each URL
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(`[EvmWalletAdapter] Updated all chain RPCs for ${Object.keys(multiChainRpcs).length} chains`);
}
async setProvider(config) {
if (!config.chainId) {
throw new AdapterError("chainId is required in NetworkConfig", {
code: WalletErrorCode.InvalidInput,
methodName: 'setProvider'
});
}
// 1) Build preferred list (hex or decimal chainId)
const cid = config.chainId;
const preferred = this.multiChainRpcs[cid] || this.multiChainRpcs[String(cid)] || [];
// 2) Ask NetworkHelper to pick a working RPC (fast‐failing if none)
const networkHelper = NetworkHelper.getInstance();
await networkHelper.ensureInitialized();
const netConf = await networkHelper.getNetworkConfig(cid, preferred, false);
if (!netConf) {
throw new AdapterError(`Failed to connect to any provided RPC URL for chain ${cid}`, { code: WalletErrorCode.ConnectionFailed, methodName: 'setProvider' });
}
this.network = null;
// 3) Record it and wire up ethers.js
this.network = netConf;
this.provider = new JsonRpcProvider(netConf.rpcUrls[0]);
if (this.wallet) {
this.wallet = this.wallet.connect(this.provider);
}
this._connected = true;
this.emitEvent(WalletEvent.chainChanged, netConf.chainId);
}
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()) {
throw new AdapterError("Wallet not connected.", { code: WalletErrorCode.WalletNotConnected });
}
const accounts = [this.wallet.address];
this.emitEvent('accountsChanged', accounts);
return accounts;
}
async getNetwork() {
if (!this.network) {
throw new AdapterError("No network configured.", { code: WalletErrorCode.WalletNotConnected, methodName: 'getNetwork' });
}
return this.network;
}
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 {
const network = await this.getNetwork();
if (!network) {
throw new AdapterError("Network not found.", { code: WalletErrorCode.NetworkError, methodName: 'signTypedData' });
}
// This throws it's own adapter errors.
EIP712Validator.validateStructure(data);
EIP712Validator.validateTypes(data.types);
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) {
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, abi) {
console.log('SENDING THIS TX FROM THE CLIENT ...', tx);
if (!this.isConnected()) {
throw new AdapterError("Wallet not connected.", { code: WalletErrorCode.WalletNotConnected, methodName: 'sendTransaction' });
}
const signer = await this.getSigner();
const txRequest = await this.prepareTransactionRequest(tx);
console.log('SENDING THIS TX FROM prepareTransactionRequest ...', txRequest);
try {
const response = await signer.sendTransaction(txRequest);
this.provider.once(response.hash, (receipt) => {
this.emitEvent('txConfirmed', receipt); // or use a callback
});
return response.hash;
}
catch (error) {
const senderAddress = (await this.getAccounts())[0];
console.log('Sender address:', senderAddress);
function decodeCustomError(abi, revertData) {
const iface = new ethers.Interface(abi);
try {
const error = iface.parseError(revertData);
return error?.name;
}
catch (e) {
return undefined;
}
}
const decoded = decodeCustomError(abi, error.data);
console.error('DECODED ERROR', decoded);
console.error('FAILED TO SEND THE TRANSACTION', 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);
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, options.abi);
}
// --- Gas & Fee Methods ---
async estimateGas(tx) {
if (!this.isConnected()) {
throw new AdapterError("Wallet not connected.", { code: WalletErrorCode.WalletNotConnected, methodName: 'estimateGas' });
}
const provider = await this.getProvider();
const signer = await this.getSigner();
const fromAddress = await signer.getAddress();
try {
// 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,
};
console.log('estimateGas-txRequest', txRequest);
// 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.provider) {
throw new AdapterError("Provider not set.", { code: WalletErrorCode.ProviderNotFound });
}
return this.provider;
}
async getSigner() {
if (!this.isConnected() || !this.wallet) {
throw new AdapterError("Wallet not connected.", { code: WalletErrorCode.WalletNotConnected });
}
return this.wallet;
}
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=ethersWallet.js.map