๐ src/web3/registry/loader.ts
D-OPEN SOVEREIGN
Documentation & Insights
Swap the cache backend at runtime โ e.g. a Node caller pointing NodeFileCacheStorage at a specific cache dir.@deprecated No-op. Kept for call-site compatibility.@deprecated Retained for fallback compat.1import { type AddressRegistryReadAPI } from '../client/types';
2import type { V2DataBlock, ResolvedSidecarUrls } from './sidecar-sources';
3import { sidecarUrlsFromManifest } from './sidecar-sources';
4
5export interface ContractLoadingStatus {
6 name: string;
7 status: 'pending' | 'loading' | 'loaded' | 'error';
8 address?: string;
9 abiUrl?: string;
10 source?: 'cache' | 'arweave' | 'local' | 'manifest';
11}
12
13export interface LoadingStatus {
14 total: number;
15 loaded: number;
16 contracts: ContractLoadingStatus[];
17}
18
19const CACHE_KEY_PREFIX = 'dcode_registry_cache_v3_';
20const CACHE_DURATION_MS = 24 * 60 * 60 * 1000;
21
22// Mirrors tools/generate_registry_manifest.js's getAbiHash() (sha256 of the
23// JSON-stringified ABI) so an ABI fetched via the on-chain fallback hashes to
24// the same value the manifest carries once it comes back online โ otherwise
25// syncAbis()/populateFromManifest()'s `existing.abiHash === entry.abiHash`
26// check can never match and every on-chain-fetched ABI gets re-fetched on the
27// next manifest load.
28async function hashAbi(abi: any): Promise<string> {
29 const bytes = new TextEncoder().encode(JSON.stringify(abi));
30 const digest = await crypto.subtle.digest('SHA-256', bytes);
31 return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
32}
33
34let registryBaseUrl = 'https://registry.worldbibliotech.com';
35
36export function setRegistryBaseUrl(url: string): void {
37 registryBaseUrl = url.replace(/\/+$/, '');
38}
39
40export function getRegistryBaseUrl(): string {
41 return registryBaseUrl;
42}
43
44export interface CacheEntry {
45 address: string;
46 abi: any;
47 txId: string;
48 abiHash: string;
49 originalName: string;
50}
51
52export interface RegistryCache {
53 timestamp: number;
54 entries: {
55 [contractName: string]: CacheEntry;
56 };
57}
58
59// Pluggable so the loader persists its cache in a browser (localStorage) and
60// in Node (a JSON file) without either backend being statically bundled into
61// the other's build โ Node access is behind a lazy `import('node:fs/...')`
62// inside NodeFileCacheStorage, never a top-level import, so bundlers targeting
63// the browser never need to resolve it.
64export interface RegistryCacheStorage {
65 get(key: string): Promise<RegistryCache | null>;
66 set(key: string, cache: RegistryCache): Promise<void>;
67}
68
69class LocalStorageCacheStorage implements RegistryCacheStorage {
70 async get(key: string): Promise<RegistryCache | null> {
71 try {
72 const data = localStorage.getItem(key);
73 return data ? JSON.parse(data) : null;
74 } catch {
75 return null;
76 }
77 }
78
79 async set(key: string, cache: RegistryCache): Promise<void> {
80 try {
81 localStorage.setItem(key, JSON.stringify(cache));
82 } catch (e) {
83 console.warn('[RegistryLoader] Failed to save to localStorage:', e);
84 }
85 }
86}
87
88class NoopCacheStorage implements RegistryCacheStorage {
89 async get(): Promise<RegistryCache | null> { return null; }
90 async set(): Promise<void> {}
91}
92
93// Node counterpart to LocalStorageCacheStorage โ used automatically when the
94// loader detects it's running under Node (e.g. the lambda's prebuild script)
95// instead of a browser, so `RegistryAddressLoader.Initialize()` can cache
96// fetched addresses/ABIs to disk across process runs the same way the browser
97// caches them across page loads.
98// Built from concatenation, not a literal โ a literal `import('node:fs/promises')`
99// survives esbuild bundling as a *resolvable-looking* bare specifier (esbuild
100// strips the `node:` prefix), which downstream apps' bundlers (e.g.
101// vite-plugin-node-polyfills) then try to statically intercept and mis-resolve.
102// A computed specifier can't be analyzed at bundle time by anyone, so it's
103// left as a genuine runtime import โ never touched, never executed in a browser
104// (NodeFileCacheStorage is only ever instantiated behind the Node-detection
105// check in detectDefaultStorage()).
106const nodeFsPromisesSpecifier = ['node', 'fs/promises'].join(':');
107const nodePathSpecifier = ['node', 'path'].join(':');
108
109// Bracket-notation, not a bare `process` identifier โ bundler polyfill plugins
110// (e.g. vite-plugin-node-polyfills) statically scan for real `process` global
111// references and inject a shim import into any chunk that has one; that shim
112// import isn't resolvable when the chunk comes from a workspace package's
113// prebuilt dist rather than the app's own src, which hard-fails the build.
114// This reads the exact same global without matching that scan.
115function getNodeProcess(): { cwd(): string; versions?: { node?: string } } | undefined {
116 return (globalThis as any)[['proc', 'ess'].join('')];
117}
118
119export class NodeFileCacheStorage implements RegistryCacheStorage {
120 constructor(private cacheDir: string = `${getNodeProcess()?.cwd() ?? '.'}/.dcode-registry-cache`) {}
121
122 private async filePath(key: string): Promise<string> {
123 const { join } = await import(/* @vite-ignore */ nodePathSpecifier);
124 return join(this.cacheDir, `${key.replace(/[^a-zA-Z0-9_.-]/g, '_')}.json`);
125 }
126
127 async get(key: string): Promise<RegistryCache | null> {
128 try {
129 const fs = await import(/* @vite-ignore */ nodeFsPromisesSpecifier);
130 const data = await fs.readFile(await this.filePath(key), 'utf-8');
131 return JSON.parse(data);
132 } catch {
133 return null;
134 }
135 }
136
137 async set(key: string, cache: RegistryCache): Promise<void> {
138 try {
139 const fs = await import(/* @vite-ignore */ nodeFsPromisesSpecifier);
140 const file = await this.filePath(key);
141 const { dirname } = await import(/* @vite-ignore */ nodePathSpecifier);
142 await fs.mkdir(dirname(file), { recursive: true });
143 await fs.writeFile(file, JSON.stringify(cache), 'utf-8');
144 } catch (e) {
145 console.warn('[RegistryLoader] Failed to save Node file cache:', e);
146 }
147 }
148}
149
150export function createNodeFileCacheStorage(cacheDir?: string): RegistryCacheStorage {
151 return new NodeFileCacheStorage(cacheDir);
152}
153
154function detectDefaultStorage(): RegistryCacheStorage {
155 if (typeof localStorage !== 'undefined') return new LocalStorageCacheStorage();
156 if (getNodeProcess()?.versions?.node) return new NodeFileCacheStorage();
157 return new NoopCacheStorage();
158}
159
160export interface ManifestContractEntry {
161 address?: string;
162 abiHash?: string;
163}
164
165export interface ManifestNetwork {
166 shortName?: string;
167 contracts: Record<string, ManifestContractEntry>;
168}
169
170export interface ManifestProvenance {
171 anchorChainId: number;
172 contractAddress: string;
173 key: string;
174 arweaveTxId: string;
175 sha256Hash: string;
176 blockNumber: number;
177}
178
179export interface Manifest {
180 generatedAt: string;
181 environment: 'production' | 'staging';
182 schemaVersion?: number;
183 corpus?: V2DataBlock;
184 provenance?: ManifestProvenance;
185 networks: {
186 evm?: Record<string, ManifestNetwork>;
187 };
188}
189
190// All current and near-future chains are EVM (spec principle 5 โ `Tech` as a
191// runtime string is deleted; a future non-EVM adapter plugs in by implementing
192// ReadOnlyDomainAPI/WriteOnlyDomainAPI, not by this loader threading a tech
193// string through every method). Every call site in this package already only
194// ever passes 'evm'; keeping it as a parameter was dead generality left over
195// from when this loader lived in a standalone `web3-registry-addresses`
196// package meant to serve multiple chain families.
197export class RegistryAddressLoader {
198 private memoryCache: Map<string, RegistryCache> = new Map();
199 private listeners: Set<(chainId: string | number, status: LoadingStatus) => void> = new Set();
200 private loadingStatuses: Map<string, LoadingStatus> = new Map();
201 private manifestPromises: Map<string, Promise<Manifest>> = new Map();
202 private loadedManifests: Map<string, Manifest> = new Map();
203 // Tracks the in-flight syncAbis() run kicked off by loadManifest() so
204 // waitForAbis() (called from EvmClient.ready) can await the *same* run
205 // instead of firing a redundant one or resolving before ABIs actually land.
206 private abiSyncPromises: Map<string, Promise<void>> = new Map();
207 private defered: { [key: string]: Promise<true> | undefined } = {};
208 private storage: RegistryCacheStorage;
209
210 constructor(storage?: RegistryCacheStorage) {
211 this.storage = storage ?? detectDefaultStorage();
212 }
213
214 /** Swap the cache backend at runtime โ e.g. a Node caller pointing NodeFileCacheStorage at a specific cache dir. */
215 public setStorage(storage: RegistryCacheStorage): void {
216 this.storage = storage;
217 }
218
219 private async fetchAbiFromRegistry(contractName: string): Promise<any> {
220 const url = `${registryBaseUrl}/abis/evm/${contractName}.json`;
221 try {
222 const res = await fetch(url);
223 if (res.ok) {
224 const abi = await res.json();
225 console.log(`[RegistryLoader] Fetched ABI: ${contractName}`);
226 return abi;
227 }
228 console.error(`[RegistryLoader] ABI fetch failed for ${contractName}: ${res.status}`);
229 } catch (e) {
230 console.warn(`[RegistryLoader] Failed to fetch ABI for ${contractName}:`, e);
231 }
232 return null;
233 }
234
235 private async syncAbis(chainId: string | number, environment: 'production' | 'staging', manifest: Manifest): Promise<void> {
236 const cacheKey = this.getCacheKey(chainId, environment);
237 const cache = this.memoryCache.get(cacheKey);
238 if (!cache) return;
239
240 const chainIdStr = String(chainId);
241 const network = manifest.networks.evm?.[chainIdStr];
242 if (!network) return;
243
244 let updated = false;
245 await Promise.all(
246 Object.entries(network.contracts).map(async ([name, entry]) => {
247 if (!entry.abiHash) return;
248 const normalizedName = name.toLowerCase();
249 const cached = cache.entries[normalizedName];
250 if (cached?.abiHash === entry.abiHash && cached?.abi) return;
251
252 const abi = await this.fetchAbiFromRegistry(name);
253 if (abi && cache.entries[normalizedName]) {
254 cache.entries[normalizedName].abi = abi;
255 cache.entries[normalizedName].abiHash = entry.abiHash;
256 updated = true;
257 }
258 })
259 );
260
261 if (updated) {
262 cache.timestamp = Date.now();
263 await this.storage.set(cacheKey, cache);
264 }
265 }
266
267 private async performSync(
268 chainId: number | string,
269 registry: AddressRegistryReadAPI,
270 oldCache: RegistryCache | null,
271 resolve: (value: true) => void,
272 environment: 'production' | 'staging' = 'production'
273 ): Promise<Record<string, string>> {
274 const cacheKey = this.getCacheKey(chainId, environment);
275 const now = Date.now();
276
277 try {
278 const contractNames = await registry.getAllContractNames();
279 console.log(`[RegistryLoader] performSync: ${contractNames.length} contracts for evm:${chainId}`);
280
281 const status: LoadingStatus = {
282 total: contractNames.length,
283 loaded: 0,
284 contracts: contractNames.map(name => ({ name, status: 'pending' })),
285 };
286 this.notify(chainId, status);
287
288 const newEntries: { [name: string]: CacheEntry } = {};
289 const addresses: Record<string, string> = {};
290
291 await Promise.all(
292 contractNames.map(async (name) => {
293 const contractStatus = status.contracts.find(c => c.name === name)!;
294 contractStatus.status = 'loading';
295 this.notify(chainId, { ...status });
296
297 try {
298 const [address, abiUrl] = await registry.getLatestContract(name);
299
300 const envSuffix = environment === 'staging' ? '_staging' : '_production';
301 const baseName = name.endsWith(envSuffix) ? name.slice(0, -envSuffix.length) : name;
302 const normalizedName = baseName.toLowerCase();
303 const camelCaseName = baseName.charAt(0).toLowerCase() + baseName.slice(1);
304
305 let abi: any = null;
306 let abiHash = '';
307 let source: 'cache' | 'arweave' | undefined;
308
309 if (oldCache?.entries[normalizedName]?.txId === abiUrl && oldCache.entries[normalizedName].abi) {
310 abi = oldCache.entries[normalizedName].abi;
311 abiHash = oldCache.entries[normalizedName].abiHash;
312 source = 'cache';
313 } else {
314 abi = await this.fetchAbiFromRegistry(baseName);
315 abiHash = abi ? await hashAbi(abi) : '';
316 source = 'arweave';
317 }
318
319 newEntries[normalizedName] = { address, abi, txId: abiUrl, abiHash, originalName: baseName };
320 addresses[camelCaseName] = address;
321 contractStatus.status = 'loaded';
322 contractStatus.address = address;
323 contractStatus.abiUrl = abiUrl;
324 contractStatus.source = source;
325 } catch (e) {
326 console.warn(`[RegistryLoader] Failed to sync ${name} for evm:${chainId}:`, e);
327 contractStatus.status = 'error';
328 } finally {
329 status.loaded++;
330 this.notify(chainId, { ...status });
331 }
332 })
333 );
334
335 const hasErrors = status.contracts.some(c => c.status === 'error');
336 const newCache: RegistryCache = {
337 timestamp: hasErrors ? (oldCache?.timestamp ?? 0) : now,
338 entries: newEntries,
339 };
340
341 this.memoryCache.set(cacheKey, newCache);
342 await this.storage.set(cacheKey, newCache);
343 resolve(true);
344 return addresses;
345 } catch (e) {
346 console.error(`[RegistryLoader] performSync failed for evm:${chainId}:`, e);
347 throw e;
348 }
349 }
350
351 private getCacheKey(chainId: string | number, environment: 'production' | 'staging' = 'production'): string {
352 return `${CACHE_KEY_PREFIX}evm_${chainId}${environment === 'staging' ? '_staging' : ''}`;
353 }
354
355 public subscribe(listener: (chainId: string | number, status: LoadingStatus) => void): () => void {
356 this.listeners.add(listener);
357 return () => this.listeners.delete(listener);
358 }
359
360 private notify(chainId: string | number, status: LoadingStatus): void {
361 const key = `evm:${chainId}`;
362 this.loadingStatuses.set(key, status);
363 this.listeners.forEach(l => l(chainId, status));
364 }
365
366 public getLoadingStatus(chainId: string | number): LoadingStatus | undefined {
367 return this.loadingStatuses.get(`evm:${chainId}`);
368 }
369
370 /** @deprecated No-op. Kept for call-site compatibility. */
371 public seedDefaults(_snapshot: any, _environment: 'production' | 'staging' = 'production'): void {}
372
373 private manifestUrl(environment: 'production' | 'staging'): string {
374 return `${registryBaseUrl}/${environment}/manifest.json`;
375 }
376
377 private getLoadedManifest(environment: 'production' | 'staging'): Manifest | null {
378 return this.loadedManifests.get(environment) ?? null;
379 }
380
381 public async loadManifest(environment: 'production' | 'staging' = 'production'): Promise<Manifest> {
382 const existing = this.manifestPromises.get(environment);
383 if (existing) return existing;
384
385 const url = this.manifestUrl(environment);
386 const promise = (async (): Promise<Manifest> => {
387 console.log(`[RegistryLoader] Fetching manifest: ${url}`);
388 let response: Response;
389 try {
390 response = await fetch(url, { cache: 'default' });
391 } catch (e: any) {
392 throw new Error(`Registry manifest network error (${url}): ${e?.message || e}`);
393 }
394 if (!response.ok) {
395 throw new Error(`Registry manifest fetch failed (${response.status} ${response.statusText}) at ${url}`);
396 }
397 let manifest: Manifest;
398 try {
399 manifest = await response.json();
400 } catch (e: any) {
401 throw new Error(`Registry manifest JSON parse failed at ${url}: ${e?.message || e}`);
402 }
403 if (!manifest?.networks?.evm) {
404 throw new Error(`Registry manifest at ${url} missing networks.evm`);
405 }
406 this.loadedManifests.set(environment, manifest);
407 await this.populateFromManifest(manifest, environment);
408 console.log(`[RegistryLoader] Manifest loaded (${Object.keys(manifest.networks.evm).length} chains, generatedAt ${manifest.generatedAt})`);
409
410 // Fetch any ABIs whose hash differs from the cached version. This does not
411 // block loadManifest()'s own caller (addresses are usable immediately), but
412 // the promise is tracked per chain so waitForAbis() โ called from
413 // EvmClient.ready โ can await it instead of racing it.
414 for (const chainId of Object.keys(manifest.networks.evm || {})) {
415 const syncKey = this.getCacheKey(chainId, environment);
416 const syncPromise = this.syncAbis(chainId, environment, manifest).catch(e =>
417 console.warn(`[RegistryLoader] ABI sync failed for ${chainId}:`, e)
418 );
419 this.abiSyncPromises.set(syncKey, syncPromise);
420 syncPromise.finally(() => {
421 if (this.abiSyncPromises.get(syncKey) === syncPromise) this.abiSyncPromises.delete(syncKey);
422 });
423 }
424 return manifest;
425 })();
426
427 this.manifestPromises.set(environment, promise);
428 promise.catch(() => this.manifestPromises.delete(environment));
429 return promise;
430 }
431
432 private async populateFromManifest(manifest: Manifest, environment: 'production' | 'staging'): Promise<void> {
433 const evm = manifest.networks.evm || {};
434 const now = Date.now();
435
436 for (const [chainId, network] of Object.entries(evm)) {
437 const cacheKey = this.getCacheKey(chainId, environment);
438 const entries: { [name: string]: CacheEntry } = {};
439 const contracts: ContractLoadingStatus[] = [];
440
441 const existingCache = (await this.storage.get(cacheKey)) ?? this.memoryCache.get(cacheKey);
442 for (const [name, entry] of Object.entries(network.contracts || {})) {
443 const normalizedName = name.toLowerCase();
444 const existing = existingCache?.entries[normalizedName];
445 const abiStillValid = entry.abiHash && existing?.abiHash === entry.abiHash && existing?.abi;
446 entries[normalizedName] = {
447 address: entry.address || '',
448 abi: abiStillValid ? existing!.abi : null,
449 txId: existing?.txId || '',
450 abiHash: entry.abiHash || existing?.abiHash || '',
451 originalName: name,
452 };
453 contracts.push({
454 name,
455 status: 'loaded',
456 address: entry.address,
457 source: 'manifest',
458 });
459 }
460
461 const cache: RegistryCache = { timestamp: now, entries };
462 this.memoryCache.set(cacheKey, cache);
463
464 this.notify(chainId, {
465 total: contracts.length,
466 loaded: contracts.length,
467 contracts,
468 });
469 }
470 }
471
472 private addressesFromCache(cache: RegistryCache): Record<string, string> {
473 const addresses: Record<string, string> = {};
474 for (const entry of Object.values(cache.entries)) {
475 if (!entry.address) continue;
476 const camelCaseName = entry.originalName.charAt(0).toLowerCase() + entry.originalName.slice(1);
477 addresses[camelCaseName] = entry.address;
478 }
479 return addresses;
480 }
481
482 public async loadAllKeys(chainId: number | string, environment: 'production' | 'staging' = 'production'): Promise<Record<string, string>> {
483 try {
484 await this.loadManifest(environment);
485 } catch (e) {
486 // Manifest fetch failed โ fall through to whatever's already cached
487 // (e.g. populated by a prior on-chain sync via Initialize()) instead of
488 // throwing outright; only throw below if there's truly nothing cached.
489 }
490 const cacheKey = this.getCacheKey(chainId, environment);
491 let cache = this.memoryCache.get(cacheKey);
492 if (!cache) {
493 // Nothing in memory yet โ e.g. this is the "fresh" loadAllKeys() call
494 // EvmClient.ready makes right after Initialize(), and both the manifest
495 // fetch and the on-chain fallback are offline. Initialize() already proved
496 // a valid cache exists on disk (it reads storage directly for its own
497 // fallback) โ check it here too instead of throwing and bricking `ready`
498 // when a perfectly usable offline cache is sitting right there.
499 cache = (await this.storage.get(cacheKey)) ?? undefined;
500 if (cache) this.memoryCache.set(cacheKey, cache);
501 }
502 if (!cache) {
503 throw new Error(`No registry data found for evm:${chainId} (env: ${environment})`);
504 }
505 return this.addressesFromCache(cache);
506 }
507
508 // Manifest (off-chain, cheap) is the primary source โ it's what's actually
509 // exercised day to day. If it fails or comes back empty (e.g.
510 // registry.worldbibliotech.com is down), fall back to a live on-chain sync
511 // via `registry` (EvmClient's bootstrap AddressRegistryReadAPI, ยง4.2a โ the
512 // only thing that can resolve contract addresses without the manifest).
513 //
514 // `staleWhileRevalidate` controls how the on-chain fallback is awaited:
515 // true returns whatever's already cached (possibly stale/empty) immediately
516 // while the sync keeps running in the background and updates the cache for
517 // the next call; false blocks until that sync completes.
518 public async Initialize(
519 chainId: number | string,
520 registry?: AddressRegistryReadAPI,
521 _registryAddress?: string,
522 staleWhileRevalidate: boolean = false,
523 environment: 'production' | 'staging' = 'production'
524 ): Promise<Record<string, string>> {
525 try {
526 // Fetch the manifest itself first (rather than going straight through
527 // loadAllKeys(), which silently falls back to whatever's cached on a
528 // manifest failure) so a genuine failure is caught *here* and always
529 // reaches the on-chain-fallback/staleWhileRevalidate logic below,
530 // instead of loadAllKeys()'s own cache fallback masking the failure and
531 // returning stale data without ever kicking off a background refresh.
532 await this.loadManifest(environment);
533 const manifestAddresses = await this.loadAllKeys(chainId, environment);
534 if (Object.keys(manifestAddresses).length > 0) return manifestAddresses;
535 } catch (e) {
536 console.warn(`[RegistryLoader] Manifest load failed for evm:${chainId}, falling back to on-chain sync:`, e);
537 }
538
539 if (!registry) {
540 throw new Error(`[RegistryLoader] No manifest data for evm:${chainId} and no on-chain registry API to fall back to`);
541 }
542
543 const promiseKey = `${chainId}-${environment}`;
544 const cacheKey = this.getCacheKey(chainId, environment);
545 const oldCache = this.memoryCache.get(cacheKey) ?? (await this.storage.get(cacheKey));
546 const staleAddresses = oldCache ? this.addressesFromCache(oldCache) : {};
547
548 // A sync for this chain+env may already be in flight (e.g. a previous
549 // Initialize() call) โ share it instead of triggering duplicate on-chain reads.
550 if (!this.defered[promiseKey]) {
551 let resolveDefered!: (value: true) => void;
552 this.defered[promiseKey] = new Promise<true>(res => { resolveDefered = res; });
553 this.performSync(chainId, registry, oldCache, resolveDefered, environment)
554 .catch(() => resolveDefered(true)) // performSync already logs; just unblock waiters on failure
555 .finally(() => { delete this.defered[promiseKey]; });
556 }
557
558 if (staleWhileRevalidate) {
559 return staleAddresses;
560 }
561
562 await this.defered[promiseKey];
563 const cache = this.memoryCache.get(cacheKey);
564 return cache ? this.addressesFromCache(cache) : staleAddresses;
565 }
566
567 public getAddress(chainId: string | number, name: string, environment: 'production' | 'staging' = 'production'): string | undefined {
568 const cacheKey = this.getCacheKey(chainId, environment);
569 const cache = this.memoryCache.get(cacheKey);
570 const addr = cache?.entries[name.toLowerCase()]?.address;
571 return addr || undefined;
572 }
573
574 public getAbi(chainId: string | number, name: string, environment: 'production' | 'staging' = 'production'): any {
575 const cacheKey = this.getCacheKey(chainId, environment);
576 const cache = this.memoryCache.get(cacheKey);
577 return cache?.entries[name.toLowerCase()]?.abi;
578 }
579
580 /** @deprecated Retained for fallback compat. */
581 public getData(chainId: string | number, key: string, environment: 'production' | 'staging' = 'production'): string | undefined {
582 return undefined;
583 }
584
585 public sidecarUrlsForManifest(
586 environment: 'production' | 'staging' = 'production',
587 lang: string = 'eng',
588 gateway: string = 'https://arweave.net'
589 ): ResolvedSidecarUrls[] {
590 const manifest = this.getLoadedManifest(environment);
591 if (!manifest?.corpus) return [];
592 return sidecarUrlsFromManifest(manifest.corpus, lang, gateway);
593 }
594
595 public getProvenance(environment: 'production' | 'staging' = 'production'): ManifestProvenance | undefined {
596 return this.getLoadedManifest(environment)?.provenance;
597 }
598
599 public getCorpus(environment: 'production' | 'staging' = 'production'): V2DataBlock | undefined {
600 return this.getLoadedManifest(environment)?.corpus;
601 }
602
603 // loadManifest() kicks off syncAbis() in the background (fire-and-forget) so
604 // browser callers get a fast manifest load and ABIs fill in progressively.
605 // A one-shot Node caller (e.g. the lambda's prebuild script) that needs every
606 // ABI resolved before it can write output should await this instead of
607 // reading getAbi() immediately after loadManifest() resolves.
608 public async waitForAbis(chainId: string | number, environment: 'production' | 'staging' = 'production'): Promise<void> {
609 const cacheKey = this.getCacheKey(chainId, environment);
610 const inFlight = this.abiSyncPromises.get(cacheKey);
611 if (inFlight) {
612 await inFlight;
613 return;
614 }
615 const manifest = this.getLoadedManifest(environment);
616 if (!manifest) return;
617 await this.syncAbis(chainId, environment, manifest);
618 }
619}
620
621export const addressLoader = new RegistryAddressLoader();
622
623export function getContractAbi(chainId: string | number, contractName: string, environment: 'production' | 'staging' = 'production'): any {
624 return addressLoader.getAbi(chainId, contractName, environment);
625}
626
627export { CACHE_DURATION_MS };
628