From af50482f6f3985593c4b35c6edd85e269eea91ba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 22:46:10 +0000 Subject: [PATCH] feat: ethagent ens --delete removes the agent's subname Clears the agent records, removes the subname from its parent, then publishes the agent unlinked, through the same planner and wallet tab as --unlink. Only the wallet that manages the parent signs it, never the operator key. A run stopped after the removal finishes when run again. README: document --delete and describe Advanced custody by what it does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HG2miyE22qTuug1Quxj7iw --- README.md | 11 ++++-- commands/ethagent.md | 2 +- src/cli/main.tsx | 1 + src/cli/onchain/ens.ts | 43 ++++++++++++++++++--- src/identity/manager/ens/headless.ts | 58 +++++++++++++++++++++++++++- src/identity/wallet/page/timeline.ts | 3 ++ test/cli/onchainCommands.test.ts | 45 +++++++++++++++++++++ 7 files changed, 151 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b9ec128..f9e0760 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ You'll need an Ethereum wallet, the one that holds and unlocks your agent. Day-t A guided menu does the rest: name it and write who it is. When you're done, your wallet signs once to put it onchain. -Prefer no menu? `ethagent create --name --network ` does the same headlessly, previewing first and minting with `--yes`. Every menu action has a command like this, except deleting an ENS subname, which stays in the menu on purpose; see [Setting up and moving an agent](#setting-up-and-moving-an-agent). +Prefer no menu? `ethagent create --name --network ` does the same headlessly, previewing first and minting with `--yes`. Every menu action has a command like this; see [Setting up and moving an agent](#setting-up-and-moving-an-agent).

ethagent main menu for a linked agent @@ -125,7 +125,7 @@ Local history is plain files beside your vault, the same as the vault itself. `e You choose how tightly the agent is held, and you can change it later. - **Simple.** One wallet owns the agent and signs every save. The default for solo use. -- **Advanced.** Most people never need this. Your main wallet owns the agent and keeps it in a Vault. Approved "operator" wallets can save backups and publish updates without the main wallet signing each time. Only the owner can move or sell the agent, so operators can **never** take it. +- **Advanced.** Your main wallet owns the agent and keeps it in a Vault. Approved "operator" wallets can save backups and publish updates without the main wallet signing each time. Only the owner can move or sell the agent, so operators can **never** take it. To move the agent to another wallet, stage a transfer snapshot with `ethagent transfer

