Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text> --network <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 <text> --network <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).

<p align="center">
<img src="preview/menu.svg" alt="ethagent main menu for a linked agent" width="100%">
Expand Down Expand Up @@ -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 <address>` 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.

Expand Down Expand Up @@ -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 <name>`, then again with `--yes` |
| Change the name's text records | `ethagent ens --set <key>=<value> --clear <key>`, 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 <args> --operator` |
| Set up IPFS storage | `ethagent storage --set`, with the Pinata JWT on stdin |
| Create an agent | `ethagent create --name <text> --network <network>`, then again with `--yes` |
Expand Down Expand Up @@ -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 <name> [--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 <key>=<value> --clear <key> [--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 |
Expand Down Expand Up @@ -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 <args> --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.
Expand Down
2 changes: 1 addition & 1 deletion commands/ethagent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 [<address>]`, `--remove-operator <address>`, and `--activate-operator <address>` 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 <name>`, `ens --unlink`, and `ens --set <key>=<value> --clear <key>` 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 <name>`, `ens --unlink`, `ens --delete` (also removes the subname from its parent; never the operator key), and `ens --set <key>=<value> --clear <key>` 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 <args> --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 <name>`, `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):
Expand Down
1 change: 1 addition & 0 deletions src/cli/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ function printHelp(): void {
' ethagent ens the linked ENS name, its records, and who controls it',
' ethagent ens <name> 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 <key>=<value> --clear <key>',
' change text records (preview, then --yes)',
' ethagent ens <args> --operator',
Expand Down
43 changes: 37 additions & 6 deletions src/cli/onchain/ens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -43,7 +44,7 @@ import {
type HistoryDeps,
} from '../history/shared.js'

export const ENS_USAGE = 'ethagent ens [<name> | --unlink | --set <key>=<value>... --clear <key>...] [--operator] [--yes] [--no-open] [--json]'
export const ENS_USAGE = 'ethagent ens [<name> | --unlink | --delete | --set <key>=<value>... --clear <key>...] [--operator] [--yes] [--no-open] [--json]'

const HELP = [
`usage: ${ENS_USAGE}`,
Expand All @@ -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 <key>=<value> --clear <key>',
' write every record change on the linked name in one',
' resolver multicall. No save is needed.',
Expand Down Expand Up @@ -95,18 +100,20 @@ const defaultSeams: EnsSeams = {
saveConfig,
}

type Mode = { kind: 'read' } | { kind: 'swap'; name: string } | { kind: 'unlink' } | { kind: 'records'; set: Record<string, string>; clear: string[] }
type Mode = { kind: 'read' } | { kind: 'swap'; name: string } | { kind: 'unlink' } | { kind: 'delete' } | { kind: 'records'; set: Record<string, string>; clear: string[] }

function parseMode(values: Record<string, unknown>, 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<string, string> = {}
for (const entry of sets) {
Expand Down Expand Up @@ -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 },
Expand All @@ -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 <token-id>`.')
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<string, unknown> | undefined, 'ensName')
Expand Down Expand Up @@ -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 <name>`.')
const plan = await planEnsRecords({
Expand Down Expand Up @@ -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'})`)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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)) {
Expand Down
Loading
Loading