diff --git a/apps/arclayer-runner/src/identity-ensure.test.ts b/apps/arclayer-runner/src/identity-ensure.test.ts index 625dac7e..a2f60f68 100644 --- a/apps/arclayer-runner/src/identity-ensure.test.ts +++ b/apps/arclayer-runner/src/identity-ensure.test.ts @@ -1,9 +1,9 @@ -// Helper: empty on-chain override (no existing identities) +// Helper: empty on-chain override (no existing identities, new getLogs signature) const emptyOnChain = { balanceOf: async () => 0n, ownerOf: async () => { throw new Error('no token'); }, - totalSupply: async () => 0n, + getLogs: async () => [] as Array<{ topics: string[]; data: string }>, }; /** @@ -20,6 +20,21 @@ const emptyOnChain = { * - Pending tx can finalize to confirmed tokenId * - Reverted tx becomes failed * - registerFn receives idempotencyKey + * - Console roster camelCase tokenId accepted + * - Console roster snake_case token_id accepted + * - Malformed Console row without tokenId/token_id ignored safely + * - On-chain scan failure + autoRegister=true blocks mint + * - On-chain scan failure does not call registerFn + * - Transfer log scan finds tokenId without totalSupply + * - getLogs chunking <= 10,000 block ranges + * - Transferred-away token ignored after ownerOf verification + * - Pending tx finalized triggers Console sync + * - Sync retryable after finalize reports retryable + * - Sync success after finalize writes confirmed identity + * - confirmSecondMint=true allows second mint path + * - confirmSecondMint=false reuses existing identity + * - Multiple identities + configured selectedAgentId selects matching token + * - Multiple identities + no selectedAgentId fails clearly */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; @@ -38,6 +53,7 @@ import { getIdentityDir, generateIdempotencyKey, finalizePendingIdentity, + scanExistingIdentityOnChain, } from "./identity-ensure"; // Use temp dir for tests @@ -492,4 +508,1106 @@ describe("identity-ensure", () => { expect(source).not.toMatch(/require\(["']fs["']\)/); }); }); + + // ── New tests for hardening fixes ────────────────────────────────────── + + describe("Fix 1: Console roster token field normalization", () => { + it("accepts camelCase tokenId from Console roster", async () => { + const registerFn = vi.fn(); + const consoleOverride = vi.fn().mockResolvedValue([ + { tokenId: "789", controller: "0x1234567890abcdef1234567890abcdef12345678" }, + ]); + + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: false, + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + consoleUrl: "https://console.example.com", + syncSecret: "test-secret", + registerFn, + _onChainOverride: emptyOnChain, + _consoleOverride: consoleOverride, + }); + + expect(result.action).toBe("confirmed_console"); + expect(result.identity.tokenId).toBe("789"); + }); + + it("accepts snake_case token_id from Console roster", async () => { + const registerFn = vi.fn(); + const consoleOverride = vi.fn().mockResolvedValue([ + { token_id: "456", controller: "0x1234567890abcdef1234567890abcdef12345678" }, + ]); + + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: false, + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + consoleUrl: "https://console.example.com", + syncSecret: "test-secret", + registerFn, + _onChainOverride: emptyOnChain, + _consoleOverride: consoleOverride, + }); + + expect(result.action).toBe("confirmed_console"); + expect(result.identity.tokenId).toBe("456"); + }); + + it("ignores malformed Console row without tokenId or token_id", async () => { + const registerFn = vi.fn(); + const consoleOverride = vi.fn().mockResolvedValue([ + { controller: "0x1234567890abcdef1234567890abcdef12345678" }, + ]); + + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: false, + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + consoleUrl: "https://console.example.com", + syncSecret: "test-secret", + registerFn, + _onChainOverride: emptyOnChain, + _consoleOverride: consoleOverride, + }); + + // No identities found — should fail + expect(result.action).toBe("failed"); + }); + }); + + describe("Fix 3: On-chain scan failure blocks auto-register", () => { + it("blocks mint when on-chain scan fails and autoRegister=true", async () => { + const registerFn = vi.fn(); + const failingOnChain = { + balanceOf: async () => { throw new Error("RPC connection failed"); }, + ownerOf: async () => { throw new Error("RPC connection failed"); }, + getLogs: async () => { throw new Error("RPC connection failed"); }, + }; + + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: true, + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + registerFn, + _onChainOverride: failingOnChain, + }); + + expect(result.action).toBe("failed"); + expect(result.message).toContain("On-chain identity scan failed"); + expect(result.message).toContain("Auto-register blocked"); + }); + + it("does not call registerFn when on-chain scan fails", async () => { + const registerFn = vi.fn(); + const failingOnChain = { + balanceOf: async () => { throw new Error("timeout"); }, + ownerOf: async () => { throw new Error("timeout"); }, + getLogs: async () => { throw new Error("timeout"); }, + }; + + await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: true, + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + registerFn, + _onChainOverride: failingOnChain, + }); + + expect(registerFn).not.toHaveBeenCalled(); + }); + }); + + describe("Fix 2: Transfer event log scanning", () => { + it("finds tokenId via Transfer logs without totalSupply", async () => { + const tokenId = "789"; + const paddedTokenId = "0x" + BigInt(tokenId).toString(16).padStart(64, "0"); + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + + const onChainOverride = { + balanceOf: async () => 1n, + ownerOf: async (id: bigint) => id.toString() === tokenId ? wallet : "0x0000000000000000000000000000000000000000", + getLogs: async () => [ + { + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678", + paddedTokenId, + ], + data: "0x", + }, + ], + }; + + const registerFn = vi.fn(); + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: false, + walletAddress: wallet, + registerFn, + _onChainOverride: onChainOverride, + }); + + expect(result.action).toBe("confirmed_onchain"); + expect(result.identity.tokenId).toBe(tokenId); + }); + + it("chunks getLogs by <= 10,000 block ranges", async () => { + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + const callRanges: Array<{ fromBlock: bigint; toBlock: bigint }> = []; + + const onChainOverride = { + balanceOf: async () => 1n, + ownerOf: async () => wallet, + getLogs: async (params: { fromBlock: bigint; toBlock: bigint }) => { + callRanges.push({ fromBlock: params.fromBlock, toBlock: params.toBlock }); + const paddedTokenId = "0x" + BigInt(1).toString(16).padStart(64, "0"); + // Only return a log in the first chunk + if (params.fromBlock === 40_000_000n) { + return [{ + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678", + paddedTokenId, + ], + data: "0x", + }]; + } + return []; + }, + }; + + // We test indirectly — the override gets called with bounded ranges + const identities = await scanExistingIdentityOnChain(wallet, onChainOverride); + expect(identities.length).toBe(1); + expect(identities[0].tokenId).toBe("1"); + + // Verify chunk size never exceeds 10,000 + for (const range of callRanges) { + expect(range.toBlock - range.fromBlock).toBeLessThanOrEqual(10_000n); + } + }); + + it("ignores transferred-away token after ownerOf verification", async () => { + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + const paddedTokenId = "0x" + BigInt(100).toString(16).padStart(64, "0"); + + const onChainOverride = { + balanceOf: async () => 0n, // balance is 0 — token was transferred away + ownerOf: async () => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // different owner + getLogs: async () => [ + { + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678", + paddedTokenId, + ], + data: "0x", + }, + ], + }; + + const identities = await scanExistingIdentityOnChain(wallet, onChainOverride); + expect(identities.length).toBe(0); + }); + }); + + describe("Fix 4: Console sync after pending tx confirmation", () => { + it("triggers Console sync when pending tx finalized", async () => { + writeRegistrationState({ + status: "submitted", + txHash: "0xabc", + metadataURI: "data:test", + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + submittedAt: new Date().toISOString(), + }); + + const registerFn = vi.fn(); + const finalizeFn = vi.fn().mockResolvedValue({ + status: "confirmed" as const, + tokenId: "42", + }); + const syncToConsoleFn = vi.fn().mockResolvedValue({ + ok: true, + tokenId: "42", + }); + + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: true, + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + registerFn, + finalizeFn, + syncToConsoleFn, + _onChainOverride: emptyOnChain, + }); + + expect(result.action).toBe("confirmed_pending"); + expect(syncToConsoleFn).toHaveBeenCalledTimes(1); + }); + + it("returns retryable when Console sync is retryable after finalize", async () => { + writeRegistrationState({ + status: "submitted", + txHash: "0xabc", + metadataURI: "data:test", + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + submittedAt: new Date().toISOString(), + }); + + const registerFn = vi.fn(); + const finalizeFn = vi.fn().mockResolvedValue({ + status: "confirmed" as const, + tokenId: "42", + }); + const syncToConsoleFn = vi.fn().mockResolvedValue({ + ok: false, + error: "tx not mined", + retryable: true, + }); + + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: true, + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + registerFn, + finalizeFn, + syncToConsoleFn, + _onChainOverride: emptyOnChain, + }); + + expect(result.action).toBe("already_pending"); + expect(result.message).toContain("Console sync pending"); + }); + + it("sync success after finalize writes confirmed identity", async () => { + writeRegistrationState({ + status: "submitted", + txHash: "0xabc", + metadataURI: "data:test", + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + submittedAt: new Date().toISOString(), + }); + + const registerFn = vi.fn(); + const finalizeFn = vi.fn().mockResolvedValue({ + status: "confirmed" as const, + tokenId: "42", + }); + const syncToConsoleFn = vi.fn().mockResolvedValue({ + ok: true, + tokenId: "42", + }); + + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: true, + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + registerFn, + finalizeFn, + syncToConsoleFn, + _onChainOverride: emptyOnChain, + }); + + expect(result.action).toBe("confirmed_pending"); + expect(result.identity.tokenId).toBe("42"); + + // Verify identity state was written as confirmed + const identity = readIdentityState(); + expect(identity.status).toBe("confirmed"); + expect(identity.tokenId).toBe("42"); + }); + }); + + describe("Fix 5: confirmSecondMint", () => { + it("confirmSecondMint=true allows second mint path", async () => { + const tokenId = "100"; + const paddedTokenId = "0x" + BigInt(tokenId).toString(16).padStart(64, "0"); + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + + // Mock on-chain override that returns one existing identity + const onChainOverride = { + balanceOf: async () => 1n, + ownerOf: async (id: bigint) => id.toString() === tokenId ? wallet : "0x0000000000000000000000000000000000000000", + getLogs: async () => [{ + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678", + paddedTokenId, + ], + data: "0x", + }], + }; + + const registerFn = vi.fn().mockResolvedValue({ ok: true, txHash: "0xnew" }); + + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: true, + confirmSecondMint: true, + walletAddress: wallet, + registerFn, + _onChainOverride: onChainOverride, + }); + + // Should proceed to mint (registerFn called) since confirmSecondMint=true + expect(result.action).toBe("registered"); + expect(registerFn).toHaveBeenCalledTimes(1); + }); + + it("confirmSecondMint=false reuses existing identity", async () => { + const tokenId = "100"; + const paddedTokenId = "0x" + BigInt(tokenId).toString(16).padStart(64, "0"); + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + + const onChainOverride = { + balanceOf: async () => 1n, + ownerOf: async (id: bigint) => id.toString() === tokenId ? wallet : "0x0000000000000000000000000000000000000000", + getLogs: async () => [{ + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678", + paddedTokenId, + ], + data: "0x", + }], + }; + + const registerFn = vi.fn(); + + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: true, + confirmSecondMint: false, + walletAddress: wallet, + registerFn, + _onChainOverride: onChainOverride, + }); + + // Should reuse existing identity + expect(result.action).toBe("confirmed_onchain"); + expect(result.identity.tokenId).toBe(tokenId); + expect(registerFn).not.toHaveBeenCalled(); + }); + }); + + describe("Fix 6: selectedAgentId with multiple identities", () => { + it("selects matching token when selectedAgentId matches", async () => { + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + const paddedToken1 = "0x" + BigInt(10).toString(16).padStart(64, "0"); + const paddedToken2 = "0x" + BigInt(20).toString(16).padStart(64, "0"); + + const onChainOverride = { + balanceOf: async () => 2n, + ownerOf: async (id: bigint) => wallet, + getLogs: async () => [ + { + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678", + paddedToken1, + ], + data: "0x", + }, + { + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678", + paddedToken2, + ], + data: "0x", + }, + ], + }; + + const registerFn = vi.fn(); + + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: false, + walletAddress: wallet, + selectedAgentId: "10", + registerFn, + _onChainOverride: onChainOverride, + }); + + expect(result.action).toBe("confirmed_onchain"); + expect(result.identity.tokenId).toBe("10"); + expect(registerFn).not.toHaveBeenCalled(); + }); + + it("fails clearly when no selectedAgentId with multiple identities", async () => { + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + const paddedToken1 = "0x" + BigInt(10).toString(16).padStart(64, "0"); + const paddedToken2 = "0x" + BigInt(20).toString(16).padStart(64, "0"); + + const onChainOverride = { + balanceOf: async () => 2n, + ownerOf: async (id: bigint) => wallet, + getLogs: async () => [ + { + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678", + paddedToken1, + ], + data: "0x", + }, + { + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678", + paddedToken2, + ], + data: "0x", + }, + ], + }; + + const registerFn = vi.fn(); + + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: false, + walletAddress: wallet, + registerFn, + _onChainOverride: onChainOverride, + }); + + expect(result.action).toBe("failed"); + expect(result.message).toContain("Multiple ERC-8004 identities"); + expect(result.message).toContain("Set ARCLAYER_AGENT_ID"); + }); + + it("fails clearly when selectedAgentId does not match any identity", async () => { + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + const paddedToken1 = "0x" + BigInt(10).toString(16).padStart(64, "0"); + const paddedToken2 = "0x" + BigInt(20).toString(16).padStart(64, "0"); + + const onChainOverride = { + balanceOf: async () => 2n, + ownerOf: async (id: bigint) => wallet, + getLogs: async () => [ + { + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678", + paddedToken1, + ], + data: "0x", + }, + { + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678", + paddedToken2, + ], + data: "0x", + }, + ], + }; + + const registerFn = vi.fn(); + + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: false, + walletAddress: wallet, + selectedAgentId: "99", + registerFn, + _onChainOverride: onChainOverride, + }); + + expect(result.action).toBe("failed"); + expect(result.message).toContain("does not match"); + expect(result.message).toContain("[10,20]"); + }); + }); + + describe("Fail-closed: getLogs chunk error blocks auto-register", () => { + it("throws when getLogs fails for a chunk and autoRegister=true blocks mint", async () => { + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + let callCount = 0; + + const failingOnChain = { + balanceOf: async () => 1n, + ownerOf: async () => wallet, + getLogs: async () => { + callCount++; + if (callCount === 1) { + throw new Error("RPC rate limit exceeded"); + } + return []; + }, + }; + + const registerFn = vi.fn(); + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: true, + walletAddress: wallet, + registerFn, + _onChainOverride: failingOnChain, + }); + + expect(result.action).toBe("failed"); + expect(result.message).toContain("On-chain identity scan failed"); + expect(result.message).toContain("Auto-register blocked"); + expect(registerFn).not.toHaveBeenCalled(); + }); + + it("does not call registerFn when getLogs chunk throws", async () => { + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + + const failingOnChain = { + balanceOf: async () => 1n, + ownerOf: async () => wallet, + getLogs: async () => { throw new Error("getLogs timeout"); }, + }; + + const registerFn = vi.fn(); + await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: true, + walletAddress: wallet, + registerFn, + _onChainOverride: failingOnChain, + }); + + expect(registerFn).not.toHaveBeenCalled(); + }); + + it("throws when balanceOf > 0 but no tokenIds found due to incomplete scan", async () => { + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + const paddedTokenId = "0x" + BigInt(100).toString(16).padStart(64, "0"); + + // balanceOf returns 1, getLogs returns a mint event, but ownerOf says different owner + // This means the token was transferred away but balance is still > 0 + // (edge case: multiple tokens, one transferred away, but scan only found the transferred one) + const incompleteOnChain = { + balanceOf: async () => 1n, + ownerOf: async () => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // different owner + getLogs: async () => [{ + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678", + paddedTokenId, + ], + data: "0x", + }], + }; + + const registerFn = vi.fn(); + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: true, + walletAddress: wallet, + registerFn, + _onChainOverride: incompleteOnChain, + }); + + // Should fail closed — scan found mint events but none are currently owned + expect(result.action).toBe("failed"); + expect(result.message).toContain("scan failed"); + expect(registerFn).not.toHaveBeenCalled(); + }); + }); + + describe("Successful scan still works", () => { + it("finds tokenId and reuses identity after successful scan", async () => { + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + const tokenId = "782224"; + const paddedTokenId = "0x" + BigInt(tokenId).toString(16).padStart(64, "0"); + + const onChainOverride = { + balanceOf: async () => 1n, + ownerOf: async (id: bigint) => id.toString() === tokenId ? wallet : "0x0000000000000000000000000000000000000000", + getLogs: async () => [{ + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678", + paddedTokenId, + ], + data: "0x", + }], + }; + + const registerFn = vi.fn(); + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: true, + walletAddress: wallet, + registerFn, + _onChainOverride: onChainOverride, + }); + + expect(result.action).toBe("confirmed_onchain"); + expect(result.identity.tokenId).toBe(tokenId); + expect(registerFn).not.toHaveBeenCalled(); + }); + + it("provider tokenId 782224 read-first path is safe", async () => { + // Simulate the exact VPS scenario: wallet 0xbcbf...8af2 has tokenId 782224 + const wallet = "0xbcbf06e5e79d5dd61a6a606ad2d4d2bd034e8af2"; + const tokenId = "782224"; + const paddedTokenId = "0x" + BigInt(tokenId).toString(16).padStart(64, "0"); + + const onChainOverride = { + balanceOf: async () => 1n, + ownerOf: async (id: bigint) => id.toString() === tokenId ? wallet : "0x0000000000000000000000000000000000000000", + getLogs: async () => [{ + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x000000000000000000000000bcbf06e5e79d5dd61a6a606ad2d4d2bd034e8af2", + paddedTokenId, + ], + data: "0x", + }], + }; + + const registerFn = vi.fn(); + const result = await ensureIdentity({ + agentName: "test-provider", + role: "provider", + autoRegister: true, + walletAddress: wallet, + registerFn, + _onChainOverride: onChainOverride, + }); + + // Should find existing identity, NOT mint + expect(result.action).toBe("confirmed_onchain"); + expect(result.identity.tokenId).toBe("782224"); + expect(registerFn).not.toHaveBeenCalled(); + }); + }); + + // ── P1: Transferred-in identity tests ───────────────────────────────── + + describe("P1: transferred-in identity scan", () => { + it("finds identity minted to another address then transferred to wallet", async () => { + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + const addrA = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const paddedAddrA = "0x" + addrA.slice(2).padStart(64, "0"); + const paddedWallet = "0x" + wallet.slice(2).padStart(64, "0"); + const tokenId = "500"; + const paddedTokenId = "0x" + BigInt(tokenId).toString(16).padStart(64, "0"); + + const onChainOverride = { + balanceOf: async () => 1n, + ownerOf: async (id: bigint) => id.toString() === tokenId ? wallet : "0x0000000000000000000000000000000000000000", + getLogs: async () => [{ + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + paddedAddrA, + paddedWallet, + paddedTokenId, + ], + data: "0x", + }], + }; + + const registerFn = vi.fn(); + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: false, + walletAddress: wallet, + registerFn, + _onChainOverride: onChainOverride, + }); + + expect(result.action).toBe("confirmed_onchain"); + expect(result.identity.tokenId).toBe(tokenId); + }); + + it("transferred-in token is verified with ownerOf", async () => { + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + const addrA = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const paddedAddrA = "0x" + addrA.slice(2).padStart(64, "0"); + const paddedWallet = "0x" + wallet.slice(2).padStart(64, "0"); + const tokenId = "500"; + const paddedTokenId = "0x" + BigInt(tokenId).toString(16).padStart(64, "0"); + + const onChainOverride = { + balanceOf: async () => 1n, + ownerOf: async () => addrA, // owner is different — transferred away + getLogs: async () => [{ + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + paddedAddrA, + paddedWallet, + paddedTokenId, + ], + data: "0x", + }], + }; + + const registerFn = vi.fn(); + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: true, + walletAddress: wallet, + registerFn, + _onChainOverride: onChainOverride, + }); + + // Should fail because ownerOf doesn't match — scan found token but not owned + expect(result.action).toBe("failed"); + expect(result.message).toContain("scan failed"); + }); + + it("transferred-in token is reused and does not call registerFn", async () => { + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + const addrA = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const paddedAddrA = "0x" + addrA.slice(2).padStart(64, "0"); + const paddedWallet = "0x" + wallet.slice(2).padStart(64, "0"); + const tokenId = "500"; + const paddedTokenId = "0x" + BigInt(tokenId).toString(16).padStart(64, "0"); + + const onChainOverride = { + balanceOf: async () => 1n, + ownerOf: async (id: bigint) => id.toString() === tokenId ? wallet : "0x0000000000000000000000000000000000000000", + getLogs: async () => [{ + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + paddedAddrA, + paddedWallet, + paddedTokenId, + ], + data: "0x", + }], + }; + + const registerFn = vi.fn(); + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: false, + walletAddress: wallet, + registerFn, + _onChainOverride: onChainOverride, + }); + + expect(result.action).toBe("confirmed_onchain"); + expect(result.identity.tokenId).toBe(tokenId); + expect(registerFn).not.toHaveBeenCalled(); + }); + + it("transferred-away token is ignored (balance 0 fast path)", async () => { + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + const paddedWallet = "0x" + wallet.slice(2).padStart(64, "0"); + const tokenId = "500"; + const paddedTokenId = "0x" + BigInt(tokenId).toString(16).padStart(64, "0"); + + const onChainOverride = { + balanceOf: async () => 0n, // balance is 0 + ownerOf: async () => "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + getLogs: async () => [{ + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + paddedWallet, + paddedTokenId, + ], + data: "0x", + }], + }; + + const identities = await scanExistingIdentityOnChain(wallet, onChainOverride); + expect(identities.length).toBe(0); + }); + }); + + // ── P2: Durable Console sync pending tests ──────────────────────────── + + describe("P2: durable Console sync pending marker", () => { + it("finalize pending + sync retryable writes durable sync-pending marker", async () => { + writeRegistrationState({ + status: "submitted", + txHash: "0xabc", + metadataURI: "data:test", + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + submittedAt: new Date().toISOString(), + }); + + const finalizeFn = vi.fn().mockResolvedValue({ + status: "confirmed" as const, + tokenId: "42", + }); + const syncToConsoleFn = vi.fn().mockResolvedValue({ + ok: false, + error: "tx not mined", + retryable: true, + }); + + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: true, + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + registerFn: vi.fn(), + finalizeFn, + syncToConsoleFn, + _onChainOverride: emptyOnChain, + }); + + expect(result.action).toBe("already_pending"); + expect(result.message).toContain("Console sync pending"); + + // Verify durable marker was written + const identity = readIdentityState(); + expect(identity.consoleSync).toBeDefined(); + expect(identity.consoleSync?.status).toBe("pending"); + expect(identity.consoleSync?.txHash).toBe("0xabc"); + }); + + it("next run retries sync before already_confirmed", async () => { + writeIdentityState({ + status: "confirmed", + tokenId: "42", + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + confirmedAt: new Date().toISOString(), + consoleSync: { status: "pending", txHash: "0xabc", metadataURI: "data:test", walletAddress: "0x1234567890abcdef1234567890abcdef12345678" }, + }); + + const syncToConsoleFn = vi.fn().mockResolvedValue({ ok: true, tokenId: "42" }); + + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: false, + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + registerFn: vi.fn(), + syncToConsoleFn, + }); + + expect(result.action).toBe("confirmed_onchain"); + expect(result.message).toContain("confirmed and synced"); + expect(syncToConsoleFn).toHaveBeenCalledTimes(1); + }); + + it("sync success clears pending marker", async () => { + writeIdentityState({ + status: "confirmed", + tokenId: "42", + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + confirmedAt: new Date().toISOString(), + consoleSync: { status: "pending", txHash: "0xabc", metadataURI: "data:test", walletAddress: "0x1234567890abcdef1234567890abcdef12345678" }, + }); + + const syncToConsoleFn = vi.fn().mockResolvedValue({ ok: true, tokenId: "42" }); + + await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: false, + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + registerFn: vi.fn(), + syncToConsoleFn, + }); + + const identity = readIdentityState(); + expect(identity.consoleSync?.status).toBe("confirmed"); + }); + + it("already_confirmed without sync pending still returns immediately", async () => { + writeIdentityState({ + status: "confirmed", + tokenId: "42", + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + confirmedAt: new Date().toISOString(), + }); + + const syncToConsoleFn = vi.fn(); + + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: false, + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + registerFn: vi.fn(), + syncToConsoleFn, + }); + + expect(result.action).toBe("already_confirmed"); + expect(syncToConsoleFn).not.toHaveBeenCalled(); + }); + }); + + // ── P3: selectedAgentId with single identity tests ──────────────────── + + describe("P3: selectedAgentId with single identity", () => { + it("single discovered identity + selectedAgentId match => confirmed", async () => { + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + const tokenId = "100"; + const paddedTokenId = "0x" + BigInt(tokenId).toString(16).padStart(64, "0"); + + const onChainOverride = { + balanceOf: async () => 1n, + ownerOf: async (id: bigint) => id.toString() === tokenId ? wallet : "0x0000000000000000000000000000000000000000", + getLogs: async () => [{ + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678", + paddedTokenId, + ], + data: "0x", + }], + }; + + const registerFn = vi.fn(); + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: false, + walletAddress: wallet, + selectedAgentId: "100", + registerFn, + _onChainOverride: onChainOverride, + }); + + expect(result.action).toBe("confirmed_onchain"); + expect(result.identity.tokenId).toBe("100"); + }); + + it("single discovered identity + selectedAgentId mismatch => failed", async () => { + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + const tokenId = "100"; + const paddedTokenId = "0x" + BigInt(tokenId).toString(16).padStart(64, "0"); + + const onChainOverride = { + balanceOf: async () => 1n, + ownerOf: async (id: bigint) => id.toString() === tokenId ? wallet : "0x0000000000000000000000000000000000000000", + getLogs: async () => [{ + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678", + paddedTokenId, + ], + data: "0x", + }], + }; + + const registerFn = vi.fn(); + const result = await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: false, + walletAddress: wallet, + selectedAgentId: "999", + registerFn, + _onChainOverride: onChainOverride, + }); + + expect(result.action).toBe("failed"); + expect(result.message).toContain("does not match"); + expect(result.message).toContain("999"); + expect(result.message).toContain("100"); + }); + + it("mismatch does not write identity.json", async () => { + const wallet = "0x1234567890abcdef1234567890abcdef12345678"; + const tokenId = "100"; + const paddedTokenId = "0x" + BigInt(tokenId).toString(16).padStart(64, "0"); + + const onChainOverride = { + balanceOf: async () => 1n, + ownerOf: async (id: bigint) => id.toString() === tokenId ? wallet : "0x0000000000000000000000000000000000000000", + getLogs: async () => [{ + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678", + paddedTokenId, + ], + data: "0x", + }], + }; + + await ensureIdentity({ + agentName: "test", + role: "provider", + autoRegister: false, + walletAddress: wallet, + selectedAgentId: "999", + registerFn: vi.fn(), + _onChainOverride: onChainOverride, + }); + + const identity = readIdentityState(); + expect(identity.status).toBe("none"); + expect(identity.tokenId).toBeUndefined(); + }); + + it("provider tokenId 782224 with selectedAgentId 782224 remains safe", async () => { + const wallet = "0xbcbf06e5e79d5dd61a6a606ad2d4d2bd034e8af2"; + const tokenId = "782224"; + const paddedTokenId = "0x" + BigInt(tokenId).toString(16).padStart(64, "0"); + + const onChainOverride = { + balanceOf: async () => 1n, + ownerOf: async (id: bigint) => id.toString() === tokenId ? wallet : "0x0000000000000000000000000000000000000000", + getLogs: async () => [{ + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x000000000000000000000000bcbf06e5e79d5dd61a6a606ad2d4d2bd034e8af2", + paddedTokenId, + ], + data: "0x", + }], + }; + + const registerFn = vi.fn(); + const result = await ensureIdentity({ + agentName: "test-provider", + role: "provider", + autoRegister: true, + walletAddress: wallet, + selectedAgentId: "782224", + registerFn, + _onChainOverride: onChainOverride, + }); + + expect(result.action).toBe("confirmed_onchain"); + expect(result.identity.tokenId).toBe("782224"); + expect(registerFn).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/arclayer-runner/src/identity-ensure.ts b/apps/arclayer-runner/src/identity-ensure.ts index 708267ca..1c276645 100644 --- a/apps/arclayer-runner/src/identity-ensure.ts +++ b/apps/arclayer-runner/src/identity-ensure.ts @@ -42,6 +42,7 @@ export type IdentityState = { idempotencyKey?: string; registeredAt?: string; confirmedAt?: string; + consoleSync?: { status: "pending" | "confirmed"; txHash?: string; metadataURI?: string; walletAddress?: string }; }; export type RegistrationState = { @@ -61,7 +62,8 @@ export type OnChainIdentity = { }; export type ConsoleRosterEntry = { - token_id: string; + token_id?: string; + tokenId?: string; agent_id?: string; controller?: string; owner?: string; @@ -221,23 +223,34 @@ export function mapToCircleIdempotencyKey(arclayerKey: string): string { /** * Check if a wallet already has ERC-8004 identity on-chain. - * Uses viem public client — no private keys, read-only. + * Uses Transfer event log scanning instead of totalSupply (which reverts). * - * Calls balanceOf(wallet) first, then scans ownerOf from totalSupply backwards. - * Returns array of tokenIds owned by the wallet. + * Scans Transfer(*, wallet, tokenId) events in chunked block ranges, + * deduplicates tokenIds, and verifies current ownership with ownerOf. */ export async function scanExistingIdentityOnChain( walletAddress: string, - _viemOverride?: { balanceOf: (addr: string) => Promise; ownerOf: (id: bigint) => Promise; totalSupply: () => Promise }, + _viemOverride?: { + getLogs: (params: { fromBlock: bigint; toBlock: bigint }) => Promise>; + ownerOf: (id: bigint) => Promise; + balanceOf: (addr: string) => Promise; + }, ): Promise { const wallet = walletAddress.toLowerCase(); - - let readFns: { balanceOf: (addr: string) => Promise; ownerOf: (id: bigint) => Promise; totalSupply: () => Promise }; + const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; + const CHUNK_SIZE = 10_000n; + + let readFns: { + getLogs: (params: { fromBlock: bigint; toBlock: bigint }) => Promise>; + ownerOf: (id: bigint) => Promise; + balanceOf: (addr: string) => Promise; + }; if (_viemOverride) { readFns = _viemOverride; } else { - const { createPublicClient, http, getContract, parseAbi } = await import("viem"); + const { createPublicClient, http, getContract, parseAbi, toHex, pad } = await import("viem"); const arcTestnet = { id: 5042002, @@ -254,37 +267,113 @@ export async function scanExistingIdentityOnChain( abi: parseAbi([ "function balanceOf(address account) view returns (uint256)", "function ownerOf(uint256 tokenId) view returns (address)", - "function totalSupply() view returns (uint256)", ]), client, }); + const registryAddress = CONTRACTS.ERC8004_IDENTITY_REGISTRY as `0x${string}`; + const paddedWallet = pad(walletAddress as `0x${string}`, { size: 32 }).toLowerCase(); + readFns = { - balanceOf: (addr: string) => registry.read.balanceOf([addr as `0x${string}`]), + getLogs: async ({ fromBlock, toBlock }) => { + const logs = await client.getLogs({ + address: registryAddress, + topics: [ + TRANSFER_TOPIC, + null, // from = any address (wildcard) + paddedWallet as `0x${string}`, // to = wallet + ], + fromBlock, + toBlock, + }); + return logs.map((l) => ({ + topics: l.topics as string[], + data: l.data as string, + })); + }, ownerOf: (id: bigint) => registry.read.ownerOf([id]), - totalSupply: () => registry.read.totalSupply(), + balanceOf: (addr: string) => registry.read.balanceOf([addr as `0x${string}`]), }; } + // Check balance first — fast path if zero const balance = await readFns.balanceOf(wallet); if (balance === 0n) { return []; } - const totalSupply = await readFns.totalSupply(); - const found: OnChainIdentity[] = []; + // Determine scan range + const scanFromBlock = process.env.ARCLAYER_ERC8004_SCAN_FROM_BLOCK + ? BigInt(process.env.ARCLAYER_ERC8004_SCAN_FROM_BLOCK) + : 40_000_000n; + + // Get current block — in real RPC mode, failure is fatal (fail closed). + // In test override mode, use a bounded fallback since we don't have getBlock. + let latestBlock: bigint; + if (_viemOverride) { + // Test override mode: bounded fallback is safe + latestBlock = scanFromBlock + 100_000n; + } else { + try { + const { createPublicClient, http } = 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() }); + latestBlock = await client.getBlockNumber(); + } catch (err) { + throw new Error(`On-chain scan failed: getBlockNumber() error — ${err instanceof Error ? err.message : String(err)}`); + } + } + + // Scan Transfer events in chunks + const tokenIdSet = new Set(); + let fromBlock = scanFromBlock; - for (let i = totalSupply; i > 0n && found.length < Number(balance); i--) { + while (fromBlock <= latestBlock) { + const toBlock = fromBlock + CHUNK_SIZE > latestBlock ? latestBlock : fromBlock + CHUNK_SIZE; try { - const owner = await readFns.ownerOf(i); + const logs = await readFns.getLogs({ fromBlock, toBlock }); + for (const log of logs) { + // Topic[3] = tokenId (indexed) — regardless of whether from was 0x0 (mint) or another address (transfer) + if (log.topics.length >= 4 && log.topics[3]) { + const tokenId = BigInt(log.topics[3]).toString(); + tokenIdSet.add(tokenId); + } + } + } catch (err) { + // If balance > 0 but getLogs fails, the scan is incomplete — fail closed + throw new Error(`On-chain scan failed: getLogs() chunk error (blocks ${fromBlock}-${toBlock}) — ${err instanceof Error ? err.message : String(err)}`); + } + fromBlock = toBlock + 1n; + } + + // Verify current ownership — ignore transferred-away tokens + const found: OnChainIdentity[] = []; + for (const tokenId of tokenIdSet) { + try { + const owner = await readFns.ownerOf(BigInt(tokenId)); if (owner.toLowerCase() === wallet) { - found.push({ tokenId: i.toString(), owner: owner.toLowerCase() }); + found.push({ tokenId, owner: owner.toLowerCase() }); } } catch { - // Burned or invalid tokenId, skip + // Burned or invalid tokenId — skip } } + // Fail-closed: if balance > 0 but scan found fewer currently-owned tokens than + // expected, the scan may be incomplete (e.g., mint events outside scan range). + // In auto-register mode this would risk duplicate mint, so throw. + if (balance > 0n && found.length === 0 && tokenIdSet.size > 0) { + throw new Error( + `On-chain scan incomplete: balanceOf=${balance} but 0 currently-owned tokens found (${tokenIdSet.size} mint events scanned, all transferred away or burned). ` + + `Cannot safely determine identity state. Set ARCLAYER_ERC8004_SCAN_FROM_BLOCK to an earlier block.` + ); + } + return found; } @@ -452,6 +541,7 @@ export async function ensureIdentity(params: { mcpEndpoint?: string; autoRegister: boolean; confirmSecondMint?: boolean; + selectedAgentId?: string; walletAddress?: string; consoleUrl?: string; syncSecret?: string; @@ -462,7 +552,11 @@ export async function ensureIdentity(params: { }>; 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 }; + _onChainOverride?: { + getLogs: (params: { fromBlock: bigint; toBlock: bigint }) => Promise>; + ownerOf: (id: bigint) => Promise; + balanceOf: (addr: string) => Promise; + }; /** Override for console roster check (testing) */ _consoleOverride?: typeof checkConsoleRoster; }): Promise { @@ -470,6 +564,31 @@ export async function ensureIdentity(params: { const existing = readIdentityState(); if (existing.status === "confirmed" && existing.tokenId) { + // Check if Console sync is still pending + if (existing.consoleSync?.status === "pending" && params.syncToConsoleFn) { + try { + const syncResult = await params.syncToConsoleFn( + existing.consoleSync.txHash ?? existing.txHash ?? "", + existing.consoleSync.walletAddress ?? existing.walletAddress ?? "", + existing.consoleSync.metadataURI ?? existing.metadataURI ?? "", + params.role, + params.agentName, + ); + if (syncResult.ok) { + // Sync succeeded — clear pending marker + writeIdentityState({ ...existing, consoleSync: { status: "confirmed" } }); + return { action: "confirmed_onchain", identity: readIdentityState(), message: `Identity confirmed and synced: tokenId=${existing.tokenId}` }; + } + if (syncResult.retryable) { + // Still retryable — keep pending marker + return { action: "already_pending", identity: existing, message: `Identity confirmed on-chain but Console sync still pending. Re-run to sync roster.` }; + } + // Permanent failure — warn + process.stderr.write(`[identity-ensure] Console sync permanent failure: ${syncResult.error}\n`); + } catch (err) { + process.stderr.write(`[identity-ensure] Console sync error: ${err instanceof Error ? err.message : String(err)}\n`); + } + } return { action: "already_confirmed", identity: existing, @@ -484,6 +603,44 @@ export async function ensureIdentity(params: { const finalizeResult = await finalizePendingIdentity({ finalizeFn: params.finalizeFn }); if (finalizeResult.action === "confirmed" && finalizeResult.tokenId) { + // Fix 4: Retry Console sync after pending tx confirmation + if (params.syncToConsoleFn && pending.txHash) { + try { + const syncResult = await params.syncToConsoleFn( + pending.txHash, + pending.walletAddress ?? "", + pending.metadataURI ?? "", + params.role, + params.agentName, + ); + + if (syncResult.ok) { + return { + action: "confirmed_pending", + identity: readIdentityState(), + message: finalizeResult.message, + }; + } + + if (syncResult.retryable) { + // Write durable sync-pending marker + const currentState = readIdentityState(); + writeIdentityState({ ...currentState, consoleSync: { status: "pending", txHash: pending.txHash, metadataURI: pending.metadataURI, walletAddress: pending.walletAddress } }); + return { + action: "already_pending", + identity: readIdentityState(), + message: `Identity confirmed on-chain but Console sync pending. Re-run to sync roster.`, + }; + } + + // Sync failed permanently — warn but keep confirmed + process.stderr.write(`[identity-ensure] Console sync failed after finalize: ${syncResult.error}\n`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[identity-ensure] Console sync error after finalize (non-fatal): ${msg}\n`); + } + } + return { action: "confirmed_pending", identity: readIdentityState(), @@ -522,12 +679,22 @@ export async function ensureIdentity(params: { const short = `${walletAddress.slice(0, 6)}...${walletAddress.slice(-4)}`; let onChainIdentities: OnChainIdentity[] = []; + let onChainScanFailed = false; 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`); + onChainScanFailed = true; + process.stderr.write(`[identity-ensure] On-chain scan failed: ${msg}\n`); + + if (params.autoRegister) { + // Fail closed — block auto-register to avoid duplicate mint + return { + action: "failed", + identity: existing, + message: "On-chain identity scan failed. Auto-register blocked to avoid duplicate mint.", + }; + } } // ── Step 4: Optionally check Console roster ─────────────────────────── @@ -544,12 +711,25 @@ export async function ensureIdentity(params: { // ── 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); + for (const entry of consoleEntries) { + const id = entry.tokenId ?? entry.token_id; + if (id) tokenIdSet.add(id); + } const allTokenIds = Array.from(tokenIdSet); - // Case: exactly one identity found — reuse it - if (allTokenIds.length === 1) { + // Case: exactly one identity found — reuse it (unless confirmSecondMint=true) + if (allTokenIds.length === 1 && !(params.confirmSecondMint && params.autoRegister)) { const tokenId = allTokenIds[0]; + + // P3: If selectedAgentId is set, verify it matches + if (params.selectedAgentId && params.selectedAgentId !== tokenId) { + return { + action: "failed", + identity: { status: "none" }, + message: `ARCLAYER_AGENT_ID=${params.selectedAgentId} does not match wallet-owned ERC-8004 tokenId=${tokenId}. Fix ARCLAYER_AGENT_ID or use the correct wallet.`, + }; + } + const source = onChainIdentities.length > 0 ? "on-chain" : "console-roster"; writeIdentityState({ @@ -571,12 +751,34 @@ export async function ensureIdentity(params: { // 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`); + + if (params.selectedAgentId && allTokenIds.includes(params.selectedAgentId)) { + // selectedAgentId matches one of the found identities + const tokenId = params.selectedAgentId; + writeIdentityState({ + status: "confirmed", + tokenId, + walletAddress, + confirmedAt: new Date().toISOString(), + }); + + process.stderr.write(`[identity-ensure] Selected tokenId=${tokenId} via selectedAgentId\n`); + + return { + action: "confirmed_onchain", + identity: readIdentityState(), + message: `Identity selected: wallet=${short} tokenId=${tokenId}`, + }; + } + 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.`, + message: params.selectedAgentId + ? `ARCLAYER_AGENT_ID=${params.selectedAgentId} does not match any of [${allTokenIds.join(",")}]. Set a valid ARCLAYER_AGENT_ID.` + : `Multiple ERC-8004 identities found for wallet ${short}: tokenIds=${allTokenIds.join(",")}. Set ARCLAYER_AGENT_ID explicitly.`, }; } @@ -708,6 +910,7 @@ export async function ensureIdentity(params: { idempotencyKey: arclayerKey, registeredAt: new Date().toISOString(), confirmedAt: new Date().toISOString(), + consoleSync: { status: "confirmed" }, }); writeRegistrationState({ diff --git a/apps/arclayer-runner/src/index.ts b/apps/arclayer-runner/src/index.ts index efb97278..65750236 100644 --- a/apps/arclayer-runner/src/index.ts +++ b/apps/arclayer-runner/src/index.ts @@ -564,6 +564,7 @@ async function main() { autoRegister: opts.autoRegister ?? false, walletAddress: config.circleWalletAddress, confirmSecondMint: opts.confirmSecondMint ?? false, + selectedAgentId: config.agentId, consoleUrl: process.env.ARCLAYER_CONSOLE_URL, syncSecret: process.env.ARCLAYER_RUNNER_SYNC_SECRET, registerFn: async (metadataURI: string, idempotencyKey: string) => { diff --git a/apps/arclayer-runner/src/services.ts b/apps/arclayer-runner/src/services.ts index 53188a5d..3e59f7c2 100644 --- a/apps/arclayer-runner/src/services.ts +++ b/apps/arclayer-runner/src/services.ts @@ -1580,7 +1580,7 @@ export class RunnerServices { role: string, agentName: string, ): Promise<{ ok: boolean; tokenId?: string; error?: string; retryable?: boolean }> { - const consoleUrl = process.env.ARCLAYER_CONSOLE_URL; + const consoleUrl = this.config.consoleUrl ?? process.env.ARCLAYER_CONSOLE_URL; const syncSecret = process.env.ARCLAYER_RUNNER_SYNC_SECRET; if (!consoleUrl || !syncSecret) {