📄 src/web3/registry/loader.test.ts
D-OPEN SOVEREIGN
1import { describe, it, expect, vi, afterEach } from 'vitest';
2import {
3  RegistryAddressLoader,
4  setRegistryBaseUrl,
5  getRegistryBaseUrl,
6  type RegistryCacheStorage,
7  type RegistryCache,
8  type Manifest,
9} from './loader';
10import type { AddressRegistryReadAPI } from '../client/types';
11
12class MemoryStorage implements RegistryCacheStorage {
13  private store = new Map<string, RegistryCache>();
14  async get(key: string): Promise<RegistryCache | null> {
15    return this.store.get(key) ?? null;
16  }
17  async set(key: string, cache: RegistryCache): Promise<void> {
18    this.store.set(key, cache);
19  }
20}
21
22async function sha256Hex(input: string): Promise<string> {
23  const bytes = new TextEncoder().encode(input);
24  const digest = await crypto.subtle.digest('SHA-256', bytes);
25  return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
26}
27
28function jsonResponse(body: any, ok = true, status = 200): Response {
29  return {
30    ok,
31    status,
32    statusText: ok ? 'OK' : 'Error',
33    json: async () => body,
34  } as Response;
35}
36
37const BASE = 'https://registry.test';
38
39function baseManifest(): Manifest {
40  return {
41    generatedAt: '2026-01-01T00:00:00Z',
42    environment: 'production',
43    networks: {
44      evm: {
45        '1116': {
46          contracts: {
47            BackupStorage: { address: '0xAAA', abiHash: 'hash-1' },
48          },
49        },
50      },
51    },
52  };
53}
54
55afterEach(() => {
56  vi.unstubAllGlobals();
57  setRegistryBaseUrl('https://registry.worldbibliotech.com');
58});
59
60describe('setRegistryBaseUrl / getRegistryBaseUrl', () => {
61  it('strips trailing slashes', () => {
62    setRegistryBaseUrl('https://example.com/');
63    expect(getRegistryBaseUrl()).toBe('https://example.com');
64  });
65
66  it('strips multiple trailing slashes', () => {
67    setRegistryBaseUrl('https://example.com///');
68    expect(getRegistryBaseUrl()).toBe('https://example.com');
69  });
70});
71
72describe('RegistryAddressLoader.loadManifest', () => {
73  it('fetches and populates addresses/abis from the manifest', async () => {
74    setRegistryBaseUrl(BASE);
75    const fetchMock = vi.fn(async (url: string) => {
76      if (url === `${BASE}/production/manifest.json`) return jsonResponse(baseManifest());
77      if (url === `${BASE}/abis/evm/BackupStorage.json`) return jsonResponse({ abi: 'stub' });
78      throw new Error(`unexpected fetch: ${url}`);
79    });
80    vi.stubGlobal('fetch', fetchMock);
81
82    const loader = new RegistryAddressLoader(new MemoryStorage());
83    const manifest = await loader.loadManifest('production');
84    expect(manifest.networks.evm?.['1116']).toBeDefined();
85
86    const addresses = await loader.loadAllKeys(1116, 'production');
87    expect(addresses.backupStorage).toBe('0xAAA');
88
89    await loader.waitForAbis(1116, 'production');
90    expect(loader.getAbi(1116, 'BackupStorage', 'production')).toEqual({ abi: 'stub' });
91  });
92
93  it('rejects when the manifest response is not ok', async () => {
94    vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(null, false, 500)));
95    const loader = new RegistryAddressLoader(new MemoryStorage());
96    await expect(loader.loadManifest('production')).rejects.toThrow(/fetch failed/);
97  });
98
99  it('rejects when the manifest is missing networks.evm', async () => {
100    vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ generatedAt: 'x', environment: 'production', networks: {} })));
101    const loader = new RegistryAddressLoader(new MemoryStorage());
102    await expect(loader.loadManifest('production')).rejects.toThrow(/missing networks.evm/);
103  });
104
105  it('dedupes concurrent calls for the same environment to a single fetch', async () => {
106    setRegistryBaseUrl(BASE);
107    const fetchMock = vi.fn(async (url: string) => {
108      if (url === `${BASE}/production/manifest.json`) return jsonResponse(baseManifest());
109      return jsonResponse({ abi: 'stub' });
110    });
111    vi.stubGlobal('fetch', fetchMock);
112
113    const loader = new RegistryAddressLoader(new MemoryStorage());
114    const [a, b] = await Promise.all([loader.loadManifest('production'), loader.loadManifest('production')]);
115    expect(a).toBe(b);
116
117    const manifestFetches = fetchMock.mock.calls.filter(([url]) => url === `${BASE}/production/manifest.json`);
118    expect(manifestFetches).toHaveLength(1);
119  });
120
121  it('allows retrying after a failed manifest fetch instead of caching the rejection', async () => {
122    setRegistryBaseUrl(BASE);
123    const fetchMock = vi
124      .fn()
125      .mockRejectedValueOnce(new Error('network down'))
126      .mockResolvedValueOnce(jsonResponse(baseManifest()))
127      .mockResolvedValue(jsonResponse({ abi: 'stub' }));
128    vi.stubGlobal('fetch', fetchMock);
129
130    const loader = new RegistryAddressLoader(new MemoryStorage());
131    await expect(loader.loadManifest('production')).rejects.toThrow('Registry manifest network error');
132    await expect(loader.loadManifest('production')).resolves.toBeDefined();
133  });
134
135  it('keeps production and staging manifests/caches independent', async () => {
136    setRegistryBaseUrl(BASE);
137    const stagingManifest: Manifest = {
138      generatedAt: '2026-01-01T00:00:00Z',
139      environment: 'staging',
140      networks: { evm: { '1114': { contracts: { BackupStorage: { address: '0xSTAGE', abiHash: 'h' } } } } },
141    };
142    const fetchMock = vi.fn(async (url: string) => {
143      if (url === `${BASE}/production/manifest.json`) return jsonResponse(baseManifest());
144      if (url === `${BASE}/staging/manifest.json`) return jsonResponse(stagingManifest);
145      return jsonResponse({ abi: 'stub' });
146    });
147    vi.stubGlobal('fetch', fetchMock);
148
149    const loader = new RegistryAddressLoader(new MemoryStorage());
150    await loader.loadManifest('production');
151    await loader.loadManifest('staging');
152
153    expect(loader.getAddress(1116, 'BackupStorage', 'production')).toBe('0xAAA');
154    expect(loader.getAddress(1114, 'BackupStorage', 'staging')).toBe('0xSTAGE');
155    expect(loader.getAddress(1114, 'BackupStorage', 'production')).toBeUndefined();
156  });
157});
158
159describe('RegistryAddressLoader.Initialize', () => {
160  function fakeRegistry(names: string[], addressOf: (name: string) => string): AddressRegistryReadAPI {
161    return {
162      getAllContractNames: async () => names,
163      getLatestContract: async (name: string) => [addressOf(name), `tx-${name}`],
164      getContractHistory: async () => [],
165      getLatestData: async () => '',
166      getAllDataKeys: async () => [],
167      getLastUpdateInfo: async () => null,
168      admin: async () => '0xADMIN',
169    };
170  }
171
172  it('falls back to on-chain sync when the manifest is unreachable', async () => {
173    vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
174    const loader = new RegistryAddressLoader(new MemoryStorage());
175    const registry = fakeRegistry(['BackupStorage'], () => '0xONCHAIN');
176
177    const addresses = await loader.Initialize(1116, registry, undefined, false, 'production');
178
179    expect(addresses.backupStorage).toBe('0xONCHAIN');
180    expect(loader.getAddress(1116, 'BackupStorage', 'production')).toBe('0xONCHAIN');
181  });
182
183  it('throws when the manifest fails and no fallback registry is provided', async () => {
184    vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
185    const loader = new RegistryAddressLoader(new MemoryStorage());
186
187    await expect(loader.Initialize(1116, undefined, undefined, false, 'production')).rejects.toThrow(
188      'No manifest data for evm:1116 and no on-chain registry API to fall back to'
189    );
190  });
191
192  it('returns stale cached addresses immediately while kicking off a background revalidation, when staleWhileRevalidate is true', async () => {
193    vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
194    const storage = new MemoryStorage();
195    await storage.set('dcode_registry_cache_v3_evm_1116', {
196      timestamp: 1,
197      entries: { backupstorage: { address: '0xOLD', abi: null, txId: '', abiHash: '', originalName: 'BackupStorage' } },
198    });
199
200    const loader = new RegistryAddressLoader(storage);
201    let resolveSync!: (names: string[]) => void;
202    const registry = fakeRegistry(['BackupStorage'], () => '0xNEW');
203    const slowRegistry: AddressRegistryReadAPI = {
204      ...registry,
205      getAllContractNames: () => new Promise(resolve => { resolveSync = resolve; }),
206    };
207
208    const stale = await loader.Initialize(1116, slowRegistry, undefined, true, 'production');
209
210    // Immediately returns what's cached, without waiting for the sync below.
211    expect(stale.backupStorage).toBe('0xOLD');
212    // ...but the background on-chain revalidation was actually kicked off.
213    expect(typeof resolveSync).toBe('function');
214
215    resolveSync(['BackupStorage']);
216    await new Promise(r => setTimeout(r, 0));
217    expect(loader.getAddress(1116, 'BackupStorage', 'production')).toBe('0xNEW');
218  });
219
220  it('does kick off a background on-chain sync when staleWhileRevalidate is true and there is no cache at all', async () => {
221    vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
222    const loader = new RegistryAddressLoader(new MemoryStorage());
223    let resolveSync!: (names: string[]) => void;
224    const registry = fakeRegistry(['BackupStorage'], () => '0xNEW');
225    const slowRegistry: AddressRegistryReadAPI = {
226      ...registry,
227      getAllContractNames: () => new Promise(resolve => { resolveSync = resolve; }),
228    };
229
230    const stale = await loader.Initialize(1116, slowRegistry, undefined, true, 'production');
231    expect(stale).toEqual({});
232    expect(typeof resolveSync).toBe('function');
233
234    resolveSync(['BackupStorage']);
235    await new Promise(r => setTimeout(r, 0));
236    expect(loader.getAddress(1116, 'BackupStorage', 'production')).toBe('0xNEW');
237  });
238
239  it('records an abiHash matching sha256(JSON.stringify(abi)) from the on-chain sync path', async () => {
240    vi.stubGlobal('fetch', vi.fn(async (url: string) => {
241      if (url.includes('/abis/evm/')) return jsonResponse(['fetched-onchain']);
242      throw new Error('offline');
243    }));
244    const storage = new MemoryStorage();
245    const loader = new RegistryAddressLoader(storage);
246    const registry = fakeRegistry(['BackupStorage'], () => '0xONCHAIN');
247
248    await loader.Initialize(1116, registry, undefined, false, 'production');
249
250    expect(loader.getAbi(1116, 'BackupStorage', 'production')).toEqual(['fetched-onchain']);
251
252    const expectedHash = await sha256Hex(JSON.stringify(['fetched-onchain']));
253    const cache = await storage.get('dcode_registry_cache_v3_evm_1116');
254    expect(cache!.entries.backupstorage.abiHash).toBe(expectedHash);
255  });
256
257  it('reuses the cached abiHash (rather than recomputing) when serving an ABI from the on-chain cache-hit path', async () => {
258    const storage = new MemoryStorage();
259    await storage.set('dcode_registry_cache_v3_evm_1116', {
260      timestamp: 1,
261      entries: {
262        backupstorage: { address: '0xOLD', abi: ['cached-abi'], txId: 'tx-BackupStorage', abiHash: 'preexisting-hash', originalName: 'BackupStorage' },
263      },
264    });
265    vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
266    const loader = new RegistryAddressLoader(storage);
267    const registry = fakeRegistry(['BackupStorage'], () => '0xNEW');
268
269    await loader.Initialize(1116, registry, undefined, false, 'production');
270
271    const cache = await storage.get('dcode_registry_cache_v3_evm_1116');
272    expect(cache!.entries.backupstorage.abiHash).toBe('preexisting-hash');
273    expect(cache!.entries.backupstorage.abi).toEqual(['cached-abi']);
274  });
275});
276
277describe('RegistryAddressLoader.subscribe', () => {
278  it('notifies listeners as the manifest populates', async () => {
279    setRegistryBaseUrl(BASE);
280    vi.stubGlobal('fetch', vi.fn(async (url: string) => {
281      if (url === `${BASE}/production/manifest.json`) return jsonResponse(baseManifest());
282      return jsonResponse({ abi: 'stub' });
283    }));
284
285    const loader = new RegistryAddressLoader(new MemoryStorage());
286    const events: Array<{ chainId: string | number; loaded: number; total: number }> = [];
287    const unsubscribe = loader.subscribe((chainId, status) => {
288      events.push({ chainId, loaded: status.loaded, total: status.total });
289    });
290
291    await loader.loadManifest('production');
292
293    expect(events.length).toBeGreaterThan(0);
294    expect(events[events.length - 1]).toEqual({ chainId: '1116', loaded: 1, total: 1 });
295
296    unsubscribe();
297  });
298
299  it('stops notifying after unsubscribe', async () => {
300    setRegistryBaseUrl(BASE);
301    vi.stubGlobal('fetch', vi.fn(async (url: string) => {
302      if (url === `${BASE}/production/manifest.json`) return jsonResponse(baseManifest());
303      return jsonResponse({ abi: 'stub' });
304    }));
305
306    const loader = new RegistryAddressLoader(new MemoryStorage());
307    const cb = vi.fn();
308    const unsubscribe = loader.subscribe(cb);
309    unsubscribe();
310
311    await loader.loadManifest('production');
312
313    expect(cb).not.toHaveBeenCalled();
314  });
315});
316
317describe('RegistryAddressLoader.getAddress / getAbi', () => {
318  it('returns undefined before anything has been loaded', () => {
319    const loader = new RegistryAddressLoader(new MemoryStorage());
320    expect(loader.getAddress(1116, 'BackupStorage')).toBeUndefined();
321    expect(loader.getAbi(1116, 'BackupStorage')).toBeUndefined();
322  });
323
324  it('is case-insensitive on contract name lookup', async () => {
325    setRegistryBaseUrl(BASE);
326    vi.stubGlobal('fetch', vi.fn(async (url: string) => {
327      if (url === `${BASE}/production/manifest.json`) return jsonResponse(baseManifest());
328      return jsonResponse({ abi: 'stub' });
329    }));
330    const loader = new RegistryAddressLoader(new MemoryStorage());
331    await loader.loadManifest('production');
332
333    expect(loader.getAddress(1116, 'backupstorage', 'production')).toBe('0xAAA');
334    expect(loader.getAddress(1116, 'BACKUPSTORAGE', 'production')).toBe('0xAAA');
335  });
336});
337