📄 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