// 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