📄 src/web3/wallet/MetaMaskConnector.ts
D-OPEN SOVEREIGN
1import { type EvmWalletConnector, type ConnectResult, type WalletType } from '../client/types';
2import { createWalletClient, custom, type TransactionRequest, type WalletClient } from 'viem';
3import { getChain, toViemChain } from '../network/chains';
4
5interface EIP6963ProviderDetail {
6  info: {
7    uuid: string;
8    name: string;
9    icon: string;
10    rdns: string;
11  };
12  provider: any;
13}
14
15let announcedMetaMaskProvider: any = null;
16if (typeof window !== 'undefined') {
17  window.addEventListener('eip6963:announceProvider', (event: any) => {
18    const detail = event.detail as EIP6963ProviderDetail;
19    if (detail.info.rdns === 'io.metamask' || detail.info.rdns === 'io.metamask.flask') {
20      announcedMetaMaskProvider = detail.provider;
21    }
22  });
23  window.dispatchEvent(new Event('eip6963:requestProvider'));
24}
25
26export class MetaMaskConnector implements EvmWalletConnector {
27  public readonly type: WalletType = 'metamask';
28  private accountChangedCallbacks: Set<(address: string) => void> = new Set();
29  private chainChangedCallbacks: Set<(chainId: number) => void> = new Set();
30  private isListening = false;
31
32  private getProvider(): any {
33    if (announcedMetaMaskProvider) return announcedMetaMaskProvider;
34    if (typeof window !== 'undefined') {
35      return (window as any).ethereum;
36    }
37    return null;
38  }
39
40  isAvailable(): boolean {
41    const provider = this.getProvider();
42    return !!provider;
43  }
44
45  private setupListeners() {
46    if (this.isListening) return;
47    const provider = this.getProvider();
48    if (!provider) return;
49
50    if (typeof provider.on === 'function') {
51      provider.on('accountsChanged', (accounts: string[]) => {
52        const address = accounts[0] || '';
53        for (const cb of this.accountChangedCallbacks) {
54          cb(address);
55        }
56      });
57
58      provider.on('chainChanged', (chainIdHex: string) => {
59        const chainId = parseInt(chainIdHex, 16);
60        for (const cb of this.chainChangedCallbacks) {
61          cb(chainId);
62        }
63      });
64
65      this.isListening = true;
66    }
67  }
68
69  async connect(): Promise<ConnectResult> {
70    const provider = this.getProvider();
71    if (!provider) {
72      throw new Error('MetaMask provider not found');
73    }
74
75    this.setupListeners();
76
77    const accounts = await provider.request({
78      method: 'eth_requestAccounts',
79    });
80
81    const chainIdHex = await provider.request({
82      method: 'eth_chainId',
83    });
84
85    return {
86      address: accounts[0],
87      chainId: parseInt(chainIdHex, 16),
88    };
89  }
90
91  async getAccounts(): Promise<string[]> {
92    const provider = this.getProvider();
93    if (!provider) return [];
94    try {
95      const accounts = await provider.request({
96        method: 'eth_accounts',
97      });
98      return accounts || [];
99    } catch {
100      return [];
101    }
102  }
103
104  async getChainId(): Promise<number> {
105    const provider = this.getProvider();
106    if (!provider) return 0;
107    try {
108      const chainIdHex = await provider.request({
109        method: 'eth_chainId',
110      });
111      return parseInt(chainIdHex, 16);
112    } catch {
113      return 0;
114    }
115  }
116
117  async requestSwitchChain(chainId: number): Promise<void> {
118    const provider = this.getProvider();
119    if (!provider) {
120      throw new Error('MetaMask provider not found');
121    }
122
123    const targetChainIdHex = '0x' + chainId.toString(16);
124
125    try {
126      await provider.request({
127        method: 'wallet_switchEthereumChain',
128        params: [{ chainId: targetChainIdHex }],
129      });
130    } catch (switchError: any) {
131      // This error code indicates that the chain has not been added to MetaMask.
132      if (switchError.code === 4902) {
133        const chainConfig = getChain(chainId);
134        if (!chainConfig) {
135          throw new Error(`No configuration found for chain ID: ${chainId}`);
136        }
137
138        await provider.request({
139          method: 'wallet_addEthereumChain',
140          params: [
141            {
142              chainId: targetChainIdHex,
143              chainName: chainConfig.name,
144              rpcUrls: [chainConfig.rpc],
145              blockExplorerUrls: [chainConfig.explorer],
146              nativeCurrency: {
147                decimals: chainConfig.decimals || 18,
148                name: chainConfig.name,
149                symbol: chainConfig.symbol,
150              },
151            },
152          ],
153        });
154      } else {
155        throw switchError;
156      }
157    }
158  }
159
160  async sendTransaction(tx: TransactionRequest, chainId: number): Promise<string> {
161    const provider = this.getProvider();
162    if (!provider) {
163      throw new Error('MetaMask provider not found');
164    }
165
166    const accounts = await this.getAccounts();
167    if (accounts.length === 0) {
168      throw new Error('No accounts connected');
169    }
170
171    const chain = toViemChain(getChain(chainId));
172
173    const walletClient = createWalletClient({
174      account: accounts[0] as `0x${string}`,
175      chain,
176      transport: custom(provider),
177    });
178
179    return await walletClient.sendTransaction(tx);
180  }
181
182  async signMessage(message: string): Promise<string> {
183    const provider = this.getProvider();
184    if (!provider) {
185      throw new Error('MetaMask provider not found');
186    }
187
188    const accounts = await this.getAccounts();
189    if (accounts.length === 0) {
190      throw new Error('No accounts connected');
191    }
192
193    const walletClient = createWalletClient({
194      account: accounts[0] as `0x${string}`,
195      transport: custom(provider),
196    });
197
198    return await walletClient.signMessage({
199      account: accounts[0] as `0x${string}`,
200      message,
201    });
202  }
203
204  async getWalletClient(chainId: number): Promise<WalletClient> {
205    const provider = this.getProvider();
206    if (!provider) {
207      throw new Error('MetaMask provider not found');
208    }
209
210    const accounts = await this.getAccounts();
211    if (accounts.length === 0) {
212      throw new Error('No accounts connected');
213    }
214
215    const chain = toViemChain(getChain(chainId));
216
217    return createWalletClient({
218      account: accounts[0] as `0x${string}`,
219      chain,
220      transport: custom(provider),
221    });
222  }
223
224  onAccountChanged(cb: (address: string) => void): () => void {
225    this.setupListeners();
226    this.accountChangedCallbacks.add(cb);
227    return () => {
228      this.accountChangedCallbacks.delete(cb);
229    };
230  }
231
232  onChainChanged(cb: (chainId: number) => void): () => void {
233    this.setupListeners();
234    this.chainChangedCallbacks.add(cb);
235    return () => {
236      this.chainChangedCallbacks.delete(cb);
237    };
238  }
239
240  disconnect(): void {
241    // EIP-1193 providers don't have a direct programmatic disconnect for MetaMask
242  }
243}
244export function createMetaMaskConnector(): MetaMaskConnector {
245  return new MetaMaskConnector();
246}
247