📄 src/web3/client/EvmClient.unit.test.ts
D-OPEN SOVEREIGN
1import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
3const mocks = vi.hoisted(() => ({
4  readContract: vi.fn(),
5  getLogs: vi.fn(),
6  getBlock: vi.fn(),
7  Initialize: vi.fn(),
8  loadAllKeys: vi.fn(),
9  waitForAbis: vi.fn(),
10  getAbi: vi.fn(),
11  getLoadingStatus: vi.fn(),
12}));
13
14vi.mock('viem', async () => {
15  const actual = await vi.importActual<any>('viem');
16  return {
17    ...actual,
18    createPublicClient: vi.fn(() => ({
19      readContract: mocks.readContract,
20      getLogs: mocks.getLogs,
21      getBlock: mocks.getBlock,
22    })),
23  };
24});
25
26vi.mock('../registry/loader', () => ({
27  addressLoader: {
28    Initialize: mocks.Initialize,
29    loadAllKeys: mocks.loadAllKeys,
30    waitForAbis: mocks.waitForAbis,
31    getAbi: mocks.getAbi,
32    getLoadingStatus: mocks.getLoadingStatus,
33  },
34}));
35
36import { EvmClient } from './EvmClient';
37
38const FAKE_ABI = [{ type: 'function', name: 'noop' }];
39
40beforeEach(() => {
41  Object.values(mocks).forEach((m) => m.mockReset());
42  mocks.Initialize.mockResolvedValue({});
43  mocks.loadAllKeys.mockResolvedValue({});
44  mocks.waitForAbis.mockResolvedValue(undefined);
45  mocks.getAbi.mockReturnValue(FAKE_ABI);
46  mocks.getLoadingStatus.mockReturnValue({ status: 'ready' });
47});
48
49afterEach(() => {
50  vi.unstubAllGlobals();
51});
52
53// Chain 1114 (tCORE2) is `stagingDeployed` with only a `stagingAddressRegistry`
54// configured, `main: false`. Chain 1116 (CORE) is `deployed` (production),
55// `main: true`, and has a `pythOracle`/`usdPriceId` price config.
56function makeClient(chainId = 1114, environment: 'production' | 'staging' = 'staging') {
57  return new EvmClient(chainId, environment);
58}
59
60describe('EvmClient constructor', () => {
61  it('throws for a chain not configured at all', () => {
62    expect(() => makeClient(999999)).toThrow('not configured');
63  });
64
65  it('throws when the environment is not deployed on that chain', () => {
66    // chain 1114 has stagingDeployed: true but no `deployed` flag
67    expect(() => makeClient(1114, 'production')).toThrow('is not deployed');
68  });
69
70  it('does not throw for a deployed environment', () => {
71    expect(() => makeClient(1114, 'staging')).not.toThrow();
72  });
73});
74
75describe('EvmClient.ready / address resolution', () => {
76  it('merges addresses from static config, Initialize(), and loadAllKeys() in that order', async () => {
77    mocks.Initialize.mockResolvedValue({ factory: '0xFromInitialize' });
78    mocks.loadAllKeys.mockResolvedValue({ factory: '0xFromLoadAllKeys', backupStorage: '0xBackup' });
79
80    const client = makeClient();
81    await client.ready;
82
83    // loadAllKeys() result should win over Initialize()'s stale value for the same key
84    expect((client as any)._contractAddresses.factory).toBe('0xFromLoadAllKeys');
85    expect((client as any)._contractAddresses.backupStorage).toBe('0xBackup');
86    expect((client as any)._contractAddresses.stagingAddressRegistry).toBe('0x4AB2FF5fAd69eFe62e7Fce1f06FeE5cfFBC31469');
87  });
88
89  it('rejects `ready` when Initialize() throws', async () => {
90    mocks.Initialize.mockRejectedValue(new Error('registry offline'));
91    const client = makeClient();
92    await expect(client.ready).rejects.toThrow('registry offline');
93  });
94});
95
96describe('EvmClient read paths via public API', () => {
97  it('archivistStorage.loadActions transforms the raw tuple into bigint[]/bigint[]/string[]', async () => {
98    mocks.loadAllKeys.mockResolvedValue({ backupStorage: '0xBackup' });
99    mocks.readContract.mockResolvedValue([[1, 2], [10, 20], ['a', 'b']]);
100    const client = makeClient();
101    await client.ready;
102
103    const [compressed, indexes, strings] = await client.archivistStorage.loadActions('user1');
104    expect(compressed).toEqual([1n, 2n]);
105    expect(indexes).toEqual([10n, 20n]);
106    expect(strings).toEqual(['a', 'b']);
107  });
108
109  it('archivistStorage.loadActions tolerates a missing/undefined tuple slot (defaults to [])', async () => {
110    mocks.loadAllKeys.mockResolvedValue({ backupStorage: '0xBackup' });
111    mocks.readContract.mockResolvedValue([undefined, undefined, undefined]);
112    const client = makeClient();
113    await client.ready;
114
115    const [compressed, indexes, strings] = await client.archivistStorage.loadActions('user1');
116    expect(compressed).toEqual([]);
117    expect(indexes).toEqual([]);
118    expect(strings).toEqual([]);
119  });
120
121  it('getWeeklyDonations maps raw rows into StripeDonation objects (array-indexed fallback)', async () => {
122    mocks.loadAllKeys.mockResolvedValue({ projectManagerStorage: '0xPMS' });
123    mocks.readContract.mockResolvedValue([
124      ['pi_123', 500, 'alice', '0xdonor', 'US', 'eng', 1700000000, 1116],
125    ]);
126    const client = makeClient(1116, 'production');
127    await client.ready;
128
129    const [donation] = await client.projectManagerStorage.getWeeklyDonations(3);
130    expect(donation).toEqual({
131      stripePaymentId: 'pi_123',
132      amountUSD: 500n,
133      username: 'alice',
134      donor: '0xdonor',
135      country: 'US',
136      language: 'eng',
137      timestamp: 1700000000,
138      chainId: 1116,
139    });
140  });
141
142  it('bouncerStorage.hasAccount swallows readContract errors and returns false', async () => {
143    mocks.getAbi.mockReturnValue(FAKE_ABI);
144    mocks.readContract.mockRejectedValue(new Error('rpc timeout'));
145    const client = makeClient();
146    await client.ready;
147
148    await expect(client.bouncerStorage.hasAccount('0xabc')).resolves.toBe(false);
149  });
150
151  it('bouncerStorage.hasAccount returns false without throwing when its ABI is not yet loaded', async () => {
152    mocks.getAbi.mockReturnValue(null);
153    const client = makeClient();
154    await client.ready;
155
156    await expect(client.bouncerStorage.hasAccount('0xabc')).resolves.toBe(false);
157  });
158
159  it('read() throws an actionable error instead of calling viem when the ABI is not loaded', async () => {
160    mocks.loadAllKeys.mockResolvedValue({ factory: '0xFactory' });
161    mocks.getAbi.mockReturnValue(null);
162    const client = makeClient();
163    await client.ready;
164
165    await expect(client.factory.owner()).rejects.toThrow(/ABI for 'Factory'.*not loaded/);
166    expect(mocks.readContract).not.toHaveBeenCalled();
167  });
168
169  it('read() throws when the resolved contract address is missing and no whenAddressMissing default is given', async () => {
170    mocks.Initialize.mockResolvedValue({});
171    mocks.loadAllKeys.mockResolvedValue({}); // factory address never resolves
172    const client = makeClient();
173    await client.ready;
174
175    await expect(client.factory.owner()).rejects.toThrow(/factory address missing/);
176  });
177
178  it('read() returns whenAddressMissing instead of throwing when the address is unresolved', async () => {
179    mocks.Initialize.mockResolvedValue({});
180    mocks.loadAllKeys.mockResolvedValue({}); // subscriptionManager address never resolves
181    const client = makeClient();
182    await client.ready;
183
184    await expect(client.subscriptionManager.getSubscriptions(1)).resolves.toEqual([]);
185    await expect(client.subscriptionManager.getSubscriptionCount(1)).resolves.toBe(0n);
186    await expect(client.subscriptionManager.isSubscribed(1, 2)).resolves.toBe(false);
187    expect(mocks.readContract).not.toHaveBeenCalled();
188  });
189
190  it('read() normalizes errors thrown by the underlying viem call', async () => {
191    const rawError = new Error('execution reverted: custom revert reason');
192    mocks.readContract.mockRejectedValue(rawError);
193    const client = makeClient();
194    await client.ready;
195
196    await expect(client.factory.owner()).rejects.toThrow();
197  });
198});
199
200describe('EvmClient.getUsdPrice', () => {
201  it('returns 1.0 immediately for a stable-priced chain without calling readContract or fetch', async () => {
202    const fetchSpy = vi.fn();
203    vi.stubGlobal('fetch', fetchSpy);
204
205    const client = makeClient(1116, 'production');
206    (client as any).config = { ...(client as any).config, price: { stable: true } };
207    await client.ready;
208
209    await expect(client.getUsdPrice()).resolves.toBe(1.0);
210    expect(mocks.readContract).not.toHaveBeenCalled();
211    expect(fetchSpy).not.toHaveBeenCalled();
212  });
213
214  it('reads on-chain via Pyth when `config.main` is true and readContract succeeds', async () => {
215    mocks.readContract.mockResolvedValue({ price: 123456n, expo: -2 });
216    const client = makeClient(1116, 'production'); // main: true
217    await client.ready;
218
219    await expect(client.getUsdPrice()).resolves.toBeCloseTo(1234.56);
220  });
221
222  it('falls back to Hermes when the on-chain Pyth read throws (main: true)', async () => {
223    mocks.readContract.mockRejectedValue(new Error('pyth unavailable'));
224    const fetchSpy = vi.fn().mockResolvedValue({
225      ok: true,
226      json: async () => ({ parsed: [{ price: { price: '250000000', expo: -8 } }] }),
227    });
228    vi.stubGlobal('fetch', fetchSpy);
229
230    const client = makeClient(1116, 'production');
231    await client.ready;
232
233    await expect(client.getUsdPrice()).resolves.toBeCloseTo(2.5);
234    expect(fetchSpy).toHaveBeenCalledTimes(1);
235  });
236
237  it('goes straight to Hermes without an on-chain attempt when `config.main` is false', async () => {
238    const fetchSpy = vi.fn().mockResolvedValue({
239      ok: true,
240      json: async () => ({ parsed: [{ price: { price: '100000000', expo: -8 } }] }),
241    });
242    vi.stubGlobal('fetch', fetchSpy);
243
244    const client = makeClient(1114, 'staging'); // main: false
245    await client.ready;
246
247    await expect(client.getUsdPrice()).resolves.toBeCloseTo(1);
248    expect(mocks.readContract).not.toHaveBeenCalled();
249  });
250
251  it('throws when Hermes responds with a non-ok status', async () => {
252    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 503 }));
253    const client = makeClient(1114, 'staging');
254    await client.ready;
255
256    await expect(client.getUsdPrice()).rejects.toThrow('Hermes price fetch failed: 503');
257  });
258
259  it('throws when Hermes responds ok but with no parsed price entry', async () => {
260    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ parsed: [] }) }));
261    const client = makeClient(1114, 'staging');
262    await client.ready;
263
264    await expect(client.getUsdPrice()).rejects.toThrow('No price data from Hermes');
265  });
266
267  it('throws a clear error when Pyth config (pythOracle/usdPriceId) is missing and the chain is non-stable', async () => {
268    const client = makeClient(1114, 'staging');
269    (client as any).config = { ...(client as any).config, price: {} };
270    await client.ready;
271
272    await expect(client.getUsdPrice()).rejects.toThrow('Pyth config missing for chain 1114');
273  });
274});
275
276describe('EvmClient.getRegistryStatus', () => {
277  it('awaits ready and delegates to addressLoader.getLoadingStatus', async () => {
278    mocks.getLoadingStatus.mockReturnValue({ status: 'synced' });
279    const client = makeClient();
280
281    const status = await client.getRegistryStatus();
282    expect(status).toEqual({ status: 'synced' });
283    expect(mocks.getLoadingStatus).toHaveBeenCalledWith(1114);
284  });
285});
286