๐Ÿ“„ 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