erc8004-identity-viewer / tree / viewer.js ยท raw (this view has old GitHub links rewritten for the mirror; raw is byte-exact)
// ERC-8004 identity viewer: the logic. Runs in the browser and in Node 20, no dependency.
import { REGISTRIES, DEFAULT_IPFS_GATEWAY } from './config.js';
import { encodeOwnerOf, encodeTokenURI, encodeReadAllFeedback, decodeAddress, decodeString, decodeFeedback } from './abi.js';

export { REGISTRIES };

const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';

/** True when an RPC error means the call reverted (JSON-RPC code 3, or a message with revert). */
export function isRevert(err) {
  if (!err) return false;
  if (Number(err.code) === 3) return true;
  return /revert/i.test(String(err.message || ''));
}

function plain(err) {
  const m = err && err.message ? String(err.message) : String(err || 'unknown error');
  return m.length > 300 ? m.slice(0, 300) : m;
}

/** ipfs://CID/path (and ipfs://ipfs/CID) to an https gateway URL. Other URIs come back unchanged. */
export function resolveUri(uri, gateway = DEFAULT_IPFS_GATEWAY) {
  if (typeof uri !== 'string') return uri;
  const m = /^ipfs:\/\/(?:ipfs\/)?(.+)$/i.exec(uri.trim());
  if (!m) return uri.trim();
  const g = gateway.endsWith('/') ? gateway : gateway + '/';
  return g + m[1];
}

function base64ToUtf8(b64) {
  const clean = b64.replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/');
  if (typeof atob === 'function') {
    const bin = atob(clean);
    const bytes = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
    return new TextDecoder().decode(bytes);
  }
  return globalThis.Buffer.from(clean, 'base64').toString('utf8');
}

/** Decodes a data:application/json URI (base64 or percent encoded) without a request. null if it is not one. */
export function decodeDataUri(uri) {
  if (typeof uri !== 'string') return null;
  const m = /^data:(application\/(?:[\w.+-]*\+)?json)((?:;[^,;]*)*?)(;base64)?,(.*)$/is.exec(uri.trim());
  if (!m) return null;
  const payload = m[4];
  const text = m[3] ? base64ToUtf8(payload) : decodeURIComponent(payload);
  return JSON.parse(text);
}

/** Pulls the fields the page shows out of a registration file, whatever its exact version. */
export function normaliseRegistration(reg) {
  if (!reg || typeof reg !== 'object' || Array.isArray(reg)) throw new Error('the registration file is not a JSON object');
  const list = Array.isArray(reg.endpoints) ? reg.endpoints : Array.isArray(reg.services) ? reg.services : [];
  const endpoints = list
    .filter((e) => e && typeof e === 'object')
    .map((e) => ({
      name: String(e.name ?? e.type ?? ''),
      endpoint: String(e.endpoint ?? e.url ?? e.uri ?? ''),
      version: e.version != null ? String(e.version) : undefined,
    }));
  const trust = Array.isArray(reg.supportedTrust) ? reg.supportedTrust.map(String) : Array.isArray(reg.supportedTrusts) ? reg.supportedTrusts.map(String) : [];
  return {
    name: typeof reg.name === 'string' ? reg.name : '',
    description: typeof reg.description === 'string' ? reg.description : '',
    image: typeof reg.image === 'string' ? reg.image : '',
    endpoints,
    supportedTrust: trust,
    raw: reg,
  };
}

/** value / 10^decimals as a JS number (safe for the score ranges ERC-8004 uses). */
export function scaleValue(value, decimals) {
  const v = BigInt(value);
  const d = Number(decimals) || 0;
  if (d === 0) return Number(v);
  const neg = v < 0n;
  const a = neg ? -v : v;
  const p = 10n ** BigInt(d);
  const s = `${a / p}.${(a % p).toString().padStart(d, '0')}`;
  return (neg ? -1 : 1) * Number(s);
}

/** {count, average, items} from decoded feedback entries. Revoked entries are left out. */
export function summariseFeedback(entries, latest = 10) {
  const live = entries.filter((e) => !e.revoked);
  const items = live.map((e) => ({
    client: e.client,
    score: scaleValue(e.value, e.valueDecimals),
    tags: [e.tag1, e.tag2].filter((t) => typeof t === 'string' && t !== ''),
    feedbackIndex: e.feedbackIndex,
  }));
  const count = items.length;
  const average = count ? Math.round((items.reduce((s, i) => s + i.score, 0) / count) * 100) / 100 : null;
  // The registry returns feedback grouped by client in index order; the latest per client come last.
  return { count, average, items: items.slice(-latest).reverse() };
}

async function ethCall(rpc, to, data) {
  return rpc('eth_call', [{ to, data }, 'latest']);
}

/**
 * Loads an agent's ERC-8004 identity, registration file and reputation. Never throws.
 * @returns {Promise<{status:'ok'|'empty'|'error', agentId, network, owner, tokenURI, registration, feedback, error?}>}
 */
