📄 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