diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..80520a3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: Typecheck and test (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [20, 22] + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm test + + contracts: + name: Contracts (forge) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + submodules: recursive + - uses: foundry-rs/foundry-toolchain@v1 + - run: forge build --sizes + working-directory: contracts + - run: forge test -vv + working-directory: contracts diff --git a/README.md b/README.md index cade78e..a7fdf9c 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ What gets kept: What you can count on: - What comes back has the same sha256 as what went in. -- Reading history never changes your vault, and only `fetch` and `status --verify` go online. +- Reading history never changes your vault. `fetch` and `status --verify` go online, and so do `show`, `diff`, and `history --stat` when an operator key is set, to fill in a snapshot that isn't cached yet. - A rollback previews first, checkpoints before it writes, and never touches the chain. - `forget` removes local copies only. The encrypted snapshots on IPFS don't change. @@ -156,6 +156,12 @@ The foundation is built on open standards, so your agent is never tied to one ap | Remove leaked content | Edit it out, `ethagent save`, then `ethagent forget --file --yes` for each version that held it | | Connect another tool | `ethagent --add ""` | | Find the vault | `ethagent --vault-dir` | +| See the Vault, its build, and who may do what | `ethagent custody`, or `ethagent custody --verify` to simulate each permission | +| Check the agent's ENS name | `ethagent ens` | +| Point the agent at another ENS name | `ethagent ens `, then again with `--yes` | +| Change the name's text records | `ethagent ens --set = --clear `, then again with `--yes` | +| Unlink the ENS name | `ethagent ens --unlink`, then again with `--yes` | +| Sign ENS changes with the operator key | `keychain exec ethagent -- ethagent ens --operator` | ### Commands @@ -178,7 +184,13 @@ Run any of these with `npx ethagent`. Commands marked interactive need a termina | `checkpoint [label] [--json]` | Record the vault as it is right now. | Writes history | | `rollback [--yes] [--json]` | Put past bytes back. Previews until `--yes`. | Writes the vault | | `forget [--yes] [--json]` | Erase versions from local history. Previews until `--yes`. | Deletes history | -| `--version`, `--help` | Version, or the full command list. Every history command also takes `--help`. | Read-only | +| `custody [--json]` | Custody mode, the Vault and its build, whether it holds the token, the Vault-level owner, and the approved operators. | Goes online | +| `custody --verify [--json]` | Also simulates, sending nothing: the owner withdrawing and changing an operator, the operator rotating the agent URI, and the operator and a stranger being refused. Exits 4 on a mismatch. | Goes online | +| `ens [--json]` | The linked name, its records, the two-way check, the resolver, who controls the name, and whether the operator key could sign for it. | Goes online | +| `ens [--operator] [--yes] [--json]` | Point the agent at a name: create it under a parent the signer controls, write the agent records, clear them on the old name, then publish the name. Previews until `--yes`. | Opens your wallet | +| `ens --unlink [--operator] [--yes] [--json]` | Clear the agent records on the linked name, then publish it unlinked. Previews until `--yes`. | Opens your wallet | +| `ens --set = --clear [--operator] [--yes] [--json]` | Write every record change in one transaction. No save needed. Previews until `--yes`. | Opens your wallet, or none with `--operator` | +| `--version`, `--help` | Version, or the full command list. Every history and onchain command also takes `--help`. | Read-only | ### Saving @@ -203,6 +215,17 @@ Run any of these with `npx ethagent`. Commands marked interactive need a termina | `3` | No working storage credential, no operator key, or the wallet was cancelled or timed out. | | `4` | Pinned, but the owner still needs to publish it. | +### Onchain identity + +`ethagent custody` and `ethagent ens` work without the manager. + +- **Previews.** ENS changes preview by default. They list every transaction, simulate the ones that can run now from the signer, and send nothing. Rerun with `--yes` to send. +- **Signers.** ENS transactions are signed in your browser wallet, in one tab for the whole change. With `--operator`, the operator key signs them with no popup instead. Run it as `keychain exec ethagent -- ethagent ens --operator`. +- **What the operator key may do.** It only writes text records and creates subnames under a parent it controls. It never sets `addr` on an existing name and never changes ownership. +- **Publishing.** Changing or unlinking the name always ends with one owner-signed save. A stolen operator key therefore can't point your agent at a name it registered. Local state takes the new name only after that save lands. +- **Control check.** Before anything is sent, the command refuses unless the signer controls the name: as its owner, its NameWrapper owner, or a delegate approved on its resolver. +- **Custody writes.** Withdrawing, depositing, and changing operators stay in the manager. + ### History commands Every history command takes a ref wherever it names a version: @@ -365,7 +388,7 @@ A manifest: ### Output and exit codes -With `--json`, every history command prints one line of ASCII-only JSON with `"schema": 1`. Fields are only ever added. A failure looks like this, and the hint names the fix: +With `--json`, every history and onchain command prints one line of ASCII-only JSON with `"schema": 1`. Fields are only ever added. A failure looks like this, and the hint names the fix: ```json { "schema": 1, "ok": false, "code": 3, "error": "snapshot bafybei... is not cached locally", "hint": "fetch it first: ..." } @@ -376,19 +399,21 @@ With `--json`, every history command prints one line of ASCII-only JSON with `"s | `0` | Done. `diff` returns 0 whether or not anything differs and reports `identical`. | | `1` | Failed: unknown ref, missing path, or a store error. | | `2` | Usage error, or a ref that matches more than one thing. | -| `3` | The snapshot isn't cached here, or there's no key to open it. | -| `4` | Partly done, or `status --verify` found a mismatch. | +| `3` | The snapshot isn't cached here or there's no key to open it, `--operator` ran without an injected operator key, or the wallet approval was cancelled. | +| `4` | Partly done, or `status --verify` or `custody --verify` found a mismatch. | ### Environment variables | Variable | Effect | | --- | --- | -| `ETHAGENT_OPERATOR_KEY` | The operator key for `save --operator` and `fetch`. Inject it from your OS keychain; never type it into a command. | +| `ETHAGENT_OPERATOR_KEY` | The operator key for `save --operator`, `fetch`, and `ens --operator`. Inject it from your OS keychain; never type it into a command. | +| `ETHAGENT_RPC_URL` | The RPC endpoint asked first for the agent's network. Use one that keeps history when the public endpoints can't reach back far enough for a wallet search. | +| `ETHAGENT_IPFS_API_URL` | Where snapshots are uploaded, in place of Pinata. | | `PINATA_JWT` | IPFS storage credential, if you haven't set one up in the manager. | | `PINATA_GATEWAY_URL` | Your own IPFS gateway. Downloads try it before any other source. | | `ETHAGENT_IPFS_GATEWAYS` | Gateways to download from, separated by commas, in place of the built-in ones. | | `ETHAGENT_IPFS_ROUTERS` | Content routers that find more sources for a file, separated by commas. Leave it empty to turn that off. | -| `ETHAGENT_HOSTS_FILE` | Where ethagent remembers how quickly each host answers, so the fastest is asked first. Defaults to `~/.ethagent/hosts.json`. Leave it empty to remember nothing. | +| `ETHAGENT_HOSTS_FILE` | Where ethagent remembers how quickly each host answers, so the fastest is asked first, and how many blocks each serves per log query. Defaults to `~/.ethagent/hosts.json`. Leave it empty to remember nothing. | | `ETHAGENT_HARNESS_FILES` | Extra instruction files to keep in sync, separated by commas. | | `ETHAGENT_NO_DAEMON` | Set to `1` to turn off background sync. | diff --git a/commands/ethagent.md b/commands/ethagent.md index b8b8a31..e991521 100644 --- a/commands/ethagent.md +++ b/commands/ethagent.md @@ -12,6 +12,7 @@ Most of these run in a separate terminal because they need a wallet or a TTY, bu - `npx ethagent --vault-dir` prints this agent's vault directory (where soul, memory, and the `skills/` folder live). Read-only and safe to run yourself. - `npx ethagent reset` deletes the local identity, vault, history, and saved secrets (the IPFS storage credential is kept), and disconnects every tool. It asks to confirm in a terminal, so it hangs inside a session unless you pass `--yes`. Run it only when the user explicitly asks. - `npx ethagent status`, `history`, `show`, `diff`, `fetch`, `checkpoint`, `rollback`, and `forget` manage continuity history headlessly. See "Continuity history" below. +- `npx ethagent custody` and `npx ethagent ens` inspect and manage the onchain identity headlessly. See "Onchain identity" below. To rebuild the agent on a new machine, the user runs `npx ethagent`; it restores the identity from an ENS name or ERC-8004 token id, then asks the wallet to sign. @@ -52,6 +53,15 @@ Every save, restore, and refetch also keeps the exact bytes of soul, memory, ski - `forget [--file PATH] | --all` erases local plaintext history (leak repair). Run it only when the user asks; it previews by default and needs `--yes`. With `--file`, that exact content is removed from every snapshot and checkpoint and is never cached again. The encrypted copies on IPFS are not affected. - Exit codes: 0 ok, 1 failure, 2 usage or ambiguous ref, 3 not cached or no key (the JSON `hint` names the fix), 4 partial or inconsistent. `diff` returns 0 either way and reports `identical`. +Onchain identity: + +Both commands take `--json` (same `schema: 1` envelope) and `--help`. + +- `custody` (read-only, safe anytime) shows the custody mode, the Vault address and its build, whether it holds the agent token, the Vault-level owner, and the approved operators. `custody --verify` also simulates with `eth_call`, sending nothing: the owner withdrawing, the owner changing an operator, the operator rotating the agent URI, and the operator and a stranger being refused. It exits 4 when a result differs from what the Vault should do. Withdrawing, depositing, and changing operators stay in `npx ethagent`. +- `ens` (read-only, safe anytime) shows the linked name, its live records, the two-way check, the resolver, who controls the name, and whether the operator key could sign for it. +- `ens `, `ens --unlink`, and `ens --set = --clear ` change ENS. Without `--yes` they only preview: they list every transaction, simulate the ones that can run now, and send nothing (JSON `applied: false`). You may run previews yourself. Anything with `--yes` sends transactions and costs gas, so it stays the user's to run; show them the preview and the command. +- ENS transactions are signed in the browser wallet by default, or by the operator key with `--operator` (run through `keychain exec ethagent -- ethagent ens --operator`; exit 3 without a key, 2 for an invalid one). The operator key only writes text records and creates subnames under a parent it controls. Publishing a name change (`ens `, `ens --unlink`) always ends with one owner-signed save in the browser. + Recipes: "what changed in my memory since a date" is `diff at:YYYY-MM-DD working --file MEMORY.md --sections`; "when did this rule appear" is `history --file MEMORY.md --sections --json`; "undo that memory edit" is `rollback latest --file MEMORY.md`, then `--yes` once the user confirms; "backfill history" is `fetch --all` with a key. If they ask "what's my agent" or "list my skills" without an identity yet, point them at `npx ethagent` to set one up first. diff --git a/src/cli/harnessGuidance.ts b/src/cli/harnessGuidance.ts index df79ac1..9efae03 100644 --- a/src/cli/harnessGuidance.ts +++ b/src/cli/harnessGuidance.ts @@ -18,6 +18,8 @@ export function buildPortableInstruction(opts: PortableInstructionOptions): stri "inside that skill's folder there. Do not hand-edit them (they are regenerated from the vault). To add or change a skill, " + 'put its folder in the vault skills dir (NOT this read-only mirror): run `npx ethagent --vault-dir` to print the vault ' + "path, then create or edit the `/SKILL.md` folder inside its `skills/` subdir. Past snapshots are kept locally " + - 'byte for byte; `npx ethagent status --json` and `npx ethagent diff --json` show what changed.' + 'byte for byte; `npx ethagent status --json` and `npx ethagent diff --json` show what changed. ' + + '`npx ethagent custody --json` and `npx ethagent ens --json` show the onchain identity; ENS changes preview ' + + "without `--yes`, and anything with `--yes` is the user's to run." ) } diff --git a/src/cli/main.tsx b/src/cli/main.tsx index 75f3565..737d205 100644 --- a/src/cli/main.tsx +++ b/src/cli/main.tsx @@ -25,6 +25,7 @@ import { ensureDaemon } from './daemon.js' import { runWatch } from './watch.js' import { runAddTool, runPause, runResume } from './prefs.js' import { HISTORY_COMMANDS } from './history/commands.js' +import { ONCHAIN_COMMANDS } from './onchain/commands.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -61,6 +62,16 @@ function printHelp(): void { ' ethagent rollback restore past bytes (preview, then --yes)', ' ethagent forget erase local history (preview, then --yes)', '', + 'onchain identity (add --json for machine output, --help for details):', + ' ethagent custody [--verify] Vault, build, holder, and operators; --verify simulates permissions', + ' ethagent ens the linked ENS name, its records, and who controls it', + ' ethagent ens point the agent at a name (preview, then --yes)', + ' ethagent ens --unlink clear the agent records and unlink (preview, then --yes)', + ' ethagent ens --set = --clear ', + ' change text records (preview, then --yes)', + ' ethagent ens --operator', + ' sign the ENS transactions with the operator key (via keychain exec)', + '', ' ethagent --version print version', ' ethagent --help print this help', ] @@ -183,6 +194,10 @@ async function main(): Promise { const { runHistoryCommand } = await import('./history/index.js') return runHistoryCommand(argv[0], argv.slice(1)) } + if (argv[0] && ONCHAIN_COMMANDS.has(argv[0])) { + const { runOnchainCommand } = await import('./onchain/index.js') + return runOnchainCommand(argv[0], argv.slice(1)) + } if (flags.has('--version') || flags.has('-v')) { process.stdout.write(`ethagent ${version}\n`) return 0 diff --git a/src/cli/onchain/commands.ts b/src/cli/onchain/commands.ts new file mode 100644 index 0000000..f98d614 --- /dev/null +++ b/src/cli/onchain/commands.ts @@ -0,0 +1,3 @@ +// Onchain identity commands. They load lazily, next to the history verbs, before +// --help and bootstrap. +export const ONCHAIN_COMMANDS = new Set(['custody', 'ens']) diff --git a/src/cli/onchain/custody.ts b/src/cli/onchain/custody.ts new file mode 100644 index 0000000..97bfe6a --- /dev/null +++ b/src/cli/onchain/custody.ts @@ -0,0 +1,239 @@ +import { getAddress, keccak256, type Address, type Hex, type PublicClient } from 'viem' +import { + VAULT_ABI, + isAgentInVault, + readMetadataOperators, + vaultBuildForHash, + vaultRevertName, + type VaultBuild, +} from '../../identity/registry/vault.js' +import { createErc8004PublicClient, type Erc8004RegistryConfig } from '../../identity/registry/erc8004.js' +import { ERC8004_ABI } from '../../identity/registry/erc8004/abi.js' +import { resolveRegistryForIdentity } from '../../identity/registry/registryConfig.js' +import { resolveVaultAddress } from '../../identity/manager/custody/transactions.js' +import { readCustodyMode, readIdentityStateString } from '../../identity/manager/custody/state.js' +import { humanOwnerAddress } from '../../identity/manager/custody/helpers.js' +import { normalizeApprovedOperatorWallets } from '../../identity/manager/shared/operatorWallets.js' +import { emitJson, failFrom, HistoryError, parseHistoryArgs, requireIdentity, type HistoryDeps } from '../history/shared.js' + +export const CUSTODY_USAGE = 'ethagent custody [--verify] [--json]' + +const STRANGER = getAddress('0x000000000000000000000000000000000000dEaD') +const ZERO = '0x0000000000000000000000000000000000000000' + +export type CustodyClient = Pick + +export type CustodySeams = { + client: (registry: Erc8004RegistryConfig) => CustodyClient +} + +const defaultSeams: CustodySeams = { + client: registry => createErc8004PublicClient(registry), +} + +type Simulation = { + check: string + from: Address + expected: 'succeed' | 'refused' + outcome: 'would succeed' | 'refused' + reason?: string + matches: boolean +} + +async function simulate( + client: CustodyClient, + check: string, + from: Address, + expected: Simulation['expected'], + call: { address: Address; functionName: 'unwrap' | 'setMetadataOperator' | 'rotateAgentURI'; args: readonly unknown[] }, +): Promise { + try { + await client.simulateContract({ + account: from, + address: call.address, + abi: VAULT_ABI, + functionName: call.functionName, + args: call.args as never, + } as never) + return { check, from, expected, outcome: 'would succeed', matches: expected === 'succeed' } + } catch (err: unknown) { + const named = vaultRevertName(err) + const reason = named ?? ((err as { shortMessage?: string }).shortMessage ?? (err instanceof Error ? err.message : String(err))).split('\n')[0]! + return { check, from, expected, outcome: 'refused', reason, matches: expected === 'refused' } + } +} + +export async function runCustodyCommand(args: string[], deps: HistoryDeps, seams: CustodySeams = defaultSeams): Promise { + const json = args.includes('--json') + try { + const { values } = parseHistoryArgs(args, { verify: { type: 'boolean' } }, CUSTODY_USAGE) + if (values.help) { + await deps.io.out([ + `usage: ${CUSTODY_USAGE}`, + 'shows the custody mode, the Vault and its build, whether it holds the agent token, the', + 'Vault-level owner, and the approved operators. Read-only.', + '--verify also simulates, with eth_call and without sending anything: the owner withdrawing,', + 'the owner changing an operator, the operator rotating the agent URI, and the operator and a', + 'stranger being refused. Exits 4 when any result differs from what the Vault should do.', + '', + ].join('\n')) + return 0 + } + const { config, identity } = await requireIdentity(deps) + if (!identity.agentId) throw new HistoryError(1, 'This identity has no agent token ID yet.', 'Create or restore it with `npx ethagent` first.') + const registry = resolveRegistryForIdentity(identity, config) + if (!registry) throw new HistoryError(1, 'No agent registry is configured for this identity.', 'Run `npx ethagent` to set it up.') + const agentId = BigInt(identity.agentId) + const state = identity.state as Record | undefined + const custodyMode = readCustodyMode(state) ?? 'simple' + const owner = getAddress(humanOwnerAddress(identity)) + const operators = normalizeApprovedOperatorWallets(state?.approvedOperatorWallets).map(record => getAddress(record.address)) + const activeOperatorRaw = readIdentityStateString(state, 'activeOperatorAddress') + const activeOperator = /^0x[0-9a-fA-F]{40}$/.test(activeOperatorRaw) ? getAddress(activeOperatorRaw) : undefined + const keyAddress = deps.operatorKey?.ok ? getAddress(deps.operatorKey.address) : undefined + const vaultAddress = resolveVaultAddress(identity, config.erc8004?.operatorVaults) + const client = seams.client(registry) + + const tokenOwner = getAddress(await client.readContract({ + address: registry.identityRegistryAddress, + abi: ERC8004_ABI, + functionName: 'ownerOf', + args: [agentId], + }) as Address) + + let vault: Record | null = null + let build: VaultBuild | undefined + let inVault = false + let vaultOwner: Address | undefined + let approvals: Record = {} + let held: { registry: Address; agentId: string; owner: Address } | null = null + const issues: string[] = [] + if (vaultAddress) { + const code = await client.getBytecode({ address: vaultAddress }) + const hasCode = Boolean(code && code !== '0x') + const observedHash: Hex | null = hasCode ? keccak256(code!) : null + build = observedHash ? vaultBuildForHash(observedHash) : undefined + if (!hasCode) issues.push(`no contract at the Vault address ${vaultAddress}`) + else if (!build) issues.push(`the code at ${vaultAddress} is not a known Vault build (hash ${observedHash})`) + if (build) { + const status = await isAgentInVault({ client, vaultAddress, registry: registry.identityRegistryAddress, agentId }) + inVault = status.inVault + vaultOwner = status.ownerAddress + if (build.hasHeldAgent) { + const [heldRegistry, heldId, heldOwner] = await client.readContract({ + address: vaultAddress, + abi: VAULT_ABI, + functionName: 'heldAgent', + }) as readonly [Address, bigint, Address] + held = heldOwner.toLowerCase() === ZERO ? null : { registry: getAddress(heldRegistry), agentId: heldId.toString(), owner: getAddress(heldOwner) } + } + const candidates = [...new Set([...operators, ...(activeOperator ? [activeOperator] : []), ...(keyAddress ? [keyAddress] : [])])] + approvals = candidates.length > 0 + ? await readMetadataOperators({ client, vaultAddress, registry: registry.identityRegistryAddress, agentId, candidates }) + : {} + } + vault = { + address: vaultAddress, + code: hasCode ? { bytes: (code!.length - 2) / 2, hash: observedHash } : null, + build: build ? { id: build.id, label: build.label, hasHeldAgent: build.hasHeldAgent } : null, + bytecode: !hasCode ? 'no code' : build ? 'known build' : 'unknown code', + holdsToken: inVault, + vaultOwner: vaultOwner ?? null, + heldAgent: build?.hasHeldAgent ? held : 'not available on this build', + operators: Object.entries(approvals).map(([address, approved]) => ({ + address, + approved, + local: operators.some(item => item.toLowerCase() === address.toLowerCase()), + active: activeOperator?.toLowerCase() === address.toLowerCase(), + operatorKey: keyAddress?.toLowerCase() === address.toLowerCase(), + })), + } + } + + let simulations: Simulation[] | undefined + if (values.verify) { + simulations = [] + if (!vaultAddress) { + issues.push('no Vault is configured, so there is nothing to simulate') + } else if (!build) { + // Unknown code is already an issue above. + } else if (!inVault || !vaultOwner) { + issues.push(`the Vault does not hold token #${agentId.toString()}, so its permissions cannot be simulated`) + } else { + const registryAddress = registry.identityRegistryAddress + const operator = keyAddress ?? activeOperator ?? operators[0] + const currentUri = await client.readContract({ + address: registryAddress, + abi: ERC8004_ABI, + functionName: 'tokenURI', + args: [agentId], + }) as string + simulations.push(await simulate(client, 'owner withdraws the token', vaultOwner, 'succeed', { + address: vaultAddress, functionName: 'unwrap', args: [registryAddress, agentId, vaultOwner], + })) + simulations.push(await simulate(client, 'owner changes an operator', vaultOwner, 'succeed', { + address: vaultAddress, functionName: 'setMetadataOperator', args: [registryAddress, agentId, operator ?? STRANGER, true], + })) + if (operator) { + const approved = approvals[operator] ?? false + simulations.push(await simulate(client, 'operator rotates the agent URI', operator, approved ? 'succeed' : 'refused', { + address: vaultAddress, functionName: 'rotateAgentURI', args: [registryAddress, agentId, currentUri], + })) + simulations.push(await simulate(client, 'operator withdraws the token', operator, 'refused', { + address: vaultAddress, functionName: 'unwrap', args: [registryAddress, agentId, operator], + })) + } + simulations.push(await simulate(client, 'stranger rotates the agent URI', STRANGER, 'refused', { + address: vaultAddress, functionName: 'rotateAgentURI', args: [registryAddress, agentId, currentUri], + })) + simulations.push(await simulate(client, 'stranger withdraws the token', STRANGER, 'refused', { + address: vaultAddress, functionName: 'unwrap', args: [registryAddress, agentId, STRANGER], + })) + for (const item of simulations) { + if (!item.matches) issues.push(`${item.check}: expected it to ${item.expected === 'succeed' ? 'succeed' : 'be refused'}, but it ${item.outcome === 'refused' ? `was refused (${item.reason})` : 'would succeed'}`) + } + } + } + + const result = { + agentId: identity.agentId, + chainId: registry.chainId, + custodyMode, + owner, + tokenOwner, + tokenHeldBy: vaultAddress && tokenOwner.toLowerCase() === vaultAddress.toLowerCase() ? 'vault' : tokenOwner.toLowerCase() === owner.toLowerCase() ? 'owner' : 'other', + vault, + ...(simulations ? { simulations } : {}), + issues, + } + const mismatch = Boolean(values.verify) && issues.length > 0 + if (json) { + await emitJson(deps.io, result) + } else { + const out: string[] = [] + out.push(`agent #${identity.agentId} · ${custodyMode} custody · owner ${owner}`) + out.push(`token held by: ${result.tokenHeldBy} (${tokenOwner})`) + if (!vaultAddress) { + out.push('vault: none configured') + } else { + out.push(`vault: ${vaultAddress}`) + out.push(` build: ${build ? `${build.label}${build.hasHeldAgent ? '' : ' (no heldAgent())'}` : 'not recognized'}`) + out.push(` bytecode: ${String(vault?.bytecode)}`) + out.push(` holds #${identity.agentId}: ${inVault ? 'yes' : 'no'}${vaultOwner ? ` · vault-level owner ${vaultOwner}` : ''}`) + const rows = (vault?.operators as Array<{ address: string; approved: boolean; operatorKey: boolean; active: boolean }> | undefined) ?? [] + if (rows.length === 0) out.push(' operators: none listed') + for (const row of rows) { + out.push(` operator ${row.address}: ${row.approved ? 'approved' : 'not approved'}${row.active ? ' · active' : ''}${row.operatorKey ? ' · operator key' : ''}`) + } + } + for (const item of simulations ?? []) { + out.push(`${item.matches ? 'ok ' : 'FAIL'} ${item.check}: ${item.outcome}${item.reason ? ` (${item.reason})` : ''}`) + } + for (const issue of issues) out.push(`issue: ${issue}`) + await deps.io.out(`${out.join('\n')}\n`) + } + return mismatch ? 4 : 0 + } catch (err) { + return failFrom(deps.io, json, err) + } +} diff --git a/src/cli/onchain/ens.ts b/src/cli/onchain/ens.ts new file mode 100644 index 0000000..5ac658f --- /dev/null +++ b/src/cli/onchain/ens.ts @@ -0,0 +1,448 @@ +import { getAddress, type Address, type PublicClient } from 'viem' +import type { EthagentConfig, EthagentIdentity } from '../../storage/config.js' +import { saveConfig } from '../../storage/config.js' +import { createMainnetClient, validateAgentEnsLink } from '../../identity/ens/ensLookup.js' +import { readAddressRecord, readTextRecords } from '../../identity/ens/ensAutomation/read.js' +import { resolveRegistryForIdentity } from '../../identity/registry/registryConfig.js' +import { DEFAULT_ETHEREUM_RPC_URL, type Erc8004RegistryConfig } from '../../identity/registry/erc8004.js' +import { humanOwnerAddress } from '../../identity/manager/custody/helpers.js' +import { readIdentityStateString } from '../../identity/manager/custody/state.js' +import { resolveVaultAddress } from '../../identity/manager/custody/transactions.js' +import { ensValidationReasonText } from '../../identity/manager/ens/state.js' +import { agentEnsRecordKeys } from '../../identity/manager/ens/records.js' +import { createMainnetEnsPublicClient, sendEnsTransaction } from '../../identity/manager/ens/transactions.js' +import { browserEnsSigner, operatorEnsSigner, type EnsSigner } from '../../identity/manager/ens/signer.js' +import { + EnsPlanRefusal, + planEnsRecords, + planEnsSwap, + planEnsUnlink, + readNameControl, + signerControlOf, + simulatePlannedTransaction, + type EnsPlannedTransaction, +} from '../../identity/manager/ens/headless.js' +import { runRebackupSigningInSession } from '../../identity/manager/continuity/effects.js' +import { isWalletCancelled } from '../../identity/manager/shared/utils.js' +import type { EffectCallbacks } from '../../identity/manager/shared/effects/types.js' +import type { Step } from '../../identity/manager/reducer.js' +import { continuityVaultStatus } from '../../identity/continuity/storage/status.js' +import { resolveValidatedPinataJwt } from '../../identity/storage/pinataJwt.js' +import { openBrowserWalletSession, type BrowserWalletSession, type BrowserWalletReady } from '../../identity/wallet/browserWallet.js' +import { createLocalKeySender, type LocalKeySender } from '../../identity/wallet/localKeyWallet.js' +import { openExternalUrl } from '../../utils/openExternal.js' +import { INVALID_OPERATOR_KEY_MESSAGE, OPERATOR_KEY_ENV } from '../operatorKey.js' +import { pullHarnessSoulMemoryIntoVault } from '../sync.js' +import { + emitJson, + failFrom, + HistoryError, + parseHistoryArgs, + requireIdentity, + stringValues, + type HistoryDeps, +} from '../history/shared.js' + +export const ENS_USAGE = 'ethagent ens [ | --unlink | --set =... --clear ...] [--operator] [--yes] [--no-open] [--json]' + +const HELP = [ + `usage: ${ENS_USAGE}`, + '', + ' ethagent ens the linked name, its live records, the two-way check, the', + ' resolver, who controls the name, and whether the operator', + ' key could sign for it. Read-only.', + ' ethagent ens point the agent at : create it when missing (under a', + ' parent the signer controls), write the agent records on it', + ' in one resolver multicall, clear them on the old name, then', + ' publish the name in one owner-signed save.', + ' ethagent ens --unlink clear the agent records on the linked name, then publish', + ' the unlinked state in one owner-signed save.', + ' ethagent ens --set = --clear ', + ' write every record change on the linked name in one', + ' resolver multicall. No save is needed.', + '', + 'Writes preview first and send nothing without --yes. ENS transactions are signed by the', + 'browser wallet, or with --operator by the operator key (run through `keychain exec ethagent', + '-- ethagent ens --operator`, which injects ' + OPERATOR_KEY_ENV + '). The operator key only', + 'writes text records and creates subnames under a parent it controls. Publishing a name', + 'change always needs the owner wallet.', + '', +].join('\n') + +export type EnsSeams = { + ensClient: () => PublicClient + readClient: () => Pick + operatorSender: (privateKey: `0x${string}`) => LocalKeySender + openSession: (onReady: (ready: BrowserWalletReady) => void) => Promise + publish: typeof runRebackupSigningInSession + resolveJwt: typeof resolveValidatedPinataJwt + vaultStatus: typeof continuityVaultStatus + pullHarness: typeof pullHarnessSoulMemoryIntoVault + openExternal: (url: string) => void + saveConfig: (config: EthagentConfig) => Promise +} + +const defaultSeams: EnsSeams = { + ensClient: () => createMainnetEnsPublicClient(), + readClient: () => createMainnetClient(), + operatorSender: privateKey => createLocalKeySender({ privateKey, chainId: 1, rpcUrl: DEFAULT_ETHEREUM_RPC_URL }), + openSession: onReady => openBrowserWalletSession({ title: 'ethagent ENS', onReady }), + publish: runRebackupSigningInSession, + resolveJwt: resolveValidatedPinataJwt, + vaultStatus: continuityVaultStatus, + pullHarness: pullHarnessSoulMemoryIntoVault, + openExternal: url => openExternalUrl(url), + saveConfig, +} + +type Mode = { kind: 'read' } | { kind: 'swap'; name: string } | { kind: 'unlink' } | { kind: 'records'; set: Record; clear: string[] } + +function parseMode(values: Record, positionals: string[]): Mode { + const sets = stringValues(values.set as string | string[] | undefined) + const clears = stringValues(values.clear as string | string[] | undefined) + const unlink = Boolean(values.unlink) + const name = positionals[0] + if (positionals.length > 1) throw new HistoryError(2, `unexpected argument: ${positionals[1]}`, `usage: ${ENS_USAGE}`) + const chosen = [name ? 'a name' : '', unlink ? '--unlink' : '', sets.length + clears.length > 0 ? '--set/--clear' : ''].filter(Boolean) + if (chosen.length > 1) throw new HistoryError(2, `choose one of ${chosen.join(', ')}`, `usage: ${ENS_USAGE}`) + if (name) return { kind: 'swap', name } + if (unlink) return { kind: 'unlink' } + if (sets.length + clears.length > 0) { + const set: Record = {} + for (const entry of sets) { + const eq = entry.indexOf('=') + if (eq <= 0) throw new HistoryError(2, `--set expects =, got: ${entry}`) + set[entry.slice(0, eq).trim()] = entry.slice(eq + 1) + } + for (const key of clears) { + if (key in set) throw new HistoryError(2, `${key} is both set and cleared`) + } + return { kind: 'records', set, clear: clears.map(key => key.trim()).filter(Boolean) } + } + return { kind: 'read' } +} + +type SignerChoice = + | { kind: 'browser'; address: Address; role: string } + | { kind: 'operator'; address: Address; role: string; key: `0x${string}` } + +function chooseSigner(deps: HistoryDeps, operator: boolean, owner: Address): SignerChoice { + if (!operator) return { kind: 'browser', address: owner, role: 'owner wallet' } + const key = deps.operatorKey + if (!key || (!key.ok && key.reason === 'missing')) { + throw new HistoryError(3, 'No operator key available.', `Run this through \`keychain exec ethagent -- ethagent ens --operator\`, which injects ${OPERATOR_KEY_ENV}.`) + } + if (!key.ok) throw new HistoryError(2, INVALID_OPERATOR_KEY_MESSAGE) + return { kind: 'operator', address: getAddress(key.address), role: 'operator key', key: key.key } +} + +function txJson(tx: EnsPlannedTransaction, simulation?: { ok: true } | { ok: false; reason: string } | 'after-previous'): Record { + return { + step: tx.step, + name: tx.name, + to: tx.to, + description: tx.description, + ...(simulation === undefined ? {} : { + simulation: simulation === 'after-previous' + ? 'runs after the step before it' + : simulation.ok ? 'would succeed' : `refused: ${simulation.reason}`, + }), + } +} + +async function readView(identity: EthagentIdentity, registry: Erc8004RegistryConfig, deps: HistoryDeps, seams: EnsSeams): Promise> { + const state = identity.state as Record | undefined + const name = readIdentityStateString(state, 'ensName') + const owner = getAddress(humanOwnerAddress(identity)) + if (!name) return { name: null, owner } + const client = seams.readClient() + const control = await readNameControl(client, name) + const keys = identity.agentId ? agentEnsRecordKeys(registry.identityRegistryAddress, identity.agentId) : [] + const records = control.resolver ? await readTextRecords(client, control.resolver, control.node, keys) : {} + const addr = control.resolver ? await readAddressRecord(client, control.resolver, control.node) : null + const validation = await validateAgentEnsLink(name, owner, { publicClient: client as PublicClient }) + const key = deps.operatorKey + let operator: Record | null = null + if (key?.ok) { + const how = await signerControlOf(client, control, getAddress(key.address)) + operator = { address: getAddress(key.address), canSign: Boolean(how), via: how } + } else if (key && !key.ok && key.reason === 'invalid') { + operator = { error: INVALID_OPERATOR_KEY_MESSAGE } + } + return { + name: control.name, + owner, + exists: control.exists, + records: { addr, text: records }, + link: validation.ok + ? { ok: true, resolvedAddress: validation.resolvedAddress } + : { ok: false, reason: validation.reason, text: ensValidationReasonText(validation.reason), ...(validation.detail ? { detail: validation.detail } : {}) }, + resolver: control.resolver, + control: { owner: control.owner, wrapped: control.wrapped, registryOwner: control.registryOwner }, + operator, + } +} + +function printView(view: Record): string { + if (!view.name) return `no ENS name is linked · owner ${String(view.owner)}\n` + const lines: string[] = [] + const link = view.link as { ok: boolean; text?: string; detail?: string } + const records = view.records as { addr: string | null; text: Record } + const control = view.control as { owner: string | null; wrapped: boolean } + lines.push(`${String(view.name)} · ${link.ok ? 'linked both ways' : `not linked: ${link.text}${link.detail ? ` (${link.detail})` : ''}`}`) + lines.push(` addr: ${records.addr ?? 'none'}`) + const entries = Object.entries(records.text) + if (entries.length === 0) lines.push(' agent records: none') + for (const [key, value] of entries) lines.push(` ${key} = ${value}`) + lines.push(` resolver: ${String(view.resolver ?? 'none')}`) + lines.push(` controlled by: ${control.owner ?? 'nobody'}${control.wrapped ? ' (wrapped)' : ''}`) + const operator = view.operator as { address?: string; canSign?: boolean; via?: string; error?: string } | null + if (operator?.error) lines.push(` operator key: ${operator.error}`) + else if (operator) lines.push(` operator key ${operator.address}: ${operator.canSign ? `can sign (${operator.via})` : 'cannot sign for this name'}`) + else lines.push(' operator key: not available in this shell') + return `${lines.join('\n')}\n` +} + +export async function runEnsCommand(args: string[], deps: HistoryDeps, seams: EnsSeams = defaultSeams): Promise { + const json = args.includes('--json') + try { + const { values, positionals } = parseHistoryArgs(args, { + operator: { type: 'boolean' }, + yes: { type: 'boolean' }, + unlink: { type: 'boolean' }, + 'no-open': { type: 'boolean' }, + set: { type: 'string', multiple: true }, + clear: { type: 'string', multiple: true }, + }, ENS_USAGE) + if (values.help) { + await deps.io.out(HELP) + return 0 + } + const mode = parseMode(values, positionals) + const { config, identity } = await requireIdentity(deps) + const registry = resolveRegistryForIdentity(identity, config) + if (!registry) throw new HistoryError(1, 'No agent registry is configured for this identity.', 'Run `npx ethagent` to set it up.') + + if (mode.kind === 'read') { + const view = await readView(identity, registry, deps, seams) + if (json) await emitJson(deps.io, view) + else await deps.io.out(printView(view)) + return 0 + } + + if (!identity.agentId) throw new HistoryError(1, 'This identity has no agent token ID yet.', 'Create or restore it with `npx ethagent` first.') + const owner = getAddress(humanOwnerAddress(identity)) + const signer = chooseSigner(deps, Boolean(values.operator), owner) + const client = seams.readClient() + const currentName = readIdentityStateString(identity.state as Record | undefined, 'ensName') + + let transactions: EnsPlannedTransaction[] + let publishName: string | null = null + let summary: Record + if (mode.kind === 'swap') { + const plan = await planEnsSwap({ + client, + newName: mode.name, + oldName: currentName || null, + signer: signer.address, + signerRole: signer.role, + agentOwner: owner, + chainId: registry.chainId, + identityRegistryAddress: registry.identityRegistryAddress, + agentId: identity.agentId, + }) + transactions = plan.transactions + publishName = plan.newName + summary = { action: 'swap', name: plan.newName, oldName: plan.oldName, create: plan.create } + } else if (mode.kind === 'unlink') { + if (!currentName) throw new HistoryError(1, 'No ENS name is linked, so there is nothing to unlink.') + const plan = await planEnsUnlink({ + client, + name: currentName, + signer: signer.address, + signerRole: signer.role, + identityRegistryAddress: registry.identityRegistryAddress, + agentId: identity.agentId, + }) + transactions = plan.transactions + publishName = '' + summary = { action: 'unlink', name: plan.name, records: plan.current } + } else { + if (!currentName) throw new HistoryError(1, 'No ENS name is linked.', 'Link one first with `ethagent ens `.') + const plan = await planEnsRecords({ + client, + name: currentName, + set: mode.set, + clear: mode.clear, + signer: signer.address, + signerRole: signer.role, + }) + transactions = plan.transactions + summary = { action: 'records', name: plan.name, changes: plan.diffs.filter(diff => diff.changed) } + } + + const publish = publishName === null ? null : { ensName: publishName, signer: 'owner wallet', via: resolveVaultAddress(identity, config.erc8004?.operatorVaults) ? 'vault' : 'registry' } + if (!values.yes) { + const simulations = new Map[1]>() + let afterPrevious = false + for (const tx of transactions) { + if (tx.dependsOnPrevious || afterPrevious) { + simulations.set(tx, 'after-previous') + afterPrevious = true + continue + } + simulations.set(tx, await simulatePlannedTransaction(client, signer.address, tx)) + } + const preview = { + applied: false, + ...summary, + signer: { kind: signer.kind, address: signer.address }, + transactions: transactions.map(tx => txJson(tx, simulations.get(tx))), + publish, + } + if (json) { + await emitJson(deps.io, preview) + } else { + const lines: string[] = [] + if (transactions.length === 0 && !publish) { + lines.push('Nothing to change: the records already match.') + } else { + lines.push(`Preview (nothing sent). Signer: ${signer.role} ${signer.address}.`) + transactions.forEach((tx, index) => { + const sim = simulations.get(tx) + const simText = sim === 'after-previous' ? 'runs after the step before it' : sim && sim.ok ? 'would succeed' : sim ? `refused: ${sim.reason}` : '' + lines.push(` ${index + 1}. Ethereum Mainnet tx to ${tx.to}: ${tx.description} [${simText}]`) + }) + if (publish) { + lines.push(` ${transactions.length + 1}. owner-signed save publishing ${publish.ensName ? `ensName ${publish.ensName}` : 'no ENS name'} (${publish.via === 'vault' ? 'through the Vault' : 'on the registry'})`) + } + lines.push('Run again with --yes to send.') + } + await deps.io.out(`${lines.join('\n')}\n`) + } + return 0 + } + + if (transactions.length === 0 && publishName === null) { + if (json) await emitJson(deps.io, { applied: false, ...summary, reason: 'no-changes' }) + else await deps.io.out('Nothing to change: the records already match.\n') + return 0 + } + + let saveReady: { jwt: string } | null = null + if (publishName !== null) { + const vault = await seams.vaultStatus(identity).catch(() => ({ ready: false })) + if (!vault.ready) throw new HistoryError(1, 'Local continuity files are not restored.', 'Run `npx ethagent` and restore this identity before changing its name. Nothing was sent.') + let jwt: string | undefined + try { + jwt = await seams.resolveJwt() + } catch (err) { + throw new HistoryError(3, `The configured Pinata JWT is invalid or unreachable (${err instanceof Error ? err.message : String(err)}). Nothing was sent.`) + } + if (!jwt) throw new HistoryError(3, 'No IPFS storage credential configured, so the name cannot be published.', 'Set up IPFS Storage in `npx ethagent` (or export PINATA_JWT), then retry. Nothing was sent.') + saveReady = { jwt } + await seams.pullHarness(identity).catch(() => []) + } + + const noOpen = Boolean(values['no-open']) + const sink = json ? deps.io.err : deps.io.out + let session: BrowserWalletSession | null = null + const ensureSession = async (): Promise => { + if (session) return session + session = await seams.openSession(ready => { + void sink(`Approve in your browser wallet tab: ${ready.url}\nThis waits until you approve or cancel.\n`) + if (!noOpen) seams.openExternal(ready.url) + }) + return session + } + const callbacks: EffectCallbacks = { + onStep: () => {}, + onWalletReady: () => {}, + onIdentityComplete: async () => {}, + } + const flowId = mode.kind === 'swap' ? 'ens-link' : mode.kind === 'unlink' ? 'ens-clear' : 'ens-update' + const sent: Array<{ step: string; name: string; txHash: string }> = [] + let savedIdentity: EthagentIdentity | undefined + try { + let ensSigner: EnsSigner + if (signer.kind === 'operator') { + ensSigner = operatorEnsSigner(seams.operatorSender(signer.key)) + } else { + ensSigner = browserEnsSigner({ account: signer.address, callbacks, session: await ensureSession() }) + } + const publicClient = seams.ensClient() + for (const tx of transactions) { + const flowStep = tx.step === 'create-subdomain' ? 1 : mode.kind === 'swap' ? 2 : 1 + await sink(`Sending: ${tx.description}\n`) + const { txHash } = await sendEnsTransaction({ + signer: ensSigner, + fullName: tx.name, + to: tx.to, + data: tx.data, + purpose: tx.purpose, + publicClient, + callbacks, + action: `ENS ${tx.step}`, + tokenChainId: registry.chainId, + flowId, + flowStep, + }) + sent.push({ step: tx.step, name: tx.name, txHash }) + await sink(`Confirmed: ${txHash}\n`) + } + if (publishName !== null && saveReady) { + const vaultAddress = resolveVaultAddress(identity, config.erc8004?.operatorVaults) + const step: Extract = { + kind: 'rebackup-signing', + identity, + registry, + pinataJwt: saveReady.jwt, + returnTo: { kind: 'menu' }, + profileUpdates: { ensName: publishName }, + ...(vaultAddress ? { vaultAddress } : {}), + } + await sink('Publishing the name change: approve one owner-signed save.\n') + await seams.publish(step, { + ...callbacks, + // Local state takes the new name only once the owner-signed save has landed. + onIdentityComplete: async nextIdentity => { + await seams.saveConfig({ ...config, identity: nextIdentity }) + savedIdentity = nextIdentity + }, + }, await ensureSession(), { flowId, flowStep: mode.kind === 'swap' ? 3 : 2 }) + } + } catch (err: unknown) { + if (isWalletCancelled(err)) { + throw new HistoryError(3, `Wallet approval was cancelled.${sent.length ? ` ${sent.length} ENS transaction(s) already confirmed: ${sent.map(item => item.txHash).join(', ')}.` : ' Nothing was sent.'}`) + } + if (err instanceof Error && sent.length > 0) { + throw new HistoryError(1, `${err.message} (already confirmed: ${sent.map(item => `${item.step} ${item.txHash}`).join(', ')})`) + } + throw err + } finally { + const open = session as BrowserWalletSession | null + if (open) await open.close().catch(() => {}) + } + + const result = { + applied: true, + ...summary, + signer: { kind: signer.kind, address: signer.address }, + transactions: sent, + ...(publishName !== null + ? { published: Boolean(savedIdentity), cid: savedIdentity?.backup?.cid ?? null, txHash: savedIdentity?.backup?.txHash ?? null } + : {}), + } + if (json) { + await emitJson(deps.io, result) + } else { + const lines = [`Done: ${sent.length} ENS transaction(s) confirmed.`] + if (publishName !== null) lines.push(savedIdentity ? `Published ${publishName ? `ensName ${publishName}` : 'the unlinked name'}.` : 'The owner-signed save did not complete; local state keeps the old name.') + await deps.io.out(`${lines.join('\n')}\n`) + } + return publishName !== null && !savedIdentity ? 1 : 0 + } catch (err) { + if (err instanceof EnsPlanRefusal) return failFrom(deps.io, json, new HistoryError(1, err.message, err.hint)) + return failFrom(deps.io, json, err) + } +} diff --git a/src/cli/onchain/index.ts b/src/cli/onchain/index.ts new file mode 100644 index 0000000..37c07cd --- /dev/null +++ b/src/cli/onchain/index.ts @@ -0,0 +1,11 @@ +import { readOperatorKey } from '../operatorKey.js' +import { defaultHistoryDeps, type HistoryDeps } from '../history/shared.js' + +export async function runOnchainCommand(verb: string, args: string[], baseDeps: HistoryDeps = defaultHistoryDeps()): Promise { + const deps: HistoryDeps = { ...baseDeps, operatorKey: baseDeps.operatorKey ?? readOperatorKey(baseDeps.env) } + switch (verb) { + case 'custody': return (await import('./custody.js')).runCustodyCommand(args, deps) + case 'ens': return (await import('./ens.js')).runEnsCommand(args, deps) + default: return 2 + } +} diff --git a/src/cli/sessionStart.ts b/src/cli/sessionStart.ts index 9a2b783..7f9c3c8 100644 --- a/src/cli/sessionStart.ts +++ b/src/cli/sessionStart.ts @@ -18,7 +18,9 @@ export function buildSessionStartContext(identity?: EthagentIdentity | null): st 'folder, private by default. The vault always exists once an identity is set, so never assume it is missing. ' + 'Every save is also kept locally byte for byte: `npx ethagent status`, `history`, `show`, and `diff` (add `--json`) ' + 'are read-only and safe to run anytime; run `rollback` or `forget` only when the user asks, preview first, and see ' + - '`npx ethagent --help` for usage.' + '`npx ethagent --help` for usage. ' + + '`npx ethagent custody` and `npx ethagent ens` (with `--json`) show the Vault and the ENS name and are safe to run; ' + + 'ENS changes preview without `--yes`, and anything with `--yes` stays the user\'s to run.' ) } diff --git a/src/identity/ens/ensAutomation/read.ts b/src/identity/ens/ensAutomation/read.ts index f83494e..7307f4e 100644 --- a/src/identity/ens/ensAutomation/read.ts +++ b/src/identity/ens/ensAutomation/read.ts @@ -1,13 +1,11 @@ import { - createPublicClient, - fallback, getAddress, - http, type Address, type Hex, type PublicClient, } from 'viem' -import { mainnet } from 'viem/chains' +import { createMainnetClient } from '../ensLookup/client.js' +import { isChainAnswer } from '../../../net/paced.js' import { recordsFromTextMap, type AgentEnsRecordState, @@ -18,7 +16,6 @@ import { ENS_AUTOMATION_RESOLVER_ABI, ENS_NAME_WRAPPER_ADDRESS_MAINNET, ENS_REGISTRY_ADDRESS_MAINNET, - ENS_RPC_URLS, ZERO_ADDRESS, } from './contracts.js' import type { EnsAutomationReadClient } from './types.js' @@ -29,11 +26,7 @@ export function shortHex(value: string): string { } export function createEnsAutomationClient(): PublicClient { - const transports = ENS_RPC_URLS.map(url => http(url, { retryCount: 0, timeout: 8_000 })) - return createPublicClient({ - chain: mainnet, - transport: transports.length === 1 ? transports[0]! : fallback(transports, { retryCount: 0 }), - }) + return createMainnetClient() } export async function readOwner(client: EnsAutomationReadClient, node: Hex): Promise
{ @@ -75,8 +68,11 @@ export async function readAddressRecord(client: EnsAutomationReadClient, resolve args: [node], }) as Address return isZero(addr) ? null : getAddress(addr) - } catch { - return null + } catch (err: unknown) { + // A resolver without addr() answers with a revert: no record. An unanswered read + // is an error, never "no record". + if (isChainAnswer(err)) return null + throw err } } @@ -96,7 +92,8 @@ export async function readTextRecords( args: [node, key], }) as string if (value) text[key] = value - } catch { + } catch (err: unknown) { + if (!isChainAnswer(err)) throw err } } return recordsFromTextMap(text) diff --git a/src/identity/ens/ensLookup/client.ts b/src/identity/ens/ensLookup/client.ts index b9f885b..88a9a15 100644 --- a/src/identity/ens/ensLookup/client.ts +++ b/src/identity/ens/ensLookup/client.ts @@ -1,41 +1,40 @@ -import { createPublicClient, fallback, http, type PublicClient } from 'viem' +import { createPublicClient, type PublicClient } from 'viem' import { mainnet } from 'viem/chains' -import { ENS_RPC_URLS, RPC_TIMEOUT_MS } from './constants.js' +import { adaptiveRpcTransport } from '../../../net/rpc.js' +import { ENS_RPC_URLS } from './constants.js' +// ENS lives on mainnet. Reads go through the adaptive transport: each endpoint gets the +// bound it has earned, a slow one gets a competitor, and nothing is cut off by a fixed +// limit. export function createMainnetClient(): PublicClient { - const transports = ENS_RPC_URLS.map(url => http(url, { retryCount: 0, timeout: RPC_TIMEOUT_MS })) return createPublicClient({ chain: mainnet, - transport: transports.length === 1 ? transports[0]! : fallback(transports, { retryCount: 0 }), + transport: adaptiveRpcTransport(ENS_RPC_URLS), }) } class AbortedError extends Error { - constructor() { super('Aborted') } + constructor() { + super('The operation was cancelled.') + this.name = 'AbortError' + } } -class TimedOutError extends Error { - constructor(label: string) { super(`${label} timed out`) } -} - -export function withDeadline(promise: Promise, ms: number, label: string, signal?: AbortSignal): Promise { - if (signal?.aborted) return Promise.reject(new AbortedError()) +// Ends the wait when the caller cancels. There is no time limit: the transport decides +// when an endpoint has stopped answering. +export function cancellable(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise + if (signal.aborted) return Promise.reject(new AbortedError()) return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new TimedOutError(label)), ms) - const onAbort = () => { - clearTimeout(timer) - reject(new AbortedError()) - } - signal?.addEventListener('abort', onAbort, { once: true }) + const onAbort = () => reject(new AbortedError()) + signal.addEventListener('abort', onAbort, { once: true }) promise.then( value => { - clearTimeout(timer) - signal?.removeEventListener('abort', onAbort) + signal.removeEventListener('abort', onAbort) resolve(value) }, err => { - clearTimeout(timer) - signal?.removeEventListener('abort', onAbort) + signal.removeEventListener('abort', onAbort) reject(err) }, ) diff --git a/src/identity/ens/ensLookup/constants.ts b/src/identity/ens/ensLookup/constants.ts index 7fcde43..fecfddf 100644 --- a/src/identity/ens/ensLookup/constants.ts +++ b/src/identity/ens/ensLookup/constants.ts @@ -1,7 +1,5 @@ import { parseAbi, type Address } from 'viem' -export const RPC_TIMEOUT_MS = 8_000 - export const ENS_REGISTRY_ADDRESS_MAINNET = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e' as Address export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address diff --git a/src/identity/ens/ensLookup/discovery.ts b/src/identity/ens/ensLookup/discovery.ts index 1836269..e67bea2 100644 --- a/src/identity/ens/ensLookup/discovery.ts +++ b/src/identity/ens/ensLookup/discovery.ts @@ -1,7 +1,6 @@ import type { Address } from 'viem' import type { DiscoverOptions, EnsNameDiscoveryResult } from './types.js' -import { RPC_TIMEOUT_MS } from './constants.js' -import { createMainnetClient } from './client.js' +import { cancellable, createMainnetClient } from './client.js' import { isEthDomain, normalizeEthDomain, splitSubdomainName } from './names.js' export async function discoverOwnedEnsNames( @@ -16,47 +15,37 @@ export async function discoverOwnedEnsNameDetails( ownerAddress: Address, opts: DiscoverOptions = {}, ): Promise { - const rpcTimeoutMs = opts.rpcTimeoutMs ?? RPC_TIMEOUT_MS - const controller = new AbortController() - const onParentAbort = () => controller.abort() - opts.signal?.addEventListener('abort', onParentAbort, { once: true }) - const budgetTimer = setTimeout(() => controller.abort(), rpcTimeoutMs) + const client = opts.publicClient ?? createMainnetClient() + if (opts.signal?.aborted) { + return { status: 'error', names: [], sourcesChecked: [], errors: ['ENS name lookup was cancelled'] } + } try { - const client = opts.publicClient ?? createMainnetClient() - if (controller.signal.aborted) { - return { status: 'error', names: [], sourcesChecked: [], errors: ['ENS name lookup was cancelled'] } - } - try { - const primary = await client.getEnsName({ address: ownerAddress }) - const names = new Set() - if (primary) { - const normalized = normalizeEthDomain(primary) - if (normalized && normalized.endsWith('.eth')) { - if (isRootEthName(normalized)) { - names.add(normalized) - } else { - const parts = splitSubdomainName(normalized) - if (parts && isRootEthName(parts.parent)) names.add(parts.parent) - } + const primary = await cancellable(client.getEnsName({ address: ownerAddress }), opts.signal) + const names = new Set() + if (typeof primary === 'string' && primary) { + const normalized = normalizeEthDomain(primary) + if (normalized && normalized.endsWith('.eth')) { + if (isRootEthName(normalized)) { + names.add(normalized) + } else { + const parts = splitSubdomainName(normalized) + if (parts && isRootEthName(parts.parent)) names.add(parts.parent) } } - return { - status: 'ok', - names: [...names].sort((a, b) => a.localeCompare(b)), - sourcesChecked: ['ENS reverse resolver'], - errors: [], - } - } catch (err) { - return { - status: 'error', - names: [], - sourcesChecked: [], - errors: [formatDiscoveryError(err)], - } } - } finally { - clearTimeout(budgetTimer) - opts.signal?.removeEventListener('abort', onParentAbort) + return { + status: 'ok', + names: [...names].sort((a, b) => a.localeCompare(b)), + sourcesChecked: ['ENS reverse resolver'], + errors: [], + } + } catch (err) { + return { + status: 'error', + names: [], + sourcesChecked: [], + errors: [formatDiscoveryError(err)], + } } } diff --git a/src/identity/ens/ensLookup/resolve.ts b/src/identity/ens/ensLookup/resolve.ts index de6d952..394ee4b 100644 --- a/src/identity/ens/ensLookup/resolve.ts +++ b/src/identity/ens/ensLookup/resolve.ts @@ -1,7 +1,7 @@ import { getAddress, namehash, type Address } from 'viem' import type { DiscoverOptions } from './types.js' -import { ENS_REGISTRY_ABI, ENS_REGISTRY_ADDRESS_MAINNET, RESOLVER_ABI, RPC_TIMEOUT_MS, ZERO_ADDRESS } from './constants.js' -import { createMainnetClient, withDeadline } from './client.js' +import { ENS_REGISTRY_ABI, ENS_REGISTRY_ADDRESS_MAINNET, RESOLVER_ABI, ZERO_ADDRESS } from './constants.js' +import { createMainnetClient, cancellable } from './client.js' import { isEthDomain, normalizeEthDomain } from './names.js' export async function resolveEnsAddress(name: string, opts: DiscoverOptions = {}): Promise
{ @@ -9,38 +9,32 @@ export async function resolveEnsAddress(name: string, opts: DiscoverOptions = {} if (!isEthDomain(trimmed)) return null const client = opts.publicClient ?? createMainnetClient() try { - const addr = await withDeadline( + const addr = await cancellable( client.getEnsAddress({ name: trimmed }), - opts.rpcTimeoutMs ?? RPC_TIMEOUT_MS, - 'getEnsAddress', opts.signal, ) - return addr ? getAddress(addr) : null + return typeof addr === 'string' ? getAddress(addr) : null } catch { return null } } +// null means the registry has no resolver for the name. A read that fails throws: an +// unreadable resolver is not "no resolver". export async function readResolverAddress(fullName: string, opts: DiscoverOptions = {}): Promise
{ if (!isEthDomain(fullName)) return null const client = opts.publicClient ?? createMainnetClient() - try { - const node = namehash(fullName) - const resolver = await withDeadline( - client.readContract({ - address: ENS_REGISTRY_ADDRESS_MAINNET, - abi: ENS_REGISTRY_ABI, - functionName: 'resolver', - args: [node], - }), - opts.rpcTimeoutMs ?? RPC_TIMEOUT_MS, - 'registry.resolver', - opts.signal, - ) as Address - return resolver === ZERO_ADDRESS ? null : resolver - } catch { - return null - } + const node = namehash(fullName) + const resolver = await cancellable( + client.readContract({ + address: ENS_REGISTRY_ADDRESS_MAINNET, + abi: ENS_REGISTRY_ABI, + functionName: 'resolver', + args: [node], + }), + opts.signal, + ) as Address + return resolver === ZERO_ADDRESS ? null : resolver } export async function readEthagentTextRecords( @@ -54,22 +48,19 @@ export async function readEthagentTextRecords( if (!resolver) return out const client = opts.publicClient ?? createMainnetClient() const node = namehash(fullName) + // An empty value means the record is unset. A read that fails throws, so a caller + // clearing records never skips a key it could not read. for (const key of keys) { - try { - const value = await withDeadline( - client.readContract({ - address: resolver, - abi: RESOLVER_ABI, - functionName: 'text', - args: [node, key], - }), - opts.rpcTimeoutMs ?? RPC_TIMEOUT_MS, - `resolver.text(${key})`, - opts.signal, - ) as string - if (value) out[key] = value - } catch { - } + const value = await cancellable( + client.readContract({ + address: resolver, + abi: RESOLVER_ABI, + functionName: 'text', + args: [node, key], + }), + opts.signal, + ) as string + if (value) out[key] = value } return out } diff --git a/src/identity/ens/ensLookup/types.ts b/src/identity/ens/ensLookup/types.ts index c77cca2..358fae7 100644 --- a/src/identity/ens/ensLookup/types.ts +++ b/src/identity/ens/ensLookup/types.ts @@ -16,9 +16,6 @@ export type EnsValidation = export type DiscoverOptions = { signal?: AbortSignal - budgetMs?: number - rpcTimeoutMs?: number - scanWindowBlocks?: bigint publicClient?: PublicClient } diff --git a/src/identity/ens/ensLookup/validation.ts b/src/identity/ens/ensLookup/validation.ts index ecf79de..169115b 100644 --- a/src/identity/ens/ensLookup/validation.ts +++ b/src/identity/ens/ensLookup/validation.ts @@ -1,7 +1,7 @@ import { getAddress, namehash, type Address } from 'viem' import type { DiscoverOptions, EnsValidation } from './types.js' -import { ENS_REGISTRY_ABI, ENS_REGISTRY_ADDRESS_MAINNET, RPC_TIMEOUT_MS, ZERO_ADDRESS } from './constants.js' -import { createMainnetClient, withDeadline } from './client.js' +import { ENS_REGISTRY_ABI, ENS_REGISTRY_ADDRESS_MAINNET, ZERO_ADDRESS } from './constants.js' +import { createMainnetClient, cancellable } from './client.js' import { isEthDomain, splitSubdomainName } from './names.js' export async function validateAgentEnsLink( @@ -19,29 +19,25 @@ export async function validateAgentEnsLink( const node = namehash(fullName) let resolver: Address try { - const owner = await withDeadline( + const owner = await cancellable( client.readContract({ address: ENS_REGISTRY_ADDRESS_MAINNET, abi: ENS_REGISTRY_ABI, functionName: 'owner', args: [node], }), - opts.rpcTimeoutMs ?? RPC_TIMEOUT_MS, - 'registry.owner', opts.signal, ) as Address if (owner === ZERO_ADDRESS) { return { ok: false, reason: 'no-owner', detail: 'name does not exist on ENS' } } - resolver = await withDeadline( + resolver = await cancellable( client.readContract({ address: ENS_REGISTRY_ADDRESS_MAINNET, abi: ENS_REGISTRY_ABI, functionName: 'resolver', args: [node], }), - opts.rpcTimeoutMs ?? RPC_TIMEOUT_MS, - 'registry.resolver', opts.signal, ) as Address if (resolver === ZERO_ADDRESS) { @@ -52,10 +48,8 @@ export async function validateAgentEnsLink( } let resolved: Address | null try { - resolved = await withDeadline( + resolved = await cancellable( client.getEnsAddress({ name: fullName }), - opts.rpcTimeoutMs ?? RPC_TIMEOUT_MS, - 'getEnsAddress', opts.signal, ) } catch (err) { diff --git a/src/identity/ens/resolverDelegation.ts b/src/identity/ens/resolverDelegation.ts index 5e8a4d8..6af84d9 100644 --- a/src/identity/ens/resolverDelegation.ts +++ b/src/identity/ens/resolverDelegation.ts @@ -6,6 +6,7 @@ import { type PublicClient, } from 'viem' import { ENS_AUTOMATION_RESOLVER_ABI } from './ensAutomation.js' +import { isChainAnswer } from '../../net/paced.js' type ResolverDelegationReadClient = Pick @@ -42,7 +43,10 @@ export async function readDelegation(args: ResolverDelegationReadArgs): Promise< args: [getAddress(args.ownerAddress), args.node, getAddress(args.delegateAddress)], }) as boolean return Boolean(approved) - } catch { - return false + } catch (err: unknown) { + // A resolver without delegation answers with a revert: not approved. An unanswered + // read is an error. + if (isChainAnswer(err)) return false + throw err } } diff --git a/src/identity/manager/continuity/effects.ts b/src/identity/manager/continuity/effects.ts index 3d29cf0..405303b 100644 --- a/src/identity/manager/continuity/effects.ts +++ b/src/identity/manager/continuity/effects.ts @@ -62,9 +62,9 @@ import { walletRestoreAccessContext, } from './snapshot.js' import { - appendResolverSyncWarning, + appendOperatorSyncWarning, markCurrentContinuityFilesPublished, - resolverSyncWarningMessage, + operatorSyncWarningMessage, syncVaultOperatorsAfterOwnerSave, } from '../shared/effects/sync.js' import { runOperatorWalletRebackup } from './vault.js' @@ -395,20 +395,21 @@ async function runRebackupSigningInner( } await recordPublishedContinuitySnapshot({ identity: nextIdentity, label: 'published encrypted snapshot' }).catch(() => null) await markCurrentContinuityFilesPublished(nextIdentity, result.prepared.publishedSources).catch(() => null) - const resolverSyncWarning = await syncVaultOperatorsAfterOwnerSave({ + const operatorSyncWarning = await syncVaultOperatorsAfterOwnerSave({ beforeIdentity: step.identity, afterIdentity: nextIdentity, registry: step.registry, ...(step.vaultAddress ? { vaultAddress: step.vaultAddress } : {}), callbacks, - }).then(() => null).catch(err => resolverSyncWarningMessage(err)) - const completionMessage = appendResolverSyncWarning( + afterDeposit: step.profileUpdates?.custodyPhase === 'switch-advanced', + }).then(() => null).catch(err => operatorSyncWarningMessage(err)) + const completionMessage = appendOperatorSyncWarning( rebackupCompletionMessage( step.profileUpdates, step.identity, readEnsOkFromState(nextIdentity.state), ), - resolverSyncWarning, + operatorSyncWarning, ) await callbacks.onIdentityComplete(nextIdentity, completionMessage, 'update') } diff --git a/src/identity/manager/create/effects.ts b/src/identity/manager/create/effects.ts index 77f6770..1b20730 100644 --- a/src/identity/manager/create/effects.ts +++ b/src/identity/manager/create/effects.ts @@ -1,4 +1,4 @@ -import type { Address, Hex } from 'viem' +import type { Address, Hex, Log } from 'viem' import type { EthagentConfig, EthagentIdentity, SelectableNetwork } from '../../../storage/config.js' import { saveConfig } from '../../../storage/config.js' import { @@ -226,7 +226,7 @@ export async function runCreateSigning( const receipt = await awaitConfirmedReceipt(client, result.txHash, 'Agent registration', { kind: 'register', chainId: step.registry.chainId }) callbacks.onCreateProgress?.({ phase: 'writing', label: 'Writing soul, memory, and skills…' }) const registered = registeredAgentFromReceipt({ - logs: receipt.logs.map(log => ({ address: log.address, topics: [...log.topics] as Hex[], data: log.data })), + logs: receipt.logs.map((log: Log) => ({ address: log.address, topics: [...log.topics] as Hex[], data: log.data })), identityRegistryAddress: step.registry.identityRegistryAddress, ownerAddress: result.prepared.ownerAddress, fallbackAgentURI: result.prepared.agentUri, diff --git a/src/identity/manager/custody/actions.ts b/src/identity/manager/custody/actions.ts index eb9db5f..901d1a5 100644 --- a/src/identity/manager/custody/actions.ts +++ b/src/identity/manager/custody/actions.ts @@ -3,7 +3,7 @@ import { createErc8004PublicClient } from '../../registry/erc8004.js' import { discoverPriorVaultFromTokenOwner, isAgentInVault } from '../../registry/vault.js' import type { ProfileUpdates, Step } from '../reducer.js' import { isCustodyEditStep } from './CustodyEditFlow.js' -import { resolveVaultAddress } from './transactions.js' +import { findReusableDeployedVault, resolveVaultAddress } from './transactions.js' import type { CustodyFlowDeps } from './types.js' import { humanOwnerAddress } from './helpers.js' @@ -63,8 +63,27 @@ export function createCustodyFlowActions({ }) return } - } catch { - void 0 + const reusable = await findReusableDeployedVault({ + registry, + agentId: BigInt(currentStep.identity.agentId ?? '0'), + owner: expectedOwnerForDiscovery, + }) + if (reusable) { + setStep({ + kind: 'custody-vault-deposit-tx', + identity: currentStep.identity, + registry, + vaultAddress: reusable, + profileUpdates: withVaultProfileUpdates(profileUpdates, reusable), + returnTo, + }) + return + } + } catch (err: unknown) { + // An unanswered read is not "no Vault yet": deploying on it could strand the + // token's existing Vault, so the error is shown and the user can retry. + handleStepError(err, { kind: 'custody-model', identity: currentStep.identity, registry, returnTo }) + return } setStep({ kind: 'custody-vault-deploy-tx', @@ -108,15 +127,7 @@ export function createCustodyFlowActions({ returnTo, }) } catch (err: unknown) { - setStep({ - kind: 'custody-vault-deposit-tx', - identity: currentStep.identity, - registry, - vaultAddress, - profileUpdates: withVaultProfileUpdates(profileUpdates, vaultAddress), - returnTo, - }) - void err + handleStepError(err, { kind: 'custody-model', identity: currentStep.identity, registry, returnTo }) } })() } diff --git a/src/identity/manager/custody/routes.tsx b/src/identity/manager/custody/routes.tsx index 4ad6724..5d4b009 100644 --- a/src/identity/manager/custody/routes.tsx +++ b/src/identity/manager/custody/routes.tsx @@ -60,47 +60,7 @@ export function renderCustodyStep({ /> ) } - if (step.kind === 'custody-vault-withdraw-pick-token') { - const activeId = step.identity.agentId - const options = step.tokens.map(t => ({ - value: t.agentId, - label: `Token #${t.agentId}`, - ...(activeId && t.agentId === activeId ? { hint: 'This agent' } : {}), - })) - return ( - ↵ select · esc back} - > - - - options={[ - ...options, - { value: 'cancel', label: 'Back', role: 'utility' }, - ]} - hintLayout="inline" - onSubmit={choice => { - if (choice === 'cancel') { - setStep({ kind: 'custody-model', identity: step.identity, registry: step.registry, returnTo: step.returnTo }) - return - } - setStep({ - kind: 'custody-vault-withdraw-tx', - identity: step.identity, - registry: step.registry, - vaultAddress: step.vaultAddress, - agentId: choice, - returnTo: step.returnTo, - ...(step.returnContext ? { returnContext: step.returnContext } : {}), - }) - }} - onCancel={() => setStep({ kind: 'custody-model', identity: step.identity, registry: step.registry, returnTo: step.returnTo })} - /> - - - ) - } + if (step.kind === 'custody-vault-withdraw-done') { const onReturnToVault = () => { setStep({ diff --git a/src/identity/manager/custody/transactions.ts b/src/identity/manager/custody/transactions.ts index 63de5bc..64317bb 100644 --- a/src/identity/manager/custody/transactions.ts +++ b/src/identity/manager/custody/transactions.ts @@ -8,16 +8,20 @@ import { VAULT_ABI, VAULT_DEPLOY_BYTECODE, assertVaultBytecode, + VaultBytecodeMismatchError, } from '../../registry/vault.js' import { + blockTimeMsForChain, createErc8004PublicClient, type Erc8004RegistryConfig, } from '../../registry/erc8004.js' -import type { EthagentIdentity } from '../../../storage/config.js' +import { describeVaultRevert, isNotAContractAnswer, type VaultBuild, type VaultCheckPacing } from '../../registry/vault.js' +import { loadConfig, saveConfigWithMerge, setConfiguredVaultAddress, type EthagentIdentity } from '../../../storage/config.js' import { readVaultAddressField, readOwnerAddressField } from '../../identityCompat.js' import { prepareTransactionGasFee, sendBrowserWalletTransaction } from '../../wallet/browserWallet.js' import { acquireTxGuard, releaseTxGuard, type TxGuardKind } from '../shared/txGuard.js' import { awaitConfirmedReceipt } from '../shared/effects/receipts.js' +import { invalidateOwnershipCache } from '../shared/reconciliation/agentReconciliation/ownership.js' import type { EffectCallbacks } from '../shared/effects/types.js' import { readCustodyMode } from './state.js' @@ -32,12 +36,59 @@ export function resolveVaultAddress( return resolveConfiguredVaultAddress(operatorVaults, identity.chainId) } +// Saves a freshly deployed Vault's address as soon as the deploy receipt names it, so +// a retry after a failed check or a closed screen reuses it instead of deploying again. +export async function recordDeployedVault(chainId: number, vaultAddress: Address): Promise { + await saveConfigWithMerge(current => { + if (!current) throw new Error('Cannot record the new Vault: no ethagent config is saved') + return setConfiguredVaultAddress(current, chainId, getAddress(vaultAddress)) + }) +} + +// A Vault recorded by an earlier deploy that this token can still go into. The Vault +// binds its registry and token at deploy time, and only the simulated deposit can tell +// which token that is, so a refusal there means "deploy a new one". Unanswered reads +// surface as errors. +export async function findReusableDeployedVault(args: { + registry: Erc8004RegistryConfig + agentId: bigint + owner: Address +}): Promise
{ + const config = await loadConfig() + const recorded = config?.erc8004?.operatorVaults?.[String(args.registry.chainId)] + if (!recorded) return undefined + const vaultAddress = getAddress(recorded) + const client = createErc8004PublicClient(args.registry) + let build: VaultBuild + try { + build = await assertVaultBytecode(client, vaultAddress, undefined, { blockTimeMs: blockTimeMsForChain(args.registry.chainId) }) + } catch (err: unknown) { + if (err instanceof VaultBytecodeMismatchError) return undefined + throw err + } + try { + await assertVaultCanAcceptAgent({ registry: args.registry, vaultAddress, agentId: args.agentId, build, owner: args.owner, client }) + } catch (err: unknown) { + if (err instanceof VaultRefusedDepositError) return undefined + throw err + } + return vaultAddress +} + +export class VaultRefusedDepositError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options) + this.name = 'VaultRefusedDepositError' + } +} + async function withTxGuard(kind: TxGuardKind, fn: () => Promise): Promise { acquireTxGuard(kind) try { return await fn() } finally { releaseTxGuard(kind) + invalidateOwnershipCache() } } @@ -46,8 +97,11 @@ export async function runVaultDeployTransaction(args: { walletAddress: Address agentId: bigint callbacks: EffectCallbacks - publicClient?: Pick + publicClient?: Pick flowId?: string + // Called as soon as the deploy receipt names the new Vault, before its code is + // checked, so a retry after a failed check reuses this Vault instead of deploying again. + onDeployed?: (vaultAddress: Address) => Promise | void }): Promise<{ txHash: Hex; vaultAddress: Address }> { return withTxGuard('vault-deploy', () => runVaultDeployTransactionInner(args)) } @@ -57,8 +111,9 @@ async function runVaultDeployTransactionInner(args: { walletAddress: Address agentId: bigint callbacks: EffectCallbacks - publicClient?: Pick + publicClient?: Pick flowId?: string + onDeployed?: (vaultAddress: Address) => Promise | void }): Promise<{ txHash: Hex; vaultAddress: Address }> { const walletAddress = getAddress(args.walletAddress) const registryAddress = getAddress(args.registry.identityRegistryAddress) @@ -92,7 +147,8 @@ async function runVaultDeployTransactionInner(args: { throw new Error('Vault deploy receipt is missing contractAddress; the transaction was not a contract creation') } const vaultAddress = getAddress(receipt.contractAddress) - await assertVaultBytecode(client, vaultAddress, result.txHash) + await args.onDeployed?.(vaultAddress) + await assertVaultBytecode(client, vaultAddress, result.txHash, receiptPacing(args.registry, client, receipt.blockNumber, args.callbacks.signal)) return { txHash: result.txHash, vaultAddress } } @@ -102,7 +158,7 @@ export async function runVaultDepositTransaction(args: { vaultAddress: Address callbacks: EffectCallbacks flowId?: string -}): Promise<{ txHash: string }> { +}): Promise<{ txHash: string; receiptBlock: bigint; build: VaultBuild }> { return withTxGuard('vault-deposit', () => runVaultDepositTransactionInner(args)) } @@ -112,17 +168,23 @@ async function runVaultDepositTransactionInner(args: { vaultAddress: Address callbacks: EffectCallbacks flowId?: string -}): Promise<{ txHash: string }> { +}): Promise<{ txHash: string; receiptBlock: bigint; build: VaultBuild }> { const { identity, registry, vaultAddress } = args if (!identity.agentId) { throw new Error('Cannot deposit token to Vault: agent token ID is missing') } - await assertVaultBytecode(createErc8004PublicClient(registry), vaultAddress) + const readClient = createErc8004PublicClient(registry) + const build = await assertVaultBytecode(readClient, vaultAddress, undefined, { + blockTimeMs: blockTimeMsForChain(registry.chainId), + ...(args.callbacks.signal ? { signal: args.callbacks.signal } : {}), + }) const tokenOwner = getAddress(identity.ownerAddress ?? identity.address) await assertVaultCanAcceptAgent({ registry, vaultAddress, agentId: BigInt(identity.agentId), + build, + owner: tokenOwner, }) const encoded = encodeDepositAgent({ registry: getAddress(registry.identityRegistryAddress), @@ -152,39 +214,99 @@ async function runVaultDepositTransactionInner(args: { }) args.callbacks.onWalletReady(null) const depositClient = createErc8004PublicClient(registry) - await awaitConfirmedReceipt( + const receipt = await awaitConfirmedReceipt( depositClient, result.txHash as Hex, 'Vault deposit', { kind: 'vault-deposit', chainId: registry.chainId }, ) - return { txHash: result.txHash } + return { txHash: result.txHash, receiptBlock: receipt.blockNumber, build } } -async function assertVaultCanAcceptAgent(args: { +export function receiptPacing( + registry: Pick, + client: Partial>, + receiptBlock: bigint | undefined, + signal?: AbortSignal, +): VaultCheckPacing { + const getBlockNumber = client.getBlockNumber?.bind(client) + return { + blockTimeMs: blockTimeMsForChain(registry.chainId), + ...(receiptBlock !== undefined && getBlockNumber ? { receiptBlock, getBlockNumber: () => getBlockNumber() } : {}), + ...(signal ? { signal } : {}), + } +} + +const ERC721_SAFE_TRANSFER_SIM_ABI = [{ + type: 'function', + name: 'safeTransferFrom', + stateMutability: 'nonpayable', + inputs: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'tokenId', type: 'uint256' }, + ], + outputs: [], +}] as const + +const ZERO = '0x0000000000000000000000000000000000000000' + +// Checks the Vault can take this token before the wallet is asked. Builds with +// heldAgent() say what they hold; on every build agentOwner for this token must be +// empty. Then the deposit itself is simulated from the owner, which is the check every +// build agrees on. Failed reads surface as errors; they never count as "accept". +export async function assertVaultCanAcceptAgent(args: { registry: Erc8004RegistryConfig vaultAddress: Address agentId: bigint + build: VaultBuild + owner: Address + client?: Pick }): Promise { - const client = createErc8004PublicClient(args.registry) - let held: readonly [Address, bigint, Address] - try { - held = await client.readContract({ - address: getAddress(args.vaultAddress), + const client = args.client ?? createErc8004PublicClient(args.registry) + const vault = getAddress(args.vaultAddress) + const expectedRegistry = getAddress(args.registry.identityRegistryAddress) + if (args.build.hasHeldAgent) { + const held = await client.readContract({ + address: vault, abi: VAULT_ABI, functionName: 'heldAgent', }) as readonly [Address, bigint, Address] - } catch { - return + const [heldRegistry, heldAgentId, heldOwner] = held + if (heldOwner && heldOwner.toLowerCase() !== ZERO) { + const sameAgent = heldRegistry.toLowerCase() === expectedRegistry.toLowerCase() && heldAgentId === args.agentId + if (sameAgent) { + throw new VaultRefusedDepositError(`Vault ${vault} already holds ERC-8004 token #${args.agentId.toString()}. Publish the pending update instead of depositing again.`) + } + throw new VaultRefusedDepositError(`Vault ${vault} already holds ERC-8004 token #${heldAgentId.toString()} for registry ${getAddress(heldRegistry)}. Deploy a fresh vault for this agent.`) + } + } else { + const owner = await client.readContract({ + address: vault, + abi: VAULT_ABI, + functionName: 'agentOwner', + args: [expectedRegistry, args.agentId], + }) as Address + if (owner && owner.toLowerCase() !== ZERO) { + throw new VaultRefusedDepositError(`Vault ${vault} already holds ERC-8004 token #${args.agentId.toString()}. Publish the pending update instead of depositing again.`) + } } - const [heldRegistry, heldAgentId, heldOwner] = held - if (!heldOwner || heldOwner.toLowerCase() === '0x0000000000000000000000000000000000000000') return - const expectedRegistry = getAddress(args.registry.identityRegistryAddress) - const sameAgent = heldRegistry.toLowerCase() === expectedRegistry.toLowerCase() && heldAgentId === args.agentId - if (sameAgent) { - throw new Error(`Vault ${getAddress(args.vaultAddress)} already holds ERC-8004 token #${args.agentId.toString()}. Publish the pending update instead of depositing again.`) + try { + await client.simulateContract({ + account: getAddress(args.owner), + address: expectedRegistry, + abi: [...ERC721_SAFE_TRANSFER_SIM_ABI, ...VAULT_ABI.filter(item => item.type === 'error')], + functionName: 'safeTransferFrom', + args: [getAddress(args.owner), vault, args.agentId], + }) + } catch (err: unknown) { + if (!isNotAContractAnswer(err)) throw err + const reason = describeVaultRevert(err) + throw new VaultRefusedDepositError( + `A simulated deposit into Vault ${vault} was refused${reason ? `: ${reason}` : ''}. Nothing was sent.`, + { cause: err }, + ) } - throw new Error(`Vault ${getAddress(args.vaultAddress)} already holds ERC-8004 token #${heldAgentId.toString()} for registry ${getAddress(heldRegistry)}. Deploy a fresh vault for this agent.`) } export async function runVaultUnwrapTransaction(args: { @@ -248,7 +370,7 @@ async function runVaultUnwrapTransactionInner(args: { ...(args.flowId ? { flowId: args.flowId } : {}), }) args.callbacks.onWalletReady(null) - await awaitConfirmedReceipt( + const receipt = await awaitConfirmedReceipt( publicClient, result.txHash as Hex, 'Vault unwrap', @@ -260,6 +382,7 @@ async function runVaultUnwrapTransactionInner(args: { registry: getAddress(registry.identityRegistryAddress), agentId: targetAgentId, recipient: ownerAddress, + pacing: receiptPacing(registry, publicClient, receipt.blockNumber, args.callbacks.signal), }) return { txHash: result.txHash } } @@ -323,7 +446,7 @@ async function runVaultWithdrawTransactionInner(args: { ...(args.callbacks.signal ? { signal: args.callbacks.signal } : {}), }) args.callbacks.onWalletReady(null) - await awaitConfirmedReceipt( + const receipt = await awaitConfirmedReceipt( publicClient, result.txHash as Hex, 'Vault withdraw', @@ -335,6 +458,7 @@ async function runVaultWithdrawTransactionInner(args: { registry: getAddress(registry.identityRegistryAddress), agentId: targetAgentId, recipient, + pacing: receiptPacing(registry, publicClient, receipt.blockNumber, args.callbacks.signal), }) return { txHash: result.txHash, recipient } } diff --git a/src/identity/manager/custody/useCustodyEffects.ts b/src/identity/manager/custody/useCustodyEffects.ts index de4eab1..6e489fc 100644 --- a/src/identity/manager/custody/useCustodyEffects.ts +++ b/src/identity/manager/custody/useCustodyEffects.ts @@ -7,6 +7,8 @@ import type { CustodyFlowDeps } from './types.js' import { scopeCallbacks } from '../shared/effects/types.js' import { humanOwnerAddress } from './helpers.js' import { + receiptPacing, + recordDeployedVault, runVaultDeployTransaction, runVaultDepositTransaction, runVaultUnwrapTransaction, @@ -41,6 +43,7 @@ export function useCustodyTransactionEffects({ agentId: BigInt(step.identity.agentId), callbacks: scope.callbacks, flowId: 'advanced-custody', + onDeployed: vaultAddress => recordDeployedVault(step.registry.chainId, vaultAddress), }) .then(async ({ vaultAddress }) => { if (cancelled) return @@ -74,7 +77,7 @@ export function useCustodyTransactionEffects({ callbacks: scope.callbacks, flowId: 'advanced-custody', }) - .then(async () => { + .then(async ({ receiptBlock }) => { if (cancelled) return invalidateOwnershipCache() refreshReconciliation() @@ -85,6 +88,7 @@ export function useCustodyTransactionEffects({ vaultAddress: step.vaultAddress, registry: step.registry.identityRegistryAddress, agentId: BigInt(step.identity.agentId ?? '0'), + pacing: receiptPacing(step.registry, probeClient, receiptBlock, scope.callbacks.signal), }) if (cancelled) return if (status.ownerAddress.toLowerCase() !== expectedOwner.toLowerCase()) { diff --git a/src/identity/manager/ens/EnsEditFlow.tsx b/src/identity/manager/ens/EnsEditFlow.tsx index af5d0e8..c78cc22 100644 --- a/src/identity/manager/ens/EnsEditFlow.tsx +++ b/src/identity/manager/ens/EnsEditFlow.tsx @@ -2,9 +2,7 @@ import React from 'react' import { getAddress, type Address } from 'viem' import type { BrowserWalletReady } from '../../wallet/browserWallet.js' import { - AGENT_TOKEN_RECORD_KEY, buildAgentEnsRecords, - buildEnsip25Key, diffRecords, recordsFromTextMap, } from '../../ens/agentRecords.js' @@ -19,7 +17,7 @@ import { preflightEnsRoot, preflightEnsSetup, } from '../../ens/ensAutomation.js' -import { SUPPORTED_ERC8004_CHAINS } from '../../registry/erc8004.js' +import { agentEnsRecordKeys } from './records.js' import { readCustodyMode, readIdentityStateString, @@ -85,8 +83,6 @@ export const EnsEditFlow: React.FC = ({ setPhase({ kind: 'discovering', mode: targetMode }) discoverOwnedEnsNameDetails(ownerAddress, { signal: controller.signal, - budgetMs: 30_000, - rpcTimeoutMs: 8_000, }) .then(result => { if (controller.signal.aborted) return @@ -173,16 +169,7 @@ export const EnsEditFlow: React.FC = ({ if (waiting(phaseRef.current)) runSimpleCreatePreflight(fullName) return } - const readKeys = identity.agentId - ? [ - ...SUPPORTED_ERC8004_CHAINS.map(chain => buildEnsip25Key({ - chainId: chain.chainId, - identityRegistryAddress: registry.identityRegistryAddress, - agentId: identity.agentId!, - })), - AGENT_TOKEN_RECORD_KEY, - ] - : [] + const readKeys = agentEnsRecordKeys(registry.identityRegistryAddress, identity.agentId) const currentText = validation.ok && readKeys.length > 0 ? await readEthagentTextRecords(fullName, readKeys) : {} @@ -272,16 +259,7 @@ export const EnsEditFlow: React.FC = ({ setValidationError(null) setPhase({ kind: 'unlink-loading', fullName }) const waiting = (current: EnsPhase) => current.kind === 'unlink-loading' && current.fullName === fullName - const readKeys = identity.agentId - ? [ - ...SUPPORTED_ERC8004_CHAINS.map(chain => buildEnsip25Key({ - chainId: chain.chainId, - identityRegistryAddress: registry.identityRegistryAddress, - agentId: identity.agentId!, - })), - AGENT_TOKEN_RECORD_KEY, - ] - : [] + const readKeys = agentEnsRecordKeys(registry.identityRegistryAddress, identity.agentId) readEthagentTextRecords(fullName, readKeys) .then(currentText => { const currentRecords = recordsFromTextMap(currentText) diff --git a/src/identity/manager/ens/EnsEditMaintenanceScreens.tsx b/src/identity/manager/ens/EnsEditMaintenanceScreens.tsx index 64b5b30..a65d781 100644 --- a/src/identity/manager/ens/EnsEditMaintenanceScreens.tsx +++ b/src/identity/manager/ens/EnsEditMaintenanceScreens.tsx @@ -24,6 +24,7 @@ import { EscCancel, } from './EnsEditRunners.js' import { ensValidationReasonText, selectEnsStatus } from './state.js' +import { agentEnsRecordKeys } from './records.js' import type { EnsEditProps, EnsPhase, @@ -215,6 +216,7 @@ export function renderEnsMaintenancePhase({ void }> = ({ onCancel }) => { export const DeleteSubdomainTxRunner: React.FC<{ plan: EnsSubdomainDeletePlan ownerAddress: Address + recordKeys: readonly string[] walletSession: BrowserWalletReady | null onWalletReady: (session: BrowserWalletReady | null) => void onDeleted: () => void onError: (msg: string) => void onCancel: () => void -}> = ({ plan, ownerAddress, walletSession, onWalletReady, onDeleted, onError, onCancel }) => { +}> = ({ plan, ownerAddress, recordKeys, walletSession, onWalletReady, onDeleted, onError, onCancel }) => { const [confirming, setConfirming] = React.useState(false) React.useEffect(() => { let cancelled = false const request = new AbortController() - sendBrowserWalletTransaction({ - chainId: mainnet.id, - expectedAccount: ownerAddress, - to: plan.transaction.to, - data: plan.transaction.data, - purpose: 'delete-ens-subdomain', - signal: request.signal, - onReady: ready => { if (!cancelled) onWalletReady(ready) }, + runDeleteEnsSubdomain({ + plan, + recordKeys, + ownerAddress, + callbacks: { + onStep: () => {}, + onIdentityComplete: async () => {}, + onWalletReady: ready => { + if (cancelled) return + onWalletReady(ready) + setConfirming(!ready) + }, + signal: request.signal, + }, }) - .then(async result => { - if (cancelled) return - onWalletReady(null) - setConfirming(true) - await createMainnetClient().waitForTransactionReceipt({ hash: result.txHash }) + .then(() => { if (!cancelled) onDeleted() }) .catch((err: unknown) => { @@ -66,9 +63,9 @@ export const DeleteSubdomainTxRunner: React.FC<{ return ( ) diff --git a/src/identity/manager/ens/headless.ts b/src/identity/manager/ens/headless.ts new file mode 100644 index 0000000..52ddce1 --- /dev/null +++ b/src/identity/manager/ens/headless.ts @@ -0,0 +1,415 @@ +import { + encodeFunctionData, + getAddress, + labelhash, + namehash, + type Address, + type Hex, + type PublicClient, +} from 'viem' +import { + buildAgentEnsRecords, + diffRecords, + type AgentEnsRecordState, + type AgentEnsRecords, + type AgentRecordDiff, +} from '../../ens/agentRecords.js' +import { normalizeEthDomain, splitSubdomainName } from '../../ens/ensLookup.js' +import { + ENS_AUTOMATION_RESOLVER_ABI, + ENS_PUBLIC_RESOLVER_ADDRESS_MAINNET, +} from '../../ens/ensAutomation.js' +import { + ENS_AUTOMATION_NAME_WRAPPER_ABI, + ENS_AUTOMATION_REGISTRY_ABI, + ENS_NAME_WRAPPER_ADDRESS_MAINNET, + ENS_REGISTRY_ADDRESS_MAINNET, + DEFAULT_EXPIRY, + DEFAULT_FUSES, + DEFAULT_TTL, +} from '../../ens/ensAutomation/contracts.js' +import { readAddressRecord, readTextRecords } from '../../ens/ensAutomation/read.js' +import { readDelegation } from '../../ens/resolverDelegation.js' +import type { WalletPurpose } from '../../wallet/browserWallet.js' +import { agentEnsRecordKeys } from './records.js' + +export type EnsReadClient = Pick + +const ZERO = '0x0000000000000000000000000000000000000000' + +function isZero(address: string): boolean { + return address.toLowerCase() === ZERO +} + +function same(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase() +} + +// Who controls a name: the registry owner, or for a wrapped name the NameWrapper +// owner, and which resolver holds its records. +export type EnsNameControl = { + name: string + node: Hex + exists: boolean + wrapped: boolean + owner: Address | null + registryOwner: Address | null + resolver: Address | null +} + +export async function readNameControl(client: EnsReadClient, name: string): Promise { + const fullName = normalizeEthDomain(name) + const node = namehash(fullName) + const registryOwner = getAddress(await client.readContract({ + address: ENS_REGISTRY_ADDRESS_MAINNET, + abi: ENS_AUTOMATION_REGISTRY_ABI, + functionName: 'owner', + args: [node], + }) as Address) + const resolver = getAddress(await client.readContract({ + address: ENS_REGISTRY_ADDRESS_MAINNET, + abi: ENS_AUTOMATION_REGISTRY_ABI, + functionName: 'resolver', + args: [node], + }) as Address) + const exists = !isZero(registryOwner) + const wrapped = exists && same(registryOwner, ENS_NAME_WRAPPER_ADDRESS_MAINNET) + const owner = !exists + ? null + : wrapped + ? getAddress(await client.readContract({ + address: ENS_NAME_WRAPPER_ADDRESS_MAINNET, + abi: ENS_AUTOMATION_NAME_WRAPPER_ABI, + functionName: 'ownerOf', + args: [BigInt(node)], + }) as Address) + : registryOwner + return { + name: fullName, + node, + exists, + wrapped, + owner, + registryOwner: exists ? registryOwner : null, + resolver: isZero(resolver) ? null : resolver, + } +} + +export type SignerControl = 'owner' | 'wrapped-owner' | 'resolver-delegate' | null + +// Whether the signer may write this name's resolver records: as the registry owner, +// the NameWrapper owner, or a delegate the owner approved on the resolver. +export async function signerControlOf(client: EnsReadClient, control: EnsNameControl, signer: Address): Promise { + if (!control.exists || !control.owner) return null + if (same(control.owner, signer)) return control.wrapped ? 'wrapped-owner' : 'owner' + if (!control.resolver) return null + const delegated = await readDelegation({ + client, + resolverAddress: control.resolver, + ownerAddress: control.owner, + node: control.node, + delegateAddress: signer, + }) + return delegated ? 'resolver-delegate' : null +} + +export type EnsPlannedTransaction = { + step: 'create-subdomain' | 'set-records' | 'clear-old-records' | 'clear-records' | 'update-records' + name: string + to: Address + data: Hex + purpose: WalletPurpose + description: string + // Simulating this before the steps ahead of it ran would read a name that does not + // exist yet, so it is simulated only as part of the real run. + dependsOnPrevious: boolean +} + +export class EnsPlanRefusal extends Error { + constructor(message: string, readonly hint?: string) { + super(message) + this.name = 'EnsPlanRefusal' + } +} + +function textCalls(node: Hex, writes: Record): Hex[] { + return Object.entries(writes).map(([key, value]) => encodeFunctionData({ + abi: ENS_AUTOMATION_RESOLVER_ABI, + functionName: 'setText', + args: [node, key, value], + })) +} + +function multicall(calls: Hex[]): Hex { + return calls.length === 1 + ? calls[0]! + : encodeFunctionData({ abi: ENS_AUTOMATION_RESOLVER_ABI, functionName: 'multicall', args: [calls] }) +} + +export function describeWrites(writes: Record): string { + return Object.entries(writes) + .map(([key, value]) => (value ? `set ${key} = ${value}` : `clear ${key}`)) + .join('; ') +} + +async function requireControl(client: EnsReadClient, control: EnsNameControl, signer: Address, role: string): Promise { + if (!control.exists) throw new EnsPlanRefusal(`${control.name} does not exist on ENS.`) + if (!control.resolver) { + throw new EnsPlanRefusal(`${control.name} has no resolver, so its records cannot be written.`, 'Set a resolver for it in the ENS app first. Nothing was sent.') + } + const how = await signerControlOf(client, control, signer) + if (!how) { + throw new EnsPlanRefusal( + `The ${role} ${signer} does not control ${control.name} (managed by ${control.owner ?? 'nobody'}).`, + 'Use the wallet that owns the name, or approve this wallet as a delegate on its resolver. Nothing was sent.', + ) + } + return how +} + +async function clearOldRecordsTx(args: { + client: EnsReadClient + oldName: string + signer: Address + role: string + keys: readonly string[] + step: 'clear-old-records' | 'clear-records' +}): Promise<{ tx: EnsPlannedTransaction | null; current: AgentEnsRecordState }> { + const control = await readNameControl(args.client, args.oldName) + if (!control.exists || !control.resolver) return { tx: null, current: {} } + const current = await readTextRecords(args.client, control.resolver, control.node, args.keys) + const writes: Record = {} + for (const key of Object.keys(current)) writes[key] = '' + if (Object.keys(writes).length === 0) return { tx: null, current } + await requireControl(args.client, control, args.signer, args.role) + return { + current, + tx: { + step: args.step, + name: control.name, + to: control.resolver, + data: multicall(textCalls(control.node, writes)), + purpose: 'clear-ens-records', + description: `${control.name}: ${describeWrites(writes)}`, + dependsOnPrevious: false, + }, + } +} + +export type EnsSwapPlan = { + kind: 'swap' + newName: string + oldName: string | null + create: boolean + transactions: EnsPlannedTransaction[] + nextRecords: AgentEnsRecords + recordDiffs: AgentRecordDiff[] +} + +// Points the agent at a new name: creates it when it is missing and the signer controls +// the parent, writes the agent records on it in one resolver multicall (and addr only on +// a name it just created), and clears the agent records on the old name. Everything is +// checked before anything is sent; the owner-signed publish of the name is separate. +export async function planEnsSwap(args: { + client: EnsReadClient + newName: string + oldName?: string | null + signer: Address + signerRole: string + agentOwner: Address + chainId: number + identityRegistryAddress: Address + agentId: string +}): Promise { + const newName = normalizeEthDomain(args.newName) + const parts = splitSubdomainName(newName) + if (!parts) { + throw new EnsPlanRefusal(`${args.newName} is not an agent subdomain.`, 'An agent name is a subdomain: one label under a .eth name.') + } + const oldName = args.oldName ? normalizeEthDomain(args.oldName) : null + if (oldName === newName) { + throw new EnsPlanRefusal(`${newName} is already the agent's name.`, 'Use `ethagent ens` to check it, or `ethagent ens --set` to change its records.') + } + const nextRecords = buildAgentEnsRecords({ + chainId: args.chainId, + identityRegistryAddress: args.identityRegistryAddress, + agentId: args.agentId, + }) + const transactions: EnsPlannedTransaction[] = [] + const control = await readNameControl(args.client, newName) + const node = namehash(newName) + let create = false + let current: AgentEnsRecordState = {} + let resolver: Address + if (!control.exists) { + const parent = await readNameControl(args.client, parts.parent) + if (!parent.exists || !parent.owner || !same(parent.owner, args.signer)) { + throw new EnsPlanRefusal( + `${newName} does not exist, and the ${args.signerRole} ${args.signer} does not control its parent ${parts.parent}${parent.owner ? ` (managed by ${parent.owner})` : ''}.`, + 'Create the name in the ENS app, or sign with the wallet that owns the parent. Nothing was sent.', + ) + } + create = true + resolver = ENS_PUBLIC_RESOLVER_ADDRESS_MAINNET + const data = parent.wrapped + ? encodeFunctionData({ + abi: ENS_AUTOMATION_NAME_WRAPPER_ABI, + functionName: 'setSubnodeRecord', + args: [parent.node, parts.label, args.signer, resolver, DEFAULT_TTL, DEFAULT_FUSES, DEFAULT_EXPIRY], + }) + : encodeFunctionData({ + abi: ENS_AUTOMATION_REGISTRY_ABI, + functionName: 'setSubnodeRecord', + args: [parent.node, labelhash(parts.label), args.signer, resolver, DEFAULT_TTL], + }) + transactions.push({ + step: 'create-subdomain', + name: newName, + to: parent.wrapped ? ENS_NAME_WRAPPER_ADDRESS_MAINNET : ENS_REGISTRY_ADDRESS_MAINNET, + data, + purpose: 'create-simple-ens-subdomain', + description: `create ${newName} under ${parts.parent}, managed by ${args.signer}, on the public resolver`, + dependsOnPrevious: false, + }) + } else { + await requireControl(args.client, control, args.signer, args.signerRole) + resolver = control.resolver! + const addr = await readAddressRecord(args.client, resolver, node) + if (!addr || !same(addr, args.agentOwner)) { + throw new EnsPlanRefusal( + `${newName} resolves to ${addr ?? 'no address'}, not the agent owner ${args.agentOwner}.`, + 'Set its address to the owner wallet in the ENS app first; ethagent writes addr only on a name it creates. Nothing was sent.', + ) + } + current = await readTextRecords(args.client, resolver, node, Object.keys(nextRecords)) + } + const recordDiffs = diffRecords(current, nextRecords) + const writes: Record = {} + for (const diff of recordDiffs) if (diff.changed) writes[diff.key] = diff.next + const calls: Hex[] = [] + if (create) { + calls.push(encodeFunctionData({ abi: ENS_AUTOMATION_RESOLVER_ABI, functionName: 'setAddr', args: [node, args.agentOwner] })) + } + calls.push(...textCalls(node, writes)) + if (calls.length > 0) { + transactions.push({ + step: 'set-records', + name: newName, + to: resolver, + data: multicall(calls), + purpose: 'set-agent-ens-records', + description: `${newName}: ${[create ? `set addr = ${args.agentOwner}` : '', describeWrites(writes)].filter(Boolean).join('; ')}`, + dependsOnPrevious: create, + }) + } + if (oldName) { + const cleared = await clearOldRecordsTx({ + client: args.client, + oldName, + signer: args.signer, + role: args.signerRole, + keys: agentEnsRecordKeys(args.identityRegistryAddress, args.agentId), + step: 'clear-old-records', + }) + if (cleared.tx) transactions.push(cleared.tx) + } + return { kind: 'swap', newName, oldName, create, transactions, nextRecords, recordDiffs } +} + +export type EnsUnlinkPlan = { + kind: 'unlink' + name: string + transactions: EnsPlannedTransaction[] + current: AgentEnsRecordState +} + +export async function planEnsUnlink(args: { + client: EnsReadClient + name: string + signer: Address + signerRole: string + identityRegistryAddress: Address + agentId: string +}): Promise { + const cleared = await clearOldRecordsTx({ + client: args.client, + oldName: args.name, + signer: args.signer, + role: args.signerRole, + keys: agentEnsRecordKeys(args.identityRegistryAddress, args.agentId), + step: 'clear-records', + }) + return { + kind: 'unlink', + name: normalizeEthDomain(args.name), + transactions: cleared.tx ? [cleared.tx] : [], + current: cleared.current, + } +} + +export type EnsRecordsPlan = { + kind: 'records' + name: string + diffs: AgentRecordDiff[] + transactions: EnsPlannedTransaction[] +} + +// Writes every requested change in one resolver multicall. Keys whose value already +// matches are left alone, so nothing is sent when nothing changes. +export async function planEnsRecords(args: { + client: EnsReadClient + name: string + set: Record + clear: readonly string[] + signer: Address + signerRole: string +}): Promise { + const name = normalizeEthDomain(args.name) + const control = await readNameControl(args.client, name) + if (!control.exists) throw new EnsPlanRefusal(`${name} does not exist on ENS.`) + if (!control.resolver) { + throw new EnsPlanRefusal(`${name} has no resolver, so its records cannot be written.`, 'Set a resolver for it in the ENS app first. Nothing was sent.') + } + const desired: Record = { ...args.set } + for (const key of args.clear) desired[key] = '' + const keys = Object.keys(desired) + const current = await readTextRecords(args.client, control.resolver, control.node, keys) + const diffs = keys.map(key => { + const before = (current[key] ?? '').trim() + const after = (desired[key] ?? '').trim() + return { key, current: before, next: after, changed: before !== after } + }) + const writes: Record = {} + for (const diff of diffs) if (diff.changed) writes[diff.key] = diff.next + if (Object.keys(writes).length === 0) return { kind: 'records', name, diffs, transactions: [] } + await requireControl(args.client, control, args.signer, args.signerRole) + return { + kind: 'records', + name, + diffs, + transactions: [{ + step: 'update-records', + name, + to: control.resolver, + data: multicall(textCalls(control.node, writes)), + purpose: 'update-ens-records', + description: `${name}: ${describeWrites(writes)}`, + dependsOnPrevious: false, + }], + } +} + +// Runs a planned transaction as an eth_call from the signer, without sending it. +export async function simulatePlannedTransaction( + client: Pick, + signer: Address, + tx: EnsPlannedTransaction, +): Promise<{ ok: true } | { ok: false; reason: string }> { + try { + await client.call({ account: signer, to: tx.to, data: tx.data }) + return { ok: true } + } catch (err: unknown) { + const message = err instanceof Error ? ((err as { shortMessage?: string }).shortMessage ?? err.message) : String(err) + return { ok: false, reason: message.split('\n')[0] ?? message } + } +} diff --git a/src/identity/manager/ens/records.ts b/src/identity/manager/ens/records.ts new file mode 100644 index 0000000..7149d38 --- /dev/null +++ b/src/identity/manager/ens/records.ts @@ -0,0 +1,17 @@ +import { AGENT_TOKEN_RECORD_KEY, buildEnsip25Key } from '../../ens/agentRecords.js' +import { SUPPORTED_ERC8004_CHAINS } from '../../registry/erc8004.js' + +// Every text record ethagent may have written for this agent on a name: the ENSIP-25 +// key for each supported chain, since the token may have moved between them, and the +// token reference. Clearing an old name clears all of them. +export function agentEnsRecordKeys(identityRegistryAddress: string, agentId: string | bigint | undefined): string[] { + if (agentId === undefined || agentId === '') return [] + return [ + ...SUPPORTED_ERC8004_CHAINS.map(chain => buildEnsip25Key({ + chainId: chain.chainId, + identityRegistryAddress, + agentId, + })), + AGENT_TOKEN_RECORD_KEY, + ] +} diff --git a/src/identity/manager/ens/signer.ts b/src/identity/manager/ens/signer.ts new file mode 100644 index 0000000..c336c12 --- /dev/null +++ b/src/identity/manager/ens/signer.ts @@ -0,0 +1,91 @@ +import { getAddress, type Address, type Hex } from 'viem' +import { + sendBrowserWalletTransaction, + type BrowserWalletSession, + type WalletPurpose, +} from '../../wallet/browserWallet.js' +import type { LocalKeySender } from '../../wallet/localKeyWallet.js' +import type { EffectCallbacks } from '../shared/effects/types.js' + +export type EnsTransactionRequest = { + to: Address + data: Hex + gas: Hex + maxFeePerGas: Hex + maxPriorityFeePerGas: Hex + purpose: WalletPurpose + tokenChainName?: string + flowId?: string + flowStep?: number +} + +// Who signs a mainnet ENS transaction: the browser wallet (one tab per request, or one +// session for a whole flow) or the operator key. Gas is always estimated from this +// account, never assumed to be the agent's owner. +export type EnsSigner = { + kind: 'browser' | 'operator' + account: Address + send: (tx: EnsTransactionRequest) => Promise<{ txHash: Hex }> +} + +export function browserEnsSigner(args: { + account: Address + callbacks: EffectCallbacks + session?: BrowserWalletSession +}): EnsSigner { + const account = getAddress(args.account) + return { + kind: 'browser', + account, + async send(tx) { + if (args.session) { + const result = await args.session.sendTransaction({ + chainId: 1, + expectedAccount: account, + to: tx.to, + data: tx.data, + gas: tx.gas, + maxFeePerGas: tx.maxFeePerGas, + maxPriorityFeePerGas: tx.maxPriorityFeePerGas, + purpose: tx.purpose, + ...(tx.tokenChainName ? { tokenChainName: tx.tokenChainName } : {}), + ...(tx.flowId ? { flowId: tx.flowId } : {}), + ...(typeof tx.flowStep === 'number' ? { flowStep: tx.flowStep } : {}), + }) + return { txHash: result.txHash } + } + const result = await sendBrowserWalletTransaction({ + chainId: 1, + expectedAccount: account, + to: tx.to, + data: tx.data, + gas: tx.gas, + maxFeePerGas: tx.maxFeePerGas, + maxPriorityFeePerGas: tx.maxPriorityFeePerGas, + purpose: tx.purpose, + onReady: args.callbacks.onWalletReady, + ...(args.callbacks.signal ? { signal: args.callbacks.signal } : {}), + ...(tx.tokenChainName ? { tokenChainName: tx.tokenChainName } : {}), + }) + args.callbacks.onWalletReady(null) + return { txHash: result.txHash } + }, + } +} + +export function operatorEnsSigner(sender: LocalKeySender): EnsSigner { + if (sender.chainId !== 1) throw new Error('ENS transactions are signed on Ethereum Mainnet (chain 1).') + return { + kind: 'operator', + account: getAddress(sender.account), + async send(tx) { + return sender.send({ + to: tx.to, + data: tx.data, + gas: tx.gas, + maxFeePerGas: tx.maxFeePerGas, + maxPriorityFeePerGas: tx.maxPriorityFeePerGas, + }) + }, + } +} diff --git a/src/identity/manager/ens/transactions.ts b/src/identity/manager/ens/transactions.ts index ea5d93f..9c6f918 100644 --- a/src/identity/manager/ens/transactions.ts +++ b/src/identity/manager/ens/transactions.ts @@ -1,17 +1,20 @@ import type { Address, Hex, PublicClient } from 'viem' import { + blockTimeMsForChain, DEFAULT_ETHEREUM_RPC_URL, RegisterAgentPreflightError, createErc8004PublicClient, supportedErc8004ChainForId, } from '../../registry/erc8004.js' import { encodeSetEnsip25TextRecord, readEthagentTextRecords } from '../../ens/ensLookup.js' -import { encodeEnsRecordsTransaction, encodeEnsRegistryTransaction, readAddressRecord, type EnsSetupPlan } from '../../ens/ensAutomation.js' +import { encodeEnsRecordsTransaction, encodeEnsRegistryTransaction, readAddressRecord, type EnsSetupPlan, type EnsSubdomainDeletePlan } from '../../ens/ensAutomation.js' import type { AgentEnsRecordState, AgentEnsRecords, AgentRecordDiff } from '../../ens/agentRecords.js' import { changedRecords, clearedRecords, diffRecords } from '../../ens/agentRecords.js' import { namehash, getAddress } from 'viem' -import { prepareTransactionGasFee, sendBrowserWalletTransaction, type BrowserWalletSession, type WalletPurpose } from '../../wallet/browserWallet.js' +import { prepareTransactionGasFee, type BrowserWalletSession, type WalletPurpose } from '../../wallet/browserWallet.js' import type { EffectCallbacks } from '../shared/effects/types.js' +import { awaitConfirmedReceipt } from '../shared/effects/receipts.js' +import { browserEnsSigner, type EnsSigner } from './signer.js' function chainLabel(chainId: number): string { return supportedErc8004ChainForId(chainId)?.name ?? `chain ${chainId}` } @@ -20,6 +23,65 @@ function ensTokenChainName(chainId: number): string | undefined { return chainId !== 1 ? chainLabel(chainId) : undefined } +// Preflights, prices, sends and confirms one mainnet ENS transaction. The preflight +// and the gas estimate run from the signer's own account. The send returns only once +// the receipt is in and succeeded, so the next step reads what this one wrote. +export async function sendEnsTransaction(args: { + signer: EnsSigner + fullName: string + to: Address + data: Hex + purpose: WalletPurpose + publicClient: PublicClient + callbacks: EffectCallbacks + action: string + tokenChainId?: number + flowId?: string + flowStep?: number +}): Promise<{ txHash: Hex }> { + await preflightEnsRecordTransaction({ + fullName: args.fullName, + account: args.signer.account, + to: args.to, + data: args.data, + publicClient: args.publicClient, + purpose: args.purpose, + }) + const tokenChainName = typeof args.tokenChainId === 'number' ? ensTokenChainName(args.tokenChainId) : undefined + const gasFee = await prepareTransactionGasFee({ + client: args.publicClient, + account: args.signer.account, + to: args.to, + data: args.data, + blockTimeMs: blockTimeMsForChain(1), + ...(args.callbacks.signal ? { signal: args.callbacks.signal } : {}), + }) + const { txHash } = await args.signer.send({ + to: args.to, + data: args.data, + ...gasFee, + purpose: args.purpose, + ...(tokenChainName ? { tokenChainName } : {}), + ...(args.flowId ? { flowId: args.flowId } : {}), + ...(typeof args.flowStep === 'number' ? { flowStep: args.flowStep } : {}), + }) + await awaitConfirmedReceipt(args.publicClient, txHash, args.action, { kind: 'ens', chainId: 1 }, args.callbacks.signal) + return { txHash } +} + +function signerFor(args: { + signer?: EnsSigner + account: Address + callbacks: EffectCallbacks + session?: BrowserWalletSession +}): EnsSigner { + return args.signer ?? browserEnsSigner({ + account: args.account, + callbacks: args.callbacks, + ...(args.session ? { session: args.session } : {}), + }) +} + export async function runUpdateEnsRecords(args: { fullName: string ownerAddress: Address @@ -33,20 +95,21 @@ export async function runUpdateEnsRecords(args: { session?: BrowserWalletSession flowId?: string flowStep?: number + signer?: EnsSigner }): Promise<{ txHash: string } | { skipped: true }> { const publicClient = args.publicClient ?? createMainnetEnsPublicClient() const queryKeys = Array.from(new Set([ ...Object.keys(args.records ?? {}), ...Object.keys(args.currentRecords ?? {}), ])) - let freshCurrent: AgentEnsRecordState = args.currentRecords ?? {} - if (queryKeys.length > 0) { - try { - freshCurrent = await readEthagentTextRecords(args.fullName, queryKeys, { publicClient }) - } catch { - freshCurrent = args.currentRecords ?? {} - } - } + // Fresh reads decide what to write. A read that fails stops the update: falling back + // to an older view could skip a record that still needs clearing. + const freshCurrent: AgentEnsRecordState = queryKeys.length > 0 + ? await readEthagentTextRecords(args.fullName, queryKeys, { + publicClient, + ...(args.callbacks.signal ? { signal: args.callbacks.signal } : {}), + }) + : {} const next = ensRecordWritesForUpdate({ records: args.records, currentRecords: freshCurrent, @@ -56,48 +119,20 @@ export async function runUpdateEnsRecords(args: { return { skipped: true } } const encoded = await encodeSetEnsip25TextRecord(args.fullName, next, { publicClient }) - await preflightEnsRecordTransaction({ + const signer = signerFor({ signer: args.signer, account: args.ownerAddress, callbacks: args.callbacks, session: args.session }) + return sendEnsTransaction({ + signer, fullName: args.fullName, - account: args.ownerAddress, to: encoded.resolverAddress, data: encoded.data, - publicClient, purpose: args.purpose ?? 'update-ens-records', + publicClient, + callbacks: args.callbacks, + action: 'ENS record update', + ...(typeof args.tokenChainId === 'number' ? { tokenChainId: args.tokenChainId } : {}), + ...(args.flowId ? { flowId: args.flowId } : {}), + ...(typeof args.flowStep === 'number' ? { flowStep: args.flowStep } : {}), }) - const tokenChainName = typeof args.tokenChainId === 'number' ? ensTokenChainName(args.tokenChainId) : undefined - const purpose = args.purpose ?? 'update-ens-records' - const gasFee = await prepareTransactionGasFee({ - client: publicClient, - account: args.ownerAddress, - to: encoded.resolverAddress, - data: encoded.data, - }) - if (args.session) { - const result = await args.session.sendTransaction({ - chainId: 1, - expectedAccount: args.ownerAddress, - to: encoded.resolverAddress, - data: encoded.data, - ...gasFee, - purpose, - ...(tokenChainName ? { tokenChainName } : {}), - ...(args.flowId ? { flowId: args.flowId } : {}), - }) - return { txHash: result.txHash } - } - const result = await sendBrowserWalletTransaction({ - chainId: 1, - expectedAccount: args.ownerAddress, - to: encoded.resolverAddress, - data: encoded.data, - ...gasFee, - purpose, - onReady: args.callbacks.onWalletReady, - ...(args.callbacks.signal ? { signal: args.callbacks.signal } : {}), - ...(tokenChainName ? { tokenChainName } : {}), - }) - args.callbacks.onWalletReady(null) - return { txHash: result.txHash } } export function ensRecordWritesForUpdate(args: { @@ -120,55 +155,26 @@ export async function runEnsSetupRegistryTransaction(args: { session?: BrowserWalletSession flowId?: string flowStep?: number + signer?: EnsSigner }): Promise<{ txHash: string } | null> { const encoded = encodeEnsRegistryTransaction(args.setup) if (!encoded) return null const publicClient = args.publicClient ?? createMainnetEnsPublicClient() const purpose: WalletPurpose = args.setup.mode === 'simple' ? 'create-simple-ens-subdomain' : 'create-agent-ens-subdomain' - await preflightEnsRecordTransaction({ + const signer = signerFor({ signer: args.signer, account: args.setup.ownerAddress, callbacks: args.callbacks, session: args.session }) + return sendEnsTransaction({ + signer, fullName: args.setup.fullName, - account: args.setup.ownerAddress, - to: encoded.to, - data: encoded.data, - publicClient, - purpose, - }) - const tokenChainName = typeof args.tokenChainId === 'number' ? ensTokenChainName(args.tokenChainId) : undefined - const gasFee = await prepareTransactionGasFee({ - client: publicClient, - account: args.setup.ownerAddress, - to: encoded.to, - data: encoded.data, - }) - if (args.session) { - const result = await args.session.sendTransaction({ - chainId: 1, - expectedAccount: args.setup.ownerAddress, - to: encoded.to, - data: encoded.data, - ...gasFee, - purpose, - ...(tokenChainName ? { tokenChainName } : {}), - ...(args.flowId ? { flowId: args.flowId } : {}), - ...(typeof args.flowStep === 'number' ? { flowStep: args.flowStep } : {}), - }) - await publicClient.waitForTransactionReceipt({ hash: result.txHash }) - return { txHash: result.txHash } - } - const result = await sendBrowserWalletTransaction({ - chainId: 1, - expectedAccount: args.setup.ownerAddress, to: encoded.to, data: encoded.data, - ...gasFee, purpose, - onReady: args.callbacks.onWalletReady, - ...(args.callbacks.signal ? { signal: args.callbacks.signal } : {}), - ...(tokenChainName ? { tokenChainName } : {}), + publicClient, + callbacks: args.callbacks, + action: 'ENS subdomain setup', + ...(typeof args.tokenChainId === 'number' ? { tokenChainId: args.tokenChainId } : {}), + ...(args.flowId ? { flowId: args.flowId } : {}), + ...(typeof args.flowStep === 'number' ? { flowStep: args.flowStep } : {}), }) - args.callbacks.onWalletReady(null) - await publicClient.waitForTransactionReceipt({ hash: result.txHash }) - return { txHash: result.txHash } } export async function runEnsSetupRecordsTransaction(args: { @@ -179,76 +185,86 @@ export async function runEnsSetupRecordsTransaction(args: { session?: BrowserWalletSession flowId?: string flowStep?: number + signer?: EnsSigner }): Promise<{ txHash: string } | null> { const publicClient = args.publicClient ?? createMainnetEnsPublicClient() - const freshSetup = await refreshEnsSetupAgainstChain(args.setup, publicClient) + const freshSetup = await refreshEnsSetupAgainstChain(args.setup, publicClient, args.callbacks.signal) const encoded = encodeEnsRecordsTransaction(freshSetup) if (!encoded) return null const purpose: WalletPurpose = freshSetup.mode === 'simple' ? 'set-simple-ens-records' : 'set-agent-ens-records' - await preflightEnsRecordTransaction({ + const signer = signerFor({ signer: args.signer, account: freshSetup.ownerAddress, callbacks: args.callbacks, session: args.session }) + return sendEnsTransaction({ + signer, fullName: freshSetup.fullName, - account: freshSetup.ownerAddress, to: encoded.to, data: encoded.data, - publicClient, purpose, + publicClient, + callbacks: args.callbacks, + action: 'ENS record setup', + ...(typeof args.tokenChainId === 'number' ? { tokenChainId: args.tokenChainId } : {}), + ...(args.flowId ? { flowId: args.flowId } : {}), + ...(typeof args.flowStep === 'number' ? { flowStep: args.flowStep } : {}), }) - const tokenChainName = typeof args.tokenChainId === 'number' ? ensTokenChainName(args.tokenChainId) : undefined - const gasFee = await prepareTransactionGasFee({ - client: publicClient, - account: freshSetup.ownerAddress, - to: encoded.to, - data: encoded.data, - }) - if (args.session) { - const result = await args.session.sendTransaction({ - chainId: 1, - expectedAccount: freshSetup.ownerAddress, - to: encoded.to, - data: encoded.data, - ...gasFee, - purpose, - ...(tokenChainName ? { tokenChainName } : {}), - ...(args.flowId ? { flowId: args.flowId } : {}), - ...(typeof args.flowStep === 'number' ? { flowStep: args.flowStep } : {}), +} + +// Removes an agent subdomain: first clears the agent's text records on it, so nothing +// keeps pointing at the token if the parent later recreates the name, then removes the +// subdomain from its parent. Each step is preflighted and confirmed before the next. +export async function runDeleteEnsSubdomain(args: { + plan: EnsSubdomainDeletePlan + recordKeys: readonly string[] + callbacks: EffectCallbacks + publicClient?: PublicClient + signer?: EnsSigner + ownerAddress: Address +}): Promise<{ clearTxHash?: string; deleteTxHash: string }> { + const publicClient = args.publicClient ?? createMainnetEnsPublicClient() + const signer = signerFor({ signer: args.signer, account: args.ownerAddress, callbacks: args.callbacks }) + let clearTxHash: string | undefined + const current = args.recordKeys.length > 0 + ? await readEthagentTextRecords(args.plan.fullName, args.recordKeys, { + publicClient, + ...(args.callbacks.signal ? { signal: args.callbacks.signal } : {}), + }) + : {} + if (Object.keys(current).length > 0) { + const cleared = await runUpdateEnsRecords({ + fullName: args.plan.fullName, + ownerAddress: signer.account, + records: {}, + currentRecords: current, + clearRecords: true, + callbacks: args.callbacks, + purpose: 'clear-ens-records', + publicClient, + signer, }) - return { txHash: result.txHash } + if ('txHash' in cleared) clearTxHash = cleared.txHash } - const result = await sendBrowserWalletTransaction({ - chainId: 1, - expectedAccount: freshSetup.ownerAddress, - to: encoded.to, - data: encoded.data, - ...gasFee, - purpose, - onReady: args.callbacks.onWalletReady, - ...(args.callbacks.signal ? { signal: args.callbacks.signal } : {}), - ...(tokenChainName ? { tokenChainName } : {}), + const deleted = await sendEnsTransaction({ + signer, + fullName: args.plan.fullName, + to: args.plan.transaction.to, + data: args.plan.transaction.data, + purpose: 'delete-ens-subdomain', + publicClient, + callbacks: args.callbacks, + action: 'ENS subdomain deletion', }) - args.callbacks.onWalletReady(null) - return { txHash: result.txHash } + return { ...(clearTxHash ? { clearTxHash } : {}), deleteTxHash: deleted.txHash } } -async function refreshEnsSetupAgainstChain(setup: EnsSetupPlan, publicClient: PublicClient): Promise { +async function refreshEnsSetupAgainstChain(setup: EnsSetupPlan, publicClient: PublicClient, signal?: AbortSignal): Promise { if (setup.registryAction !== 'none' && !setup.recordDiffs.length && !setup.addressRecord.changed) { return setup } const node = namehash(setup.fullName) const keys = Array.from(new Set(setup.recordDiffs.map(diff => diff.key))) - let freshCurrent: AgentEnsRecordState = setup.currentRecords - if (keys.length > 0) { - try { - freshCurrent = await readEthagentTextRecords(setup.fullName, keys, { publicClient }) - } catch { - freshCurrent = setup.currentRecords - } - } - let freshAddress = setup.addressRecord.current - try { - freshAddress = await readAddressRecord(publicClient, setup.resolverAddress, node) - } catch { - freshAddress = setup.addressRecord.current - } + const freshCurrent: AgentEnsRecordState = keys.length > 0 + ? await readEthagentTextRecords(setup.fullName, keys, { publicClient, ...(signal ? { signal } : {}) }) + : setup.currentRecords + const freshAddress = await readAddressRecord(publicClient, setup.resolverAddress, node) const addressChanged = !freshAddress || getAddress(freshAddress).toLowerCase() !== getAddress(setup.addressRecord.next).toLowerCase() const recordDiffs: AgentRecordDiff[] = diffRecords(freshCurrent, setup.nextRecords) return { diff --git a/src/identity/manager/profile/effects.ts b/src/identity/manager/profile/effects.ts index 34b3ec4..f04271e 100644 --- a/src/identity/manager/profile/effects.ts +++ b/src/identity/manager/profile/effects.ts @@ -36,9 +36,9 @@ import { readEnsOkFromState, } from '../shared/effects/profilePrep.js' import { - appendResolverSyncWarning, + appendOperatorSyncWarning, markCurrentContinuityFilesPublished, - resolverSyncWarningMessage, + operatorSyncWarningMessage, syncVaultOperatorsAfterOwnerSave, } from '../shared/effects/sync.js' import { @@ -230,19 +230,19 @@ async function runPublicProfileSigningInner( } await writeAgentCardFile(nextIdentity, result.prepared.agentCardJson) await markCurrentContinuityFilesPublished(nextIdentity) - const resolverSyncWarning = await syncVaultOperatorsAfterOwnerSave({ + const operatorSyncWarning = await syncVaultOperatorsAfterOwnerSave({ beforeIdentity: step.identity, afterIdentity: nextIdentity, registry: step.registry, callbacks, - }).then(() => null).catch(err => resolverSyncWarningMessage(err)) - const completionMessage = appendResolverSyncWarning( + }).then(() => null).catch(err => operatorSyncWarningMessage(err)) + const completionMessage = appendOperatorSyncWarning( rebackupCompletionMessage( step.profileUpdates, step.identity, readEnsOkFromState(nextIdentity.state), ), - resolverSyncWarning, + operatorSyncWarning, ) await callbacks.onIdentityComplete(nextIdentity, completionMessage, 'update') } diff --git a/src/identity/manager/profile/state.ts b/src/identity/manager/profile/state.ts index 409a24e..8c8ac13 100644 --- a/src/identity/manager/profile/state.ts +++ b/src/identity/manager/profile/state.ts @@ -110,6 +110,7 @@ export function applyOperatorProfileState( clearOwnerAddressField(state) delete state.approvedOperatorWallets delete state.activeOperatorAddress + clearVaultAddressField(state) if (profile.restoreAccessEpoch !== undefined) { state.restoreAccessEpoch = profile.restoreAccessEpoch } diff --git a/src/identity/manager/reducer.ts b/src/identity/manager/reducer.ts index 4dc210a..a094263 100644 --- a/src/identity/manager/reducer.ts +++ b/src/identity/manager/reducer.ts @@ -88,7 +88,6 @@ export type Step = | { kind: 'custody-vault-deposit-tx'; identity: EthagentIdentity; registry: Erc8004RegistryConfig; vaultAddress: `0x${string}`; profileUpdates: ProfileUpdates; returnTo?: Step } | { kind: 'custody-vault-unwrap-tx'; identity: EthagentIdentity; registry: Erc8004RegistryConfig; vaultAddress: `0x${string}`; profileUpdates: ProfileUpdates; returnTo?: Step; agentIds?: string[] } | { kind: 'custody-vault-withdraw-discovering'; identity: EthagentIdentity; registry: Erc8004RegistryConfig; vaultAddress: `0x${string}`; returnTo?: Step; returnContext?: 'ens' | 'simple-exit' } - | { kind: 'custody-vault-withdraw-pick-token'; identity: EthagentIdentity; registry: Erc8004RegistryConfig; vaultAddress: `0x${string}`; tokens: ReadonlyArray<{ agentId: string }>; returnTo?: Step; returnContext?: 'ens' | 'simple-exit' } | { kind: 'custody-vault-withdraw-tx'; identity: EthagentIdentity; registry: Erc8004RegistryConfig; vaultAddress: `0x${string}`; agentId?: string; returnTo?: Step; returnContext?: 'ens' | 'simple-exit' } | { kind: 'custody-vault-withdraw-done'; identity: EthagentIdentity; registry: Erc8004RegistryConfig; vaultAddress: `0x${string}`; recipient: `0x${string}`; returnTo?: Step; returnContext?: 'ens' | 'simple-exit' } | { kind: 'custody-advanced-done'; identity: EthagentIdentity; registry: Erc8004RegistryConfig; vaultAddress?: `0x${string}`; returnTo?: Step } @@ -223,7 +222,6 @@ function backStep(from: Step): Step { case 'custody-vault-deposit-tx': case 'custody-vault-unwrap-tx': case 'custody-vault-withdraw-discovering': - case 'custody-vault-withdraw-pick-token': case 'custody-vault-withdraw-tx': case 'custody-vault-withdraw-done': return { kind: 'custody-model', identity: from.identity, registry: from.registry, returnTo: from.returnTo } diff --git a/src/identity/manager/shared/components/MenuScreen.tsx b/src/identity/manager/shared/components/MenuScreen.tsx index 1f42391..0c5ef04 100644 --- a/src/identity/manager/shared/components/MenuScreen.tsx +++ b/src/identity/manager/shared/components/MenuScreen.tsx @@ -197,6 +197,7 @@ function renderReconciliationBanner(r: AgentReconciliation, identity: EthagentId if (r.custody !== 'mid-flow-uri-pending' && r.agentUri === 'local-newer') lines.push('Your newest snapshot is not onchain yet.') if (r.custody !== 'mid-flow-uri-pending' && r.agentUri === 'chain-newer') lines.push('Newer snapshot onchain. Use Refetch Latest.') if (r.vault === 'missing') lines.push('The Vault contract was not found.') + if (r.vault === 'unrecognized') lines.push('The Vault address holds code that is not a known Vault build.') if (lines.length === 0) return null return ( <> diff --git a/src/identity/manager/shared/components/menuFlagsFromReconciliation.ts b/src/identity/manager/shared/components/menuFlagsFromReconciliation.ts index ca53ead..768e1ae 100644 --- a/src/identity/manager/shared/components/menuFlagsFromReconciliation.ts +++ b/src/identity/manager/shared/components/menuFlagsFromReconciliation.ts @@ -43,7 +43,7 @@ export function menuFlagsFromReconciliation(r: AgentReconciliation, perspective: ensNameHint = 'Token unlinked' } - const custodyAsterisk = r.custody === 'mid-flow-uri-pending' || r.vault === 'missing' + const custodyAsterisk = r.custody === 'mid-flow-uri-pending' || r.vault === 'missing' || r.vault === 'unrecognized' let custodyHint: string | undefined if (isOperator) { custodyHint = undefined @@ -51,6 +51,8 @@ export function menuFlagsFromReconciliation(r: AgentReconciliation, perspective: custodyHint = 'Setup pending, open to finish' } else if (r.vault === 'missing') { custodyHint = 'Vault missing, open to redeploy' + } else if (r.vault === 'unrecognized') { + custodyHint = 'Vault code not recognized' } const agentUriLocalNewer = r.agentUri === 'local-newer' && r.custody !== 'mid-flow-uri-pending' diff --git a/src/identity/manager/shared/effects/receipts.ts b/src/identity/manager/shared/effects/receipts.ts index df3e437..595cbd8 100644 --- a/src/identity/manager/shared/effects/receipts.ts +++ b/src/identity/manager/shared/effects/receipts.ts @@ -1,13 +1,15 @@ -import type { Hex, PublicClient } from 'viem' +import type { Hex, PublicClient, TransactionReceipt } from 'viem' import type { PendingTxKind } from '../../../../storage/config.js' import { clearPendingTx, recordPendingTx } from '../../../../storage/config.js' +import { cancellable } from '../../../ens/ensLookup/client.js' export async function awaitConfirmedReceipt( client: Pick, hash: Hex, action: string, pending?: { kind: PendingTxKind; chainId: number }, -): ReturnType { + signal?: AbortSignal, +): Promise { if (pending) { await recordPendingTx({ hash, @@ -17,7 +19,7 @@ export async function awaitConfirmedReceipt( }).catch(() => null) } try { - const receipt = await client.waitForTransactionReceipt({ hash, timeout: 0 }) + const receipt = await cancellable(client.waitForTransactionReceipt({ hash, timeout: 0 }), signal) if (receipt.status !== 'success') { throw new Error(`${action} reverted onchain (tx ${hash}). Check the transaction on a block explorer for the revert reason.`) } diff --git a/src/identity/manager/shared/effects/sync.ts b/src/identity/manager/shared/effects/sync.ts index 1deb3cb..4a1a752 100644 --- a/src/identity/manager/shared/effects/sync.ts +++ b/src/identity/manager/shared/effects/sync.ts @@ -1,9 +1,11 @@ import { getAddress, type Address } from 'viem' import type { EthagentIdentity } from '../../../../storage/config.js' import { + blockTimeMsForChain, createErc8004PublicClient, type Erc8004RegistryConfig, } from '../../../registry/erc8004.js' +import { pacedConfirm, PacedTimeoutError } from '../../../../net/paced.js' import { VAULT_ABI, encodeSetMetadataOperator, @@ -26,11 +28,11 @@ import { captureSnapshot } from '../../../continuity/snapshotCapture.js' import type { EffectCallbacks } from './types.js' import { awaitConfirmedReceipt } from './receipts.js' -export function resolverSyncWarningMessage(err: unknown): string { +export function operatorSyncWarningMessage(err: unknown): string { return err instanceof Error ? err.message : String(err) } -export function appendResolverSyncWarning(message: string, warning: string | null): string { +export function appendOperatorSyncWarning(message: string, warning: string | null): string { if (!warning) return message return `${message}\n\nWarning: ${warning}` } @@ -41,12 +43,30 @@ export async function syncVaultOperatorsAfterOwnerSave(args: { registry: Erc8004RegistryConfig vaultAddress?: Address callbacks: EffectCallbacks + // The save follows a deposit. Each deposit starts a new operator epoch on the Vault, + // which wipes every approval, so operators that local state still lists are approved + // again even though the local diff is empty. + afterDeposit?: boolean }): Promise { const beforeState = (args.beforeIdentity.state ?? {}) as Record const afterState = (args.afterIdentity.state ?? {}) as Record const before = normalizeApprovedOperatorWallets(beforeState.approvedOperatorWallets) const after = normalizeApprovedOperatorWallets(afterState.approvedOperatorWallets) - const diff = computeApprovalDiff(before, after) + let diff = computeApprovalDiff(before, after) + if (args.afterDeposit && args.vaultAddress && args.afterIdentity.agentId && after.length > 0) { + const onchain = await readMetadataOperators({ + client: createErc8004PublicClient(args.registry), + vaultAddress: getAddress(args.vaultAddress), + registry: getAddress(args.registry.identityRegistryAddress), + agentId: BigInt(args.afterIdentity.agentId), + candidates: after.map(record => getAddress(record.address)), + }) + const added = new Map(diff.added.map(address => [address.toLowerCase(), address])) + for (const [address, approved] of Object.entries(onchain)) { + if (!approved) added.set(address.toLowerCase(), getAddress(address)) + } + diff = { added: [...added.values()], removed: diff.removed } + } if (diff.added.length === 0 && diff.removed.length === 0) return const ownerAddressRaw = readOwnerAddressField(afterState) ?? args.afterIdentity.ownerAddress ?? args.afterIdentity.address @@ -77,17 +97,14 @@ export async function syncVaultMetadataOperatorsAfterOwnerSave(args: { const registryAddress = getAddress(args.registry.identityRegistryAddress) const vaultAddress = getAddress(args.vaultAddress) const probeClient = createErc8004PublicClient(args.registry) - let depositor: Address | undefined - try { - depositor = await probeClient.readContract({ - address: vaultAddress, - abi: VAULT_ABI, - functionName: 'agentOwner', - args: [registryAddress, agentId], - }) as Address - } catch { - depositor = undefined - } + // An unanswered read surfaces: skipping the sync on it would leave operators that + // local state lists as approved without an onchain approval. + const depositor = await probeClient.readContract({ + address: vaultAddress, + abi: VAULT_ABI, + functionName: 'agentOwner', + args: [registryAddress, agentId], + }) as Address if (!depositor || depositor.toLowerCase() !== args.ownerAddress.toLowerCase()) return const operations: Array<{ operator: Address; approved: boolean }> = [] @@ -123,33 +140,40 @@ export async function syncVaultMetadataOperatorsAfterOwnerSave(args: { await awaitConfirmedReceipt(probeClient, tx.txHash, 'Vault operator sync') } - const VERIFY_MAX_ATTEMPTS = 5 - const VERIFY_DELAY_MS = 1500 let lastMismatch: { op: typeof operations[number]; observed: boolean } | undefined - for (let attempt = 0; attempt < VERIFY_MAX_ATTEMPTS; attempt++) { - if (attempt > 0) await new Promise(resolve => setTimeout(resolve, VERIFY_DELAY_MS)) - const final = await readMetadataOperators({ - client: probeClient, - vaultAddress, - registry: registryAddress, - agentId, - candidates: operations.map(o => o.operator), - }) - lastMismatch = undefined - for (const op of operations) { - const observed = Boolean(final[op.operator]) - if (observed !== op.approved) { - lastMismatch = { op, observed } - break - } - } - if (!lastMismatch) return - } - if (lastMismatch) { + try { + await pacedConfirm( + 'The operator change', + async () => { + const final = await readMetadataOperators({ + client: probeClient, + vaultAddress, + registry: registryAddress, + agentId, + candidates: operations.map(o => o.operator), + }) + lastMismatch = undefined + for (const op of operations) { + const observed = Boolean(final[op.operator]) + if (observed !== op.approved) { + lastMismatch = { op, observed } + return { done: false, observed: `${op.operator} ${observed ? 'approved' : 'not approved'}` } + } + } + return { done: true, value: undefined } + }, + { + blockTimeMs: blockTimeMsForChain(args.registry.chainId), + ...(args.callbacks.signal ? { signal: args.callbacks.signal } : {}), + }, + ) + } catch (err: unknown) { + if (!(err instanceof PacedTimeoutError) || !lastMismatch) throw err + const mismatch: { op: typeof operations[number]; observed: boolean } = lastMismatch throw new Error( - lastMismatch.op.approved - ? `Vault operator authorization didn't land for ${lastMismatch.op.operator}. Your wallet may have rejected the inner transaction; retry the save to apply it.` - : `Vault operator revocation didn't land for ${lastMismatch.op.operator}. Your wallet may have rejected the inner transaction; retry the save to apply it.`, + mismatch.op.approved + ? `Vault operator authorization didn't land for ${mismatch.op.operator}. Your wallet may have rejected the inner transaction; retry the save to apply it.` + : `Vault operator revocation didn't land for ${mismatch.op.operator}. Your wallet may have rejected the inner transaction; retry the save to apply it.`, ) } } diff --git a/src/identity/manager/shared/reconciliation/agentReconciliation/ownership.ts b/src/identity/manager/shared/reconciliation/agentReconciliation/ownership.ts index b8153ba..6afab08 100644 --- a/src/identity/manager/shared/reconciliation/agentReconciliation/ownership.ts +++ b/src/identity/manager/shared/reconciliation/agentReconciliation/ownership.ts @@ -12,13 +12,10 @@ export type OwnershipGuardResult = | { ok: true; effectiveOwner: Address; heldByVault: boolean } | { ok: false; reason: 'not-owned' | 'lookup-failed'; detail: string; onChainOwner?: Address } -type OwnershipCacheEntry = { - expiresAt: number - result: OwnershipGuardResult -} - -const OWNERSHIP_CACHE_TTL_MS = 30_000 -const ownershipCache = new Map() +// Ownership changes only through a custody transaction or a transfer, and each of those +// clears this cache when it ends, so an entry stays good until then. A failed lookup is +// never kept. +const ownershipCache = new Map() function ownershipCacheKey(args: { registry: Erc8004RegistryConfig @@ -53,9 +50,9 @@ export async function preflightTokenOwnership(args: { const agentId = BigInt(args.identity.agentId) const key = ownershipCacheKey({ registry: args.registry, agentId, expectedSigner, requiredRole: args.requiredRole }) const cached = ownershipCache.get(key) - if (cached && cached.expiresAt > Date.now()) return cached.result + if (cached) return cached const result = await runOwnershipPreflight({ ...args, agentId, expectedSigner }) - ownershipCache.set(key, { result, expiresAt: Date.now() + OWNERSHIP_CACHE_TTL_MS }) + if (result.ok || result.reason !== 'lookup-failed') ownershipCache.set(key, result) return result } diff --git a/src/identity/manager/shared/reconciliation/agentReconciliation/run.ts b/src/identity/manager/shared/reconciliation/agentReconciliation/run.ts index 48aacfa..534ff12 100644 --- a/src/identity/manager/shared/reconciliation/agentReconciliation/run.ts +++ b/src/identity/manager/shared/reconciliation/agentReconciliation/run.ts @@ -5,7 +5,7 @@ import { validateErc8004TokenOwner, type Erc8004RegistryConfig, } from '../../../../registry/erc8004.js' -import { isAgentInVault, resolveConfiguredVaultAddress } from '../../../../registry/vault.js' +import { isAgentInVault, resolveConfiguredVaultAddress, vaultBuildForCode, type VaultBuild } from '../../../../registry/vault.js' import type { EthagentConfig, EthagentIdentity } from '../../../../../storage/config.js' import { readVaultAddressField } from '../../../../identityCompat.js' import { readCustodyMode } from '../../../custody/state.js' @@ -95,6 +95,7 @@ export async function runReconciliation( custody: custody.kind, agentUri: agentUri.kind, vault: vault.kind, + ...(vault.kind === 'confirmed' ? { vaultBuild: { id: vault.build.id, label: vault.build.label, hasHeldAgent: vault.build.hasHeldAgent } } : {}), workingTree: workingTree.kind, rpc, driftCount: 0, @@ -225,8 +226,12 @@ async function probeAgentUri(args: { } } -type VaultProbe = { kind: 'confirmed' | 'missing' | 'unset' | 'unknown' } +type VaultProbe = + | { kind: 'confirmed'; build: VaultBuild } + | { kind: 'missing' | 'unrecognized' | 'unset' | 'unknown' } +// Code at the address is not enough: the probe names the build, so an unknown contract +// reads as unrecognized instead of confirmed. async function probeVault(args: { client: PublicClient vaultAddress?: Address @@ -235,7 +240,8 @@ async function probeVault(args: { try { const code = await args.client.getBytecode({ address: args.vaultAddress }) if (!code || code === '0x') return { kind: 'missing' } - return { kind: 'confirmed' } + const build = vaultBuildForCode(code) + return build ? { kind: 'confirmed', build } : { kind: 'unrecognized' } } catch { return { kind: 'unknown' } } @@ -264,7 +270,7 @@ function computeDriftCount(r: AgentReconciliation): number { if (r.token === 'unlinked') n++ if (r.custody === 'mid-flow-uri-pending') n++ if ((r.agentUri === 'local-newer' || r.agentUri === 'chain-newer') && r.custody !== 'mid-flow-uri-pending') n++ - if (r.vault === 'missing') n++ + if (r.vault === 'missing' || r.vault === 'unrecognized') n++ if (r.workingTree === 'dirty') n++ return n } diff --git a/src/identity/manager/shared/reconciliation/agentReconciliation/types.ts b/src/identity/manager/shared/reconciliation/agentReconciliation/types.ts index 0351af5..7e7fff3 100644 --- a/src/identity/manager/shared/reconciliation/agentReconciliation/types.ts +++ b/src/identity/manager/shared/reconciliation/agentReconciliation/types.ts @@ -5,7 +5,9 @@ export type AgentReconciliation = { onChainOwner?: string custody: 'simple' | 'advanced' | 'withdrawn' | 'mid-flow-uri-pending' | 'unknown' agentUri: 'in-sync' | 'chain-newer' | 'local-newer' | 'unknown' - vault: 'confirmed' | 'missing' | 'unset' | 'unknown' + // 'unrecognized': the address has code, but not any known Vault build. + vault: 'confirmed' | 'missing' | 'unrecognized' | 'unset' | 'unknown' + vaultBuild?: { id: string; label: string; hasHeldAgent: boolean } workingTree: 'clean' | 'dirty' | 'unknown' rpc: 'reachable' | 'failing' driftCount: number diff --git a/src/identity/manager/shared/txGuard.ts b/src/identity/manager/shared/txGuard.ts index 68079a6..870b104 100644 --- a/src/identity/manager/shared/txGuard.ts +++ b/src/identity/manager/shared/txGuard.ts @@ -21,7 +21,10 @@ export function isTxGuardBusy(kind: TxGuardKind): boolean { return inFlight.has(kind) } +// Refuses a second send of the same kind while one is in flight: a second wallet +// request for the same deploy, deposit or save would race the first onchain. export function acquireTxGuard(kind: TxGuardKind): void { + if (inFlight.has(kind)) throw new TxGuardBusyError(kind, txGuardBusyMessage(kind)) inFlight.add(kind) } diff --git a/src/identity/manager/useSideEffects.ts b/src/identity/manager/useSideEffects.ts index c04dbb9..ec0e838 100644 --- a/src/identity/manager/useSideEffects.ts +++ b/src/identity/manager/useSideEffects.ts @@ -343,19 +343,10 @@ export function useIdentityManagerSideEffects({ runEnsSetupRecordsTransaction({ setup: step.setup, callbacks: scope.callbacks, tokenChainId: step.registry.chainId }) .then(() => { if (cancelled) return - const ensUpdates = step.setup.mode === 'advanced' - ? { - ensName: step.setup.fullName, - custodyMode: 'advanced' as const, - ownerAddress: step.setup.ownerAddress, - approvedOperatorWallets: [step.setup.operatorAddress], - activeOperatorAddress: step.setup.operatorAddress, - } - : { - ensName: step.setup.fullName, - custodyMode: 'simple' as const, - } - triggerRebackup(step.returnTo ?? { kind: 'menu' }, ensUpdates) + // Creating a name changes only the name. Custody stays as the identity has it: + // a Simple create must not drop an advanced setup, and an advanced create must + // not list the owner as its own operator. + triggerRebackup(step.returnTo ?? { kind: 'menu' }, { ensName: step.setup.fullName }) }) .catch((err: unknown) => { if (cancelled) return diff --git a/src/identity/registry/erc8004.ts b/src/identity/registry/erc8004.ts index ab12491..d242829 100644 --- a/src/identity/registry/erc8004.ts +++ b/src/identity/registry/erc8004.ts @@ -1,5 +1,6 @@ export { DEFAULT_ERC8004_CHAIN_ID, + blockTimeMsForChain, DEFAULT_ERC8004_IDENTITY_REGISTRY_ADDRESS, DEFAULT_ETHEREUM_RPC_URL, MissingRegistryAddressError, diff --git a/src/identity/registry/erc8004/chains.ts b/src/identity/registry/erc8004/chains.ts index 3eaeeae..de0bba3 100644 --- a/src/identity/registry/erc8004/chains.ts +++ b/src/identity/registry/erc8004/chains.ts @@ -102,12 +102,6 @@ export function logBlockRangeForChain(chainId: number): bigint { return chain.logBlockRange } -export function minLogBlockRangeForChain(chainId: number): bigint { - const chain = supportedErc8004ChainForId(chainId) - if (!chain) return 2_000n - return chain.kind === 'l2' ? chain.logBlockRange : chain.logBlockRange / 2n || 1n -} - export function rpcUrlsForClient(args: Pick): string[] { const chain = supportedErc8004ChainForId(args.chainId) return uniqueStrings([ @@ -125,6 +119,11 @@ export function chainForId(chainId: number): Chain | undefined { } } +// The chain's own block time, the natural pause before looking again for a change. +export function blockTimeMsForChain(chainId: number): number { + return chainForId(chainId)?.blockTime ?? mainnet.blockTime ?? 12_000 +} + function chainEntry( chainId: number, name: string, diff --git a/src/identity/registry/erc8004/discovery.ts b/src/identity/registry/erc8004/discovery.ts index dd66d70..da4fe11 100644 --- a/src/identity/registry/erc8004/discovery.ts +++ b/src/identity/registry/erc8004/discovery.ts @@ -1,4 +1,4 @@ -import { getAddress, isAddress, type Address, type PublicClient } from 'viem' +import { createPublicClient, getAddress, isAddress, type Address, type PublicClient } from 'viem' import { mainnet } from 'viem/chains' import { DEFAULT_IPFS_API_URL } from '../../storage/ipfs.js' import { isAgentInVault } from '../vault.js' @@ -7,10 +7,13 @@ import { SUPPORTED_ERC8004_CHAINS, chainSortIndex, erc8004ConfigForSupportedChain, + chainForId, logBlockRangeForChain, - minLogBlockRangeForChain, + rpcUrlsForClient, supportedErc8004ChainForId, } from './chains.js' +import { adaptiveRpcTransport } from '../../../net/rpc.js' +import { scanLogs } from '../../../net/logs.js' import { createErc8004PublicClient } from './client.js' import { parseEthagentBackupPointer, parseEthagentOperatorsPointer, parseEthagentPublicDiscoveryPointer } from './metadata.js' import type { DiscoverOwnedAgentsAcrossSupportedNetworksArgs, DiscoverOwnedAgentsArgs, Erc8004AgentCandidate, Erc8004RegistryConfig, EthagentOperatorsPointer } from './types.js' @@ -70,6 +73,7 @@ export async function discoverOwnedAgentBackups(args: DiscoverOwnedAgentsArgs): const fromBlock = args.fromBlock ?? supportedErc8004ChainForId(args.chainId)?.fromBlock ?? 0n const tokenIds = await findCandidateTokenIds({ publicClient, + injectedClient: Boolean(args.publicClient), registry: args, ownerAddress, fromBlock, @@ -148,6 +152,7 @@ export async function discoverOwnedAgentBackupsAcrossSupportedNetworks( async function findCandidateTokenIds(args: { publicClient: PublicClient + injectedClient: boolean registry: Erc8004RegistryConfig ownerAddress: Address fromBlock: bigint @@ -183,6 +188,7 @@ async function findCandidateTokenIds(args: { try { for await (const logs of getTransferLogChunksBackwards({ publicClient: args.publicClient, + injectedClient: args.injectedClient, registry: args.registry, ownerAddress: args.ownerAddress, fromBlock: args.fromBlock, @@ -216,36 +222,42 @@ async function findCandidateTokenIds(args: { return [...tokenIds] } +// Transfer logs into the owner, newest first, through scanLogs: each endpoint's +// learned block range instead of fixed windows, handing over when an endpoint cannot +// reach back far enough. An injected client (tests, callers with their own transport) +// answers for every endpoint. async function* getTransferLogChunksBackwards(args: { publicClient: PublicClient + injectedClient: boolean registry: Erc8004RegistryConfig ownerAddress: Address fromBlock: bigint }): AsyncGenerator { const latest = await args.publicClient.getBlockNumber() if (args.fromBlock > latest) return - - const ranges = blockRangesBackwards(args.fromBlock, latest, logBlockRangeForChain(args.registry.chainId)) - const CONCURRENCY = 5 - - for (let i = 0; i < ranges.length; i += CONCURRENCY) { - const batch = ranges.slice(i, i + CONCURRENCY) - const logsArrays = await Promise.all(batch.map(async range => { - try { - return await getTransferLogsAdaptive({ - ...args, - fromBlock: range.fromBlock, - toBlock: range.toBlock, - minBlockRange: minLogBlockRangeForChain(args.registry.chainId), - }) - } catch { - return [] as TransferLog[] - } - })) - for (const logs of logsArrays) { - if (logs.length > 0) yield logs + const clients = new Map() + const clientFor = (url: string): PublicClient => { + if (args.injectedClient) return args.publicClient + let client = clients.get(url) + if (!client) { + client = createPublicClient({ chain: chainForId(args.registry.chainId), transport: adaptiveRpcTransport([url]) }) + clients.set(url, client) } + return client } + yield* scanLogs({ + urls: args.injectedClient ? [args.registry.rpcUrl] : rpcUrlsForClient(args.registry), + fromBlock: args.fromBlock, + toBlock: latest, + initialRange: logBlockRangeForChain(args.registry.chainId), + query: async (url, fromBlock, toBlock) => await clientFor(url).getLogs({ + address: args.registry.identityRegistryAddress, + event: TRANSFER_EVENT, + args: { to: args.ownerAddress }, + fromBlock, + toBlock, + }) as TransferLog[], + }) } async function findEnumerableTokenIds(args: { @@ -271,35 +283,6 @@ async function findEnumerableTokenIds(args: { } } -async function getTransferLogsAdaptive(args: { - publicClient: PublicClient - registry: Erc8004RegistryConfig - ownerAddress: Address - fromBlock: bigint - toBlock: bigint - minBlockRange: bigint -}): Promise { - const size = args.toBlock - args.fromBlock + 1n - try { - const logs = await args.publicClient.getLogs({ - address: args.registry.identityRegistryAddress, - event: TRANSFER_EVENT, - args: { to: args.ownerAddress }, - fromBlock: args.fromBlock, - toBlock: args.toBlock, - }) - return logs as TransferLog[] - } catch (err: unknown) { - if (size <= args.minBlockRange) throw err - const mid = args.fromBlock + size / 2n - 1n - const [newer, older] = await Promise.all([ - getTransferLogsAdaptive({ ...args, fromBlock: mid + 1n, toBlock: args.toBlock }), - getTransferLogsAdaptive({ ...args, fromBlock: args.fromBlock, toBlock: mid }), - ]) - return [...newer, ...older] - } -} - class TokenOwnerMismatchError extends Error { constructor() { super('Wallet is not the token owner or an operator wallet') @@ -462,17 +445,3 @@ function compareCandidatesByNetworkThenNewest(a: Erc8004AgentCandidate, b: Erc80 return Number(b.agentId - a.agentId) } -function blockRangesBackwards( - fromBlock: bigint, - latest: bigint, - blockRange: bigint, -): Array<{ fromBlock: bigint; toBlock: bigint }> { - const ranges: Array<{ fromBlock: bigint; toBlock: bigint }> = [] - for (let end = latest; end >= fromBlock;) { - const start = end - blockRange + 1n > fromBlock ? end - blockRange + 1n : fromBlock - ranges.push({ fromBlock: start, toBlock: end }) - if (start === fromBlock) break - end = start - 1n - } - return ranges -} diff --git a/src/identity/registry/erc8004/ownership.ts b/src/identity/registry/erc8004/ownership.ts index a19969f..95515e5 100644 --- a/src/identity/registry/erc8004/ownership.ts +++ b/src/identity/registry/erc8004/ownership.ts @@ -1,5 +1,7 @@ import { getAddress, type Address, type PublicClient } from 'viem' -import { VAULT_ABI } from '../vault.js' +import { VAULT_ABI } from '../vault/constants.js' +import { NetError } from '../../../net/adaptive.js' +import { RpcUnansweredError } from '../../../net/rpc.js' import { ERC8004_ABI } from './abi.js' import { createErc8004PublicClient } from './client.js' import type { Erc8004RegistryConfig } from './types.js' @@ -106,13 +108,20 @@ async function readVaultLevelOwner(args: Erc8004RegistryConfig & { if (normalizedVaultOwner.toLowerCase() === ZERO_ADDRESS) return { kind: 'empty' } return { kind: 'ok', ownerAddress: normalizedVaultOwner } } catch (err: unknown) { - return looksLikeTransientVaultReadFailure(err) ? { kind: 'error', error: err } : { kind: 'not-vault' } + return isUnansweredRead(err) ? { kind: 'error', error: err } : { kind: 'not-vault' } } } -function looksLikeTransientVaultReadFailure(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err) - return /timeout|network|header not found|rate|server|rpc/i.test(message) +// A read that no endpoint answered (the transport's own error types) is an error, not +// "not a Vault", whatever its text says. Anything the chain answered, such as a revert +// or no data from an address without the function, means the owner is not a Vault. +function isUnansweredRead(err: unknown): boolean { + let current: unknown = err + for (let depth = 0; depth < 6 && current; depth += 1) { + if (current instanceof NetError || current instanceof RpcUnansweredError) return true + current = (current as { cause?: unknown }).cause + } + return false } function shortHex(value: string): string { diff --git a/src/identity/registry/vault.ts b/src/identity/registry/vault.ts index 819d8d8..a6d6918 100644 --- a/src/identity/registry/vault.ts +++ b/src/identity/registry/vault.ts @@ -12,7 +12,17 @@ export { assertVaultBytecode, formatVaultBytecodeMismatchDetail, } from './vault/bytecode.js' -export type { AssertVaultBytecodeClient } from './vault/bytecode.js' +export type { AssertVaultBytecodeClient, VaultCheckPacing } from './vault/bytecode.js' +export { + CURRENT_VAULT_BUILD, + FIRST_COMMITTED_VAULT_BUILD, + PRE_RELEASE_VAULT_BUILD_HASH, + knownVaultBuilds, + vaultBuildForCode, + vaultBuildForHash, +} from './vault/builds.js' +export type { VaultBuild } from './vault/builds.js' +export { describeVaultRevert, vaultRevertName } from './vault/errors.js' export { encodeDepositAgent, encodeRotateAgentURI, @@ -29,15 +39,14 @@ export { confirmAgentWithdrawnFromVault, confirmAgentInVault, discoverPriorVaultFromTokenOwner, - discoverVaultedTokens, isAgentInVault, + isNotAContractAnswer, readMetadataOperators, } from './vault/read.js' export type { ConfirmAgentWithdrawnArgs, DiscoverPriorVaultArgs, DiscoverPriorVaultClient, - DiscoverVaultedTokensArgs, IsAgentInVaultArgs, VaultReadClient, ReadMetadataOperatorsArgs, diff --git a/src/identity/registry/vault/builds.ts b/src/identity/registry/vault/builds.ts new file mode 100644 index 0000000..c954c9d --- /dev/null +++ b/src/identity/registry/vault/builds.ts @@ -0,0 +1,56 @@ +import { keccak256, type Hex } from 'viem' +import { VAULT_RUNTIME_BYTECODE_HASH } from './constants.js' + +// The Vault's constructor arguments live in storage, not in the code, so one runtime +// hash verifies every deployment of a build. Deployed builds are immutable: a build +// stays in this table for as long as any Vault running it may hold a token. +export type VaultBuild = { + id: 'current' | 'first-committed' | 'pre-release' + label: string + runtimeHash: Hex + hasHeldAgent: boolean +} + +export const CURRENT_VAULT_BUILD: VaultBuild = { + id: 'current', + label: 'current build', + runtimeHash: VAULT_RUNTIME_BYTECODE_HASH, + hasHeldAgent: true, +} + +// contracts/src/OperatorVault.sol at c216256, before the rename to Vault. Same +// behavior as the current build; only the contract name in the metadata differs. +export const FIRST_COMMITTED_VAULT_BUILD: VaultBuild = { + id: 'first-committed', + label: 'first committed build', + runtimeHash: '0xfea7e898c15b1e72a5a54ec35bdad917dfed8ea3d4bfe078fafa3cf00784cde4', + hasHeldAgent: true, +} + +// A build deployed before the source was committed (1,955 bytes). It behaves like the +// current build except that it has no heldAgent() view. Its full runtime hash starts +// 0xf8f23197; it is filled in from a live read of a deployed Vault running it +// (`ethagent custody --json` prints `build.observedHash`). +export const PRE_RELEASE_VAULT_BUILD_HASH: Hex | undefined = undefined + +export function knownVaultBuilds(): VaultBuild[] { + const builds: VaultBuild[] = [CURRENT_VAULT_BUILD, FIRST_COMMITTED_VAULT_BUILD] + if (PRE_RELEASE_VAULT_BUILD_HASH) { + builds.push({ + id: 'pre-release', + label: 'pre-release build, no heldAgent()', + runtimeHash: PRE_RELEASE_VAULT_BUILD_HASH, + hasHeldAgent: false, + }) + } + return builds +} + +export function vaultBuildForHash(hash: Hex, builds: readonly VaultBuild[] = knownVaultBuilds()): VaultBuild | undefined { + const wanted = hash.toLowerCase() + return builds.find(build => build.runtimeHash.toLowerCase() === wanted) +} + +export function vaultBuildForCode(code: Hex, builds: readonly VaultBuild[] = knownVaultBuilds()): VaultBuild | undefined { + return vaultBuildForHash(keccak256(code), builds) +} diff --git a/src/identity/registry/vault/bytecode.ts b/src/identity/registry/vault/bytecode.ts index 5ebb66e..8f88736 100644 --- a/src/identity/registry/vault/bytecode.ts +++ b/src/identity/registry/vault/bytecode.ts @@ -1,5 +1,7 @@ import { getAddress, keccak256, type Address, type Hex, type PublicClient } from 'viem' import { VAULT_RUNTIME_BYTECODE, VAULT_RUNTIME_BYTECODE_HASH } from './constants.js' +import { CURRENT_VAULT_BUILD, knownVaultBuilds, vaultBuildForHash, type VaultBuild } from './builds.js' +import { pacedConfirm, PacedTimeoutError, type PacedOptions } from '../../../net/paced.js' export class VaultBytecodeMismatchError extends Error { readonly vaultAddress: Address @@ -15,7 +17,9 @@ export class VaultBytecodeMismatchError extends Error { txHash?: Hex, ) { super( - 'Deployed contract bytecode does not match the expected Vault. The deploy transaction may have been intercepted.', + txHash + ? 'Deployed contract bytecode does not match the expected Vault. The deploy transaction may have been intercepted.' + : 'The contract at this address is not a known Vault build. It may have been replaced or intercepted.', ) this.name = 'VaultBytecodeMismatchError' this.vaultAddress = vaultAddress @@ -29,51 +33,75 @@ export class VaultBytecodeMismatchError extends Error { export type AssertVaultBytecodeClient = Pick -export const VAULT_POLL_MAX_ATTEMPTS = 5 -export const VAULT_POLL_DELAY_MS = 1500 -export function delayMs(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) +export type VaultCheckPacing = { + // The chain's block time; the first pause before looking again. + blockTimeMs?: number + // The block that carried the deploy or deposit; reads wait until the endpoint has it. + receiptBlock?: bigint + getBlockNumber?: () => Promise + signal?: AbortSignal + pause?: (ms: number, signal?: AbortSignal) => Promise } -async function readVaultBytecodeWithPoll( +const DEFAULT_BLOCK_TIME_MS = 2_000 + +export function pacingOptions(pacing: VaultCheckPacing = {}): PacedOptions { + return { + blockTimeMs: pacing.blockTimeMs ?? DEFAULT_BLOCK_TIME_MS, + ...(pacing.receiptBlock !== undefined && pacing.getBlockNumber + ? { minBlock: { block: pacing.receiptBlock, getBlockNumber: pacing.getBlockNumber } } + : {}), + ...(pacing.signal ? { signal: pacing.signal } : {}), + ...(pacing.pause ? { pause: pacing.pause } : {}), + } +} + +// Reads the code at latest (never at a block number: followers that lag answer "header +// not found" for a block they have not seen) until it is visible. Empty code and read +// errors are "not visible yet"; any non-empty code is the answer. +async function readVisibleBytecode( client: AssertVaultBytecodeClient, address: Address, + pacing: VaultCheckPacing, ): Promise { - let lastErr: unknown - let lastCode: Hex | undefined - for (let attempt = 0; attempt < VAULT_POLL_MAX_ATTEMPTS; attempt++) { - if (attempt > 0) await delayMs(VAULT_POLL_DELAY_MS) - try { - const code = await client.getBytecode({ address }) - lastErr = undefined - lastCode = code - const isEmpty = !code || code === '0x' - if (!isEmpty) return code - } catch (err) { - lastErr = err - lastCode = undefined - } + try { + return await pacedConfirm( + `Vault code at ${address}`, + async () => { + const code = await client.getBytecode({ address }) + if (!code || code === '0x') return { done: false, observed: 'no code yet' } + return { done: true, value: code } + }, + pacingOptions(pacing), + ) + } catch (err: unknown) { + if (err instanceof PacedTimeoutError) return undefined + throw err } - if (lastErr) throw lastErr - return lastCode } +// Verifies the Vault's runtime code and returns its build. After a fresh deploy +// (txHash given) only the current build passes. An existing Vault may run any known +// build, since deployed Vaults never change. Unknown code throws. export async function assertVaultBytecode( client: AssertVaultBytecodeClient, vaultAddress: Address, txHash?: Hex, -): Promise { + pacing: VaultCheckPacing & { builds?: readonly VaultBuild[] } = {}, +): Promise { const address = getAddress(vaultAddress) - const code = await readVaultBytecodeWithPoll(client, address) + const code = await readVisibleBytecode(client, address, pacing) if (!code || code === '0x') { throw new VaultBytecodeMismatchError(address, null, 0, txHash) } const observedLength = (code.length - 2) / 2 const observed = keccak256(code).toLowerCase() as Hex - const expected = VAULT_RUNTIME_BYTECODE_HASH.toLowerCase() as Hex - if (observed !== expected) { + const allowed = txHash ? [CURRENT_VAULT_BUILD] : (pacing.builds ?? knownVaultBuilds()) + const build = vaultBuildForHash(observed, allowed) + if (!build) { throw new VaultBytecodeMismatchError(address, observed, observedLength, txHash) } + return build } function shortHash(hash: Hex): string { diff --git a/src/identity/registry/vault/constants.ts b/src/identity/registry/vault/constants.ts index 96926d5..27c9973 100644 --- a/src/identity/registry/vault/constants.ts +++ b/src/identity/registry/vault/constants.ts @@ -8,6 +8,11 @@ export const VAULT_ABI = parseAbi([ 'function setMetadataOperator(address registry, uint256 agentId, address operator, bool approved)', 'function unwrap(address registry, uint256 agentId, address recipient)', 'function rotateAgentURI(address registry, uint256 agentId, string newURI)', + 'function onERC721Received(address operator, address from, uint256 tokenId, bytes data) returns (bytes4)', + 'error AlreadyDeposited()', + 'error UnexpectedToken()', + 'error NotOwner()', + 'error NotAuthorized()', 'event AgentDeposited(address indexed registry, uint256 indexed agentId, address indexed owner)', 'event AgentUnwrapped(address indexed registry, uint256 indexed agentId, address indexed recipient)', 'event MetadataOperatorChanged(address indexed registry, uint256 indexed agentId, address indexed operator, bool approved)', diff --git a/src/identity/registry/vault/errors.ts b/src/identity/registry/vault/errors.ts new file mode 100644 index 0000000..0194375 --- /dev/null +++ b/src/identity/registry/vault/errors.ts @@ -0,0 +1,49 @@ +import { decodeErrorResult, type Hex } from 'viem' +import { VAULT_ABI } from './constants.js' + +export type VaultRevertName = 'AlreadyDeposited' | 'UnexpectedToken' | 'NotOwner' | 'NotAuthorized' + +const VAULT_REVERT_TEXT: Record = { + AlreadyDeposited: 'the Vault already holds a token (AlreadyDeposited)', + UnexpectedToken: 'this Vault was deployed for a different registry or token (UnexpectedToken)', + NotOwner: 'only the wallet that deposited the token may do this (NotOwner)', + NotAuthorized: 'the signer is neither the depositor nor an approved operator (NotAuthorized)', +} + +function revertDataOf(err: unknown): Hex | undefined { + let current: unknown = err + for (let depth = 0; depth < 8 && current; depth += 1) { + const item = current as { data?: unknown; raw?: unknown; cause?: unknown } + for (const candidate of [item.data, item.raw, (item.data as { data?: unknown } | undefined)?.data]) { + if (typeof candidate === 'string' && /^0x[0-9a-fA-F]{8}/.test(candidate)) return candidate as Hex + } + const named = (item.data as { errorName?: unknown } | undefined)?.errorName + if (typeof named === 'string' && named in VAULT_REVERT_TEXT) return undefined + current = item.cause + } + return undefined +} + +// The Vault's custom error behind a refusal, from a revert's data or viem's decoded +// error, whichever the error chain carries. +export function vaultRevertName(err: unknown): VaultRevertName | undefined { + let current: unknown = err + for (let depth = 0; depth < 8 && current; depth += 1) { + const named = ((current as { data?: unknown }).data as { errorName?: unknown } | undefined)?.errorName + if (typeof named === 'string' && named in VAULT_REVERT_TEXT) return named as VaultRevertName + current = (current as { cause?: unknown }).cause + } + const data = revertDataOf(err) + if (!data) return undefined + try { + const decoded = decodeErrorResult({ abi: VAULT_ABI, data }) + return decoded.errorName in VAULT_REVERT_TEXT ? decoded.errorName as VaultRevertName : undefined + } catch { + return undefined + } +} + +export function describeVaultRevert(err: unknown): string | undefined { + const name = vaultRevertName(err) + return name ? VAULT_REVERT_TEXT[name] : undefined +} diff --git a/src/identity/registry/vault/read.ts b/src/identity/registry/vault/read.ts index c78c31d..f34bfe2 100644 --- a/src/identity/registry/vault/read.ts +++ b/src/identity/registry/vault/read.ts @@ -1,9 +1,7 @@ -import { getAddress, parseAbi, parseAbiItem, type Address, type PublicClient } from 'viem' +import { getAddress, parseAbi, type Address, type PublicClient } from 'viem' import { VAULT_ABI } from './constants.js' -import { delayMs, VAULT_POLL_DELAY_MS, VAULT_POLL_MAX_ATTEMPTS } from './bytecode.js' - -const DISCOVER_LOG_WINDOW_MAX_ATTEMPTS = 3 -const DISCOVER_LOG_WINDOW_DELAY_MS = 2000 +import { pacingOptions, type VaultCheckPacing } from './bytecode.js' +import { isChainAnswer, pacedConfirm, PacedTimeoutError } from '../../../net/paced.js' export type VaultReadClient = Pick @@ -48,8 +46,11 @@ export async function discoverPriorVaultFromTokenOwner( functionName: 'agentOwner', args: [registryAddr, args.agentId], }) as Address - } catch { - return { found: false } + } catch (err: unknown) { + // A contract that answers agentOwner with a revert or no data is not a Vault. Any + // other failure is an unanswered read, which must not read as "no Vault here". + if (isNotAContractAnswer(err)) return { found: false } + throw err } if (!vaultLevelOwner || vaultLevelOwner.toLowerCase() !== expected) { return { found: false } @@ -80,25 +81,26 @@ export async function isAgentInVault( } export async function confirmAgentInVault( - args: IsAgentInVaultArgs, + args: IsAgentInVaultArgs & { pacing?: VaultCheckPacing }, ): Promise<{ inVault: true; ownerAddress: Address }> { - let lastErr: unknown - for (let attempt = 0; attempt < VAULT_POLL_MAX_ATTEMPTS; attempt++) { - if (attempt > 0) await delayMs(VAULT_POLL_DELAY_MS) - try { - const status = await isAgentInVault(args) - lastErr = undefined - if (status.inVault && status.ownerAddress) { - return { inVault: true, ownerAddress: status.ownerAddress } - } - } catch (err) { - lastErr = err - } + try { + return await pacedConfirm( + 'The deposit', + async () => { + const status = await isAgentInVault(args) + if (status.inVault && status.ownerAddress) { + return { done: true, value: { inVault: true as const, ownerAddress: status.ownerAddress } } + } + return { done: false, observed: 'vault reports no owner yet' } + }, + pacingOptions(args.pacing), + ) + } catch (err: unknown) { + if (!(err instanceof PacedTimeoutError)) throw err + throw new Error( + `Vault ${getAddress(args.vaultAddress)} does not hold agent token #${args.agentId.toString()} for registry ${getAddress(args.registry)} after waiting for new blocks. The deposit transaction may have been re-orged or applied to the wrong vault. Re-run the switch.`, + ) } - if (lastErr) throw lastErr - throw new Error( - `Vault ${getAddress(args.vaultAddress)} does not hold agent token #${args.agentId.toString()} for registry ${getAddress(args.registry)} after the deposit-confirmation budget was exhausted. The deposit transaction may have been re-orged or applied to the wrong vault. Re-run the switch.`, - ) } export type ConfirmAgentWithdrawnArgs = IsAgentInVaultArgs & { @@ -106,115 +108,38 @@ export type ConfirmAgentWithdrawnArgs = IsAgentInVaultArgs & { } export async function confirmAgentWithdrawnFromVault( - args: ConfirmAgentWithdrawnArgs, + args: ConfirmAgentWithdrawnArgs & { pacing?: VaultCheckPacing }, ): Promise<{ inVault: false; ownerAddress: Address }> { const recipient = getAddress(args.recipient) - let lastErr: unknown let lastObserved: string | undefined - for (let attempt = 0; attempt < VAULT_POLL_MAX_ATTEMPTS; attempt++) { - if (attempt > 0) await delayMs(VAULT_POLL_DELAY_MS) - try { - const status = await isAgentInVault(args) - const tokenOwner = await args.client.readContract({ - address: getAddress(args.registry), - abi: ERC721_OWNER_OF_ABI, - functionName: 'ownerOf', - args: [args.agentId], - }) as Address - const ownerAddress = getAddress(tokenOwner) - lastErr = undefined - lastObserved = status.inVault - ? `vault owner ${status.ownerAddress ?? 'unknown'}, token owner ${ownerAddress}` - : `token owner ${ownerAddress}` - if (!status.inVault && ownerAddress.toLowerCase() === recipient.toLowerCase()) { - return { inVault: false, ownerAddress } - } - } catch (err) { - lastErr = err - } - } - if (lastErr) throw lastErr - throw new Error( - `Vault ${getAddress(args.vaultAddress)} did not release agent token #${args.agentId.toString()} to ${recipient} after the withdraw-confirmation budget was exhausted. Last observed: ${lastObserved ?? 'unknown'}.`, - ) -} - -const AGENT_DEPOSITED_EVENT = parseAbiItem( - 'event AgentDeposited(address indexed registry, uint256 indexed agentId, address indexed owner)', -) - -export type DiscoverVaultedTokensArgs = { - client: PublicClient - vaultAddress: Address - registry: Address - depositorAddress: Address - fromBlock?: bigint -} - -const DISCOVER_VAULTED_TOKENS_BLOCK_WINDOW = 9_000n - -export async function discoverVaultedTokens( - args: DiscoverVaultedTokensArgs, -): Promise> { - const vaultAddr = getAddress(args.vaultAddress) - const registryAddr = getAddress(args.registry) - const depositor = getAddress(args.depositorAddress) - const fromBlock = args.fromBlock ?? 0n - const latest = await args.client.getBlockNumber() - const fetchWindow = async (cursorFrom: bigint, cursorTo: bigint) => - args.client.getLogs({ - address: vaultAddr, - event: AGENT_DEPOSITED_EVENT, - args: { registry: registryAddr, owner: depositor }, - fromBlock: cursorFrom, - toBlock: cursorTo, - }) - type DepositLog = Awaited>[number] - const fetchWindowWithRetry = async (cursorFrom: bigint, cursorTo: bigint): Promise => { - let lastErr: unknown - for (let attempt = 0; attempt < DISCOVER_LOG_WINDOW_MAX_ATTEMPTS; attempt++) { - if (attempt > 0) await delayMs(DISCOVER_LOG_WINDOW_DELAY_MS) - try { - return await fetchWindow(cursorFrom, cursorTo) - } catch (err) { - lastErr = err - } - } - throw lastErr ?? new Error(`failed to fetch AgentDeposited logs for window [${cursorFrom},${cursorTo}]`) - } - const logs: DepositLog[] = [] - let cursor = fromBlock - while (cursor <= latest) { - const windowEnd = cursor + DISCOVER_VAULTED_TOKENS_BLOCK_WINDOW - 1n - const toBlock = windowEnd < latest ? windowEnd : latest - const window = await fetchWindowWithRetry(cursor, toBlock) - logs.push(...window) - if (toBlock === latest) break - cursor = toBlock + 1n - } - const seen = new Set() - const candidates: Array<{ registry: Address; agentId: bigint }> = [] - for (const log of logs) { - const agentId = log.args.agentId - if (agentId === undefined) continue - const key = `${registryAddr.toLowerCase()}:${agentId.toString()}` - if (seen.has(key)) continue - seen.add(key) - candidates.push({ registry: registryAddr, agentId }) - } - const out: Array<{ registry: Address; agentId: bigint }> = [] - for (const candidate of candidates) { - const status = await isAgentInVault({ - client: args.client, - vaultAddress: vaultAddr, - registry: candidate.registry, - agentId: candidate.agentId, - }) - if (status.inVault && status.ownerAddress?.toLowerCase() === depositor.toLowerCase()) { - out.push(candidate) - } + try { + return await pacedConfirm( + 'The withdrawal', + async () => { + const status = await isAgentInVault(args) + const tokenOwner = await args.client.readContract({ + address: getAddress(args.registry), + abi: ERC721_OWNER_OF_ABI, + functionName: 'ownerOf', + args: [args.agentId], + }) as Address + const ownerAddress = getAddress(tokenOwner) + lastObserved = status.inVault + ? `vault owner ${status.ownerAddress ?? 'unknown'}, token owner ${ownerAddress}` + : `token owner ${ownerAddress}` + if (!status.inVault && ownerAddress.toLowerCase() === recipient.toLowerCase()) { + return { done: true, value: { inVault: false as const, ownerAddress } } + } + return { done: false, observed: lastObserved } + }, + pacingOptions(args.pacing), + ) + } catch (err: unknown) { + if (!(err instanceof PacedTimeoutError)) throw err + throw new Error( + `Vault ${getAddress(args.vaultAddress)} did not release agent token #${args.agentId.toString()} to ${recipient} after waiting for new blocks. Last observed: ${lastObserved ?? 'unknown'}.`, + ) } - return out } export type ReadMetadataOperatorsArgs = { @@ -230,17 +155,19 @@ export async function readMetadataOperators( ): Promise> { const out: Record = {} for (const candidate of args.candidates) { - try { - const approved = await args.client.readContract({ - address: getAddress(args.vaultAddress), - abi: VAULT_ABI, - functionName: 'metadataOperators', - args: [getAddress(args.registry), args.agentId, getAddress(candidate)], - }) as boolean - out[candidate] = Boolean(approved) - } catch { - out[candidate] = false - } + // A failed read is an error, never "not approved": treating it as a revocation + // would make a verification report one that did not happen. + const approved = await args.client.readContract({ + address: getAddress(args.vaultAddress), + abi: VAULT_ABI, + functionName: 'metadataOperators', + args: [getAddress(args.registry), args.agentId, getAddress(candidate)], + }) as boolean + out[candidate] = Boolean(approved) } return out } + +export function isNotAContractAnswer(err: unknown): boolean { + return isChainAnswer(err) +} diff --git a/src/identity/wallet/browserWallet/gas.ts b/src/identity/wallet/browserWallet/gas.ts index f811eac..0ff2c44 100644 --- a/src/identity/wallet/browserWallet/gas.ts +++ b/src/identity/wallet/browserWallet/gas.ts @@ -1,41 +1,53 @@ import { numberToHex } from 'viem' +import { isDefinitiveChainError, pacedConfirm } from '../../../net/paced.js' +import { describeVaultRevert } from '../../registry/vault/errors.js' import type { PreparedGasFee, PrepareTransactionGasFeeArgs, PrepareTransactionGasFeeClient, } from './types.js' -const GAS_FEE_PREP_MAX_ATTEMPTS = 5 -const GAS_FEE_PREP_DELAY_MS = 1500 - -function gasFeeDelay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) -} +const DEFAULT_BLOCK_TIME_MS = 2_000 +// Estimates gas and fees, looking again once per new block while the estimate fails for +// a reason a later block can fix (a follower behind the state the call depends on). A +// revert is the chain's answer, so it surfaces at once, named when it is a Vault error. export async function prepareTransactionGasFee(args: PrepareTransactionGasFeeArgs): Promise { - let lastErr: unknown - for (let attempt = 0; attempt < GAS_FEE_PREP_MAX_ATTEMPTS; attempt++) { - if (attempt > 0) await gasFeeDelay(GAS_FEE_PREP_DELAY_MS) - try { - const estimateArgs: Parameters[0] = { - account: args.account, - data: args.data, - ...(args.to ? { to: args.to } : {}), - ...(args.value !== undefined ? { value: args.value } : {}), - } - const [gas, fees] = await Promise.all([ - args.client.estimateGas(estimateArgs), - args.client.estimateFeesPerGas(), - ]) - const gasWithBuffer = (gas * 12n) / 10n - return { - gas: numberToHex(gasWithBuffer), - maxFeePerGas: numberToHex(fees.maxFeePerGas), - maxPriorityFeePerGas: numberToHex(fees.maxPriorityFeePerGas), - } - } catch (err) { - lastErr = err + const estimateArgs: Parameters[0] = { + account: args.account, + data: args.data, + ...(args.to ? { to: args.to } : {}), + ...(args.value !== undefined ? { value: args.value } : {}), + } + try { + return await pacedConfirm( + 'The gas estimate', + async () => { + const [gas, fees] = await Promise.all([ + args.client.estimateGas(estimateArgs), + args.client.estimateFeesPerGas(), + ]) + const gasWithBuffer = (gas * 12n) / 10n + return { + done: true, + value: { + gas: numberToHex(gasWithBuffer), + maxFeePerGas: numberToHex(fees.maxFeePerGas), + maxPriorityFeePerGas: numberToHex(fees.maxPriorityFeePerGas), + }, + } + }, + { + blockTimeMs: args.blockTimeMs ?? (args.client as { chain?: { blockTime?: number } }).chain?.blockTime ?? DEFAULT_BLOCK_TIME_MS, + ...(args.signal ? { signal: args.signal } : {}), + ...(args.pause ? { pause: args.pause } : {}), + }, + ) + } catch (err: unknown) { + if (isDefinitiveChainError(err)) { + const vault = describeVaultRevert(err) + if (vault) throw new Error(`The transaction would be refused: ${vault}.`, { cause: err }) } + throw err } - throw lastErr ?? new Error('failed to prepare transaction gas/fee') } diff --git a/src/identity/wallet/browserWallet/types.ts b/src/identity/wallet/browserWallet/types.ts index 25820a2..d472966 100644 --- a/src/identity/wallet/browserWallet/types.ts +++ b/src/identity/wallet/browserWallet/types.ts @@ -145,6 +145,10 @@ export type PrepareTransactionGasFeeArgs = { to?: Address data: Hex value?: bigint + // The chain's block time, the pause before estimating again. + blockTimeMs?: number + signal?: AbortSignal + pause?: (ms: number, signal?: AbortSignal) => Promise } export type PreparedGasFee = { diff --git a/src/identity/wallet/localKeyWallet.ts b/src/identity/wallet/localKeyWallet.ts index c425b19..ff29f22 100644 --- a/src/identity/wallet/localKeyWallet.ts +++ b/src/identity/wallet/localKeyWallet.ts @@ -1,6 +1,7 @@ -import { createWalletClient, getAddress, http, type Hex } from 'viem' +import { createWalletClient, getAddress, hexToBigInt, type Address, type Hex, type Transport } from 'viem' import { privateKeyToAccount } from 'viem/accounts' -import { chainForId } from '../registry/erc8004/chains.js' +import { chainForId, rpcUrlsForClient } from '../registry/erc8004/chains.js' +import { adaptiveRpcTransport } from '../../net/rpc.js' import type { BrowserWalletSignAndTransaction, SignAndTransactionRequest, @@ -10,15 +11,63 @@ export type SignAndTransactionRunner = ( req: SignAndTransactionRequest, ) => Promise> +export type LocalKeyTransaction = { + to: Address + data: Hex + value?: Hex + gas?: Hex + maxFeePerGas?: Hex + maxPriorityFeePerGas?: Hex +} + +export type LocalKeySender = { + account: Address + chainId: number + send: (tx: LocalKeyTransaction) => Promise<{ txHash: Hex }> +} + +// The operator key signs and broadcasts without a browser. The broadcast goes through +// the adaptive transport, which sends a raw transaction to exactly one endpoint and +// never repeats it, and the gas and fees the caller prepared are used as given. +export function createLocalKeySender(args: { + privateKey: Hex + chainId: number + rpcUrl?: string + transport?: Transport +}): LocalKeySender { + const account = privateKeyToAccount(args.privateKey) + const chain = chainForId(args.chainId) + if (!chain) throw new Error(`Unsupported chain id ${args.chainId} for local-key signing`) + const transport = args.transport ?? adaptiveRpcTransport(rpcUrlsForClient({ + chainId: args.chainId, + rpcUrl: args.rpcUrl ?? chain.rpcUrls.default.http[0]!, + })) + const walletClient = createWalletClient({ account, chain, transport }) + return { + account: account.address, + chainId: args.chainId, + async send(tx) { + const txHash = await walletClient.sendTransaction({ + to: tx.to, + data: tx.data, + ...(tx.value ? { value: hexToBigInt(tx.value) } : {}), + ...(tx.gas ? { gas: hexToBigInt(tx.gas) } : {}), + ...(tx.maxFeePerGas ? { maxFeePerGas: hexToBigInt(tx.maxFeePerGas) } : {}), + ...(tx.maxPriorityFeePerGas ? { maxPriorityFeePerGas: hexToBigInt(tx.maxPriorityFeePerGas) } : {}), + }) + return { txHash } + }, + } +} + export function createLocalKeySignAndTransaction(args: { privateKey: Hex rpcUrl: string chainId: number + transport?: Transport }): SignAndTransactionRunner { const account = privateKeyToAccount(args.privateKey) - const chain = chainForId(args.chainId) - if (!chain) throw new Error(`Unsupported chain id ${args.chainId} for local-key signing`) - const walletClient = createWalletClient({ account, chain, transport: http(args.rpcUrl) }) + const sender = createLocalKeySender(args) return async ( req: SignAndTransactionRequest, @@ -33,11 +82,7 @@ export function createLocalKeySignAndTransaction(args: { } const signature = await account.signMessage({ message }) const next = await req.prepareTransaction({ account: account.address, message, signature }) - const txHash = await walletClient.sendTransaction({ - to: next.to, - data: next.data, - ...(next.value ? { value: BigInt(next.value) } : {}), - }) + const { txHash } = await sender.send(next) return { account: account.address, message, signature, txHash, prepared: next.prepared } } } diff --git a/src/net/adaptive.ts b/src/net/adaptive.ts index babbe42..ff82d52 100644 --- a/src/net/adaptive.ts +++ b/src/net/adaptive.ts @@ -23,6 +23,8 @@ type HostStats = { failures: number lastFailureAt?: number lastSuccessAt?: number + // The widest eth_getLogs block range this host has served or named as its limit. + logRange?: number } const hosts = new Map() @@ -54,6 +56,7 @@ function load(): void { ...(Number.isFinite(value.rttvar) ? { rttvar: Number(value.rttvar) } : {}), ...(Number.isFinite(value.lastFailureAt) ? { lastFailureAt: Number(value.lastFailureAt) } : {}), ...(Number.isFinite(value.lastSuccessAt) ? { lastSuccessAt: Number(value.lastSuccessAt) } : {}), + ...(Number.isFinite(value.logRange) && Number(value.logRange) > 0 ? { logRange: Number(value.logRange) } : {}), }) } } catch { @@ -62,7 +65,7 @@ function load(): void { } function statsText(): string { - const measured = [...hosts].filter(([, stats]) => stats.samples > 0 || stats.failures > 0) + const measured = [...hosts].filter(([, stats]) => stats.samples > 0 || stats.failures > 0 || stats.logRange !== undefined) return `${JSON.stringify(Object.fromEntries(measured), null, 2)}\n` } @@ -125,6 +128,8 @@ export function rto(host: string): number { return Math.min(MAX_RTO_MS, Math.max(MIN_RTO_MS, stats.srtt + VARIANCE_WEIGHT * stats.rttvar)) } +export const BACKOFF_CEILING_MS = MAX_RTO_MS + export function withinBackoffCeiling(ms: number): boolean { return ms <= MAX_RTO_MS } @@ -152,6 +157,17 @@ export function noteFailure(host: string): void { persist() } +export function learnedLogRange(host: string): number | undefined { + return statsFor(host).logRange +} + +export function noteLogRange(host: string, blocks: number): void { + const stats = statsFor(host) + if (stats.logRange === blocks) return + stats.logRange = blocks + persist() +} + export function isFailing(host: string): boolean { return statsFor(host).failures > 0 } diff --git a/src/net/logs.ts b/src/net/logs.ts new file mode 100644 index 0000000..9b1b672 --- /dev/null +++ b/src/net/logs.ts @@ -0,0 +1,146 @@ +import { hostOf, isAbortError, learnedLogRange, noteLogRange } from './adaptive.js' + +// eth_getLogs without fixed windows. Each endpoint caps how many blocks one query may +// span (mainnet.base.org: 500) and how far back it keeps logs (base.publicnode.com: +// about a day without a token). A scan starts from the range the endpoint has shown +// before, takes the limit an endpoint names in its refusal, otherwise halves on a range +// refusal, and remembers what worked in hosts.json. An endpoint that refuses the depth +// of history hands the scan to the next one. + +const RANGE_RPC_CODES = new Set([-32614, -32005]) +const RANGE_MESSAGE = /block range|range (?:is )?(?:too (?:large|wide)|limit|exceed)|limited to (?:a )?\d+|exceed(?:s|ed)? (?:the )?max(?:imum)?|query returned more than|too many (?:results|logs|blocks)|response size|HTTP 413|payload too large/i +const NAMED_LIMIT = /limited to (?:a )?(\d[\d,]*)|(\d[\d,]*) blocks? (?:range|max)|max(?:imum)? (?:block )?range(?: of| is|:)? (\d[\d,]*)|range (?:is )?(?:limited|capped) to (\d[\d,]*)/i +const HISTORY_MESSAGE = /histor|pruned|archive|missing trie node|older than|not available|beyond|too old|state.*not found/i + +type ErrorFacts = { code?: number; text: string } + +function facts(err: unknown): ErrorFacts { + const texts: string[] = [] + let code: number | undefined + let current: unknown = err + for (let depth = 0; depth < 8 && current; depth += 1) { + const item = current as { code?: unknown; message?: unknown; details?: unknown; shortMessage?: unknown; cause?: unknown } + if (code === undefined && typeof item.code === 'number') code = item.code + for (const part of [item.details, item.shortMessage, item.message]) { + if (typeof part === 'string') texts.push(part) + } + current = item.cause + } + return { ...(code !== undefined ? { code } : {}), text: texts.join(' | ') } +} + +export type LogQueryRefusal = + | { kind: 'range'; limit?: number } + | { kind: 'history' } + | { kind: 'other' } + +export function classifyLogQueryError(err: unknown): LogQueryRefusal { + const { code, text } = facts(err) + if ((code !== undefined && RANGE_RPC_CODES.has(code)) || RANGE_MESSAGE.test(text)) { + const match = NAMED_LIMIT.exec(text) + const named = match ? Number((match[1] ?? match[2] ?? match[3] ?? match[4] ?? '').replace(/,/g, '')) : NaN + return Number.isFinite(named) && named > 0 ? { kind: 'range', limit: named } : { kind: 'range' } + } + if (code === -32602 || HISTORY_MESSAGE.test(text)) return { kind: 'history' } + return { kind: 'other' } +} + +export class LogScanError extends Error { + readonly notes: string[] + constructor(notes: string[], cause?: unknown) { + super(`${notes.join('; ') || 'no endpoint is configured'}; set an RPC with history (ETHAGENT_RPC_URL) and try again.`, { cause }) + this.name = 'LogScanError' + this.notes = notes + } +} + +export type ScanLogsArgs = { + // Endpoints in the order to ask them; the scan moves on when one cannot serve it. + urls: readonly string[] + fromBlock: bigint + toBlock: bigint + // Where a host with nothing learned starts. + initialRange: bigint + // One eth_getLogs against one endpoint for an inclusive block span. + query: (url: string, fromBlock: bigint, toBlock: bigint) => Promise + // An endpoint that would need more queries than this to cover the span is passed + // over, since a 500-block limit would otherwise mean tens of thousands of requests. + // Defaults to twice what the span takes at the initial range, and at least 400. + maxQueriesPerEndpoint?: number + signal?: AbortSignal +} + +const DEFAULT_MAX_QUERIES = 400 + +function plural(blocks: bigint | number): string { + return `${blocks.toString()} block${blocks.toString() === '1' ? '' : 's'}` +} + +// Scans newest first and yields each span's logs as it arrives, so a caller can stop +// once it has found what it needs. +export async function* scanLogs(args: ScanLogsArgs): AsyncGenerator { + if (args.fromBlock > args.toBlock) return + const span = args.toBlock - args.fromBlock + 1n + const atInitial = (span + args.initialRange - 1n) / args.initialRange + const maxQueries = args.maxQueriesPerEndpoint !== undefined + ? BigInt(args.maxQueriesPerEndpoint) + : (atInitial * 2n > BigInt(DEFAULT_MAX_QUERIES) ? atInitial * 2n : BigInt(DEFAULT_MAX_QUERIES)) + const notes: string[] = [] + let lastError: unknown + let urlIndex = 0 + let end = args.toBlock + + endpoints: while (end >= args.fromBlock) { + const url = args.urls[urlIndex] + if (url === undefined) throw new LogScanError(notes, lastError) + const host = hostOf(url) + let range = BigInt(learnedLogRange(host) ?? Number(args.initialRange)) + let queries = 0n + while (end >= args.fromBlock) { + if (args.signal?.aborted) throw abortError() + const remaining = end - args.fromBlock + 1n + if (queries + (remaining + range - 1n) / range > maxQueries) { + notes.push(`${host} serves ${plural(range)} per query, too few for ${plural(remaining)} of history`) + urlIndex += 1 + continue endpoints + } + const start = end - range + 1n > args.fromBlock ? end - range + 1n : args.fromBlock + let logs: T[] + try { + logs = await args.query(url, start, end) + } catch (err: unknown) { + if (isAbortError(err)) throw err + lastError = err + const refusal = classifyLogQueryError(err) + if (refusal.kind === 'range') { + const span = end - start + 1n + const next = refusal.limit !== undefined && BigInt(refusal.limit) < span ? BigInt(refusal.limit) : span / 2n + if (next < 1n) { + notes.push(`${host} refused even a one-block query`) + urlIndex += 1 + continue endpoints + } + range = next + noteLogRange(host, Number(range)) + continue + } + notes.push(refusal.kind === 'history' + ? `${host} does not keep logs back to block ${start.toString()}` + : `${host} failed: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`) + urlIndex += 1 + continue endpoints + } + queries += 1n + if (end - start + 1n === range && learnedLogRange(host) === undefined) noteLogRange(host, Number(range)) + if (logs.length > 0) yield logs + if (start === args.fromBlock) return + end = start - 1n + } + } +} + +function abortError(): Error { + const err = new Error('The operation was cancelled.') + err.name = 'AbortError' + return err +} diff --git a/src/net/paced.ts b/src/net/paced.ts new file mode 100644 index 0000000..2fa8425 --- /dev/null +++ b/src/net/paced.ts @@ -0,0 +1,121 @@ +import { BACKOFF_CEILING_MS, DefinitiveError, isAbortError, sleep } from './adaptive.js' + +// What one look at the chain found. `pending` means the answer is not visible yet (a +// follower behind the block that carries the change, an empty read of fresh code), so +// the look is repeated on a later block. `done` ends the wait with the value. +export type PacedLook = { done: true; value: T } | { done: false; observed?: string } + +export type PacedOptions = { + // The chain's block time: the first pause, so a retry lands on a new block. + blockTimeMs: number + // Before the first look, wait until the endpoint's head reaches this block, so a + // read at latest can see what the receipt recorded. + minBlock?: { block: bigint; getBlockNumber: () => Promise } + signal?: AbortSignal + // Test seam: how a pause is spent. + pause?: (ms: number, signal?: AbortSignal) => Promise + // The largest single pause. Defaults to the adaptive backoff ceiling. + ceilingMs?: number +} + +export class PacedTimeoutError extends Error { + readonly observed: string | undefined + constructor(label: string, observed: string | undefined, waitedMs: number) { + super(`${label} was still not visible after ${Math.round(waitedMs / 1000)} seconds of new blocks${observed ? ` (last seen: ${observed})` : ''}.`) + this.name = 'PacedTimeoutError' + this.observed = observed + } +} + +// A revert, a rejected signature, or an empty balance reads the same from every block, +// so waiting never changes it. +export function isDefinitiveChainError(err: unknown): boolean { + if (err instanceof DefinitiveError) return true + let current: unknown = err + for (let depth = 0; depth < 6 && current; depth += 1) { + const item = current as { name?: unknown; code?: unknown; message?: unknown; shortMessage?: unknown; cause?: unknown } + const name = typeof item.name === 'string' ? item.name : '' + if (/^(ExecutionRevertedError|ContractFunctionRevertedError|InsufficientFundsError|UserRejectedRequestError)$/.test(name)) return true + if (item.code === 3 || item.code === 4001) return true + const text = `${typeof item.shortMessage === 'string' ? item.shortMessage : ''} ${typeof item.message === 'string' ? item.message : ''}` + if (/execution reverted|insufficient funds|reverted with/i.test(text)) return true + current = item.cause + } + return false +} + +// Looks at the chain once per new block until the answer is visible. Pauses start at +// the block time and double, and the wait gives up once the next pause would pass the +// backoff ceiling. A definitive answer (a thrown revert, or a look that throws +// DefinitiveError) ends it at once; other throws count as "not visible yet", and the +// last one is what surfaces when the wait gives up. +export async function pacedConfirm( + label: string, + look: () => Promise>, + options: PacedOptions, +): Promise { + const pause = options.pause ?? sleep + const ceiling = options.ceilingMs ?? BACKOFF_CEILING_MS + const floor = Math.max(1, options.blockTimeMs) + let delay = 0 + let waited = 0 + let lastError: unknown + let observed: string | undefined + let headReached = !options.minBlock + + for (;;) { + if (options.signal?.aborted) throw abortFrom(options.signal) + if (delay > 0) { + await pause(delay, options.signal) + waited += delay + } + try { + if (!headReached && options.minBlock) { + const head = await options.minBlock.getBlockNumber() + if (head >= options.minBlock.block) { + headReached = true + } else { + observed = `endpoint head ${head.toString()}, waiting for block ${options.minBlock.block.toString()}` + } + } + if (headReached) { + const result = await look() + if (result.done) return result.value + lastError = undefined + if (result.observed !== undefined) observed = result.observed + } + } catch (err: unknown) { + if (isAbortError(err)) throw err + if (err instanceof DefinitiveError) throw err.inner + if (isDefinitiveChainError(err)) throw err + lastError = err + } + const next = delay === 0 ? floor : delay * 2 + if (next > ceiling) { + if (lastError) throw lastError + throw new PacedTimeoutError(label, observed, waited) + } + delay = next + } +} + +function abortFrom(signal: AbortSignal): Error { + const reason = signal.reason + if (reason instanceof Error && reason.name === 'AbortError') return reason + const err = new Error('The operation was cancelled.') + err.name = 'AbortError' + return err +} + +// The chain answered, as opposed to nobody answering: a revert, or no data from an +// address that has no such function. Callers treat these as facts about the contract. +export function isChainAnswer(err: unknown): boolean { + if (isDefinitiveChainError(err)) return true + let current: unknown = err + for (let depth = 0; depth < 6 && current; depth += 1) { + const name = (current as { name?: unknown }).name + if (name === 'ContractFunctionZeroDataError' || name === 'AbiDecodingZeroDataError') return true + current = (current as { cause?: unknown }).cause + } + return false +} diff --git a/src/storage/config.ts b/src/storage/config.ts index 4ac1234..5d23c64 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -64,6 +64,7 @@ const IdentitySchema = z.object({ 'vault-deposit', 'vault-unwrap', 'vault-withdraw', + 'ens', ]), chainId: z.number().int().positive(), submittedAt: z.string(), diff --git a/test/cli/onchainCommands.test.ts b/test/cli/onchainCommands.test.ts new file mode 100644 index 0000000..2e8d4a8 --- /dev/null +++ b/test/cli/onchainCommands.test.ts @@ -0,0 +1,342 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { decodeFunctionData, getAddress, keccak256, namehash, type Address, type Hex } from 'viem' +import { runOnchainCommand } from '../../src/cli/onchain/index.js' +import { runCustodyCommand, type CustodySeams } from '../../src/cli/onchain/custody.js' +import { runEnsCommand, type EnsSeams } from '../../src/cli/onchain/ens.js' +import type { HistoryDeps } from '../../src/cli/history/shared.js' +import type { EthagentConfig, EthagentIdentity } from '../../src/storage/config.js' +import { VAULT_RUNTIME_BYTECODE } from '../../src/identity/registry/vault.js' +import { ENS_AUTOMATION_RESOLVER_ABI } from '../../src/identity/ens/ensAutomation.js' +import { ENS_AUTOMATION_REGISTRY_ABI } from '../../src/identity/ens/ensAutomation/contracts.js' +import { AGENT_TOKEN_RECORD_KEY, buildEnsip25Key } from '../../src/identity/ens/agentRecords.js' +import { captureIo, type CapturedIo } from '../support/home.js' + +const REGISTRY = getAddress('0x8004A169FB4a3325136EB29fA0ceB6D2e539a432') +const OWNER = getAddress('0xA1E90000000000000000000000000000000089CE') +const OPERATOR_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as const +const OPERATOR = getAddress('0x70997970C51812dc3A010C7d01b50e0d17dc79C8') +const VAULT = getAddress('0x6bdC0000000000000000000000000000000051d7') +const RESOLVER = getAddress('0xF29100983E058B709F3D539b0c765937B804AC15') +const ENS_REGISTRY = getAddress('0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e') +const ZERO = '0x0000000000000000000000000000000000000000' as Address + +function identity(state: Record = {}): EthagentIdentity { + return { + source: 'erc8004', + address: OWNER, + ownerAddress: OWNER, + createdAt: '2026-01-01T00:00:00.000Z', + chainId: 8453, + rpcUrl: 'https://mainnet.base.org', + identityRegistryAddress: REGISTRY, + agentId: '45744', + state: { + custodyMode: 'advanced', + ownerAddress: OWNER, + approvedOperatorWallets: [{ address: OPERATOR }], + activeOperatorAddress: OPERATOR, + operatorVaultAddress: VAULT, + ...state, + }, + } as EthagentIdentity +} + +function deps(id: EthagentIdentity, env: NodeJS.ProcessEnv = {}): HistoryDeps & { io: CapturedIo } { + const config = { + version: 2, + firstSeenAt: id.createdAt, + identity: id, + erc8004: { chainId: 8453, rpcUrl: 'https://mainnet.base.org', identityRegistryAddress: REGISTRY }, + } as EthagentConfig + return { + io: captureIo(), + env: { ...env }, + now: () => new Date('2026-10-06T00:00:00.000Z'), + loadConfig: async () => config, + listLedger: async () => [], + } +} + +// --- custody ----------------------------------------------------------------------- + +function custodyClient(opts: { code?: Hex; operatorApproved?: boolean; refuse?: (fn: string, from: string) => string | null } = {}): CustodySeams { + const refuse = opts.refuse ?? ((fn: string, from: string) => { + if (fn === 'unwrap' && from.toLowerCase() !== OWNER.toLowerCase()) return '0x30cd7471' + if (fn === 'setMetadataOperator' && from.toLowerCase() !== OWNER.toLowerCase()) return '0x30cd7471' + if (fn === 'rotateAgentURI' && from.toLowerCase() !== OWNER.toLowerCase() && !(from.toLowerCase() === OPERATOR.toLowerCase() && (opts.operatorApproved ?? true))) return '0xea8e4eb5' + return null + }) + return { + client: () => ({ + getBytecode: async () => opts.code ?? VAULT_RUNTIME_BYTECODE, + readContract: async (call: { functionName: string }) => { + if (call.functionName === 'ownerOf') return VAULT + if (call.functionName === 'agentOwner') return OWNER + if (call.functionName === 'heldAgent') return [REGISTRY, 45744n, OWNER] + if (call.functionName === 'metadataOperators') return opts.operatorApproved ?? true + if (call.functionName === 'tokenURI') return 'ipfs://current' + throw new Error(`unexpected read ${call.functionName}`) + }, + simulateContract: async (call: { functionName: string; account: string }) => { + const data = refuse(call.functionName, call.account) + if (data) throw Object.assign(new Error('execution reverted'), { code: 3, data }) + return { result: undefined, request: {} } + }, + }) as never, + } +} + +test('custody shows the Vault build, the holder, the Vault-level owner, and the operators', async () => { + const d = deps(identity(), { ETHAGENT_OPERATOR_KEY: OPERATOR_KEY }) + const { readOperatorKey } = await import('../../src/cli/operatorKey.js') + d.operatorKey = readOperatorKey(d.env) + const code = await runCustodyCommand(['--json'], d, custodyClient()) + assert.equal(code, 0) + const out = d.io.json() + assert.equal(out.ok, true) + assert.equal(out.tokenHeldBy, 'vault') + const vault = out.vault as Record + assert.equal(vault.build.id, 'current') + assert.equal(vault.holdsToken, true) + assert.equal(vault.vaultOwner, OWNER) + assert.deepEqual(vault.heldAgent, { registry: REGISTRY, agentId: '45744', owner: OWNER }) + assert.equal(vault.operators[0].address, OPERATOR) + assert.equal(vault.operators[0].approved, true) + assert.equal(vault.operators[0].operatorKey, true) +}) + +test('custody --verify reports each simulation and exits 0 when the Vault behaves', async () => { + const d = deps(identity()) + const code = await runCustodyCommand(['--verify', '--json'], d, custodyClient()) + assert.equal(code, 0) + const sims = d.io.json().simulations as Array<{ check: string; outcome: string; reason?: string; matches: boolean }> + const byCheck = Object.fromEntries(sims.map(item => [item.check, item])) + assert.equal(byCheck['owner withdraws the token']!.outcome, 'would succeed') + assert.equal(byCheck['owner changes an operator']!.outcome, 'would succeed') + assert.equal(byCheck['operator rotates the agent URI']!.outcome, 'would succeed') + assert.equal(byCheck['operator withdraws the token']!.reason, 'NotOwner') + assert.equal(byCheck['stranger rotates the agent URI']!.reason, 'NotAuthorized') + assert.ok(sims.every(item => item.matches)) +}) + +test('custody --verify exits 4 when a simulation differs from what the Vault should do', async () => { + const d = deps(identity()) + const code = await runCustodyCommand(['--verify'], d, custodyClient({ refuse: () => null })) + assert.equal(code, 4) + assert.match(d.io.stdout(), /FAIL stranger rotates the agent URI: would succeed/) +}) + +test('custody flags code that is not a known Vault build and --verify exits 4', async () => { + const unknown = ('0x' + '60'.repeat(1955)) as Hex + const plain = deps(identity()) + assert.equal(await runCustodyCommand(['--json'], plain, custodyClient({ code: unknown })), 0) + const vault = plain.io.json().vault as Record + assert.equal(vault.bytecode, 'unknown code') + assert.equal(vault.code.hash, keccak256(unknown)) + assert.equal(vault.code.bytes, 1955) + const verify = deps(identity()) + assert.equal(await runCustodyCommand(['--verify', '--json'], verify, custodyClient({ code: unknown })), 4) + assert.match((verify.io.json().issues as string[])[0]!, /not a known Vault build/) +}) + +test('custody and ens dispatch through the onchain command set', async () => { + const d = deps(identity()) + assert.equal(await runOnchainCommand('custody', ['--help'], d), 0) + assert.match(d.io.stdout(), /usage: ethagent custody/) + const e = deps(identity()) + assert.equal(await runOnchainCommand('ens', ['--help'], e), 0) + assert.match(e.io.stdout(), /--operator/) +}) + +// --- ens --------------------------------------------------------------------------- + +type FakeName = { owner: Address; resolver?: Address; addr?: Address; text?: Record; delegates?: Address[] } + +function ensSeams(names: Record, calls: Array<{ account: string; to: string; data: Hex }> = []): EnsSeams { + const byNode = new Map() + for (const [name, value] of Object.entries(names)) byNode.set(namehash(name), value) + const client = { + readContract: async (call: { address: string; functionName: string; args: readonly unknown[] }) => { + const node = call.args?.[0] as string + if (call.address.toLowerCase() === ENS_REGISTRY.toLowerCase()) { + const entry = byNode.get(node) + if (call.functionName === 'owner') return entry?.owner ?? ZERO + if (call.functionName === 'resolver') return entry?.resolver ?? ZERO + } + if (call.functionName === 'text') return byNode.get(call.args[0] as string)?.text?.[call.args[1] as string] ?? '' + if (call.functionName === 'addr') return byNode.get(node)?.addr ?? ZERO + if (call.functionName === 'isApprovedFor') { + const entry = byNode.get(call.args[1] as string) + return Boolean(entry?.delegates?.some(item => item.toLowerCase() === String(call.args[2]).toLowerCase())) + } + throw new Error(`unexpected read ${call.functionName}`) + }, + getEnsAddress: async ({ name }: { name: string }) => byNode.get(namehash(name))?.addr ?? null, + call: async (request: { account: string; to: string; data: Hex }) => { + calls.push(request) + return { data: '0x' } + }, + } + return { + ensClient: () => { throw new Error('a preview must not build a sending client') }, + readClient: () => client as never, + operatorSender: () => { throw new Error('a preview must not send') }, + openSession: async () => { throw new Error('a preview must not open the wallet') }, + publish: async () => { throw new Error('a preview must not publish') }, + resolveJwt: async () => 'jwt', + vaultStatus: async () => ({ ready: true }) as never, + pullHarness: async () => [], + openExternal: () => {}, + saveConfig: async () => {}, + } +} + +const ENSIP25_BASE = buildEnsip25Key({ chainId: 8453, identityRegistryAddress: REGISTRY, agentId: '45744' }) +const ENSIP25_MAINNET = buildEnsip25Key({ chainId: 1, identityRegistryAddress: REGISTRY, agentId: '45744' }) +const TOKEN_VALUE = `eip155:8453:${REGISTRY.toLowerCase()}:45744` + +function operatorDeps(id: EthagentIdentity): HistoryDeps & { io: CapturedIo } { + const d = deps(id) + d.operatorKey = { ok: true, key: OPERATOR_KEY, address: OPERATOR } + return d +} + +test('ens shows the linked name, its records, the two-way verdict, and whether the operator key can sign', async () => { + const id = identity({ ensName: 'meow.femboi.eth' }) + const d = operatorDeps(id) + const code = await runEnsCommand(['--json'], d, ensSeams({ + 'meow.femboi.eth': { owner: OPERATOR, resolver: RESOLVER, addr: OWNER, text: { [ENSIP25_BASE]: '1', [AGENT_TOKEN_RECORD_KEY]: TOKEN_VALUE } }, + })) + assert.equal(code, 0) + const out = d.io.json() as Record + assert.equal(out.name, 'meow.femboi.eth') + assert.equal(out.records.addr, OWNER) + assert.equal(out.records.text[ENSIP25_BASE], '1') + assert.equal(out.link.ok, true) + assert.equal(out.control.owner, OPERATOR) + assert.deepEqual(out.operator, { address: OPERATOR, canSign: true, via: 'owner' }) +}) + +test('ens --operator previews creating the name, one records multicall, and clearing the old name', async () => { + const id = identity({ ensName: 'meow.femboi.eth' }) + const d = operatorDeps(id) + const simulated: Array<{ account: string; to: string; data: Hex }> = [] + const code = await runEnsCommand(['purr.femboi.eth', '--operator', '--json'], d, ensSeams({ + 'femboi.eth': { owner: OPERATOR, resolver: RESOLVER }, + 'meow.femboi.eth': { owner: OPERATOR, resolver: RESOLVER, addr: OWNER, text: { [ENSIP25_BASE]: '1', [AGENT_TOKEN_RECORD_KEY]: TOKEN_VALUE } }, + }, simulated)) + assert.equal(code, 0) + const out = d.io.json() as Record + assert.equal(out.applied, false) + assert.equal(out.create, true) + assert.deepEqual(out.signer, { kind: 'operator', address: OPERATOR }) + assert.deepEqual(out.transactions.map((tx: any) => tx.step), ['create-subdomain', 'set-records', 'clear-old-records']) + assert.equal(out.transactions[0].simulation, 'would succeed') + assert.equal(out.transactions[1].simulation, 'runs after the step before it') + assert.deepEqual(out.publish, { ensName: 'purr.femboi.eth', signer: 'owner wallet', via: 'vault' }) + // Each independent transaction was simulated from the operator, not the owner. + assert.ok(simulated.length >= 1) + assert.ok(simulated.every(call => call.account.toLowerCase() === OPERATOR.toLowerCase())) + const create = decodeFunctionData({ abi: ENS_AUTOMATION_REGISTRY_ABI, data: simulated[0]!.data }) + assert.equal(create.functionName, 'setSubnodeRecord') + assert.equal((create.args as readonly unknown[])[2], OPERATOR) +}) + +test('ens writes addr only on a name it creates, and the old-name clear covers every agent key present', async () => { + const id = identity({ ensName: 'meow.femboi.eth' }) + const d = deps(id) + const seams = ensSeams({ + 'femboi.eth': { owner: OWNER, resolver: RESOLVER }, + 'meow.femboi.eth': { owner: OWNER, resolver: RESOLVER, addr: OWNER, text: { [ENSIP25_BASE]: '1', [ENSIP25_MAINNET]: '1', [AGENT_TOKEN_RECORD_KEY]: TOKEN_VALUE } }, + }) + const { planEnsSwap } = await import('../../src/identity/manager/ens/headless.js') + const plan = await planEnsSwap({ + client: seams.readClient(), + newName: 'purr.femboi.eth', + oldName: 'meow.femboi.eth', + signer: OWNER, + signerRole: 'owner wallet', + agentOwner: OWNER, + chainId: 8453, + identityRegistryAddress: REGISTRY, + agentId: '45744', + }) + const records = plan.transactions.find(tx => tx.step === 'set-records')! + const multicall = decodeFunctionData({ abi: ENS_AUTOMATION_RESOLVER_ABI, data: records.data }) + assert.equal(multicall.functionName, 'multicall') + const inner = (multicall.args![0] as Hex[]).map(data => decodeFunctionData({ abi: ENS_AUTOMATION_RESOLVER_ABI, data })) + assert.deepEqual(inner.map(call => call.functionName), ['setAddr', 'setText', 'setText']) + assert.equal((inner[0]!.args as readonly unknown[])[1], OWNER) + const clear = plan.transactions.find(tx => tx.step === 'clear-old-records')! + const cleared = decodeFunctionData({ abi: ENS_AUTOMATION_RESOLVER_ABI, data: clear.data }) + const clearedKeys = (cleared.args![0] as Hex[]).map(data => (decodeFunctionData({ abi: ENS_AUTOMATION_RESOLVER_ABI, data }).args as readonly unknown[])) + assert.deepEqual(clearedKeys.map(args => args[1]).sort(), [ENSIP25_BASE, ENSIP25_MAINNET, AGENT_TOKEN_RECORD_KEY].sort()) + assert.ok(clearedKeys.every(args => args[2] === '')) + void d +}) + +test('ens on an existing name never sets addr and refuses when it resolves elsewhere', async () => { + const id = identity({ ensName: '' }) + const d = deps(id) + const code = await runEnsCommand(['other.femboi.eth', '--json'], d, ensSeams({ + 'other.femboi.eth': { owner: OWNER, resolver: RESOLVER, addr: OPERATOR }, + })) + assert.equal(code, 1) + assert.match(String(d.io.json().error), /resolves to .* not the agent owner/) + assert.match(String(d.io.json().hint), /Nothing was sent/) +}) + +test('ens refuses before sending when the signer does not control the name', async () => { + const id = identity({ ensName: 'meow.femboi.eth' }) + const d = deps(id) + const code = await runEnsCommand(['--set', 'url=https://example.com', '--json'], d, ensSeams({ + 'meow.femboi.eth': { owner: OPERATOR, resolver: RESOLVER, addr: OWNER }, + })) + assert.equal(code, 1) + assert.match(String(d.io.json().error), /does not control meow\.femboi\.eth/) +}) + +test('ens --set accepts a resolver delegate and writes every change in one multicall; no change sends nothing', async () => { + const id = identity({ ensName: 'meow.femboi.eth' }) + const names = { 'meow.femboi.eth': { owner: OPERATOR, resolver: RESOLVER, addr: OWNER, delegates: [OWNER], text: { url: 'https://old.example', description: 'agent' } } } + const d = deps(id) + const code = await runEnsCommand(['--set', 'url=https://new.example', '--clear', 'description', '--set', 'avatar=ipfs://x', '--json'], d, ensSeams(names)) + assert.equal(code, 0) + const out = d.io.json() as Record + assert.equal(out.applied, false) + assert.equal(out.transactions.length, 1) + assert.equal(out.publish, null) + assert.deepEqual(out.changes.map((change: any) => change.key).sort(), ['avatar', 'description', 'url']) + const same = deps(id) + assert.equal(await runEnsCommand(['--set', 'url=https://old.example', '--json'], same, ensSeams(names)), 0) + assert.deepEqual(same.io.json().transactions, []) +}) + +test('ens --unlink previews clearing the records and an owner-signed save with no name', async () => { + const id = identity({ ensName: 'meow.femboi.eth' }) + const d = deps(id) + const code = await runEnsCommand(['--unlink', '--json'], d, ensSeams({ + 'meow.femboi.eth': { owner: OWNER, resolver: RESOLVER, addr: OWNER, text: { [ENSIP25_BASE]: '1' } }, + })) + assert.equal(code, 0) + const out = d.io.json() as Record + assert.equal(out.transactions[0].step, 'clear-records') + assert.equal(out.publish.ensName, '') +}) + +test('ens --operator exits 3 without a key and 2 with an invalid one', async () => { + const id = identity({ ensName: 'meow.femboi.eth' }) + const missing = deps(id) + missing.operatorKey = { ok: false, reason: 'missing' } + assert.equal(await runEnsCommand(['--unlink', '--operator', '--json'], missing, ensSeams({})), 3) + assert.match(String(missing.io.json().hint), /keychain exec/) + const invalid = deps(id) + invalid.operatorKey = { ok: false, reason: 'invalid' } + assert.equal(await runEnsCommand(['--unlink', '--operator', '--json'], invalid, ensSeams({})), 2) +}) + +test('ens rejects mixing a name with --unlink or --set', async () => { + const d = deps(identity()) + assert.equal(await runEnsCommand(['a.femboi.eth', '--unlink'], d, ensSeams({})), 2) +}) diff --git a/test/identity/ens/ensLookup.test.ts b/test/identity/ens/ensLookup.test.ts index 2616d80..4df7526 100644 --- a/test/identity/ens/ensLookup.test.ts +++ b/test/identity/ens/ensLookup.test.ts @@ -84,7 +84,6 @@ test('ENS discovery returns the wallet primary name from the reverse resolver', const result = await discoverOwnedEnsNameDetails(owner, { publicClient: fakeClient, - rpcTimeoutMs: 1_000, }) assert.equal(result.status, 'ok') @@ -100,7 +99,6 @@ test('ENS discovery extracts the root parent when the primary name is a subdomai const result = await discoverOwnedEnsNameDetails(owner, { publicClient: fakeClient, - rpcTimeoutMs: 1_000, }) assert.equal(result.status, 'ok') @@ -115,7 +113,6 @@ test('ENS discovery returns an empty list when the wallet has no primary name', const result = await discoverOwnedEnsNameDetails(owner, { publicClient: fakeClient, - rpcTimeoutMs: 1_000, }) assert.equal(result.status, 'ok') @@ -132,7 +129,6 @@ test('ENS discovery reports an error when the reverse-resolver lookup fails', as const result = await discoverOwnedEnsNameDetails(owner, { publicClient: failingClient, - rpcTimeoutMs: 1_000, }) assert.equal(result.status, 'error') diff --git a/test/identity/manager/effects/ensEffects.test.ts b/test/identity/manager/effects/ensEffects.test.ts index 00d0126..21aad66 100644 --- a/test/identity/manager/effects/ensEffects.test.ts +++ b/test/identity/manager/effects/ensEffects.test.ts @@ -165,6 +165,7 @@ test('ENS record update preflights resolver transaction before opening wallet', publicClient: { readContract: async (call: { functionName: string }) => { if (call.functionName === 'resolver') return '0x0000000000000000000000000000000000001234' + if (call.functionName === 'text') return '' throw new Error(`unexpected read: ${call.functionName}`) }, estimateGas: async (call: { account: string }) => { diff --git a/test/identity/manager/txGuard.test.ts b/test/identity/manager/txGuard.test.ts index cb970a6..500b787 100644 --- a/test/identity/manager/txGuard.test.ts +++ b/test/identity/manager/txGuard.test.ts @@ -6,6 +6,7 @@ import { releaseTxGuard, resetTxGuardForTest, txGuardBusyMessage, + TxGuardBusyError, } from '../../../src/identity/manager/shared/txGuard.js' test('txGuard acquire and release toggles busy flag', () => { @@ -17,15 +18,20 @@ test('txGuard acquire and release toggles busy flag', () => { assert.equal(isTxGuardBusy('rebackup'), false) }) -test('txGuard tolerates re-acquire of same kind without throwing', () => { +test('txGuard blocks a second send of the same kind while one is in flight', () => { resetTxGuardForTest() acquireTxGuard('rebackup') try { - assert.doesNotThrow(() => acquireTxGuard('rebackup')) + assert.throws( + () => acquireTxGuard('rebackup'), + (err: unknown) => err instanceof TxGuardBusyError && err.kind === 'rebackup' && /already in flight/.test(err.message), + ) assert.equal(isTxGuardBusy('rebackup'), true) } finally { releaseTxGuard('rebackup') } + assert.doesNotThrow(() => acquireTxGuard('rebackup')) + releaseTxGuard('rebackup') }) test('txGuard tracks each kind independently', () => { diff --git a/test/identity/registry/vault.test.ts b/test/identity/registry/vault.test.ts index 8e82c75..879ffb9 100644 --- a/test/identity/registry/vault.test.ts +++ b/test/identity/registry/vault.test.ts @@ -2,17 +2,24 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { decodeFunctionData, keccak256, parseAbi, type Hex } from 'viem' +import { encodeErrorResult } from 'viem' +import { advance, track } from '../../support/time.js' import { assertVaultBytecode, confirmAgentInVault, confirmAgentWithdrawnFromVault, - discoverVaultedTokens, encodeDepositAgent, encodeRotateAgentURI, encodeSetMetadataOperator, encodeUnwrapAgent, isAgentInVault, readMetadataOperators, + CURRENT_VAULT_BUILD, + FIRST_COMMITTED_VAULT_BUILD, + vaultBuildForCode, + vaultBuildForHash, + describeVaultRevert, + type VaultBuild, resolveConfiguredVaultAddress, VAULT_ABI, VAULT_ADDRESSES, @@ -146,7 +153,7 @@ test('isAgentInVault reports inVault=true and surfaces the vault-level owner', a assert.equal(result.ownerAddress?.toLowerCase(), OWNER.toLowerCase()) }) -test('readMetadataOperators returns per-address authorization map and treats RPC errors as false', async () => { +test('readMetadataOperators surfaces a failed read instead of reporting the operator as not approved', async () => { const candidates = [OPERATOR, RECIPIENT] as const const client = { readContract: async (args: { args?: readonly unknown[] }) => { @@ -155,15 +162,12 @@ test('readMetadataOperators returns per-address authorization map and treats RPC throw new Error('boom') }, } as unknown as Parameters[0]['client'] - const result = await readMetadataOperators({ - client, - vaultAddress: VAULT, - registry: REGISTRY, - agentId: 5n, - candidates, - }) - assert.equal(result[OPERATOR], true) - assert.equal(result[RECIPIENT], false) + await assert.rejects( + () => readMetadataOperators({ client, vaultAddress: VAULT, registry: REGISTRY, agentId: 5n, candidates }), + /boom/, + ) + const approvedOnly = await readMetadataOperators({ client, vaultAddress: VAULT, registry: REGISTRY, agentId: 5n, candidates: [OPERATOR] }) + assert.equal(approvedOnly[OPERATOR], true) }) test('vaultAddressForChain returns undefined when no deployment is recorded', () => { @@ -292,7 +296,15 @@ test('assertVaultBytecode never passes blockNumber to getBytecode (latest only, assert.deepEqual(observedKeys[0], ['address']) }) -test('assertVaultBytecode polls past a transient BlockNotFoundError before succeeding', async () => { +// Records each pause instead of sleeping, so the pacing is checked without waiting. +function recordedPauses(): { pauses: number[]; pause: (ms: number) => Promise } { + const pauses: number[] = [] + return { pauses, pause: async (ms: number) => { pauses.push(ms) } } +} + +const PACING = { blockTimeMs: 2_000 } + +test('assertVaultBytecode looks again on a new block past a transient BlockNotFoundError', async () => { let calls = 0 const client = { getBytecode: async () => { @@ -305,11 +317,14 @@ test('assertVaultBytecode polls past a transient BlockNotFoundError before succe return VAULT_RUNTIME_BYTECODE }, } as unknown as AssertVaultBytecodeClient - await assertVaultBytecode(client, VAULT) + const { pauses, pause } = recordedPauses() + const build = await assertVaultBytecode(client, VAULT, undefined, { ...PACING, pause }) assert.equal(calls, 2) + assert.deepEqual(pauses, [2_000]) + assert.equal(build.id, 'current') }) -test('assertVaultBytecode polls past an InvalidParamsRpcError-shaped throw (publicnode -32602 "header not found")', async () => { +test('assertVaultBytecode looks again past a "header not found" answer', async () => { let calls = 0 const client = { getBytecode: async () => { @@ -322,37 +337,46 @@ test('assertVaultBytecode polls past an InvalidParamsRpcError-shaped throw (publ return VAULT_RUNTIME_BYTECODE }, } as unknown as AssertVaultBytecodeClient - await assertVaultBytecode(client, VAULT) + const { pause } = recordedPauses() + await assertVaultBytecode(client, VAULT, undefined, { ...PACING, pause }) assert.equal(calls, 2) }) -test('assertVaultBytecode polls past arbitrary throws (any provider error during a fresh-deploy read is treated as transient)', async () => { +test('assertVaultBytecode looks again on empty code (follower behind the deploy block)', async () => { let calls = 0 const client = { getBytecode: async () => { calls += 1 - if (calls === 1) throw new Error('completely unrelated provider message') + if (calls === 1) return '0x' as Hex return VAULT_RUNTIME_BYTECODE }, } as unknown as AssertVaultBytecodeClient - await assertVaultBytecode(client, VAULT) + const { pause } = recordedPauses() + await assertVaultBytecode(client, VAULT, undefined, { ...PACING, pause }) assert.equal(calls, 2) }) -test('assertVaultBytecode retries on empty code (follower latest behind newly-deployed contract)', async () => { - let calls = 0 +test('assertVaultBytecode waits for the endpoint head to reach the receipt block before reading', async () => { + const heads = [99n, 99n, 100n] + let reads = 0 const client = { getBytecode: async () => { - calls += 1 - if (calls === 1) return '0x' as Hex + reads += 1 return VAULT_RUNTIME_BYTECODE }, } as unknown as AssertVaultBytecodeClient - await assertVaultBytecode(client, VAULT) - assert.equal(calls, 2) + const { pauses, pause } = recordedPauses() + await assertVaultBytecode(client, VAULT, ('0x' + 'ab'.repeat(32)) as Hex, { + ...PACING, + pause, + receiptBlock: 100n, + getBlockNumber: async () => heads.shift() ?? 100n, + }) + assert.equal(reads, 1) + assert.deepEqual(pauses, [2_000, 4_000]) }) -test('assertVaultBytecode surfaces the last error after the retry budget is exhausted', async () => { +test('assertVaultBytecode doubles its pause from the block time and gives up at the backoff ceiling', async () => { let calls = 0 const client = { getBytecode: async () => { @@ -362,11 +386,50 @@ test('assertVaultBytecode surfaces the last error after the retry budget is exha throw err }, } as unknown as AssertVaultBytecodeClient + const { pauses, pause } = recordedPauses() await assert.rejects( - () => assertVaultBytecode(client, VAULT), + () => assertVaultBytecode(client, VAULT, undefined, { ...PACING, pause }), (err: unknown) => err instanceof Error && err.name === 'InvalidParamsRpcError', ) - assert.equal(calls, 5) + assert.deepEqual(pauses, [2_000, 4_000, 8_000, 16_000, 32_000]) + assert.equal(calls, 6) +}) + +test('assertVaultBytecode reports no code once the ceiling passes with the address still empty', async () => { + const client = { getBytecode: async () => '0x' as Hex } as unknown as AssertVaultBytecodeClient + const { pause } = recordedPauses() + await assert.rejects( + () => assertVaultBytecode(client, VAULT, undefined, { blockTimeMs: 12_000, pause }), + (err: unknown) => err instanceof VaultBytecodeMismatchError && err.observedHash === null, + ) +}) + +test('assertVaultBytecode spends real block-time pauses that a cancel ends', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }) + let calls = 0 + const client = { + getBytecode: async () => { + calls += 1 + return calls < 3 ? '0x' as Hex : VAULT_RUNTIME_BYTECODE + }, + } as unknown as AssertVaultBytecodeClient + const done = track(assertVaultBytecode(client, VAULT, undefined, PACING)) + await advance(t, 1_750) + assert.equal(calls, 1) + await advance(t, 750) + assert.equal(calls, 2) + await advance(t, 4_500) + assert.equal(done.settled, true) + assert.equal(done.error, undefined) + + const controller = new AbortController() + const empty = { getBytecode: async () => '0x' as Hex } as unknown as AssertVaultBytecodeClient + const cancelled = track(assertVaultBytecode(empty, VAULT, undefined, { ...PACING, signal: controller.signal })) + await advance(t, 500) + controller.abort() + await advance(t, 250) + assert.equal(cancelled.settled, true) + assert.equal((cancelled.error as Error).name, 'AbortError') }) test('assertVaultBytecode never retries a real bytecode mismatch', async () => { @@ -379,13 +442,55 @@ test('assertVaultBytecode never retries a real bytecode mismatch', async () => { }, } as unknown as AssertVaultBytecodeClient await assert.rejects( - () => assertVaultBytecode(client, VAULT), + () => assertVaultBytecode(client, VAULT, undefined, PACING), (err: unknown) => err instanceof VaultBytecodeMismatchError, ) assert.equal(calls, 1) }) -test('confirmAgentInVault polls past a transient inVault:false (follower behind deposit block)', async () => { +test('assertVaultBytecode accepts any known build for an existing Vault but only the current one after a deploy', async () => { + const otherCode = ('0x' + '60'.repeat(40)) as Hex + const otherBuild: VaultBuild = { + id: 'pre-release', + label: 'test build without heldAgent', + runtimeHash: keccak256(otherCode), + hasHeldAgent: false, + } + const client = { getBytecode: async () => otherCode } as unknown as AssertVaultBytecodeClient + const build = await assertVaultBytecode(client, VAULT, undefined, { ...PACING, builds: [CURRENT_VAULT_BUILD, otherBuild] }) + assert.equal(build.id, 'pre-release') + assert.equal(build.hasHeldAgent, false) + await assert.rejects( + () => assertVaultBytecode(client, VAULT, ('0x' + 'ab'.repeat(32)) as Hex, { ...PACING, builds: [CURRENT_VAULT_BUILD, otherBuild] }), + (err: unknown) => err instanceof VaultBytecodeMismatchError && /intercepted/.test(err.message), + ) +}) + +test('the builds table knows the current and first committed builds by runtime hash', () => { + assert.equal(vaultBuildForCode(VAULT_RUNTIME_BYTECODE)?.id, 'current') + assert.equal(vaultBuildForHash(FIRST_COMMITTED_VAULT_BUILD.runtimeHash)?.id, 'first-committed') + assert.equal(FIRST_COMMITTED_VAULT_BUILD.runtimeHash, '0xfea7e898c15b1e72a5a54ec35bdad917dfed8ea3d4bfe078fafa3cf00784cde4') + assert.equal(vaultBuildForHash(('0x' + '11'.repeat(32)) as Hex), undefined) +}) + +test('VAULT_ABI names the Vault custom errors by their selectors', () => { + const selectors = Object.fromEntries( + ['AlreadyDeposited', 'UnexpectedToken', 'NotOwner', 'NotAuthorized'].map(name => [ + name, + encodeErrorResult({ abi: VAULT_ABI, errorName: name as 'NotOwner' }), + ]), + ) + assert.deepEqual(selectors, { + AlreadyDeposited: '0xd5a82115', + UnexpectedToken: '0x9667ffdf', + NotOwner: '0x30cd7471', + NotAuthorized: '0xea8e4eb5', + }) + const revert = Object.assign(new Error('execution reverted'), { code: 3, data: '0x30cd7471' }) + assert.match(describeVaultRevert(new Error('outer', { cause: revert })) ?? '', /NotOwner/) +}) + +test('confirmAgentInVault looks again past a transient inVault:false (follower behind deposit block)', async () => { let calls = 0 const client = { readContract: async () => { @@ -394,13 +499,14 @@ test('confirmAgentInVault polls past a transient inVault:false (follower behind return OWNER }, } as unknown as Parameters[0]['client'] - const status = await confirmAgentInVault({ client, vaultAddress: VAULT, registry: REGISTRY, agentId: 5n }) + const { pause } = recordedPauses() + const status = await confirmAgentInVault({ client, vaultAddress: VAULT, registry: REGISTRY, agentId: 5n, pacing: { ...PACING, pause } }) assert.equal(calls, 2) assert.equal(status.inVault, true) assert.equal(status.ownerAddress.toLowerCase(), OWNER.toLowerCase()) }) -test('confirmAgentInVault polls past a thrown read error', async () => { +test('confirmAgentInVault looks again past a thrown read error', async () => { let calls = 0 const client = { readContract: async () => { @@ -409,12 +515,13 @@ test('confirmAgentInVault polls past a thrown read error', async () => { return OWNER }, } as unknown as Parameters[0]['client'] - const status = await confirmAgentInVault({ client, vaultAddress: VAULT, registry: REGISTRY, agentId: 5n }) + const { pause } = recordedPauses() + const status = await confirmAgentInVault({ client, vaultAddress: VAULT, registry: REGISTRY, agentId: 5n, pacing: { ...PACING, pause } }) assert.equal(calls, 2) assert.equal(status.ownerAddress.toLowerCase(), OWNER.toLowerCase()) }) -test('confirmAgentInVault exhausts the budget on persistent inVault:false and throws with vault context', async () => { +test('confirmAgentInVault gives up at the ceiling on persistent inVault:false and throws with vault context', async () => { let calls = 0 const client = { readContract: async () => { @@ -422,31 +529,44 @@ test('confirmAgentInVault exhausts the budget on persistent inVault:false and th return '0x0000000000000000000000000000000000000000' as `0x${string}` }, } as unknown as Parameters[0]['client'] + const { pauses, pause } = recordedPauses() await assert.rejects( - () => confirmAgentInVault({ client, vaultAddress: VAULT, registry: REGISTRY, agentId: 5n }), + () => confirmAgentInVault({ client, vaultAddress: VAULT, registry: REGISTRY, agentId: 5n, pacing: { ...PACING, pause } }), (err: unknown) => err instanceof Error && err.message.toLowerCase().includes(VAULT.toLowerCase()) && err.message.includes('#5'), ) - assert.equal(calls, 5) + assert.equal(calls, pauses.length + 1) + assert.ok(pauses.every(ms => ms <= 60_000)) }) -test('confirmAgentInVault surfaces the last error after the retry budget exhausts', async () => { - let calls = 0 +test('confirmAgentInVault surfaces the last error once the ceiling passes', async () => { const client = { readContract: async () => { - calls += 1 throw new Error('persistent rpc timeout') }, } as unknown as Parameters[0]['client'] + const { pause } = recordedPauses() await assert.rejects( - () => confirmAgentInVault({ client, vaultAddress: VAULT, registry: REGISTRY, agentId: 5n }), + () => confirmAgentInVault({ client, vaultAddress: VAULT, registry: REGISTRY, agentId: 5n, pacing: { ...PACING, pause } }), (err: unknown) => err instanceof Error && /persistent rpc timeout/.test(err.message), ) - assert.equal(calls, 5) }) -test('confirmAgentWithdrawnFromVault polls until the token owner is the withdraw recipient', async () => { +test('confirmAgentInVault stops at once on a revert', async () => { + let calls = 0 + const client = { + readContract: async () => { + calls += 1 + throw Object.assign(new Error('execution reverted'), { code: 3 }) + }, + } as unknown as Parameters[0]['client'] + const { pause } = recordedPauses() + await assert.rejects(() => confirmAgentInVault({ client, vaultAddress: VAULT, registry: REGISTRY, agentId: 5n, pacing: { ...PACING, pause } })) + assert.equal(calls, 1) +}) + +test('confirmAgentWithdrawnFromVault looks again until the token owner is the withdraw recipient', async () => { let ownerOfCalls = 0 const client = { readContract: async (call: { functionName: string }) => { @@ -458,60 +578,17 @@ test('confirmAgentWithdrawnFromVault polls until the token owner is the withdraw throw new Error(`unexpected read: ${call.functionName}`) }, } as unknown as Parameters[0]['client'] + const { pause } = recordedPauses() const status = await confirmAgentWithdrawnFromVault({ client, vaultAddress: VAULT, registry: REGISTRY, agentId: 5n, recipient: RECIPIENT, + pacing: { ...PACING, pause }, }) assert.equal(ownerOfCalls, 2) assert.equal(status.inVault, false) assert.equal(status.ownerAddress.toLowerCase(), RECIPIENT.toLowerCase()) }) - -test('discoverVaultedTokens retries a single window after a transient getLogs throw and continues', async () => { - let getLogsCalls = 0 - const client = { - getBlockNumber: async () => 100n, - getLogs: async () => { - getLogsCalls += 1 - if (getLogsCalls === 1) throw new Error('The request took too long to respond') - return [] - }, - readContract: async () => '0x0000000000000000000000000000000000000000', - } as unknown as Parameters[0]['client'] - const out = await discoverVaultedTokens({ - client, - vaultAddress: VAULT, - registry: REGISTRY, - depositorAddress: OWNER, - fromBlock: 0n, - }) - assert.deepEqual(out, []) - assert.ok(getLogsCalls >= 2, 'expected at least one retry on the first window') -}) - -test('discoverVaultedTokens exhausts the per-window budget on persistent getLogs throws and surfaces the error', async () => { - let getLogsCalls = 0 - const client = { - getBlockNumber: async () => 100n, - getLogs: async () => { - getLogsCalls += 1 - throw new Error('persistent getLogs timeout') - }, - readContract: async () => '0x0000000000000000000000000000000000000000', - } as unknown as Parameters[0]['client'] - await assert.rejects( - () => discoverVaultedTokens({ - client, - vaultAddress: VAULT, - registry: REGISTRY, - depositorAddress: OWNER, - fromBlock: 0n, - }), - (err: unknown) => err instanceof Error && /persistent getLogs timeout/.test(err.message), - ) - assert.equal(getLogsCalls, 3) -}) diff --git a/test/identity/wallet/localKeyWallet.test.ts b/test/identity/wallet/localKeyWallet.test.ts new file mode 100644 index 0000000..5f870b4 --- /dev/null +++ b/test/identity/wallet/localKeyWallet.test.ts @@ -0,0 +1,222 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { custom, getAddress, parseTransaction, type Hex } from 'viem' +import { createLocalKeySender, createLocalKeySignAndTransaction } from '../../../src/identity/wallet/localKeyWallet.js' +import { prepareTransactionGasFee } from '../../../src/identity/wallet/browserWallet/gas.js' +import { operatorEnsSigner } from '../../../src/identity/manager/ens/signer.js' +import { assertVaultCanAcceptAgent } from '../../../src/identity/manager/custody/transactions.js' +import { createMainnetClient } from '../../../src/identity/ens/ensLookup.js' +import { readEthagentTextRecords } from '../../../src/identity/ens/ensLookup.js' +import { OPERATOR_KEY_ENV } from '../../../src/cli/operatorKey.js' +import type { VaultBuild } from '../../../src/identity/registry/vault.js' + +const KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as const +const ACCOUNT = getAddress('0x70997970C51812dc3A010C7d01b50e0d17dc79C8') +const TO = getAddress('0x000000000000000000000000000000000000bEEF') + +// A transport that answers the few calls a local-key send makes and records the rest. +function recordingTransport() { + const requests: Array<{ method: string; params: unknown[] }> = [] + const transport = custom({ + async request({ method, params }: { method: string; params?: unknown[] }) { + requests.push({ method, params: params ?? [] }) + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_getTransactionCount') return '0x7' + if (method === 'eth_sendRawTransaction') return '0x' + 'ab'.repeat(32) + throw new Error(`unexpected ${method}`) + }, + }) + return { requests, transport } +} + +test('the local-key sender broadcasts once with the prepared gas and fees', async () => { + const { requests, transport } = recordingTransport() + const sender = createLocalKeySender({ privateKey: KEY, chainId: 1, transport }) + assert.equal(sender.account, ACCOUNT) + const { txHash } = await sender.send({ to: TO, data: '0x1234', gas: '0xf000', maxFeePerGas: '0x77359400', maxPriorityFeePerGas: '0x3b9aca00' }) + assert.equal(txHash, '0x' + 'ab'.repeat(32)) + const sends = requests.filter(item => item.method === 'eth_sendRawTransaction') + assert.equal(sends.length, 1) + assert.ok(!requests.some(item => item.method === 'eth_estimateGas' || item.method === 'eth_maxPriorityFeePerGas')) + const tx = parseTransaction(sends[0]!.params[0] as Hex) + assert.equal(tx.gas, 0xf000n) + assert.equal(tx.maxFeePerGas, 0x77359400n) + assert.equal(tx.maxPriorityFeePerGas, 0x3b9aca00n) + assert.equal(tx.chainId, 1) + assert.equal(tx.to?.toLowerCase(), TO.toLowerCase()) +}) + +test('the operator ENS signer only signs on mainnet', () => { + const { transport } = recordingTransport() + assert.throws(() => operatorEnsSigner(createLocalKeySender({ privateKey: KEY, chainId: 8453, transport })), /Ethereum Mainnet/) + assert.equal(operatorEnsSigner(createLocalKeySender({ privateKey: KEY, chainId: 1, transport })).account, ACCOUNT) +}) + +test('createLocalKeySignAndTransaction signs the challenge, prepares, and sends with the prepared fees', async () => { + const { requests, transport } = recordingTransport() + const run = createLocalKeySignAndTransaction({ privateKey: KEY, rpcUrl: 'https://rpc.example', chainId: 1, transport }) + const result = await run({ + chainId: 1, + expectedAccount: ACCOUNT, + messageForAccount: account => `restore access for ${account}`, + prepareTransaction: async wallet => { + assert.equal(wallet.account, ACCOUNT) + assert.match(wallet.signature, /^0x[0-9a-f]{130}$/) + return { to: TO, data: '0x', gas: '0x5208', maxFeePerGas: '0x2', maxPriorityFeePerGas: '0x1', prepared: { ok: true } } + }, + }) + assert.equal(result.prepared.ok, true) + assert.equal(result.message, `restore access for ${ACCOUNT}`) + const raw = requests.find(item => item.method === 'eth_sendRawTransaction')!.params[0] as Hex + assert.equal(parseTransaction(raw).gas, 0x5208n) +}) + +test('createLocalKeySignAndTransaction refuses a key that is not the expected operator', async () => { + const { transport } = recordingTransport() + const run = createLocalKeySignAndTransaction({ privateKey: KEY, rpcUrl: 'https://rpc.example', chainId: 1, transport }) + await assert.rejects( + run({ + chainId: 1, + expectedAccount: TO, + message: 'x', + prepareTransaction: async () => { throw new Error('must not prepare') }, + }), + /not this agent's authorized operator wallet/, + ) +}) + +test('runOperatorSave exits 3 without a key and 2 with an invalid one', async () => { + const { runOperatorSave } = await import('../../../src/cli/operatorSave.js') + const write = process.stdout.write.bind(process.stdout) + const previous = process.env[OPERATOR_KEY_ENV] + process.stdout.write = (() => true) as typeof process.stdout.write + try { + delete process.env[OPERATOR_KEY_ENV] + assert.equal(await runOperatorSave(['--json']), 3) + process.env[OPERATOR_KEY_ENV] = '0x00' + assert.equal(await runOperatorSave(['--json']), 2) + } finally { + process.stdout.write = write + if (previous === undefined) delete process.env[OPERATOR_KEY_ENV] + else process.env[OPERATOR_KEY_ENV] = previous + } +}) + +test('the gas estimate looks again once per block past a not-yet-visible answer', async () => { + let estimates = 0 + const pauses: number[] = [] + const prepared = await prepareTransactionGasFee({ + client: { + estimateGas: async () => { + estimates += 1 + if (estimates === 1) throw new Error('header not found') + return 100_000n + }, + estimateFeesPerGas: async () => ({ maxFeePerGas: 2n, maxPriorityFeePerGas: 1n }), + } as never, + account: ACCOUNT, + to: TO, + data: '0x', + blockTimeMs: 12_000, + pause: async ms => { pauses.push(ms) }, + }) + assert.equal(prepared.gas, '0x1d4c0') + assert.deepEqual(pauses, [12_000]) +}) + +test('the gas estimate surfaces a Vault refusal at once, by name', async () => { + let estimates = 0 + await assert.rejects( + prepareTransactionGasFee({ + client: { + estimateGas: async () => { + estimates += 1 + throw Object.assign(new Error('execution reverted'), { code: 3, data: '0x30cd7471' }) + }, + estimateFeesPerGas: async () => ({ maxFeePerGas: 2n, maxPriorityFeePerGas: 1n }), + } as never, + account: ACCOUNT, + to: TO, + data: '0x', + pause: async () => { throw new Error('a revert must not wait') }, + }), + /would be refused: only the wallet that deposited the token may do this \(NotOwner\)/, + ) + assert.equal(estimates, 1) +}) + +const REGISTRY = getAddress('0x8004A169FB4a3325136EB29fA0ceB6D2e539a432') +const VAULT = getAddress('0x00000000000000000000000000000000000077ab') +const OWNER = getAddress('0x000000000000000000000000000000000000abcd') +const NO_HELD_AGENT: VaultBuild = { id: 'pre-release', label: 'pre-release build', runtimeHash: '0x' + '00'.repeat(32) as Hex, hasHeldAgent: false } + +test('the deposit preflight reads agentOwner on a build without heldAgent() and simulates the deposit', async () => { + const reads: string[] = [] + const simulations: Array<{ account: string; functionName: string; args: readonly unknown[] }> = [] + await assertVaultCanAcceptAgent({ + registry: { chainId: 8453, rpcUrl: 'https://mainnet.base.org', identityRegistryAddress: REGISTRY }, + vaultAddress: VAULT, + agentId: 45744n, + build: NO_HELD_AGENT, + owner: OWNER, + client: { + readContract: async (call: { functionName: string }) => { + reads.push(call.functionName) + return '0x0000000000000000000000000000000000000000' + }, + simulateContract: async (call: { account: string; functionName: string; args: readonly unknown[] }) => { + simulations.push(call) + return { result: undefined, request: {} } + }, + } as never, + }) + assert.deepEqual(reads, ['agentOwner']) + assert.equal(simulations.length, 1) + assert.equal(simulations[0]!.functionName, 'safeTransferFrom') + assert.equal(simulations[0]!.account, OWNER) + assert.deepEqual(simulations[0]!.args, [OWNER, VAULT, 45744n]) +}) + +test('the deposit preflight names a refused deposit and never treats a failed read as accept', async () => { + const registry = { chainId: 8453, rpcUrl: 'https://mainnet.base.org', identityRegistryAddress: REGISTRY } + await assert.rejects( + assertVaultCanAcceptAgent({ + registry, vaultAddress: VAULT, agentId: 1n, build: NO_HELD_AGENT, owner: OWNER, + client: { + readContract: async () => '0x0000000000000000000000000000000000000000', + simulateContract: async () => { throw Object.assign(new Error('execution reverted'), { code: 3, data: '0x9667ffdf' }) }, + } as never, + }), + /refused: this Vault was deployed for a different registry or token \(UnexpectedToken\)/, + ) + await assert.rejects( + assertVaultCanAcceptAgent({ + registry, vaultAddress: VAULT, agentId: 1n, build: { ...NO_HELD_AGENT, hasHeldAgent: true }, owner: OWNER, + client: { + readContract: async () => { throw new Error('No RPC endpoint answered') }, + simulateContract: async () => ({ result: undefined, request: {} }), + } as never, + }), + /No RPC endpoint answered/, + ) +}) + +test('the manager ENS client reads through the adaptive transport', () => { + const client = createMainnetClient() + assert.equal(client.transport.type, 'custom') + assert.equal(client.chain?.id, 1) +}) + +test('an unreadable ENS text record is an error, not an empty record', async () => { + await assert.rejects( + readEthagentTextRecords('agent.example.eth', ['org.ethagent.token'], { + publicClient: { + readContract: async (call: { functionName: string }) => { + if (call.functionName === 'resolver') return '0x0000000000000000000000000000000000001234' + throw new Error('No RPC endpoint answered') + }, + } as never, + }), + /No RPC endpoint answered/, + ) +}) diff --git a/test/net/logs.test.ts b/test/net/logs.test.ts new file mode 100644 index 0000000..1b19c90 --- /dev/null +++ b/test/net/logs.test.ts @@ -0,0 +1,129 @@ +import test, { afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { createPublicClient, parseAbiItem, type PublicClient } from 'viem' +import { base } from 'viem/chains' +import { createAdaptiveFetch, learnedLogRange, resetHostStatsForTest } from '../../src/net/adaptive.js' +import { adaptiveRpcTransport } from '../../src/net/rpc.js' +import { classifyLogQueryError, LogScanError, scanLogs } from '../../src/net/logs.js' + +const BASE_ORG = 'https://mainnet.base.org' +const PUBLICNODE = 'https://base.publicnode.com' +const ARCHIVE = 'https://archive.example' +const TRANSFER = parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)') + +// Learned ranges live in the shared host table; later tests must not inherit them. +afterEach(() => resetHostStatsForTest()) + +type Span = { from: bigint; to: bigint } + +// Endpoints that behave like the public Base ones: mainnet.base.org refuses more than +// 500 blocks per query with -32614 (or HTTP 413), publicnode refuses history older than +// a day with -32602, and the archive serves anything. +function chain(opts: { http413?: boolean } = {}) { + const asked: Array<{ host: string } & Span> = [] + const fetchImpl = createAdaptiveFetch(async (input, init) => { + const host = new URL(String(input)).host + const request = JSON.parse(String(init?.body)) as { id: number; method: string; params: Array<{ fromBlock: string; toBlock: string }> } + const from = BigInt(request.params[0]!.fromBlock) + const to = BigInt(request.params[0]!.toBlock) + asked.push({ host, from, to }) + const reply = (body: unknown) => Response.json({ jsonrpc: '2.0', id: request.id, ...body as object }) + if (host === 'mainnet.base.org' && to - from + 1n > 500n) { + if (opts.http413) return new Response('too large', { status: 413 }) + return reply({ error: { code: -32614, message: 'eth_getLogs is limited to a 500 range' } }) + } + if (host === 'base.publicnode.com' && from < 9_000n) { + return reply({ error: { code: -32602, message: 'state histories older than 24 hours are not available' } }) + } + const logs = from <= 1_234n && 1_234n <= to + ? [{ + address: '0x8004a169fb4a3325136eb29fa0ceb6d2e539a432', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x000000000000000000000000000000000000000000000000000000000000dead', + '0x000000000000000000000000000000000000000000000000000000000000b2b0', + ], + data: '0x', + blockNumber: '0x4d2', + transactionHash: '0x' + '11'.repeat(32), + transactionIndex: '0x0', + blockHash: '0x' + '22'.repeat(32), + logIndex: '0x0', + removed: false, + }] + : [] + return reply({ result: logs }) + }) + const clients = new Map() + const query = async (url: string, fromBlock: bigint, toBlock: bigint) => { + let client = clients.get(url) + if (!client) { + client = createPublicClient({ chain: base, transport: adaptiveRpcTransport([url], fetchImpl) }) as PublicClient + clients.set(url, client) + } + return client.getLogs({ event: TRANSFER, fromBlock, toBlock }) + } + return { asked, query } +} + +async function collect(gen: AsyncGenerator): Promise { + const out: T[] = [] + for await (const chunk of gen) out.push(...chunk) + return out +} + +test('scanLogs takes the limit an endpoint names and remembers it for the host', async () => { + resetHostStatsForTest() + const net = chain() + const logs = await collect(scanLogs({ urls: [BASE_ORG], fromBlock: 0n, toBlock: 1_999n, initialRange: 10_000n, query: net.query })) + assert.equal(logs.length, 1) + assert.equal(learnedLogRange('mainnet.base.org'), 500) + const served = net.asked.filter(item => item.to - item.from + 1n <= 500n) + assert.equal(served.length, 4) + // A second scan starts from the learned range instead of being refused again. + const before = net.asked.length + await collect(scanLogs({ urls: [BASE_ORG], fromBlock: 0n, toBlock: 999n, initialRange: 10_000n, query: net.query })) + assert.equal(net.asked.length - before, 2) +}) + +test('scanLogs halves on a range refusal that names no limit (HTTP 413)', async () => { + resetHostStatsForTest() + const net = chain({ http413: true }) + await collect(scanLogs({ urls: [BASE_ORG], fromBlock: 0n, toBlock: 1_999n, initialRange: 2_000n, query: net.query })) + assert.equal(learnedLogRange('mainnet.base.org'), 500) + assert.deepEqual(net.asked.slice(0, 3).map(item => item.to - item.from + 1n), [2_000n, 1_000n, 500n]) +}) + +test('scanLogs hands over to the next endpoint when one refuses the depth of history', async () => { + resetHostStatsForTest() + const net = chain() + const logs = await collect(scanLogs({ urls: [PUBLICNODE, ARCHIVE], fromBlock: 0n, toBlock: 9_999n, initialRange: 1_000n, query: net.query })) + assert.equal(logs.length, 1) + assert.ok(net.asked.some(item => item.host === 'base.publicnode.com')) + assert.ok(net.asked.some(item => item.host === 'archive.example' && item.from === 0n)) +}) + +test('scanLogs explains why when no endpoint can cover the span', async () => { + resetHostStatsForTest() + const net = chain() + await assert.rejects( + collect(scanLogs({ urls: [BASE_ORG, PUBLICNODE], fromBlock: 0n, toBlock: 999_999n, initialRange: 10_000n, query: net.query })), + (err: unknown) => { + assert.ok(err instanceof LogScanError) + assert.match(err.message, /mainnet\.base\.org serves 500 blocks per query/) + assert.match(err.message, /base\.publicnode\.com does not keep logs back to block/) + assert.match(err.message, /set an RPC with history/) + return true + }, + ) +}) + +test('classifyLogQueryError reads range limits and history refusals', () => { + assert.deepEqual(classifyLogQueryError({ code: -32614, message: 'eth_getLogs is limited to a 500 range' }), { kind: 'range', limit: 500 }) + assert.deepEqual(classifyLogQueryError({ code: -32005, message: 'query returned more than 10000 results' }), { kind: 'range' }) + assert.deepEqual(classifyLogQueryError(new Error('No RPC endpoint answered (x: HTTP 413)')), { kind: 'range' }) + assert.deepEqual(classifyLogQueryError({ code: -32602, message: 'invalid block range params' }), { kind: 'range' }) + assert.deepEqual(classifyLogQueryError({ code: -32602, message: 'state histories older than 24 hours are not available' }), { kind: 'history' }) + assert.deepEqual(classifyLogQueryError(new Error('boom')), { kind: 'other' }) +}) diff --git a/test/run.ts b/test/run.ts index 25a0c3b..d8433a4 100644 --- a/test/run.ts +++ b/test/run.ts @@ -2,6 +2,7 @@ import './support/forceColor.js' import './support/netIsolation.js' import './net/adaptive.test.js' import './net/rpc.test.js' +import './net/logs.test.js' import './cli/syncAdapters/managedBlock.test.js' import './cli/syncAdapters/mirrorSkillFolders.test.js' import './cli/syncOnEdit.test.js' @@ -15,6 +16,7 @@ import './cli/autostartAndSkillEdits.test.js' import './cli/resetMirrors.test.js' import './cli/save.test.js' import './cli/historyCommands.test.js' +import './cli/onchainCommands.test.js' import './storage/config.test.js' import './storage/gitignore.test.js' import './storage/reset.test.js' @@ -51,6 +53,7 @@ import './identity/storage/ipfsRead.test.js' import './identity/storage/cid.test.js' import './identity/storage/pinataJwt.test.js' import './identity/wallet/browserWallet.test.js' +import './identity/wallet/localKeyWallet.test.js' import './identity/profile/imagePicker.test.js' import './identity/manager/reducer.test.js' import './identity/manager/lazyMenuTruncation.test.js'