📄 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