export async function loadAgent(input) {
  const opts = input && typeof input === 'object' ? input : {};
  const { agentId, network, rpc, fetchJson, ipfsGateway = DEFAULT_IPFS_GATEWAY, latest = 10 } = opts;
  const reg = REGISTRIES[network] || {};
  const identityRegistry = opts.identityRegistry || reg.identity;
  const reputationRegistry = opts.reputationRegistry || undefined;
  const out = { status: 'error', agentId: agentId == null ? null : String(agentId), network: network ?? null, owner: null, tokenURI: null, registration: null, feedback: null };
  const fail = (msg) => ({ ...out, status: 'error', error: msg });

  try {
    if (agentId == null || !/^\d+$/.test(String(agentId).trim())) return fail('The agent id must be a whole number, like 42.');
    if (typeof rpc !== 'function') return fail('No RPC is configured.');
    if (!identityRegistry || !/^0x[0-9a-fA-F]{40}$/.test(identityRegistry)) return fail('No identity registry is configured for this network.');
    const id = String(agentId).trim();
    out.agentId = id;

    // 1. Owner: a revert or the zero address means there is no such agent.
    let owner;
    try {
      owner = decodeAddress(await ethCall(rpc, identityRegistry, encodeOwnerOf(id)));
    } catch (e) {
      if (isRevert(e)) return { ...out, status: 'empty', error: `No agent ${id} is registered on ${network}.` };
      return fail(`The RPC call failed: ${plain(e)}`);
    }
    if (owner === ZERO_ADDRESS) return { ...out, status: 'empty', error: `No agent ${id} is registered on ${network}.` };
    out.owner = owner;

    // 2. Registration file.
    let tokenURI;
    try {
      tokenURI = decodeString(await ethCall(rpc, identityRegistry, encodeTokenURI(id)));
    } catch (e) {
      return fail(`Reading the tokenURI failed: ${plain(e)}`);
    }
    out.tokenURI = tokenURI;
    if (!tokenURI) return fail('The agent has no tokenURI, so there is no registration file to read.');
    try {
      const data = decodeDataUri(tokenURI);
      let file;
      if (data !== null) file = data;
      else if (typeof fetchJson === 'function') file = await fetchJson(resolveUri(tokenURI, ipfsGateway));
      else return fail('No fetchJson is configured to read the registration file.');
      out.registration = normaliseRegistration(file);
    } catch (e) {
      return fail(`The registration file could not be read: ${plain(e)}`);
    }

    // 3. Reputation, when a registry is given.
    if (reputationRegistry) {
      try {
        const entries = decodeFeedback(await ethCall(rpc, reputationRegistry, encodeReadAllFeedback(id)));
        out.feedback = summariseFeedback(entries, latest);
      } catch (e) {
        out.feedback = { count: 0, average: null, items: [], error: `Reputation could not be read: ${plain(e)}` };
      }
    }
    return { ...out, status: 'ok' };
  } catch (e) {
    return fail(plain(e));
  }
}

// ---------------------------------------------------------------------------
// Browser and Node helpers for the page: a JSON-RPC client and a JSON fetcher.
// ---------------------------------------------------------------------------

/** rpc(method, params) over HTTP JSON-RPC. Throws an Error with the JSON-RPC code and message. */
export function httpRpc(url, fetchImpl = globalThis.fetch) {
  let id = 0;
  return async (method, params) => {
    let res;
    try {
      res = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }) });
    } catch (e) {
      throw Object.assign(new Error(`network error: ${plain(e)}`), { code: -1 });
    }
    let body;
    try {
      body = await res.json();
    } catch {
      throw Object.assign(new Error(`the RPC answered HTTP ${res.status} without JSON`), { code: -2 });
    }
    if (body && body.error) throw Object.assign(new Error(String(body.error.message || 'RPC error')), { code: body.error.code, data: body.error.data });
    if (!body || typeof body.result !== 'string') throw Object.assign(new Error('the RPC answer has no result'), { code: -3 });
    return body.result;
  };
}

/** fetchJson(url) for https URLs (ipfs:// and data: are handled by loadAgent). */
export function httpFetchJson(fetchImpl = globalThis.fetch) {
  return async (url) => {
    if (!/^https?:\/\//i.test(url)) throw new Error(`unsupported URI scheme: ${String(url).slice(0, 40)}`);
    let res;
    try {
      res = await fetchImpl(url, { headers: { accept: 'application/json' } });
    } catch {
      throw new Error(`${new URL(url).host} could not be reached, or it does not allow browser requests (CORS)`);
    }
    if (!res.ok) throw new Error(`HTTP ${res.status} from ${new URL(url).host}`);
    return res.json();
  };
}

agentgate-source: code mirror on Cloudflare Pages. All repos