diff --git a/apps/arclayer-runner/src/identity-ensure.test.ts b/apps/arclayer-runner/src/identity-ensure.test.ts index 099d7042..625dac7e 100644 --- a/apps/arclayer-runner/src/identity-ensure.test.ts +++ b/apps/arclayer-runner/src/identity-ensure.test.ts @@ -1,3 +1,11 @@ + +// Helper: empty on-chain override (no existing identities) +const emptyOnChain = { + balanceOf: async () => 0n, + ownerOf: async () => { throw new Error('no token'); }, + totalSupply: async () => 0n, +}; + /** * Identity ensure tests. * @@ -173,7 +181,9 @@ describe("identity-ensure", () => { agentName: "test", role: "provider", autoRegister: true, + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", registerFn, + _onChainOverride: emptyOnChain, }); expect(registerFn).toHaveBeenCalledTimes(1); @@ -183,7 +193,9 @@ describe("identity-ensure", () => { agentName: "test", role: "provider", autoRegister: true, + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", registerFn, + _onChainOverride: emptyOnChain, }); expect(result.action).toBe("already_pending"); @@ -334,8 +346,10 @@ describe("identity-ensure", () => { agentName: "test", role: "provider", autoRegister: true, + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", registerFn, finalizeFn, + _onChainOverride: emptyOnChain, }); expect(result.action).toBe("confirmed_pending"); @@ -359,8 +373,10 @@ describe("identity-ensure", () => { agentName: "test", role: "provider", autoRegister: true, + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", registerFn, finalizeFn, + _onChainOverride: emptyOnChain, }); // Should attempt to re-register since the previous failed diff --git a/apps/arclayer-runner/src/identity-ensure.ts b/apps/arclayer-runner/src/identity-ensure.ts index 80437063..708267ca 100644 --- a/apps/arclayer-runner/src/identity-ensure.ts +++ b/apps/arclayer-runner/src/identity-ensure.ts @@ -6,11 +6,14 @@ * ~/.arclayer/runner/identity-registration.json — pending registration * ~/.arclayer/runner/identity.lock — prevents concurrent mint * - * Changes from previous version: - * - ESM-safe: all fs imports at top, no dynamic require() - * - Atomic lock: exclusive openSync("wx") prevents race conditions - * - IdempotencyKey: stable key for identity mint, stored in registration state - * - Finalize pending: can check tx receipt and confirm tokenId + * Read-first flow: + * 1. Check local identity.json + * 2. Check on-chain balanceOf(wallet) + ownerOf scan + * 3. Optionally check Console erc8004_agents roster + * 4. If identity exists → reuse, do not mint + * 5. If none → auto-register (if --auto-register) + * 6. If multiple → require explicit ARCLAYER_AGENT_ID + * 7. If second mint → require --confirm-second-mint */ import { @@ -26,7 +29,7 @@ import { } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; // ── Types ────────────────────────────────────────────────────────────────── @@ -52,6 +55,19 @@ export type RegistrationState = { error?: string; }; +export type OnChainIdentity = { + tokenId: string; + owner: string; +}; + +export type ConsoleRosterEntry = { + token_id: string; + agent_id?: string; + controller?: string; + owner?: string; + metadata_json?: Record; +}; + // ── Paths ────────────────────────────────────────────────────────────────── export function getIdentityDir(): string { @@ -119,36 +135,28 @@ export function writeRegistrationState(state: RegistrationState): void { } // ── ESM-Safe Atomic Lock Management ──────────────────────────────────────── -// -// Uses exclusive file creation (openSync "wx") to prevent two processes -// from acquiring the lock simultaneously. No dynamic require() calls. -// Stale locks (> 10 minutes) are removed and retried once. const STALE_LOCK_MS = 10 * 60 * 1000; // 10 minutes export function acquireLock(): boolean { const lockPath = getLockPath(); - // First attempt: exclusive create if (tryExclusiveAcquire(lockPath)) { return true; } - // Lock exists — check if stale try { const stat = statSync(lockPath); const ageMs = Date.now() - stat.mtimeMs; if (ageMs > STALE_LOCK_MS) { - // Stale lock, remove and retry once try { unlinkSync(lockPath); } catch { - // Another process may have removed it — fine + // Another process may have removed it } return tryExclusiveAcquire(lockPath); } } catch { - // stat failed — lock may have been removed between check and stat return tryExclusiveAcquire(lockPath); } @@ -157,7 +165,7 @@ export function acquireLock(): boolean { function tryExclusiveAcquire(lockPath: string): boolean { try { - const fd = openSync(lockPath, "wx"); // exclusive create — fails if exists + const fd = openSync(lockPath, "wx"); const content = JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() }); writeFileSync(fd, content, "utf8"); closeSync(fd); @@ -179,11 +187,11 @@ export function releaseLock(): void { } // ── Idempotency Key ──────────────────────────────────────────────────────── -// -// Generate a stable idempotency key tied to wallet address + metadataURI. -// Uses keccak256-like pattern: sha256 of wallet+metadataURI. -// Same key is reused on rerun to prevent duplicate registration. +/** + * Generate a stable ArcLayer-internal idempotency key. + * Format: erc8004-register:: + */ export function generateIdempotencyKey(walletAddress: string, metadataURI: string): string { const hash = createHash("sha256") .update(`${walletAddress.toLowerCase()}:${metadataURI}`) @@ -191,23 +199,173 @@ export function generateIdempotencyKey(walletAddress: string, metadataURI: strin return `erc8004-register:${walletAddress.toLowerCase()}:${hash}`; } +/** + * Map ArcLayer internal idempotency key to UUID format for Circle API. + * Circle SDK requires UUID-format idempotency keys. + * Deterministic: same input → same UUID. + */ +export function mapToCircleIdempotencyKey(arclayerKey: string): string { + // Generate deterministic UUID from ArcLayer key + const hash = createHash("sha256").update(arclayerKey).digest("hex"); + // Format as UUID: 8-4-4-4-12 + return [ + hash.slice(0, 8), + hash.slice(8, 12), + hash.slice(12, 16), + hash.slice(16, 20), + hash.slice(20, 32), + ].join("-"); +} + +// ── On-Chain Identity Scan ───────────────────────────────────────────────── + +/** + * Check if a wallet already has ERC-8004 identity on-chain. + * Uses viem public client — no private keys, read-only. + * + * Calls balanceOf(wallet) first, then scans ownerOf from totalSupply backwards. + * Returns array of tokenIds owned by the wallet. + */ +export async function scanExistingIdentityOnChain( + walletAddress: string, + _viemOverride?: { balanceOf: (addr: string) => Promise; ownerOf: (id: bigint) => Promise; totalSupply: () => Promise }, +): Promise { + const wallet = walletAddress.toLowerCase(); + + let readFns: { balanceOf: (addr: string) => Promise; ownerOf: (id: bigint) => Promise; totalSupply: () => Promise }; + + if (_viemOverride) { + readFns = _viemOverride; + } else { + const { createPublicClient, http, getContract, parseAbi } = await import("viem"); + + const arcTestnet = { + id: 5042002, + name: "Arc Testnet", + nativeCurrency: { name: "USDC", symbol: "USDC", decimals: 18 }, + rpcUrls: { default: { http: ["https://rpc.testnet.arc.network"] } }, + } as const; + + const client = createPublicClient({ chain: arcTestnet, transport: http() }); + const { CONTRACTS } = await import("@arclayer/sdk"); + + const registry = getContract({ + address: CONTRACTS.ERC8004_IDENTITY_REGISTRY as `0x${string}`, + abi: parseAbi([ + "function balanceOf(address account) view returns (uint256)", + "function ownerOf(uint256 tokenId) view returns (address)", + "function totalSupply() view returns (uint256)", + ]), + client, + }); + + readFns = { + balanceOf: (addr: string) => registry.read.balanceOf([addr as `0x${string}`]), + ownerOf: (id: bigint) => registry.read.ownerOf([id]), + totalSupply: () => registry.read.totalSupply(), + }; + } + + const balance = await readFns.balanceOf(wallet); + if (balance === 0n) { + return []; + } + + const totalSupply = await readFns.totalSupply(); + const found: OnChainIdentity[] = []; + + for (let i = totalSupply; i > 0n && found.length < Number(balance); i--) { + try { + const owner = await readFns.ownerOf(i); + if (owner.toLowerCase() === wallet) { + found.push({ tokenId: i.toString(), owner: owner.toLowerCase() }); + } + } catch { + // Burned or invalid tokenId, skip + } + } + + return found; +} + +// ── Console Roster Check ─────────────────────────────────────────────────── + +/** + * Check Console erc8004_agents for existing identity owned by this wallet. + * Returns matching entries from the roster. + */ +export async function checkConsoleRoster( + walletAddress: string, + consoleUrl: string, + syncSecret: string, + _fetchImpl?: typeof fetch, +): Promise { + const fetchFn = _fetchImpl ?? fetch; + const wallet = walletAddress.toLowerCase(); + + try { + // Query erc8004_agents filtered by controller/owner + const url = `${consoleUrl}/api/erc8004/agents?controller=${encodeURIComponent(wallet)}`; + const resp = await fetchFn(url, { + headers: { + "Authorization": `Bearer ${syncSecret}`, + "x-arclayer-runner-sync-secret": syncSecret, + }, + }); + + if (!resp.ok) { + // Console may not have this endpoint — non-fatal + return []; + } + + const data = await resp.json() as { agents?: ConsoleRosterEntry[]; ok?: boolean }; + if (!data.ok || !Array.isArray(data.agents)) { + return []; + } + + return data.agents.filter((a) => { + const ctrl = (a.controller ?? "").toLowerCase(); + const own = (a.owner ?? "").toLowerCase(); + return ctrl === wallet || own === wallet; + }); + } catch { + // Console unreachable — non-fatal, fall through to on-chain check + return []; + } +} + // ── Metadata URI Builder ─────────────────────────────────────────────────── +/** + * Build ERC-8004 metadata URI with dashboard roster fields. + * Uses data: URI for on-chain metadata (no external hosting dependency). + */ export function buildMetadataURI(params: { agentName: string; role: string; description?: string; capabilities?: string; + endpoint?: string; + mcpEndpoint?: string; }): string { + const capabilities = params.capabilities + ? params.capabilities.split(",").map((s) => s.trim()).filter(Boolean) + : ["erc8183", "langchain", "x402", "circle-dev-wallet"]; + const metadata: Record = { + schema: "arclayer.agent/v1", name: params.agentName, role: params.role, - version: 1, + description: params.description ?? "", + capabilities, + categories: ["agentic-commerce", params.role], + autonomous: true, + x402: "enabled", }; - if (params.description) metadata.description = params.description; - if (params.capabilities) metadata.capabilities = params.capabilities.split(",").map((s) => s.trim()); - // Use data: URI for on-chain metadata (no external hosting dependency) + if (params.endpoint) metadata.endpoint = params.endpoint; + if (params.mcpEndpoint) metadata.mcp = params.mcpEndpoint; + const json = JSON.stringify(metadata); return `data:application/json;base64,${Buffer.from(json, "utf8").toString("base64")}`; } @@ -215,7 +373,7 @@ export function buildMetadataURI(params: { // ── Ensure Logic ─────────────────────────────────────────────────────────── export type EnsureResult = { - action: "already_confirmed" | "already_pending" | "registered" | "confirmed_pending" | "failed"; + action: "already_confirmed" | "already_pending" | "registered" | "confirmed_pending" | "confirmed_onchain" | "confirmed_console" | "failed"; identity: IdentityState; message: string; }; @@ -228,13 +386,6 @@ export type FinalizeResult = { /** * Check if a pending tx has been finalized. - * Returns confirmed with tokenId if tx succeeded and tokenId can be extracted. - * Returns still_pending if tx not yet mined. - * Returns reverted if tx reverted. - * Returns not_found if tx hash is not found. - * - * The finalizeFn is injected by the caller (runner CLI) to avoid coupling - * to a specific chain query mechanism. */ export async function finalizePendingIdentity(params: { finalizeFn: (txHash: string, metadataURI?: string) => Promise<{ @@ -250,7 +401,6 @@ export async function finalizePendingIdentity(params: { const result = await params.finalizeFn(registration.txHash, registration.metadataURI); if (result.status === "confirmed" && result.tokenId) { - // Write confirmed identity writeIdentityState({ status: "confirmed", tokenId: result.tokenId, @@ -262,7 +412,6 @@ export async function finalizePendingIdentity(params: { confirmedAt: new Date().toISOString(), }); - // Update registration state writeRegistrationState({ ...registration, status: "confirmed", @@ -288,7 +437,6 @@ export async function finalizePendingIdentity(params: { }; } - // still_pending or not_found return { action: result.status, message: `Tx ${registration.txHash} status: ${result.status}`, @@ -300,18 +448,27 @@ export async function ensureIdentity(params: { role: string; description?: string; capabilities?: string; + endpoint?: string; + mcpEndpoint?: string; autoRegister: boolean; + confirmSecondMint?: boolean; walletAddress?: string; + consoleUrl?: string; + syncSecret?: string; registerFn: (metadataURI: string, idempotencyKey: string) => Promise<{ ok: boolean; txHash?: string; result?: unknown }>; - /** If provided, attempt to finalize pending registrations */ finalizeFn?: (txHash: string, metadataURI?: string) => Promise<{ status: "confirmed" | "still_pending" | "reverted" | "not_found"; tokenId?: string; }>; + syncToConsoleFn?: (txHash: string, controllerAddress: string, metadataURI: string, role: string, agentName: string) => Promise<{ ok: boolean; tokenId?: string; error?: string; retryable?: boolean }>; + /** Override for on-chain reads (testing) */ + _onChainOverride?: { balanceOf: (addr: string) => Promise; ownerOf: (id: bigint) => Promise; totalSupply: () => Promise }; + /** Override for console roster check (testing) */ + _consoleOverride?: typeof checkConsoleRoster; }): Promise { + // ── Step 1: Check local identity.json ────────────────────────────────── const existing = readIdentityState(); - // Already confirmed if (existing.status === "confirmed" && existing.tokenId) { return { action: "already_confirmed", @@ -320,26 +477,23 @@ export async function ensureIdentity(params: { }; } - // Check for pending registration — attempt to finalize if finalizeFn provided + // ── Step 2: Check for pending registration ──────────────────────────── const pending = readRegistrationState(); if (pending && pending.status === "submitted" && pending.txHash) { - // Try to finalize pending tx if (params.finalizeFn) { const finalizeResult = await finalizePendingIdentity({ finalizeFn: params.finalizeFn }); if (finalizeResult.action === "confirmed" && finalizeResult.tokenId) { return { action: "confirmed_pending", - identity: readIdentityState(), // re-read after finalize wrote + identity: readIdentityState(), message: finalizeResult.message, }; } if (finalizeResult.action === "reverted") { - // Tx reverted — fall through to re-register if autoRegister - // (Don't return, let it proceed to re-register below) + // Tx reverted — fall through to re-register } else { - // still_pending or not_found return { action: "already_pending", identity: { ...existing, status: "pending", txHash: pending.txHash }, @@ -347,7 +501,6 @@ export async function ensureIdentity(params: { }; } } else { - // No finalizeFn — just report pending return { action: "already_pending", identity: { ...existing, status: "pending", txHash: pending.txHash }, @@ -356,16 +509,115 @@ export async function ensureIdentity(params: { } } - // Need to register + // ── Step 3: Read-first — check on-chain for existing identity ───────── + const walletAddress = params.walletAddress ?? ""; + if (!walletAddress) { + return { + action: "failed", + identity: existing, + message: "CIRCLE_WALLET_ADDRESS required for identity ensure", + }; + } + + const short = `${walletAddress.slice(0, 6)}...${walletAddress.slice(-4)}`; + + let onChainIdentities: OnChainIdentity[] = []; + try { + onChainIdentities = await scanExistingIdentityOnChain(walletAddress, params._onChainOverride); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // Non-fatal — continue with other checks + process.stderr.write(`[identity-ensure] On-chain scan failed (non-fatal): ${msg}\n`); + } + + // ── Step 4: Optionally check Console roster ─────────────────────────── + let consoleEntries: ConsoleRosterEntry[] = []; + if (params.consoleUrl && params.syncSecret) { + const checkFn = params._consoleOverride ?? checkConsoleRoster; + try { + consoleEntries = await checkFn(walletAddress, params.consoleUrl, params.syncSecret); + } catch { + // Non-fatal + } + } + + // ── Step 5: Merge results — deduplicate by tokenId ──────────────────── + const tokenIdSet = new Set(); + for (const id of onChainIdentities) tokenIdSet.add(id.tokenId); + for (const entry of consoleEntries) tokenIdSet.add(entry.token_id); + const allTokenIds = Array.from(tokenIdSet); + + // Case: exactly one identity found — reuse it + if (allTokenIds.length === 1) { + const tokenId = allTokenIds[0]; + const source = onChainIdentities.length > 0 ? "on-chain" : "console-roster"; + + writeIdentityState({ + status: "confirmed", + tokenId, + walletAddress, + confirmedAt: new Date().toISOString(), + }); + + process.stderr.write(`[identity-ensure] Found existing identity: wallet=${short} tokenId=${tokenId} source=${source}\n`); + + return { + action: source === "on-chain" ? "confirmed_onchain" : "confirmed_console", + identity: readIdentityState(), + message: `Identity found (${source}): wallet=${short} tokenId=${tokenId}`, + }; + } + + // Case: multiple identities — require explicit selection + if (allTokenIds.length > 1) { + process.stderr.write(`[identity-ensure] Multiple identities found for wallet ${short}: tokenIds=${allTokenIds.join(",")}\n`); + process.stderr.write(`[identity-ensure] Set ARCLAYER_AGENT_ID explicitly in .env.runner\n`); + + return { + action: "failed", + identity: { status: "none" }, + message: `Multiple ERC-8004 identities found for wallet ${short}: tokenIds=${allTokenIds.join(",")}. Set ARCLAYER_AGENT_ID explicitly.`, + }; + } + + // ── Step 6: No identity found — need to register ────────────────────── if (!params.autoRegister) { return { action: "failed", identity: existing, - message: "Identity not found. Run with --auto-register to mint.", + message: "No ERC-8004 identity found. Run with --auto-register to mint.", + }; + } + + // Check if wallet already has on-chain identity (balance > 0) but scan missed it + // This means the wallet has an identity but we couldn't find it — block second mint + if (onChainIdentities.length === 0 && params.walletAddress) { + // Double-check: if we have a pending registration with a txHash, the wallet may + // already have a minted identity from a previous attempt + const regState = readRegistrationState(); + if (regState && regState.txHash && regState.status === "submitted") { + return { + action: "already_pending", + identity: { status: "pending", txHash: regState.txHash, walletAddress }, + message: `Previous registration pending: txHash=${regState.txHash}. Re-run after tx confirms.`, + }; + } + } + + // Second mint guard: if wallet already has identity, require --confirm-second-mint + if (onChainIdentities.length > 0 && !params.confirmSecondMint) { + const existingIds = onChainIdentities.map((id) => id.tokenId).join(","); + process.stderr.write(`[identity-ensure] Wallet ${short} already has identity: tokenIds=${existingIds}\n`); + process.stderr.write(`[identity-ensure] To mint a second identity, pass --confirm-second-mint\n`); + + return { + action: "failed", + identity: { status: "none" }, + message: `Wallet ${short} already has ERC-8004 identity: tokenIds=${existingIds}. Pass --confirm-second-mint to mint another.`, }; } - // Acquire lock to prevent double-mint (atomic exclusive create) + // ── Step 7: Acquire lock and register ───────────────────────────────── if (!acquireLock()) { return { action: "failed", @@ -380,29 +632,31 @@ export async function ensureIdentity(params: { role: params.role, description: params.description, capabilities: params.capabilities, + endpoint: params.endpoint, + mcpEndpoint: params.mcpEndpoint, }); - // Generate stable idempotency key - const walletAddress = params.walletAddress ?? ""; - const idempotencyKey = generateIdempotencyKey(walletAddress, metadataURI); + // Generate stable ArcLayer idempotency key + const arclayerKey = generateIdempotencyKey(walletAddress, metadataURI); // Write pending state before calling register (crash-safe) writeRegistrationState({ status: "submitted", metadataURI, walletAddress, - idempotencyKey, + idempotencyKey: arclayerKey, submittedAt: new Date().toISOString(), }); - const result = await params.registerFn(metadataURI, idempotencyKey); + // Pass ArcLayer key to registerFn — services.ts will map to UUID for Circle + const result = await params.registerFn(metadataURI, arclayerKey); if (!result.ok) { writeRegistrationState({ status: "failed", metadataURI, walletAddress, - idempotencyKey, + idempotencyKey: arclayerKey, submittedAt: new Date().toISOString(), error: "Registration returned ok=false", }); @@ -419,20 +673,77 @@ export async function ensureIdentity(params: { txHash: result.txHash, metadataURI, walletAddress, - idempotencyKey, + idempotencyKey: arclayerKey, submittedAt: new Date().toISOString(), }); - // Write identity as pending (will be confirmed on next run) writeIdentityState({ status: "pending", walletAddress, metadataURI, txHash: result.txHash, - idempotencyKey, + idempotencyKey: arclayerKey, registeredAt: new Date().toISOString(), }); + // ── Step 8: Sync to Console if syncToConsoleFn provided ───────────── + if (params.syncToConsoleFn && result.txHash) { + try { + const syncResult = await params.syncToConsoleFn( + result.txHash, + walletAddress, + metadataURI, + params.role, + params.agentName, + ); + + if (syncResult.ok && syncResult.tokenId) { + // Sync succeeded — write confirmed identity + writeIdentityState({ + status: "confirmed", + tokenId: syncResult.tokenId, + walletAddress, + metadataURI, + txHash: result.txHash, + idempotencyKey: arclayerKey, + registeredAt: new Date().toISOString(), + confirmedAt: new Date().toISOString(), + }); + + writeRegistrationState({ + status: "confirmed", + txHash: result.txHash, + metadataURI, + walletAddress, + idempotencyKey: arclayerKey, + submittedAt: new Date().toISOString(), + confirmedAt: new Date().toISOString(), + }); + + return { + action: "registered", + identity: readIdentityState(), + message: `Identity minted and synced: tokenId=${syncResult.tokenId} txHash=${result.txHash}`, + }; + } + + if (syncResult.retryable) { + // Tx not mined yet — return retryable + return { + action: "registered", + identity: { status: "pending", walletAddress, metadataURI, txHash: result.txHash, idempotencyKey: arclayerKey, registeredAt: new Date().toISOString() }, + message: `Registration submitted: txHash=${result.txHash}. Sync pending (tx not mined). Re-run to finalize.`, + }; + } + + // Sync failed permanently + process.stderr.write(`[identity-ensure] Console sync failed: ${syncResult.error}\n`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[identity-ensure] Console sync error (non-fatal): ${msg}\n`); + } + } + return { action: "registered", identity: { @@ -440,7 +751,7 @@ export async function ensureIdentity(params: { walletAddress, metadataURI, txHash: result.txHash, - idempotencyKey, + idempotencyKey: arclayerKey, registeredAt: new Date().toISOString(), }, message: `Registration submitted: txHash=${result.txHash}. Re-run after tx confirms to finalize.`, diff --git a/apps/arclayer-runner/src/index.ts b/apps/arclayer-runner/src/index.ts index 35e0a507..efb97278 100644 --- a/apps/arclayer-runner/src/index.ts +++ b/apps/arclayer-runner/src/index.ts @@ -518,12 +518,14 @@ async function main() { .option("--description ", "Agent description") .option("--capabilities ", "Comma-separated capabilities") .option("--auto-register", "Register identity if missing (requires allowIdentityRegister=true)") + .option("--confirm-second-mint", "Allow minting a second ERC-8004 identity (wallet already has one)") .action(async (opts: { agentName: string; role: string; description?: string; capabilities?: string; autoRegister?: boolean; + confirmSecondMint?: boolean; }) => { const config = loadRunnerConfig(); @@ -561,6 +563,9 @@ async function main() { capabilities: opts.capabilities, autoRegister: opts.autoRegister ?? false, walletAddress: config.circleWalletAddress, + confirmSecondMint: opts.confirmSecondMint ?? false, + consoleUrl: process.env.ARCLAYER_CONSOLE_URL, + syncSecret: process.env.ARCLAYER_RUNNER_SYNC_SECRET, registerFn: async (metadataURI: string, idempotencyKey: string) => { return services.registerIdentityViaWallet({ metadataURI, idempotencyKey }) as Promise<{ ok: boolean; @@ -571,6 +576,9 @@ async function main() { finalizeFn: async (txHash: string, metadataURI?: string) => { return services.finalizeIdentityRegistration(txHash, metadataURI); }, + syncToConsoleFn: async (txHash: string, controllerAddress: string, metadataURI: string, role: string, agentName: string) => { + return services.syncToConsole(txHash, controllerAddress, metadataURI, role, agentName); + }, }); console.log(`\n${result.action === "already_confirmed" || result.action === "confirmed_pending" ? "✅" : result.action === "registered" ? "🔧" : "⚠️"} ${result.message}`); diff --git a/apps/arclayer-runner/src/services.ts b/apps/arclayer-runner/src/services.ts index 3ad5bfb8..53188a5d 100644 --- a/apps/arclayer-runner/src/services.ts +++ b/apps/arclayer-runner/src/services.ts @@ -28,6 +28,7 @@ import { CONTRACTS } from "@arclayer/sdk"; import type { RuntimeConnector } from "./runtime"; import { safeHostFromUrl, sanitizeTaskForUntrustedRuntime } from "./runtime"; import type { ArcLayerMcpConnector } from "./mcp-connector"; +import { mapToCircleIdempotencyKey } from "./identity-ensure"; import { isBrokerAbortOrTimeout } from "./mcp-broker"; import { randomUUID } from "node:crypto"; import { ExecutionGateway, assertGatewayWriteSucceeded } from "./execution-gateway"; @@ -1568,6 +1569,73 @@ export class RunnerServices { } } + /** + * Sync a confirmed ERC-8004 identity to Console erc8004_agents roster. + * Called after successful mint to make the provider visible on the dashboard. + */ + async syncToConsole( + txHash: string, + controllerAddress: string, + metadataURI: string, + role: string, + agentName: string, + ): Promise<{ ok: boolean; tokenId?: string; error?: string; retryable?: boolean }> { + const consoleUrl = process.env.ARCLAYER_CONSOLE_URL; + const syncSecret = process.env.ARCLAYER_RUNNER_SYNC_SECRET; + + if (!consoleUrl || !syncSecret) { + // Console sync not configured — non-fatal + return { ok: false, error: 'console_sync_not_configured' }; + } + + try { + const resp = await fetch(consoleUrl + "/api/erc8004/register/sync", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + syncSecret, + 'x-arclayer-runner-sync-secret': syncSecret, + }, + body: JSON.stringify({ + txHash, + controllerAddress, + metadataURI, + role, + agentName, + metadataJson: { + schema: 'arclayer.agent/v1', + name: agentName, + role, + autonomous: true, + source: 'runner_identity_ensure', + }, + }), + }); + + const data = await resp.json() as { + ok: boolean; + tokenId?: string; + error?: string; + detail?: string; + retryable?: boolean; + }; + + if (data.ok && data.tokenId) { + return { ok: true, tokenId: data.tokenId }; + } + + // Check if retryable (tx not mined yet) + if (data.retryable || resp.status === 425) { + return { ok: false, error: data.detail ?? data.error, retryable: true }; + } + + return { ok: false, error: data.detail ?? data.error, retryable: false }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, error: 'console_sync_fetch_failed: ' + msg, retryable: false }; + } + } + /** * Register ERC-8004 identity via wallet adapter and sync to erc8004_agents. * Called by ApprovalManager after approval is approved.