` or from the menu. Both wallets sign locally to re-encrypt your soul, memory, and skills for the new owner, so both must be on the same machine. Then transfer the token, and the new owner restores the agent exactly as you left it. @@ -167,6 +167,7 @@ The foundation is built on open standards, so your agent is never tied to one ap | 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` | +| Delete the agent's ENS subname | `ethagent ens --delete`, then again with `--yes` | | Sign ENS changes with the operator key | `keychain exec ethagent -- ethagent ens --operator` | | Set up IPFS storage | `ethagent storage --set`, with the Pinata JWT on stdin | | Create an agent | `ethagent create --name --network `, then again with `--yes` | @@ -215,6 +216,7 @@ Run any of these with `npx ethagent`. Commands marked interactive need a termina | `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 --delete [--yes] [--json]` | Unlink as above, then remove the subname from its parent. Signed by the wallet that manages the parent. 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` | | `custody --advanced [--yes] [--json]` | Deploy a Vault (or reuse one), deposit the token, and save. Previews until `--yes`. | Opens your wallet | | `custody --simple [--yes] [--json]` | Revoke the Vault's operators, withdraw the token, and save. Previews until `--yes`. | Opens your wallet | @@ -252,12 +254,13 @@ Run any of these with `npx ethagent`. Commands marked interactive need a termina - **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. +- **Deleting.** `ens --delete` clears the records, removes the subname from its parent, then publishes the agent unlinked. Only the wallet that manages the parent can sign it, never the operator key. Whoever manages the parent can create the name again, unless its fuses forbid that. A run stopped after the removal finishes when run again. +- **Publishing.** Changing, unlinking, or deleting 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. ### Setting up and moving an agent -`create`, `restore`, `profile`, `transfer`, `storage`, and the custody changes run without the manager too. Everything the manager can do has a command, except deleting an ENS subname, which stays in the manager on purpose; `ens --unlink` clears the agent's records instead. +`create`, `restore`, `profile`, `transfer`, `storage`, and the custody changes run without the manager too. Everything the manager can do has a command. - **Previews.** Anything that signs or sends previews first: every signature and transaction, who signs it, and what can be simulated now. Nothing happens without `--yes`. - **One tab.** Each command runs all its wallet prompts in one browser tab. `--no-open` prints the link instead of opening it. diff --git a/commands/ethagent.md b/commands/ethagent.md index f0655ac..e5916cb 100644 --- a/commands/ethagent.md +++ b/commands/ethagent.md @@ -62,7 +62,7 @@ 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. - `custody --advanced`, `custody --simple`, `custody --add-operator [
]`, `--remove-operator
`, and `--activate-operator
` change custody. They are planned from chain state, so running one again after it stopped part way (exit 4, or 3 if a prompt was cancelled) finishes it. `custody --add-operator --operator` lets the injected operator key sign its own proof. - `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 `, `ens --unlink`, `ens --delete` (also removes the subname from its parent; never the operator key), 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. Setting up and moving an agent (all take `--json` and `--help`; anything that signs or sends previews until `--yes`, and the `--yes` run is the user's): diff --git a/src/cli/main.tsx b/src/cli/main.tsx index 7114d04..4d356bf 100644 --- a/src/cli/main.tsx +++ b/src/cli/main.tsx @@ -78,6 +78,7 @@ function printHelp(): void { ' 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 --delete unlink, then remove the subname from its parent (preview, then --yes)', ' ethagent ens --set = --clear ', ' change text records (preview, then --yes)', ' ethagent ens --operator', diff --git a/src/cli/onchain/ens.ts b/src/cli/onchain/ens.ts index 106a94e..b83f5a4 100644 --- a/src/cli/onchain/ens.ts +++ b/src/cli/onchain/ens.ts @@ -14,6 +14,7 @@ import { createMainnetEnsPublicClient, sendEnsTransaction } from '../../identity import { browserEnsSigner, operatorEnsSigner, type EnsSigner } from '../../identity/manager/ens/signer.js' import { EnsPlanRefusal, + planEnsDelete, planEnsRecords, planEnsSwap, planEnsUnlink, @@ -43,7 +44,7 @@ import { type HistoryDeps, } from '../history/shared.js' -export const ENS_USAGE = 'ethagent ens [ | --unlink | --set =... --clear ...] [--operator] [--yes] [--no-open] [--json]' +export const ENS_USAGE = 'ethagent ens [ | --unlink | --delete | --set =... --clear ...] [--operator] [--yes] [--no-open] [--json]' const HELP = [ `usage: ${ENS_USAGE}`, @@ -57,6 +58,10 @@ const HELP = [ ' 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 --delete unlink as above, then remove the subname from its parent.', + ' Signed by the wallet that manages the parent, never the', + ' operator key. The parent can recreate it, unless its fuses', + ' forbid that.', ' ethagent ens --set = --clear ', ' write every record change on the linked name in one', ' resolver multicall. No save is needed.', @@ -95,18 +100,20 @@ const defaultSeams: EnsSeams = { saveConfig, } -type Mode = { kind: 'read' } | { kind: 'swap'; name: string } | { kind: 'unlink' } | { kind: 'records'; set: Record; clear: string[] } +type Mode = { kind: 'read' } | { kind: 'swap'; name: string } | { kind: 'unlink' } | { kind: 'delete' } | { 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 del = Boolean(values.delete) 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) + const chosen = [name ? 'a name' : '', unlink ? '--unlink' : '', del ? '--delete' : '', 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 (del) return { kind: 'delete' } if (sets.length + clears.length > 0) { const set: Record = {} for (const entry of sets) { @@ -210,6 +217,7 @@ export async function runEnsCommand(args: string[], deps: HistoryDeps, seams: En operator: { type: 'boolean' }, yes: { type: 'boolean' }, unlink: { type: 'boolean' }, + delete: { type: 'boolean' }, 'no-open': { type: 'boolean' }, set: { type: 'string', multiple: true }, clear: { type: 'string', multiple: true }, @@ -232,6 +240,9 @@ export async function runEnsCommand(args: string[], deps: HistoryDeps, seams: En if (!identity.agentId) throw new HistoryError(1, 'This identity has no agent token ID yet.', 'Mint one with `ethagent create`, or bring one back with `ethagent restore `.') const owner = getAddress(humanOwnerAddress(identity)) + if (mode.kind === 'delete' && values.operator) { + throw new HistoryError(2, '--delete is signed by the wallet that manages the parent name, never the operator key.', 'Drop --operator; `ethagent ens --unlink --operator` clears the records instead.') + } const signer = chooseSigner(deps, Boolean(values.operator), owner) const client = seams.readClient() const currentName = readIdentityStateString(identity.state as Record | undefined, 'ensName') @@ -267,6 +278,25 @@ export async function runEnsCommand(args: string[], deps: HistoryDeps, seams: En transactions = plan.transactions publishName = '' summary = { action: 'unlink', name: plan.name, records: plan.current } + } else if (mode.kind === 'delete') { + if (!currentName) throw new HistoryError(1, 'No ENS name is linked, so there is nothing to delete.') + const plan = await planEnsDelete({ + client, + name: currentName, + signer: signer.address, + signerRole: signer.role, + identityRegistryAddress: registry.identityRegistryAddress, + agentId: identity.agentId, + }) + transactions = plan.transactions + publishName = '' + summary = { + action: 'delete', + name: plan.name, + parent: plan.parentName, + records: plan.current, + note: `${plan.name} stops resolving. Whoever manages ${plan.parentName} can create it again, unless its fuses forbid that.`, + } } else { if (!currentName) throw new HistoryError(1, 'No ENS name is linked.', 'Link one first with `ethagent ens `.') const plan = await planEnsRecords({ @@ -313,6 +343,7 @@ export async function runEnsCommand(args: string[], deps: HistoryDeps, seams: En 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 (typeof summary.note === 'string') lines.push(` note: ${summary.note}`) 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'})`) } @@ -360,7 +391,7 @@ export async function runEnsCommand(args: string[], deps: HistoryDeps, seams: En onWalletReady: () => {}, onIdentityComplete: async () => {}, } - const flowId = mode.kind === 'swap' ? 'ens-link' : mode.kind === 'unlink' ? 'ens-clear' : 'ens-update' + const flowId = mode.kind === 'swap' ? 'ens-link' : mode.kind === 'unlink' ? 'ens-clear' : mode.kind === 'delete' ? 'ens-delete' : 'ens-update' const sent: Array<{ step: string; name: string; txHash: string }> = [] let savedIdentity: EthagentIdentity | undefined try { @@ -372,7 +403,7 @@ export async function runEnsCommand(args: string[], deps: HistoryDeps, seams: En } const publicClient = seams.ensClient() for (const tx of transactions) { - const flowStep = tx.step === 'create-subdomain' ? 1 : mode.kind === 'swap' ? 2 : 1 + const flowStep = tx.step === 'create-subdomain' ? 1 : tx.step === 'delete-subdomain' ? 2 : mode.kind === 'swap' ? 2 : 1 await sink(`Sending: ${tx.description}\n`) const { txHash } = await sendEnsTransaction({ signer: ensSigner, @@ -409,7 +440,7 @@ export async function runEnsCommand(args: string[], deps: HistoryDeps, seams: En await seams.saveConfig({ ...config, identity: nextIdentity }) savedIdentity = nextIdentity }, - }, await ensureSession(), { flowId, flowStep: mode.kind === 'swap' ? 3 : 2 }) + }, await ensureSession(), { flowId, flowStep: mode.kind === 'swap' || mode.kind === 'delete' ? 3 : 2 }) } } catch (err: unknown) { if (isWalletCancelled(err)) { diff --git a/src/identity/manager/ens/headless.ts b/src/identity/manager/ens/headless.ts index 52ddce1..37819f3 100644 --- a/src/identity/manager/ens/headless.ts +++ b/src/identity/manager/ens/headless.ts @@ -15,6 +15,7 @@ import { type AgentRecordDiff, } from '../../ens/agentRecords.js' import { normalizeEthDomain, splitSubdomainName } from '../../ens/ensLookup.js' +import { preflightDeleteSubdomain } from '../../ens/ensAutomation/delete.js' import { ENS_AUTOMATION_RESOLVER_ABI, ENS_PUBLIC_RESOLVER_ADDRESS_MAINNET, @@ -114,7 +115,7 @@ export async function signerControlOf(client: EnsReadClient, control: EnsNameCon } export type EnsPlannedTransaction = { - step: 'create-subdomain' | 'set-records' | 'clear-old-records' | 'clear-records' | 'update-records' + step: 'create-subdomain' | 'set-records' | 'clear-old-records' | 'clear-records' | 'update-records' | 'delete-subdomain' name: string to: Address data: Hex @@ -347,6 +348,61 @@ export async function planEnsUnlink(args: { } } +export type EnsDeletePlan = { + kind: 'delete' + name: string + parentName: string + transactions: EnsPlannedTransaction[] + current: AgentEnsRecordState +} + +// Unlink, then remove the subname from its parent. Only the parent's manager can remove +// it, and the records are cleared first so nothing points at the token if the parent +// later recreates the name. +export async function planEnsDelete(args: { + client: EnsReadClient + name: string + signer: Address + signerRole: string + identityRegistryAddress: Address + agentId: string +}): Promise { + const preflight = await preflightDeleteSubdomain({ fullName: args.name, expectedOwnerAddress: args.signer, ensClient: args.client }) + if (!preflight.ok && preflight.reason === 'subdomain-missing') { + // Already removed, for example by a run stopped before its save: only the save is left. + const name = normalizeEthDomain(args.name) + return { kind: 'delete', name, parentName: splitSubdomainName(name)?.parent ?? '', transactions: [], current: {} } + } + if (!preflight.ok) { + const hint = preflight.reason === 'not-a-subdomain' + ? 'Only a subname can be deleted. `ethagent ens --unlink` clears the agent records instead.' + : preflight.reason === 'parent-owner-mismatch' + ? 'Run it with the wallet that manages the parent name, or use `ethagent ens --unlink` to clear the agent records instead.' + : undefined + throw new EnsPlanRefusal(`Cannot delete ${args.name}: ${preflight.detail}.`, hint) + } + const unlink = await planEnsUnlink(args) + const plan = preflight.plan + return { + kind: 'delete', + name: plan.fullName, + parentName: plan.parentName, + current: unlink.current, + transactions: [ + ...unlink.transactions, + { + step: 'delete-subdomain', + name: plan.fullName, + to: plan.transaction.to, + data: plan.transaction.data, + purpose: 'delete-ens-subdomain', + description: `remove ${plan.fullName} from ${plan.parentName}${plan.parentWrapped ? ' (through the NameWrapper)' : ''}; ${args.signerRole} ${args.signer} manages the parent`, + dependsOnPrevious: false, + }, + ], + } +} + export type EnsRecordsPlan = { kind: 'records' name: string diff --git a/src/identity/wallet/page/timeline.ts b/src/identity/wallet/page/timeline.ts index 6afdb0e..9d7c75c 100644 --- a/src/identity/wallet/page/timeline.ts +++ b/src/identity/wallet/page/timeline.ts @@ -1,5 +1,6 @@ type LifecycleId = | "ens-clear" + | "ens-delete" | "ens-link" | "ens-update" | "ens-register" @@ -8,6 +9,7 @@ type LifecycleId = const LIFECYCLE_DEFINITIONS: Record = { "ens-clear": { steps: ["Clear Records on Mainnet", "Save Cleared Snapshot"] }, + "ens-delete": { steps: ["Clear Records on Mainnet", "Delete Subdomain", "Save Cleared Snapshot"] }, "ens-link": { steps: ["Create Subdomain", "Set Records", "Save Snapshot"] }, "ens-update": { steps: ["Update Records on Mainnet", "Save Updated Snapshot"] }, "ens-register": { steps: ["Commit ENS Name", "Register ENS Name"] }, @@ -17,6 +19,7 @@ const LIFECYCLE_DEFINITIONS: Record = { const FLOW_LIFECYCLE: Record = { "ens-clear": "ens-clear", + "ens-delete": "ens-delete", "ens-link": "ens-link", "ens-update": "ens-update", "ens-register": "ens-register", diff --git a/test/cli/onchainCommands.test.ts b/test/cli/onchainCommands.test.ts index 2e8d4a8..1f43b5b 100644 --- a/test/cli/onchainCommands.test.ts +++ b/test/cli/onchainCommands.test.ts @@ -340,3 +340,48 @@ 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) }) + +test('ens --delete previews clearing the records, removing the subname from its parent, and an owner-signed save', async () => { + const id = identity({ ensName: 'meow.femboi.eth' }) + const d = deps(id) + const code = await runEnsCommand(['--delete', '--json'], d, ensSeams({ + 'femboi.eth': { owner: OWNER }, + 'meow.femboi.eth': { owner: OWNER, resolver: RESOLVER, addr: OWNER, text: { [ENSIP25_BASE]: '1' } }, + })) + assert.equal(code, 0, d.io.stdout()) + const out = d.io.json() as Record + assert.equal(out.applied, false) + assert.equal(out.action, 'delete') + assert.equal(out.parent, 'femboi.eth') + assert.deepEqual(out.transactions.map((tx: any) => tx.step), ['clear-records', 'delete-subdomain']) + assert.equal(getAddress(out.transactions[1].to), ENS_REGISTRY) + assert.equal(out.publish.ensName, '') + assert.match(String(out.note), /can create it again/) +}) + +test('ens --delete refuses when the signer does not manage the parent, and never takes the operator key', async () => { + const id = identity({ ensName: 'meow.femboi.eth' }) + const other = getAddress('0x00000000000000000000000000000000000beef1') + const d = deps(id) + assert.equal(await runEnsCommand(['--delete', '--json'], d, ensSeams({ + 'femboi.eth': { owner: other }, + 'meow.femboi.eth': { owner: OWNER, resolver: RESOLVER }, + })), 1) + assert.match(String(d.io.json().hint), /--unlink/) + const op = operatorDeps(id) + assert.equal(await runEnsCommand(['--delete', '--operator', '--json'], op, ensSeams({})), 2) + const top = deps(identity({ ensName: 'femboi.eth' })) + assert.equal(await runEnsCommand(['--delete', '--json'], top, ensSeams({ 'femboi.eth': { owner: OWNER } })), 1) + assert.match(String(top.io.json().error), /not a subdomain/) + const mixed = deps(id) + assert.equal(await runEnsCommand(['--delete', '--unlink', '--json'], mixed, ensSeams({})), 2) +}) + +test('ens --delete resumes: a subname already removed leaves only the save', async () => { + const id = identity({ ensName: 'meow.femboi.eth' }) + const d = deps(id) + assert.equal(await runEnsCommand(['--delete', '--json'], d, ensSeams({ 'femboi.eth': { owner: OWNER } })), 0, d.io.stdout()) + const out = d.io.json() as Record + assert.deepEqual(out.transactions, []) + assert.equal(out.publish.ensName, '') +})