📄 src/web3/wallet/PrivateKeyConnector.test.ts
D-OPEN SOVEREIGN
1import { describe, it, expect, vi, beforeEach } from 'vitest';
2
3const mocks = vi.hoisted(() => ({
4  getPrivateKey: vi.fn(),
5  hasPrivateKey: vi.fn(),
6}));
7
8vi.mock('./credentials', () => ({
9  getPrivateKey: mocks.getPrivateKey,
10  hasPrivateKey: mocks.hasPrivateKey,
11}));
12
13import {
14  PrivateKeyConnector,
15  createPrivateKeyConnector,
16  addressFromPrivateKey,
17  createPrivateKeyWalletClient,
18} from './PrivateKeyConnector';
19import { DEFAULT_CHAIN_ID } from '../network/chains';
20
21// Hardhat's well-known default account #0 — deterministic, safe to hardcode.
22const TEST_PK = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
23const TEST_ADDRESS = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266';
24
25beforeEach(() => {
26  mocks.getPrivateKey.mockReset();
27  mocks.hasPrivateKey.mockReset();
28});
29
30describe('addressFromPrivateKey', () => {
31  it('derives the correct checksummed address from a private key', () => {
32    expect(addressFromPrivateKey(TEST_PK)).toBe(TEST_ADDRESS);
33  });
34
35  it('accepts a private key without the 0x prefix', () => {
36    expect(addressFromPrivateKey(TEST_PK.slice(2))).toBe(TEST_ADDRESS);
37  });
38});
39
40describe('createPrivateKeyWalletClient', () => {
41  it('builds a wallet client bound to the given chain and account', () => {
42    const client = createPrivateKeyWalletClient(1116, TEST_PK);
43    expect(client.account?.address).toBe(TEST_ADDRESS);
44    expect(client.chain?.id).toBe(1116);
45  });
46
47  it('throws for an unconfigured chainId', () => {
48    expect(() => createPrivateKeyWalletClient(999999, TEST_PK)).toThrow('Chain 999999 not configured');
49  });
50});
51
52describe('PrivateKeyConnector', () => {
53  describe('connect', () => {
54    it('connects using the stored key for the given chain', async () => {
55      mocks.hasPrivateKey.mockReturnValue(true);
56      mocks.getPrivateKey.mockReturnValue(TEST_PK);
57      const connector = new PrivateKeyConnector();
58
59      const result = await connector.connect(1116);
60
61      expect(result).toEqual({ address: TEST_ADDRESS, chainId: 1116 });
62    });
63
64    it('defaults to DEFAULT_CHAIN_ID when no chainId is given', async () => {
65      mocks.hasPrivateKey.mockReturnValue(true);
66      mocks.getPrivateKey.mockReturnValue(TEST_PK);
67      const connector = new PrivateKeyConnector();
68
69      const result = await connector.connect();
70
71      expect(result.chainId).toBe(DEFAULT_CHAIN_ID);
72    });
73
74    it('throws when no private key is configured for the chain', async () => {
75      mocks.hasPrivateKey.mockReturnValue(false);
76      const connector = new PrivateKeyConnector();
77
78      await expect(connector.connect(1116)).rejects.toThrow('No private key configured for chain 1116');
79    });
80
81    it('does not set activeChainId when connect fails, so getAccounts stays empty', async () => {
82      mocks.hasPrivateKey.mockReturnValue(false);
83      const connector = new PrivateKeyConnector();
84
85      await expect(connector.connect(1116)).rejects.toThrow();
86      expect(await connector.getAccounts()).toEqual([]);
87    });
88  });
89
90  describe('getAccounts / getChainId', () => {
91    it('returns an empty array before connect() has been called', async () => {
92      const connector = new PrivateKeyConnector();
93      expect(await connector.getAccounts()).toEqual([]);
94      expect(await connector.getChainId()).toBe(0);
95    });
96
97    it('returns the connected address and chainId after connect()', async () => {
98      mocks.hasPrivateKey.mockReturnValue(true);
99      mocks.getPrivateKey.mockReturnValue(TEST_PK);
100      const connector = new PrivateKeyConnector();
101      await connector.connect(1116);
102
103      expect(await connector.getAccounts()).toEqual([TEST_ADDRESS]);
104      expect(await connector.getChainId()).toBe(1116);
105    });
106
107    it('returns an empty array if the key was removed from storage after connecting', async () => {
108      mocks.hasPrivateKey.mockReturnValue(true);
109      mocks.getPrivateKey.mockReturnValue(TEST_PK);
110      const connector = new PrivateKeyConnector();
111      await connector.connect(1116);
112
113      mocks.getPrivateKey.mockReturnValue(null);
114      expect(await connector.getAccounts()).toEqual([]);
115    });
116  });
117
118  describe('requestSwitchChain', () => {
119    it('switches the active chain and notifies listeners when a key exists for the target chain', async () => {
120      mocks.getPrivateKey.mockReturnValue(TEST_PK);
121      const connector = new PrivateKeyConnector();
122      const cb = vi.fn();
123      connector.onChainChanged(cb);
124
125      await connector.requestSwitchChain(1114);
126
127      expect(cb).toHaveBeenCalledWith(1114);
128      expect(await connector.getChainId()).toBe(1114);
129    });
130
131    it('throws and does not change the active chain when no key exists for the target', async () => {
132      mocks.hasPrivateKey.mockReturnValue(true);
133      mocks.getPrivateKey
134        .mockReturnValueOnce(TEST_PK) // initial connect
135        .mockReturnValueOnce(null); // switch attempt
136      const connector = new PrivateKeyConnector();
137      await connector.connect(1116);
138
139      await expect(connector.requestSwitchChain(1114)).rejects.toThrow('No private key configured for chain 1114');
140      expect(await connector.getChainId()).toBe(1116);
141    });
142
143    it('unsubscribed chain-change listeners are not called', async () => {
144      mocks.getPrivateKey.mockReturnValue(TEST_PK);
145      const connector = new PrivateKeyConnector();
146      const cb = vi.fn();
147      const unsubscribe = connector.onChainChanged(cb);
148      unsubscribe();
149
150      await connector.requestSwitchChain(1114);
151
152      expect(cb).not.toHaveBeenCalled();
153    });
154  });
155
156  describe('signMessage', () => {
157    it('throws when not connected', async () => {
158      const connector = new PrivateKeyConnector();
159      await expect(connector.signMessage('hello')).rejects.toThrow('Wallet not connected');
160    });
161
162    it('signs a message once connected', async () => {
163      mocks.hasPrivateKey.mockReturnValue(true);
164      mocks.getPrivateKey.mockReturnValue(TEST_PK);
165      const connector = new PrivateKeyConnector();
166      await connector.connect(1116);
167
168      const signature = await connector.signMessage('hello world');
169
170      expect(signature).toMatch(/^0x[0-9a-f]+$/);
171    });
172
173    it('throws if the key disappears from storage between connect() and signMessage()', async () => {
174      mocks.hasPrivateKey.mockReturnValue(true);
175      mocks.getPrivateKey.mockReturnValue(TEST_PK);
176      const connector = new PrivateKeyConnector();
177      await connector.connect(1116);
178
179      mocks.getPrivateKey.mockReturnValue(null);
180      await expect(connector.signMessage('hello')).rejects.toThrow('No private key found');
181    });
182  });
183
184  describe('sendTransaction / getWalletClient', () => {
185    it('sendTransaction throws when no key is configured for the given chain', async () => {
186      mocks.getPrivateKey.mockReturnValue(null);
187      const connector = new PrivateKeyConnector();
188      await expect(connector.sendTransaction({} as any, 1116)).rejects.toThrow('No private key found for chain 1116');
189    });
190
191    it('getWalletClient throws when no key is configured for the given chain', async () => {
192      mocks.getPrivateKey.mockReturnValue(null);
193      const connector = new PrivateKeyConnector();
194      await expect(connector.getWalletClient(1116)).rejects.toThrow('No private key configured for chain 1116');
195    });
196
197    it('getWalletClient returns a client bound to the requested chain\'s key, independent of the connected chain', async () => {
198      mocks.getPrivateKey.mockReturnValue(TEST_PK);
199      const connector = new PrivateKeyConnector();
200
201      const client = await connector.getWalletClient(1114);
202
203      expect(client.chain?.id).toBe(1114);
204      expect(client.account?.address).toBe(TEST_ADDRESS);
205    });
206  });
207
208  describe('disconnect', () => {
209    it('clears the active chain, so getAccounts/getChainId reset', async () => {
210      mocks.hasPrivateKey.mockReturnValue(true);
211      mocks.getPrivateKey.mockReturnValue(TEST_PK);
212      const connector = new PrivateKeyConnector();
213      await connector.connect(1116);
214
215      connector.disconnect();
216
217      expect(await connector.getChainId()).toBe(0);
218      expect(await connector.getAccounts()).toEqual([]);
219    });
220  });
221
222  describe('isAvailable / type / onAccountChanged', () => {
223    it('is always available', () => {
224      expect(new PrivateKeyConnector().isAvailable()).toBe(true);
225    });
226
227    it('reports its type as private_key', () => {
228      expect(new PrivateKeyConnector().type).toBe('private_key');
229    });
230
231    it('onAccountChanged returns a no-op unsubscribe and never invokes the callback (accounts never change on their own)', () => {
232      const connector = new PrivateKeyConnector();
233      const cb = vi.fn();
234      const unsubscribe = connector.onAccountChanged(cb);
235      expect(() => unsubscribe()).not.toThrow();
236      expect(cb).not.toHaveBeenCalled();
237    });
238  });
239
240  it('createPrivateKeyConnector() returns a fresh PrivateKeyConnector instance', () => {
241    expect(createPrivateKeyConnector()).toBeInstanceOf(PrivateKeyConnector);
242  });
243});
244