📄 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