📄 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