📄 src/web3/client/EvmWriteClient.test.ts
D-OPEN SOVEREIGN
1import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
3const mocks = vi.hoisted(() => ({
4 getAbi: vi.fn(),
5 getAddress: vi.fn(),
6}));
7
8vi.mock('../registry/loader', () => ({
9 addressLoader: {
10 getAbi: mocks.getAbi,
11 getAddress: mocks.getAddress,
12 },
13}));
14
15import { EvmWriteClient } from './EvmWriteClient';
16
17const FAKE_ABI = [{ type: 'function', name: 'noop' }];
18const FACTORY_ADDRESS = '0xFactory0000000000000000000000000000001';
19const REGISTRY_ADDRESS = '0xRegistry000000000000000000000000000001';
20const ACCOUNT = '0xAccount0000000000000000000000000000001';
21
22function fakeWalletClient(overrides: any = {}) {
23 return {
24 account: overrides.account !== undefined ? overrides.account : { address: ACCOUNT },
25 getAddresses: vi.fn().mockResolvedValue([ACCOUNT]),
26 writeContract: vi.fn().mockResolvedValue('0xhash'),
27 ...overrides,
28 };
29}
30
31function fakeReadonlyApi(overrides: any = {}) {
32 return {
33 chainId: 1116,
34 environment: 'production',
35 chain: { id: 1116 },
36 publicClient: {
37 readContract: vi.fn(),
38 waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: 'success' }),
39 },
40 config: {
41 main: false,
42 addresses: { addressRegistry: REGISTRY_ADDRESS },
43 price: { stable: true },
44 },
45 bouncerStorage: {
46 userIdToUsername: vi.fn().mockResolvedValue('alice'),
47 },
48 ...overrides,
49 };
50}
51
52beforeEach(() => {
53 mocks.getAbi.mockReturnValue(FAKE_ABI);
54 mocks.getAddress.mockReturnValue(FACTORY_ADDRESS);
55 vi.unstubAllGlobals();
56 delete (globalThis as any).window;
57});
58
59afterEach(() => {
60 vi.unstubAllGlobals();
61});
62
63describe('EvmWriteClient: account-missing guard', () => {
64 it.each([
65 ['factory.donateToProject', (c: EvmWriteClient) => c.factory.donateToProject(1, 'eng', 10)],
66 ['factory.register', (c: EvmWriteClient) => c.factory.register('US', 'eng', 1)],
67 ['factory.unregister', (c: EvmWriteClient) => c.factory.unregister()],
68 ['factory.save', (c: EvmWriteClient) => c.factory.save([1n], 1, [1], ['a'])],
69 ['subscriptionManager.subscribeTo', (c: EvmWriteClient) => c.subscriptionManager.subscribeTo(1, 2)],
70 ['subscriptionManager.unsubscribeFrom', (c: EvmWriteClient) => c.subscriptionManager.unsubscribeFrom(1, 2)],
71 ['addressRegistry.addData', (c: EvmWriteClient) => c.addressRegistry.addData('k', 'v')],
72 ])('%s throws when no wallet account is connected', async (_name, call) => {
73 const walletClient = fakeWalletClient({ account: undefined });
74 const client = new EvmWriteClient('metamask', fakeReadonlyApi() as any, walletClient as any, ACCOUNT);
75
76 await expect(call(client)).rejects.toThrow('No account connected to wallet');
77 });
78});
79
80describe('EvmWriteClient: factory address resolution', () => {
81 it('throws an actionable error when the factory address has not resolved yet', async () => {
82 mocks.getAddress.mockReturnValue(undefined);
83 const walletClient = fakeWalletClient();
84 const client = new EvmWriteClient('metamask', fakeReadonlyApi() as any, walletClient as any, ACCOUNT);
85
86 await expect(client.factory.unregister()).rejects.toThrow(/Factory address not resolved/);
87 });
88});
89
90describe('EvmWriteClient: registry address resolution', () => {
91 it('uses stagingAddressRegistry when environment is staging', async () => {
92 const walletClient = fakeWalletClient();
93 const readonlyApi = fakeReadonlyApi({
94 environment: 'staging',
95 config: { main: false, addresses: { stagingAddressRegistry: '0xStaging00000000000000000000000000000001' }, price: { stable: true } },
96 });
97 const client = new EvmWriteClient('metamask', readonlyApi as any, walletClient as any, ACCOUNT);
98
99 await client.addressRegistry.addData('k', 'v');
100 expect(walletClient.writeContract).toHaveBeenCalledWith(
101 expect.objectContaining({ address: '0xStaging00000000000000000000000000000001' })
102 );
103 });
104
105 it('throws when no registry address is configured for the environment', async () => {
106 const walletClient = fakeWalletClient();
107 const readonlyApi = fakeReadonlyApi({ config: { main: false, addresses: {}, price: { stable: true } } });
108 const client = new EvmWriteClient('metamask', readonlyApi as any, walletClient as any, ACCOUNT);
109
110 await expect(client.addressRegistry.addData('k', 'v')).rejects.toThrow(/AddressRegistry address not configured/);
111 });
112});
113
114describe('EvmWriteClient: normalizeError wrapping', () => {
115 // Regression test: donateToProject/subscribeTo/unsubscribeFrom previously
116 // called walletClient.writeContract with no try/catch, so the raw wallet
117 // error propagated unnormalized instead of the { code, message, reason,
118 // originalError } NormalizedError shape every sibling write method
119 // guarantees (and normalizeError()'s registerOnErrorCb() side-channel never
120 // fired for them). Now fixed to match every other write method below.
121 it.each([
122 ['factory.register', (c: EvmWriteClient) => c.factory.register('US', 'eng', 1)],
123 ['factory.unregister', (c: EvmWriteClient) => c.factory.unregister()],
124 ['factory.save', (c: EvmWriteClient) => c.factory.save([1n], 1, [1], ['a'])],
125 ['factory.createProject', (c: EvmWriteClient) =>
126 c.factory.createProject('eng', 'uri', 0, 'name', 'headline', 'logo', 'banner', 'desc', 0, 0, [], 0)],
127 ['factory.registerStripe', (c: EvmWriteClient) => c.factory.registerStripe('US', 'eng', 'pi_1', 1n, '0xsig')],
128 ['factory.fundStripe', (c: EvmWriteClient) => c.factory.fundStripe('pi_1', 1n, '0xsig')],
129 ['factory.addProjectVersion', (c: EvmWriteClient) =>
130 c.factory.addProjectVersion(1, 'eng', 0, 'uri', 'name', 'headline', 'logo', 'banner', 'desc', 0, 0, [], 0, 0)],
131 ['factory.deleteProject', (c: EvmWriteClient) => c.factory.deleteProject(1, 'eng')],
132 ['addressRegistry.addData', (c: EvmWriteClient) => c.addressRegistry.addData('k', 'v')],
133 ['factory.donateToProject (fixed)', (c: EvmWriteClient) => c.factory.donateToProject(1, 'eng', 10)],
134 ['subscriptionManager.subscribeTo (fixed)', (c: EvmWriteClient) => c.subscriptionManager.subscribeTo(1, 2)],
135 ['subscriptionManager.unsubscribeFrom (fixed)', (c: EvmWriteClient) => c.subscriptionManager.unsubscribeFrom(1, 2)],
136 ])('%s rethrows a normalized error (code assigned) instead of the raw wallet error', async (_name, call) => {
137 const walletClient = fakeWalletClient({
138 writeContract: vi.fn().mockRejectedValue({ code: 4001, message: 'User rejected the request' }),
139 });
140 const client = new EvmWriteClient('metamask', fakeReadonlyApi() as any, walletClient as any, ACCOUNT);
141
142 await expect(call(client)).rejects.toMatchObject({ code: 'USER_REJECTED' });
143 });
144});
145
146describe('EvmWriteClient: subscription target resolution', () => {
147 it('subscribeTo throws when the target user has no registered username', async () => {
148 const walletClient = fakeWalletClient();
149 const readonlyApi = fakeReadonlyApi({
150 bouncerStorage: { userIdToUsername: vi.fn().mockResolvedValue('') },
151 });
152 const client = new EvmWriteClient('metamask', readonlyApi as any, walletClient as any, ACCOUNT);
153
154 await expect(client.subscriptionManager.subscribeTo(1, 2)).rejects.toThrow(/not registered/);
155 expect(walletClient.writeContract).not.toHaveBeenCalled();
156 });
157
158 it('unsubscribeFrom resolves the target username and calls unsubscribeFrom(username) on Factory', async () => {
159 const walletClient = fakeWalletClient();
160 const readonlyApi = fakeReadonlyApi();
161 const client = new EvmWriteClient('metamask', readonlyApi as any, walletClient as any, ACCOUNT);
162
163 await client.subscriptionManager.unsubscribeFrom(1, 2);
164 expect(walletClient.writeContract).toHaveBeenCalledWith(
165 expect.objectContaining({ functionName: 'unsubscribeFrom', args: ['alice'] })
166 );
167 });
168});
169
170describe('EvmWriteClient: TransactionOptions', () => {
171 it('calls onAccepted with the tx hash before awaiting the receipt', async () => {
172 const walletClient = fakeWalletClient();
173 const client = new EvmWriteClient('metamask', fakeReadonlyApi() as any, walletClient as any, ACCOUNT);
174 const onAccepted = vi.fn();
175
176 await client.factory.unregister({ onAccepted });
177 expect(onAccepted).toHaveBeenCalledWith('0xhash');
178 });
179
180 it('passes options.gasLimit through as the `gas` field on writeContract', async () => {
181 const walletClient = fakeWalletClient();
182 const client = new EvmWriteClient('metamask', fakeReadonlyApi() as any, walletClient as any, ACCOUNT);
183
184 await client.factory.unregister({ gasLimit: 500000n });
185 expect(walletClient.writeContract).toHaveBeenCalledWith(expect.objectContaining({ gas: 500000n }));
186 });
187
188 it('dispatches a wallet_transaction_accepted window event when window is defined', async () => {
189 const dispatchEvent = vi.fn();
190 vi.stubGlobal('window', { dispatchEvent });
191 const walletClient = fakeWalletClient();
192 const client = new EvmWriteClient('metamask', fakeReadonlyApi() as any, walletClient as any, ACCOUNT);
193
194 await client.factory.unregister();
195 expect(dispatchEvent).toHaveBeenCalledTimes(1);
196 expect(dispatchEvent.mock.calls[0][0].type).toBe('wallet_transaction_accepted');
197 });
198
199 it('does not throw when window is undefined (non-browser environment)', async () => {
200 const walletClient = fakeWalletClient();
201 const client = new EvmWriteClient('metamask', fakeReadonlyApi() as any, walletClient as any, ACCOUNT);
202
203 await expect(client.factory.unregister()).resolves.toBeDefined();
204 });
205});
206
207describe('EvmWriteClient: pushPythPrice (invoked internally by factory.register)', () => {
208 function readyReadonlyApi(priceOverride?: any) {
209 return fakeReadonlyApi({
210 config: {
211 main: true,
212 addresses: { addressRegistry: REGISTRY_ADDRESS },
213 price: priceOverride ?? { pythOracle: '0xPyth00000000000000000000000000000000001', usdPriceId: '0xfeed' },
214 },
215 });
216 }
217
218 it('skips the price push entirely when config.main is false', async () => {
219 const fetchSpy = vi.fn();
220 vi.stubGlobal('fetch', fetchSpy);
221 const walletClient = fakeWalletClient();
222 const client = new EvmWriteClient('metamask', fakeReadonlyApi() as any, walletClient as any, ACCOUNT); // main: false
223
224 await client.factory.register('US', 'eng', 1);
225 expect(fetchSpy).not.toHaveBeenCalled();
226 expect(walletClient.writeContract).toHaveBeenCalledTimes(1); // only the register() call, no pyth update
227 });
228
229 it('skips the price push when the chain price config is stable', async () => {
230 const fetchSpy = vi.fn();
231 vi.stubGlobal('fetch', fetchSpy);
232 const walletClient = fakeWalletClient();
233 const readonlyApi = readyReadonlyApi({ stable: true });
234 const client = new EvmWriteClient('metamask', readonlyApi as any, walletClient as any, ACCOUNT);
235
236 await client.factory.register('US', 'eng', 1);
237 expect(fetchSpy).not.toHaveBeenCalled();
238 });
239
240 it('skips the price push when pythOracle/usdPriceId are missing even though main is true', async () => {
241 const fetchSpy = vi.fn();
242 vi.stubGlobal('fetch', fetchSpy);
243 const walletClient = fakeWalletClient();
244 const readonlyApi = fakeReadonlyApi({ config: { main: true, addresses: { addressRegistry: REGISTRY_ADDRESS }, price: {} } });
245 const client = new EvmWriteClient('metamask', readonlyApi as any, walletClient as any, ACCOUNT);
246
247 await client.factory.register('US', 'eng', 1);
248 expect(fetchSpy).not.toHaveBeenCalled();
249 });
250
251 it('pushes a Pyth price update before registering when main is true and price data is available', async () => {
252 const fetchSpy = vi.fn().mockResolvedValue({
253 ok: true,
254 json: async () => ({ binary: { data: ['deadbeef'] } }),
255 });
256 vi.stubGlobal('fetch', fetchSpy);
257 const readonlyApi = readyReadonlyApi();
258 (readonlyApi.publicClient.readContract as any).mockResolvedValue(123n); // getUpdateFee
259 const walletClient = fakeWalletClient();
260 const client = new EvmWriteClient('metamask', readonlyApi as any, walletClient as any, ACCOUNT);
261
262 await client.factory.register('US', 'eng', 1);
263
264 expect(fetchSpy).toHaveBeenCalledTimes(1);
265 // First writeContract call is the Pyth updatePriceFeeds, second is Factory.register
266 expect(walletClient.writeContract).toHaveBeenCalledTimes(2);
267 expect(walletClient.writeContract.mock.calls[0][0]).toMatchObject({ functionName: 'updatePriceFeeds', value: 123n });
268 expect(walletClient.writeContract.mock.calls[1][0]).toMatchObject({ functionName: 'register' });
269 expect(readonlyApi.publicClient.waitForTransactionReceipt).toHaveBeenCalledTimes(2);
270 });
271
272 it('propagates as a normalized error when the Hermes fetch fails during register()', async () => {
273 vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 503 }));
274 const readonlyApi = readyReadonlyApi();
275 const walletClient = fakeWalletClient();
276 const client = new EvmWriteClient('metamask', readonlyApi as any, walletClient as any, ACCOUNT);
277
278 await expect(client.factory.register('US', 'eng', 1)).rejects.toThrow();
279 expect(walletClient.writeContract).not.toHaveBeenCalled();
280 });
281
282 it('throws when Hermes returns no binary update data', async () => {
283 vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ binary: { data: [] } }) }));
284 const readonlyApi = readyReadonlyApi();
285 const walletClient = fakeWalletClient();
286 const client = new EvmWriteClient('metamask', readonlyApi as any, walletClient as any, ACCOUNT);
287
288 await expect(client.factory.register('US', 'eng', 1)).rejects.toThrow(/No price update data/);
289 });
290});
291
292describe('EvmWriteClient: misc', () => {
293 it('getChainId() delegates to readonly.chainId', () => {
294 const client = new EvmWriteClient('metamask', fakeReadonlyApi({ chainId: 1114 }) as any, fakeWalletClient() as any, ACCOUNT);
295 expect(client.getChainId()).toBe(1114);
296 });
297
298 it('account() returns the account passed to the constructor, independent of walletClient.account', () => {
299 const client = new EvmWriteClient('metamask', fakeReadonlyApi() as any, fakeWalletClient() as any, ACCOUNT);
300 expect(client.account()).toBe(ACCOUNT);
301 });
302
303 it('getNormalizedAddress lowercases the address', () => {
304 const client = new EvmWriteClient('metamask', fakeReadonlyApi() as any, fakeWalletClient() as any, ACCOUNT);
305 expect(client.getNormalizedAddress('0xABCDEF')).toBe('0xabcdef');
306 });
307});
308