diff --git a/README.md b/README.md index a7fdf9c..9da89aa 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,13 @@ 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; see [Setting up and moving an agent](#setting-up-and-moving-an-agent). +

ethagent main menu for a linked agent

-**2. Use it anywhere.** That's the whole setup. ethagent wires itself into the AI tools you use, on any machine or OS, and your agent follows you and keeps learning. After this you rarely open the menu again: only to edit something by hand, change custody, or restore on a new machine. [Underneath](#underneath) shows what runs in the meantime. +**2. Use it anywhere.** That's the whole setup. ethagent wires itself into the AI tools you use, on any machine or OS, and your agent follows you and keeps learning. After this you rarely open the menu again: only to edit something by hand, change custody, or restore on a new machine, and each of those has a headless command too. [Underneath](#underneath) shows what runs in the meantime. If a tool wasn't picked up, `npx ethagent --status` lists the ones ethagent keeps in sync. Open the missing tool and paste this to its agent: @@ -162,6 +164,17 @@ The foundation is built on open standards, so your agent is never tied to one ap | 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` | +| Set up IPFS storage | `ethagent storage --set`, with the Pinata JWT on stdin | +| Create an agent | `ethagent create --name --network `, then again with `--yes` | +| List the agents a wallet holds | `ethagent restore --owner
` | +| Restore an agent on a new machine | `ethagent restore --network `, then again with `--yes` | +| Restore with the operator key, no browser | `keychain exec ethagent -- ethagent restore --network --operator --yes` | +| Pull the newest onchain snapshot into the vault | `ethagent restore`, then again with `--yes` | +| Change the public name, description, or image | `ethagent profile --name --description --image `, then again with `--yes` | +| Switch to Advanced custody | `ethagent custody --advanced`, then again with `--yes` | +| Switch back to Simple custody | `ethagent custody --simple`, then again with `--yes` | +| Approve the operator key as an operator | `keychain exec ethagent -- ethagent custody --add-operator --operator`, then again with `--yes` | +| Prepare the agent for a new owner | `ethagent transfer
`, then again with `--yes` | ### Commands @@ -169,7 +182,7 @@ Run any of these with `npx ethagent`. Commands marked interactive need a termina | Command | What it does | Effect | | --- | --- | --- | -| `ethagent` | Open the manager: create, restore, edit, custody, ENS, transfer. | Interactive | +| `ethagent` | Open the manager: create, restore, edit, custody, ENS, transfer. Each also has a command below. | Interactive | | `save [--json] [--no-open] [--operator]` | Back up your agent onchain. See [Saving](#saving). | Opens your wallet | | `--add ` | Connect a tool by the instructions file it loads every session. Adds one marked ethagent block to that file and a `skills` folder beside it, keeps both in sync, and leaves the rest of the file alone. | Writes that file | | `pause` / `resume` | Stop or restart background sync. | Local | @@ -184,12 +197,21 @@ 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 | +| `storage [--set \| --forget] [--json]` | Where snapshots are pinned and whether a credential is set. `--set` saves a Pinata JWT read from stdin; `--forget` removes it after a preview. | Read-only; `--set` and `--forget` write secrets | +| `create --name --network [--advanced] [--import] [--yes] [--json]` | Mint a new agent with its first snapshot. `--advanced` continues into a Vault in the same tab. Previews until `--yes`. | Opens your wallet | +| `restore [ \| ] [--network ] [--operator] [--yes] [--json]` | Rebuild an agent on this machine, or with no target pull the newest onchain snapshot into the vault. Previews until `--yes`. | Opens your wallet, or none with `--operator` | +| `restore --owner [--json]` | List the agents a wallet holds or operates. | Goes online | +| `profile [--name ] [--description ] [--image ] [--operator] [--yes] [--json]` | Show the public profile, or change it and publish in one save. Previews until `--yes`. | Opens your wallet, or none with `--operator` | | `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` | +| `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 | +| `custody --add-operator [
] \| --remove-operator
\| --activate-operator
[--operator] [--yes] [--json]` | Manage operators; the owner approves the change and the Vault approvals follow. Previews until `--yes`. | Opens your wallet | +| `transfer [--yes] [--json]` | Re-encrypt the agent for a new owner and publish it; you then send the token yourself. Previews until `--yes`. | Opens your wallet | | `--version`, `--help` | Version, or the full command list. Every history and onchain command also takes `--help`. | Read-only | ### Saving @@ -217,14 +239,27 @@ Run any of these with `npx ethagent`. Commands marked interactive need a termina ### Onchain identity -`ethagent custody` and `ethagent ens` work without the manager. +`ethagent custody` and `ethagent ens` work without the manager. See also [Setting up and moving an agent](#setting-up-and-moving-an-agent). - **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. + +### 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 now 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. +- **Resuming.** Custody changes are planned from the chain each run. A run that stops part way, for example a cancelled prompt after the Vault was deployed, says what landed (exit 4, or 3 when cancelled), and running the same command again finishes the rest. +- **Restore and the operator key.** `restore --operator` decrypts with the operator key through its slot in the snapshot. Nothing opens in the browser, so an agent can restore itself on a new machine. The owner must have saved once since that operator was approved. Whatever the vault held is checkpointed first, and `ethagent rollback --undo --yes` puts it back. +- **Approving the operator key.** `custody --add-operator --operator` has the injected key sign its own proof locally, so only the owner approves in the browser. +- **Profile with the operator key.** `profile --operator` works in Advanced custody with a linked ENS name and the key approved as an operator. Otherwise it says what is missing. +- **Simple custody revokes first.** `custody --simple` revokes every operator the Vault still approves before withdrawing the token, so leftover approvals can't outlive the switch. +- **Transfers.** `transfer` re-encrypts the agent for the receiver, with both wallets signing in the same browser. ethagent never moves the token: send it yourself afterwards, and the receiver runs `ethagent restore `. +- **Storage.** `storage --set` reads the JWT from stdin, never from an argument. ### History commands @@ -400,13 +435,13 @@ With `--json`, every history and onchain command prints one line of ASCII-only J | `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, `--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. | +| `4` | Partly done (a custody change or `create --advanced` that stopped after something landed; run it again to finish), or `status --verify` or `custody --verify` found a mismatch. | ### Environment variables | Variable | Effect | | --- | --- | -| `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_OPERATOR_KEY` | The operator key for `save --operator`, `fetch`, `restore --operator`, `profile --operator`, `custody --add-operator --operator`, 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. | diff --git a/commands/ethagent.md b/commands/ethagent.md index e991521..216a7f5 100644 --- a/commands/ethagent.md +++ b/commands/ethagent.md @@ -5,7 +5,7 @@ description: Point the user at ethagent. ethagent stores an agent's identity onc Most of these run in a separate terminal because they need a wallet or a TTY, but a few are safe to run from inside this session; each bullet says which: -- `npx ethagent` opens the interactive identity manager for anything that needs a wallet signature (create agent, set ENS, switch custody, prepare transfer). +- `npx ethagent` opens the interactive identity manager. Every action in it also has a headless command (`create`, `restore`, `profile`, `custody`, `ens`, `transfer`, `storage`); see "Onchain identity" below. - `ethagent save` runs Save Snapshot (encrypt, pin to IPFS, rotate the onchain pointer). **You can run this yourself** as a normal tool call (e.g. `npx ethagent save`): no separate terminal and no TTY, and it will not hang. It prints a localhost wallet URL and opens the browser tab so the user approves the signature and transaction there. You trigger and run it; the user only approves in the wallet. Pass `--no-open` to just print the URL. Only run it when the user specifically asks to save or back up the agent; never run it on your own initiative or as a side effect of other work. It is a no-op (no wallet, no gas) when there are no local changes since the last snapshot. - Syncing is automatic: ethagent keeps the agent's soul, memory, and skills in step across every detected tool in the background, both ways, newest edit wins. There is no sync command to run. Pause it with `npx ethagent pause` and resume with `npx ethagent resume`. - `npx ethagent --status` prints a short summary: agent id, chain, address, any local changes, the vault path, and the connected tools. @@ -16,7 +16,7 @@ Most of these run in a separate terminal because they need a wallet or a TTY, bu 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. -You may run the read-only non-interactive commands yourself whenever they help (`--status`, `--vault-dir`, and the read-only history commands below). `ethagent save` is different: run it only when the user specifically asks you to save or back up the agent, never on your own initiative or as a side effect of other work. When they do ask, you can run it directly: there is no CLI step for the user and no separate terminal needed. It is headless, will not hang, prints a wallet URL, and opens the browser tab where the user approves the signature. You trigger and run the command; you never sign. (It is also a no-op when there are no local changes since the last snapshot.) Never launch the bare interactive `npx ethagent` from inside a session: it opens a full-screen terminal app that needs a TTY and will hang the tool call. Anything else that needs a wallet signature (create, ENS, custody, transfer), the user always runs themselves. +You may run the read-only non-interactive commands yourself whenever they help (`--status`, `--vault-dir`, and the read-only history commands below). `ethagent save` is different: run it only when the user specifically asks you to save or back up the agent, never on your own initiative or as a side effect of other work. When they do ask, you can run it directly: there is no CLI step for the user and no separate terminal needed. It is headless, will not hang, prints a wallet URL, and opens the browser tab where the user approves the signature. You trigger and run the command; you never sign. (It is also a no-op when there are no local changes since the last snapshot.) Never launch the bare interactive `npx ethagent` from inside a session: it opens a full-screen terminal app that needs a TTY and will hang the tool call. For anything else that signs or sends (`create`, `restore`, `profile`, `custody`, `ens`, `transfer`), run the preview yourself and leave the `--yes` run to the user. Where the synced files land: @@ -57,11 +57,20 @@ 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`. +- `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 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): + +- `profile` (read-only) shows the public name, description, and image; `profile --name --description --image ` changes them in one save. With `--operator` the operator key signs, when advanced custody with a linked ENS name allows it. +- `restore --owner ` (read-only) lists the agents a wallet holds. `restore --network ` or `restore ` rebuilds an agent on this machine; `restore` with no target pulls the newest onchain snapshot into the vault. `restore --operator --yes` decrypts with the operator key and opens no browser, so you may run it yourself when the user asks you to restore or refresh the agent (through `keychain exec ethagent -- ...`). It checkpoints the vault first; `rollback --undo --yes` reverses it. +- `create --name --network ` mints a new agent; `--advanced` continues into a Vault. It costs gas: preview it, then leave `--yes` to the user. +- `transfer ` re-encrypts the agent for a new owner; both wallets sign in the user's browser, and the user sends the token afterwards. +- `storage` (read-only) says whether IPFS storage is set up; `storage --set` reads a Pinata JWT from stdin. Never put the JWT in a command line. + 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. +If they ask "what's my agent" or "list my skills" without an identity yet, point them at `npx ethagent`, or preview `ethagent create` or `ethagent restore` for them. diff --git a/src/cli/harnessGuidance.ts b/src/cli/harnessGuidance.ts index 9efae03..0b9660a 100644 --- a/src/cli/harnessGuidance.ts +++ b/src/cli/harnessGuidance.ts @@ -19,7 +19,7 @@ export function buildPortableInstruction(opts: PortableInstructionOptions): stri '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. ' + - '`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." + '`npx ethagent custody --json`, `ens --json`, and `profile --json` show the onchain identity; `create`, `restore`, ' + + "`profile`, `custody`, `ens`, and `transfer` 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 737d205..4968a15 100644 --- a/src/cli/main.tsx +++ b/src/cli/main.tsx @@ -62,8 +62,17 @@ function printHelp(): void { ' ethagent rollback restore past bytes (preview, then --yes)', ' ethagent forget erase local history (preview, then --yes)', '', + 'setting up and moving an agent (preview first, act with --yes; --help for details):', + ' ethagent create mint a new agent (--name, --network, --advanced, --import)', + ' ethagent restore [target] rebuild an agent here, or pull the newest snapshot (--operator: no browser)', + ' ethagent profile show or change the public name, description, and image', + ' ethagent transfer re-encrypt the agent for a new owner', + ' ethagent storage IPFS storage credential (--set reads it from stdin)', + '', 'onchain identity (add --json for machine output, --help for details):', ' ethagent custody [--verify] Vault, build, holder, and operators; --verify simulates permissions', + ' ethagent custody --advanced | --simple | --add-operator | --remove-operator | --activate-operator', + ' change custody (preview, then --yes)', ' 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)', diff --git a/src/cli/onchain/commands.ts b/src/cli/onchain/commands.ts index f98d614..71eada0 100644 --- a/src/cli/onchain/commands.ts +++ b/src/cli/onchain/commands.ts @@ -1,3 +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']) +export const ONCHAIN_COMMANDS = new Set(['custody', 'ens', 'restore', 'profile', 'create', 'storage', 'transfer']) diff --git a/src/cli/onchain/create.ts b/src/cli/onchain/create.ts new file mode 100644 index 0000000..4353b57 --- /dev/null +++ b/src/cli/onchain/create.ts @@ -0,0 +1,169 @@ +import type { EthagentConfig, EthagentIdentity } from '../../storage/config.js' +import { saveConfig } from '../../storage/config.js' +import { resolveValidatedPinataJwt } from '../../identity/storage/pinataJwt.js' +import { runCreateSigning } from '../../identity/manager/create/effects.js' +import { scanImportCandidates, type ImportCandidate } from '../../identity/manager/create/importScan.js' +import { openBrowserWalletSession, type BrowserWalletReady, type BrowserWalletSession } from '../../identity/wallet/browserWallet.js' +import { openExternalUrl } from '../../utils/openExternal.js' +import { emitJson, failFrom, HistoryError, parseHistoryArgs, type HistoryDeps } from '../history/shared.js' +import { isWalletCancelled } from '../../identity/manager/shared/utils.js' +import { defaultCustodyWriteSeams, runCustodyWrite, type CustodyWriteSeams } from './custodyWrite.js' +import { parseNetwork, persistIdentity, quietCallbacks, registryForNetwork, requireStorage, walletCancelled, WalletTab } from './shared.js' + +export const CREATE_USAGE = 'ethagent create --name --network mainnet|base [--description ] [--advanced] [--import] [--replace] [--yes] [--no-open] [--json]' + +const HELP = [ + `usage: ${CREATE_USAGE}`, + '', + 'Mints a new ERC-8004 agent token to the wallet you approve with, with an encrypted first', + 'snapshot of its soul, memory, and skills. Previews until --yes. Needs gas on the network.', + '', + ' --advanced continue into Advanced custody in the same browser tab: deploy a Vault,', + ' deposit the token, and save', + ' --import fold the notes this machine\'s tools already hold into the first MEMORY.md', + ' --replace allowed when this machine already has an agent; its vault stays on disk', + '', + 'Add an image afterwards with `ethagent profile --image `.', + '', +].join('\n') + +export type CreateSeams = { + sign: typeof runCreateSigning + scanImports: () => Promise + resolveJwt: typeof resolveValidatedPinataJwt + openSession: (onReady: (ready: BrowserWalletReady) => void) => Promise + openExternal: (url: string) => void + saveConfig: (config: EthagentConfig) => Promise + custody: CustodyWriteSeams +} + +const defaultSeams: CreateSeams = { + sign: runCreateSigning, + scanImports: scanImportCandidates, + resolveJwt: resolveValidatedPinataJwt, + openSession: onReady => openBrowserWalletSession({ title: 'ethagent create', onReady }), + openExternal: url => openExternalUrl(url), + saveConfig, + custody: defaultCustodyWriteSeams, +} + +export async function runCreateCommand(args: string[], deps: HistoryDeps, seams: CreateSeams = defaultSeams): Promise { + const json = args.includes('--json') + try { + const { values, positionals } = parseHistoryArgs(args, { + name: { type: 'string' }, + description: { type: 'string' }, + network: { type: 'string' }, + advanced: { type: 'boolean' }, + import: { type: 'boolean' }, + replace: { type: 'boolean' }, + yes: { type: 'boolean' }, + 'no-open': { type: 'boolean' }, + }, CREATE_USAGE) + if (values.help) { + await deps.io.out(HELP) + return 0 + } + if (positionals.length > 0) throw new HistoryError(2, `unexpected argument: ${positionals[0]}`, `usage: ${CREATE_USAGE}`) + const name = typeof values.name === 'string' ? values.name.trim() : '' + if (name.length < 2) throw new HistoryError(2, '--name needs at least 2 characters', `usage: ${CREATE_USAGE}`) + const network = parseNetwork(values.network) + if (!network) throw new HistoryError(2, 'Which network should the agent live on?', 'Pass --network mainnet or --network base; minting costs gas there.') + const description = typeof values.description === 'string' ? values.description.trim() : '' + const custodyMode = values.advanced ? 'advanced' as const : 'simple' as const + + const config = await deps.loadConfig().catch(() => null) + const existing = config?.identity + if (existing?.agentId && !values.replace) { + throw new HistoryError(1, `This machine already has agent #${existing.agentId}.`, 'Pass --replace to create another; the current vault stays on disk and `ethagent restore` brings it back.') + } + const registry = registryForNetwork(network) + const imports = values.import ? await seams.scanImports() : [] + const summary = { + action: 'create', + name, + description: description || null, + network, + chainId: registry.chainId, + custody: custodyMode, + imports: imports.map(item => ({ source: item.source, lines: item.contentLines })), + replaces: existing?.agentId ?? null, + steps: [ + { step: 'mint', description: `mint the agent token on ${network} and pin its first snapshot`, signer: 'the wallet you approve with (it becomes the owner)' }, + ...(custodyMode === 'advanced' + ? [ + { step: 'deploy', description: 'deploy a Vault bound to the new token', signer: 'owner wallet' }, + { step: 'deposit', description: 'deposit the token into the Vault', signer: 'owner wallet' }, + { step: 'save', description: 'publish Advanced custody, through the Vault', signer: 'owner wallet' }, + ] + : []), + ], + } + if (!values.yes) { + if (json) { + await emitJson(deps.io, { applied: false, ...summary }) + } else { + const lines = [`Preview (nothing pinned or sent). Create "${name}" on ${network}${custodyMode === 'advanced' ? ' in Advanced custody' : ''}.`] + summary.steps.forEach((step, index) => lines.push(` ${index + 1}. ${step.description} [${step.signer}]`)) + if (values.import) lines.push(imports.length ? ` imports notes from ${imports.map(item => item.source).join(', ')}` : ' no notes worth importing were found') + if (existing?.agentId) lines.push(` note: replaces agent #${existing.agentId} on this machine; its vault stays on disk`) + lines.push('Run again with --yes to create. Every step runs in one browser tab.') + await deps.io.out(`${lines.join('\n')}\n`) + } + return 0 + } + + const jwt = await requireStorage(seams.resolveJwt) + const tab = new WalletTab(seams.openSession, deps.io, json, Boolean(values['no-open']), seams.openExternal) + let created: EthagentIdentity | undefined + let createdConfig: EthagentConfig | undefined + let custodyResult: Record | undefined + try { + await seams.sign({ + kind: 'create-signing', + name, + description, + registry, + custodyMode, + pinataJwt: jwt, + ...(imports.length ? { importNotes: imports } : {}), + }, quietCallbacks({ + onIdentityComplete: async next => { + createdConfig = await persistIdentity(next, { loadConfig: deps.loadConfig, saveConfig: seams.saveConfig }) + created = next + }, + }), { session: await tab.get() }) + if (!created || !createdConfig) throw new HistoryError(1, 'The mint did not complete; nothing was saved.') + if (custodyMode === 'advanced') { + await runCustodyWrite({ kind: 'advanced' }, { yes: true, json, noOpen: Boolean(values['no-open']), operator: false }, deps, createdConfig, created, seams.custody, { + tab, + onResult: result => { custodyResult = result }, + }) + } + } catch (err: unknown) { + if (created) { + // The mint landed and is saved; only the custody switch is left. + const reason = isWalletCancelled(err) ? 'Wallet approval was cancelled.' : err instanceof Error ? err.message : String(err) + throw new HistoryError(4, `${reason} Agent #${created.agentId} was minted and saved on this machine; finish Advanced custody with \`ethagent custody --advanced\`.`) + } + const cancelled = walletCancelled(err, []) + if (cancelled) throw cancelled + throw err + } finally { + await tab.close() + } + const result = { + applied: true, + ...summary, + agentId: created.agentId ?? null, + owner: created.ownerAddress ?? null, + txHash: created.backup?.txHash ?? null, + ...(custodyResult ? { custodyResult } : {}), + } + if (json) await emitJson(deps.io, result) + else await deps.io.out(`Created agent #${created.agentId} on ${network}${custodyResult ? ' in Advanced custody' : ''}.\n`) + return 0 + } catch (err) { + return failFrom(deps.io, json, err) + } +} diff --git a/src/cli/onchain/custody.ts b/src/cli/onchain/custody.ts index 97bfe6a..a62d2e9 100644 --- a/src/cli/onchain/custody.ts +++ b/src/cli/onchain/custody.ts @@ -15,8 +15,9 @@ import { readCustodyMode, readIdentityStateString } from '../../identity/manager 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' +import { defaultCustodyWriteSeams, parseCustodyWrite, runCustodyWrite, type CustodyWriteSeams } from './custodyWrite.js' -export const CUSTODY_USAGE = 'ethagent custody [--verify] [--json]' +export const CUSTODY_USAGE = 'ethagent custody [--verify | --advanced | --simple | --add-operator [
] | --remove-operator
| --activate-operator
] [--operator] [--yes] [--no-open] [--json]' const STRANGER = getAddress('0x000000000000000000000000000000000000dEaD') const ZERO = '0x0000000000000000000000000000000000000000' @@ -63,10 +64,25 @@ async function simulate( } } -export async function runCustodyCommand(args: string[], deps: HistoryDeps, seams: CustodySeams = defaultSeams): Promise { +export async function runCustodyCommand( + args: string[], + deps: HistoryDeps, + seams: CustodySeams = defaultSeams, + writeSeams: CustodyWriteSeams = defaultCustodyWriteSeams, +): Promise { const json = args.includes('--json') try { - const { values } = parseHistoryArgs(args, { verify: { type: 'boolean' } }, CUSTODY_USAGE) + const { values, positionals } = parseHistoryArgs(args, { + verify: { type: 'boolean' }, + advanced: { type: 'boolean' }, + simple: { type: 'boolean' }, + 'add-operator': { type: 'boolean' }, + 'remove-operator': { type: 'string' }, + 'activate-operator': { type: 'string' }, + operator: { type: 'boolean' }, + yes: { type: 'boolean' }, + 'no-open': { type: 'boolean' }, + }, CUSTODY_USAGE) if (values.help) { await deps.io.out([ `usage: ${CUSTODY_USAGE}`, @@ -76,10 +92,29 @@ export async function runCustodyCommand(args: string[], deps: HistoryDeps, seams '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.', '', + 'Changes (preview until --yes, every wallet prompt in one browser tab, owner wallet signs):', + ' --advanced deploy a Vault (or reuse one), deposit the token, then save', + ' --simple revoke the Vault operators, withdraw the token, then save', + ' --add-operator [
] approve an operator; it signs a proof in the browser, or', + ' with --operator the injected operator key signs it locally', + ' --remove-operator
remove an operator and revoke it on the Vault', + ' --activate-operator
make an approved operator the active one', + 'Each change is planned from chain state, so a run that stops part way resumes when run again.', + '', ].join('\n')) return 0 } const { config, identity } = await requireIdentity(deps) + const write = parseCustodyWrite(values, positionals) + if (write) { + return await runCustodyWrite(write, { + yes: Boolean(values.yes), + json, + noOpen: Boolean(values['no-open']), + operator: Boolean(values.operator), + }, deps, config, identity, writeSeams) + } + if (values.yes || values.operator) throw new HistoryError(2, '--yes and --operator need a change: --advanced, --simple, or an operator flag', `usage: ${CUSTODY_USAGE}`) 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.') diff --git a/src/cli/onchain/custodyWrite.ts b/src/cli/onchain/custodyWrite.ts new file mode 100644 index 0000000..5dcc7d7 --- /dev/null +++ b/src/cli/onchain/custodyWrite.ts @@ -0,0 +1,378 @@ +import { getAddress, isAddress, type Address } from 'viem' +import type { EthagentConfig, EthagentIdentity } from '../../storage/config.js' +import { saveConfig } from '../../storage/config.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 { + assertVaultBytecode, + confirmAgentInVault, + discoverPriorVaultFromTokenOwner, + isAgentInVault, + readMetadataOperators, +} from '../../identity/registry/vault.js' +import { + assertVaultCanAcceptAgent, + findReusableDeployedVault, + receiptPacing, + recordDeployedVault, + resolveVaultAddress, + runVaultDeployTransaction, + runVaultDepositTransaction, + runVaultUnwrapTransaction, + VaultRefusedDepositError, +} from '../../identity/manager/custody/transactions.js' +import { humanOwnerAddress, localOperatorAddresses } from '../../identity/manager/custody/helpers.js' +import { readCustodyMode } from '../../identity/manager/custody/state.js' +import { runRebackupSigningInSession } from '../../identity/manager/continuity/effects.js' +import { revokeVaultOperatorsBeforeWithdraw } from '../../identity/manager/shared/effects/sync.js' +import { + activateOperatorUpdates, + addOperatorUpdates, + operatorAccessContext, + operatorProofChallenge, + operatorRecordFromProof, + removeOperatorUpdates, +} from '../../identity/manager/shared/operatorAccess.js' +import type { ProfileUpdates, Step } from '../../identity/manager/reducer.js' +import { continuityVaultStatus } from '../../identity/continuity/storage/status.js' +import { resolveValidatedPinataJwt } from '../../identity/storage/pinataJwt.js' +import { signMessage } from '../../identity/crypto/eth.js' +import { openBrowserWalletSession, type BrowserWalletReady, type BrowserWalletSession } from '../../identity/wallet/browserWallet.js' +import { openExternalUrl } from '../../utils/openExternal.js' +import { pullHarnessSoulMemoryIntoVault } from '../sync.js' +import { emitJson, HistoryError, type HistoryDeps } from '../history/shared.js' +import { quietCallbacks, requireOperatorKey, requireStorage, walletCancelled, WalletTab } from './shared.js' + +export type CustodyWrite = + | { kind: 'advanced' } + | { kind: 'simple' } + | { kind: 'add-operator'; address?: Address } + | { kind: 'remove-operator'; address: Address } + | { kind: 'activate-operator'; address: Address } + +export type CustodyWriteSeams = { + client: (registry: Erc8004RegistryConfig) => Pick, 'readContract' | 'getBytecode' | 'simulateContract' | 'getBlockNumber'> + priorVault: typeof discoverPriorVaultFromTokenOwner + reusableVault: typeof findReusableDeployedVault + deploy: typeof runVaultDeployTransaction + deposit: typeof runVaultDepositTransaction + confirmDeposit: typeof confirmAgentInVault + unwrap: typeof runVaultUnwrapTransaction + revoke: typeof revokeVaultOperatorsBeforeWithdraw + recordVault: typeof recordDeployedVault + publish: typeof runRebackupSigningInSession + resolveJwt: typeof resolveValidatedPinataJwt + vaultStatus: typeof continuityVaultStatus + pullHarness: typeof pullHarnessSoulMemoryIntoVault + openSession: (onReady: (ready: BrowserWalletReady) => void) => Promise + openExternal: (url: string) => void + saveConfig: (config: EthagentConfig) => Promise +} + +export const defaultCustodyWriteSeams: CustodyWriteSeams = { + client: registry => createErc8004PublicClient(registry), + priorVault: discoverPriorVaultFromTokenOwner, + reusableVault: findReusableDeployedVault, + deploy: runVaultDeployTransaction, + deposit: runVaultDepositTransaction, + confirmDeposit: confirmAgentInVault, + unwrap: runVaultUnwrapTransaction, + revoke: revokeVaultOperatorsBeforeWithdraw, + recordVault: recordDeployedVault, + publish: runRebackupSigningInSession, + resolveJwt: resolveValidatedPinataJwt, + vaultStatus: continuityVaultStatus, + pullHarness: pullHarnessSoulMemoryIntoVault, + openSession: onReady => openBrowserWalletSession({ title: 'ethagent custody', onReady }), + openExternal: url => openExternalUrl(url), + saveConfig, +} + +type PlannedStep = { step: string; description: string; signer: string; simulation?: string } + +export function parseCustodyWrite(values: Record, positionals: string[]): CustodyWrite | null { + const chosen = ['advanced', 'simple', 'add-operator', 'remove-operator', 'activate-operator'].filter(flag => values[flag] !== undefined && values[flag] !== false) + if (chosen.length === 0) { + if (positionals.length > 0) throw new HistoryError(2, `unexpected argument: ${positionals[0]}`) + return null + } + if (chosen.length > 1) throw new HistoryError(2, `choose one of ${chosen.map(flag => `--${flag}`).join(', ')}`) + if (values.verify) throw new HistoryError(2, '--verify is read-only; run it on its own') + const flag = chosen[0]! + const address = (raw: unknown, name: string): Address => { + if (typeof raw !== 'string' || !isAddress(raw, { strict: false })) throw new HistoryError(2, `--${name} expects an address`) + return getAddress(raw) + } + if (flag === 'advanced' || flag === 'simple') { + if (positionals.length > 0) throw new HistoryError(2, `unexpected argument: ${positionals[0]}`) + return { kind: flag } + } + if (flag === 'add-operator') { + if (positionals.length > 1) throw new HistoryError(2, `unexpected argument: ${positionals[1]}`) + return positionals[0] ? { kind: 'add-operator', address: address(positionals[0], 'add-operator') } : { kind: 'add-operator' } + } + return { kind: flag as 'remove-operator' | 'activate-operator', address: address(values[flag], flag) } +} + +// Runs a custody change: plans it from chain state (so a re-run resumes where the +// last one stopped), previews it, and with --yes runs every wallet prompt in one tab. +export async function runCustodyWrite( + write: CustodyWrite, + flags: { yes: boolean; json: boolean; noOpen: boolean; operator: boolean }, + deps: HistoryDeps, + config: EthagentConfig, + identity: EthagentIdentity, + seams: CustodyWriteSeams, + // A caller that already has a wallet tab open (create --advanced) passes it, and + // reports the outcome itself. + embed?: { tab: WalletTab; onResult: (result: Record) => void }, +): Promise { + if (!identity.agentId) throw new HistoryError(1, 'This identity has no agent token ID yet.') + const registry = resolveRegistryForIdentity(identity, config) + if (!registry) throw new HistoryError(1, 'No agent registry is configured for this identity.') + const agentId = BigInt(identity.agentId) + const owner = getAddress(humanOwnerAddress(identity)) + const mode = readCustodyMode(identity.state as Record | undefined) ?? 'simple' + const client = seams.client(registry) + const operatorKey = flags.operator ? requireOperatorKey(deps, 'custody') : undefined + if (operatorKey && write.kind !== 'add-operator') { + throw new HistoryError(2, '--operator only applies to --add-operator, where the operator key signs its own proof') + } + + const steps: PlannedStep[] = [] + let profileUpdates: ProfileUpdates | null = null + let vaultAddress: Address | undefined = resolveVaultAddress(identity, config.erc8004?.operatorVaults) + let needDeploy = false + let needDeposit = false + let needUnwrap = false + let revokeCandidates: Address[] = [] + let addProof: { challenge: (account: Address) => string; ctx: ReturnType } | null = null + + if (write.kind === 'advanced') { + const tokenOwner = getAddress(await client.readContract({ + address: registry.identityRegistryAddress, abi: ERC8004_ABI, functionName: 'ownerOf', args: [agentId], + }) as Address) + if (!vaultAddress) { + const prior = await seams.priorVault({ client, registry: registry.identityRegistryAddress, agentId, expectedOwner: owner }) + if (prior.found) vaultAddress = prior.vaultAddress + } + let inVault = false + if (vaultAddress) { + const status = await isAgentInVault({ client, vaultAddress, registry: registry.identityRegistryAddress, agentId }) + inVault = status.inVault + if (status.inVault && status.ownerAddress?.toLowerCase() !== owner.toLowerCase()) { + throw new HistoryError(1, `Vault ${vaultAddress} holds token #${agentId.toString()} for ${status.ownerAddress}, not the owner wallet ${owner}.`, 'Only that depositor can withdraw it.') + } + } + if (!inVault && tokenOwner.toLowerCase() !== owner.toLowerCase()) { + throw new HistoryError(1, `Token #${agentId.toString()} is held by ${tokenOwner}, not the owner wallet ${owner}.`) + } + if (mode === 'advanced' && inVault) { + return done(deps, flags.json, 'Already in Advanced custody: the Vault holds the token.') + } + if (!vaultAddress && !inVault) { + const reusable = await seams.reusableVault({ registry, agentId, owner }) + if (reusable) vaultAddress = reusable + } + if (!vaultAddress) { + needDeploy = true + steps.push({ step: 'deploy', description: 'deploy a Vault bound to this token', signer: `owner wallet ${owner}` }) + } + if (!inVault) { + needDeposit = true + let simulation: string | undefined + if (vaultAddress) { + try { + const build = await assertVaultBytecode(client, vaultAddress) + await assertVaultCanAcceptAgent({ registry, vaultAddress, agentId, build, owner, client }) + simulation = 'would succeed' + } catch (err: unknown) { + if (!(err instanceof VaultRefusedDepositError) && !(err instanceof Error && err.name === 'VaultBytecodeMismatchError')) throw err + simulation = `refused: ${err.message}` + } + } + steps.push({ step: 'deposit', description: `deposit token #${agentId.toString()} into ${vaultAddress ?? 'the new Vault'}`, signer: `owner wallet ${owner}`, ...(simulation ? { simulation } : {}) }) + } + profileUpdates = { custodyMode: 'advanced', ownerAddress: owner, bumpRestoreAccessEpoch: true, custodyPhase: 'switch-advanced' } + steps.push({ step: 'save', description: 'publish Advanced custody, through the Vault', signer: `owner wallet ${owner}` }) + } else if (write.kind === 'simple') { + if (vaultAddress) { + const status = await isAgentInVault({ client, vaultAddress, registry: registry.identityRegistryAddress, agentId }) + if (status.inVault) { + if (status.ownerAddress?.toLowerCase() !== owner.toLowerCase()) { + throw new HistoryError(1, `Vault ${vaultAddress} holds token #${agentId.toString()} for ${status.ownerAddress}, not the owner wallet ${owner}.`, 'Only that depositor can withdraw it.') + } + const approvals = await readMetadataOperators({ + client, vaultAddress, registry: registry.identityRegistryAddress, agentId, candidates: localOperatorAddresses(identity), + }) + revokeCandidates = Object.entries(approvals).filter(([, on]) => on).map(([address]) => getAddress(address)) + for (const operator of revokeCandidates) { + steps.push({ step: 'revoke', description: `revoke operator ${operator} on the Vault`, signer: `owner wallet ${owner}` }) + } + needUnwrap = true + steps.push({ step: 'withdraw', description: `withdraw token #${agentId.toString()} from ${vaultAddress} to ${owner}`, signer: `owner wallet ${owner}` }) + } + } + if (mode === 'simple' && !needUnwrap) return done(deps, flags.json, 'Already in Simple custody.') + profileUpdates = { + custodyMode: 'simple', + bumpRestoreAccessEpoch: true, + custodyPhase: 'switch-simple', + approvedOperatorWallets: [], + activeOperatorAddress: '', + operatorVaultAddress: '', + } + steps.push({ step: 'save', description: 'publish Simple custody', signer: `owner wallet ${owner}` }) + } else { + if (mode !== 'advanced') throw new HistoryError(1, 'Operators need Advanced custody.', 'Switch first with `ethagent custody --advanced`.') + let ctx: ReturnType + try { + ctx = operatorAccessContext(identity, registry) + } catch (err: unknown) { + throw new HistoryError(1, err instanceof Error ? err.message : String(err)) + } + if (write.kind === 'add-operator') { + const target = operatorKey ? operatorKey.address : write.address + if (operatorKey && write.address && write.address.toLowerCase() !== operatorKey.address.toLowerCase()) { + throw new HistoryError(2, `--operator adds the injected key ${operatorKey.address}, not ${write.address}`) + } + if (target && target.toLowerCase() === owner.toLowerCase()) throw new HistoryError(1, 'The operator wallet must differ from the owner wallet.') + if (target && ctx.records.some(record => record.address.toLowerCase() === target.toLowerCase())) { + return done(deps, flags.json, `${target} is already an approved operator.`) + } + addProof = { ctx, challenge: account => operatorProofChallenge(ctx, account) } + steps.push({ + step: 'operator-proof', + description: `the operator ${target ?? 'wallet that connects'} signs its restore-access proof`, + signer: operatorKey ? `operator key ${operatorKey.address}, locally` : `operator wallet ${target ?? '(any)'}`, + }) + } else if (write.kind === 'remove-operator') { + try { + profileUpdates = removeOperatorUpdates(ctx, write.address) + } catch (err: unknown) { + throw new HistoryError(1, err instanceof Error ? err.message : String(err)) + } + } else { + try { + profileUpdates = activateOperatorUpdates(ctx, write.address) + } catch (err: unknown) { + throw new HistoryError(1, err instanceof Error ? err.message : String(err)) + } + if (ctx.activeOperatorAddress?.toLowerCase() === write.address.toLowerCase()) { + return done(deps, flags.json, `${write.address} is already the active operator.`) + } + } + steps.push({ step: 'save', description: 'publish the operator list and sync the Vault approvals', signer: `owner wallet ${owner}` }) + } + + const summary = { action: write.kind, owner, vault: vaultAddress ?? null, steps } + if (!flags.yes) { + if (flags.json) { + await emitJson(deps.io, { applied: false, ...summary }) + } else { + const lines = ['Preview (nothing signed or sent).'] + steps.forEach((step, index) => lines.push(` ${index + 1}. ${step.description} [${step.signer}]${step.simulation ? ` (${step.simulation})` : ''}`)) + lines.push('Run again with --yes. A run that stops part way resumes where it left off.') + await deps.io.out(`${lines.join('\n')}\n`) + } + return 0 + } + + // Everything that could stop the final save is checked before the first transaction. + const vault = await seams.vaultStatus(identity).catch(() => ({ ready: false })) + if (!vault.ready) throw new HistoryError(1, 'Local continuity files are not restored.', 'Run `ethagent restore` first. Nothing was sent.') + const jwt = await requireStorage(seams.resolveJwt) + await seams.pullHarness(identity).catch(() => []) + + const tab = embed?.tab ?? new WalletTab(seams.openSession, deps.io, flags.json, flags.noOpen, seams.openExternal) + const confirmed: string[] = [] + let saved: EthagentIdentity | undefined + const callbacks = quietCallbacks() + try { + if (needDeploy) { + const deployed = await seams.deploy({ + registry, walletAddress: owner, agentId, callbacks, session: await tab.get(), flowId: 'custody-switch', flowStep: 1, + onDeployed: address => seams.recordVault(registry.chainId, address), + }) + vaultAddress = deployed.vaultAddress + confirmed.push(`Vault ${deployed.vaultAddress} deployed (${deployed.txHash})`) + } + if (needDeposit && vaultAddress) { + const deposited = await seams.deposit({ identity, registry, vaultAddress, callbacks, session: await tab.get(), flowId: 'custody-switch', flowStep: 2 }) + const status = await seams.confirmDeposit({ + client, vaultAddress, registry: registry.identityRegistryAddress, agentId, + pacing: receiptPacing(registry, client, deposited.receiptBlock), + }) + if (status.ownerAddress.toLowerCase() !== owner.toLowerCase()) { + throw new Error(`The Vault recorded the depositor as ${status.ownerAddress}, not ${owner}.`) + } + confirmed.push(`token deposited (${deposited.txHash})`) + } + if (revokeCandidates.length > 0 && vaultAddress) { + const revoked = await seams.revoke({ identity, registry, vaultAddress, ownerAddress: owner, candidates: revokeCandidates, callbacks, session: await tab.get() }) + if (revoked.length > 0) confirmed.push(`revoked ${revoked.join(', ')}`) + } + if (needUnwrap && vaultAddress) { + const unwrapped = await seams.unwrap({ identity, registry, vaultAddress, callbacks, session: await tab.get(), agentId }) + if (unwrapped) confirmed.push(`token withdrawn (${unwrapped.txHash})`) + } + if (addProof) { + let proof: { account: Address; message: string; signature: string } + if (operatorKey) { + const message = addProof.challenge(operatorKey.address) + proof = { account: operatorKey.address, message, signature: signMessage(operatorKey.key, message) } + } else { + const target = write.kind === 'add-operator' ? write.address : undefined + proof = await (await tab.get()).requestSignature({ + chainId: registry.chainId, + purpose: 'operator-proof', + ...(target ? { expectedAccount: target } : {}), + messageForAccount: account => addProof!.challenge(account), + }) + } + profileUpdates = addOperatorUpdates(addProof.ctx, operatorRecordFromProof(addProof.ctx, proof)) + } + if (write.kind === 'advanced' && vaultAddress) profileUpdates = { ...profileUpdates, operatorVaultAddress: vaultAddress } + const step: Extract = { + kind: 'rebackup-signing', + identity, + registry, + pinataJwt: jwt, + profileUpdates: profileUpdates!, + returnTo: { kind: 'menu' }, + ...(write.kind !== 'simple' && vaultAddress ? { vaultAddress } : {}), + } + await seams.publish(step, quietCallbacks({ + onIdentityComplete: async next => { + await seams.saveConfig({ ...config, identity: next }) + saved = next + }, + }), await tab.get(), write.kind === 'advanced' ? { flowId: 'custody-switch', flowStep: 3 } : undefined) + } catch (err: unknown) { + const cancelled = walletCancelled(err, confirmed) + if (cancelled) throw cancelled + if (confirmed.length > 0) { + throw new HistoryError(4, `${err instanceof Error ? err.message : String(err)} Already confirmed: ${confirmed.join('; ')}. Run the same command again to finish.`) + } + throw err + } finally { + if (!embed) await tab.close() + } + if (!saved) throw new HistoryError(4, `The custody save did not complete.${confirmed.length ? ` Already confirmed: ${confirmed.join('; ')}.` : ''} Run the same command again to finish.`) + const result = { applied: true, ...summary, vault: write.kind === 'simple' ? null : vaultAddress ?? null, confirmed, txHash: saved.backup?.txHash ?? null } + if (embed) { + embed.onResult(result) + return 0 + } + if (flags.json) await emitJson(deps.io, result) + else await deps.io.out(`Done: ${[...confirmed, 'custody saved'].join('; ')}.\n`) + return 0 +} + +async function done(deps: HistoryDeps, json: boolean, message: string): Promise { + if (json) await emitJson(deps.io, { applied: false, reason: 'nothing-to-do', message }) + else await deps.io.out(`${message} Nothing to do.\n`) + return 0 +} + diff --git a/src/cli/onchain/index.ts b/src/cli/onchain/index.ts index 37c07cd..a7eabca 100644 --- a/src/cli/onchain/index.ts +++ b/src/cli/onchain/index.ts @@ -6,6 +6,11 @@ export async function runOnchainCommand(verb: string, args: string[], baseDeps: switch (verb) { case 'custody': return (await import('./custody.js')).runCustodyCommand(args, deps) case 'ens': return (await import('./ens.js')).runEnsCommand(args, deps) + case 'restore': return (await import('./restore.js')).runRestoreCommand(args, deps) + case 'profile': return (await import('./profile.js')).runProfileCommand(args, deps) + case 'create': return (await import('./create.js')).runCreateCommand(args, deps) + case 'storage': return (await import('./storage.js')).runStorageCommand(args, deps) + case 'transfer': return (await import('./transfer.js')).runTransferCommand(args, deps) default: return 2 } } diff --git a/src/cli/onchain/profile.ts b/src/cli/onchain/profile.ts new file mode 100644 index 0000000..f15f0fc --- /dev/null +++ b/src/cli/onchain/profile.ts @@ -0,0 +1,241 @@ +import fs from 'node:fs/promises' +import { getAddress } from 'viem' +import type { EthagentConfig, EthagentIdentity } from '../../storage/config.js' +import { saveConfig } from '../../storage/config.js' +import { resolveRegistryForIdentity } from '../../identity/registry/registryConfig.js' +import { continuityVaultStatus } from '../../identity/continuity/storage/status.js' +import { resolveValidatedPinataJwt } from '../../identity/storage/pinataJwt.js' +import { validateAgentIconReference, isAgentIconUrl } from '../../identity/profile/agentIcon.js' +import { resolveAgentIconPath, deriveAgentName } from '../../identity/manager/shared/effects/profilePrep.js' +import { runRebackupSigningInSession } from '../../identity/manager/continuity/effects.js' +import { runOperatorWalletRebackup } from '../../identity/manager/continuity/vault.js' +import { + advancedCustodyEnsAvailable, + snapshotSaveRequiresOwnerSigner, + snapshotSaveWalletRole, +} from '../../identity/manager/continuity/snapshot.js' +import { resolveVaultAddress } from '../../identity/manager/custody/transactions.js' +import { readCustodyMode } from '../../identity/manager/custody/state.js' +import { normalizeApprovedOperatorWallets } from '../../identity/manager/shared/operatorWallets.js' +import type { ProfileUpdates, Step } from '../../identity/manager/reducer.js' +import { createLocalKeySignAndTransaction } from '../../identity/wallet/localKeyWallet.js' +import { openBrowserWalletSession, type BrowserWalletReady, type BrowserWalletSession } from '../../identity/wallet/browserWallet.js' +import { openExternalUrl } from '../../utils/openExternal.js' +import { pullHarnessSoulMemoryIntoVault } from '../sync.js' +import { emitJson, failFrom, HistoryError, parseHistoryArgs, requireIdentity, type HistoryDeps } from '../history/shared.js' +import { quietCallbacks, requireOperatorKey, requireStorage, walletCancelled, WalletTab } from './shared.js' + +export const PROFILE_USAGE = 'ethagent profile [--name ] [--description ] [--image ] [--operator] [--yes] [--no-open] [--json]' + +const HELP = [ + `usage: ${PROFILE_USAGE}`, + '', + ' ethagent profile the public name, description, image, and agent card. Read-only.', + ' ethagent profile --name --description --image ', + ' change any of them, then publish in one save. --image none', + ' removes the image. Previews until --yes; nothing is sent when', + ' nothing changes.', + '', + 'The owner wallet signs in one browser tab. With --operator the operator key signs with no', + 'popup, which needs advanced custody, a linked ENS name, and the key approved as an operator.', + '', +].join('\n') + +export type ProfileSeams = { + publishOwner: typeof runRebackupSigningInSession + publishOperator: typeof runOperatorWalletRebackup + operatorRunner: typeof createLocalKeySignAndTransaction + resolveJwt: typeof resolveValidatedPinataJwt + vaultStatus: typeof continuityVaultStatus + pullHarness: typeof pullHarnessSoulMemoryIntoVault + imageExists: (path: string) => Promise + openSession: (onReady: (ready: BrowserWalletReady) => void) => Promise + openExternal: (url: string) => void + saveConfig: (config: EthagentConfig) => Promise +} + +const defaultSeams: ProfileSeams = { + publishOwner: runRebackupSigningInSession, + publishOperator: runOperatorWalletRebackup, + operatorRunner: createLocalKeySignAndTransaction, + resolveJwt: resolveValidatedPinataJwt, + vaultStatus: continuityVaultStatus, + pullHarness: pullHarnessSoulMemoryIntoVault, + imageExists: async file => fs.stat(file).then(stat => stat.isFile(), () => false), + openSession: onReady => openBrowserWalletSession({ title: 'ethagent profile', onReady }), + openExternal: url => openExternalUrl(url), + saveConfig, +} + +function readProfile(identity: EthagentIdentity): { name: string; description: string; image: string | null; agentCard: string | null } { + const state = (identity.state ?? {}) as Record + return { + name: typeof state.name === 'string' && state.name.trim() ? state.name.trim() : deriveAgentName(identity), + description: typeof state.description === 'string' ? state.description : '', + image: typeof state.imageUrl === 'string' && state.imageUrl ? state.imageUrl : null, + agentCard: identity.agentCard?.cid ?? null, + } +} + +// Why the operator key cannot sign this profile change, in the order a user would fix it. +function operatorBlockers(identity: EthagentIdentity, updates: ProfileUpdates, keyAddress: string): string[] { + const state = (identity.state ?? {}) as Record + const out: string[] = [] + if (readCustodyMode(state) !== 'advanced') out.push('custody is not advanced (`ethagent custody --advanced`)') + if (!(typeof state.ensName === 'string' && state.ensName.trim())) out.push('no ENS name is linked (`ethagent ens `)') + const approved = normalizeApprovedOperatorWallets(state.approvedOperatorWallets) + if (!approved.some(record => record.address.toLowerCase() === keyAddress.toLowerCase())) { + out.push(`the operator key ${keyAddress} is not an approved operator (\`ethagent custody --add-operator --operator\`)`) + } + if (out.length === 0 && snapshotSaveRequiresOwnerSigner(identity, updates)) out.push('this change needs the owner wallet') + if (out.length === 0 && !advancedCustodyEnsAvailable(identity)) out.push('advanced custody with an ENS name is not set up') + if (out.length === 0 && snapshotSaveWalletRole(identity, updates) !== 'operator') { + out.push('the owner has not saved since this operator was approved, so the snapshot has no slot for it (`ethagent save` with the owner wallet)') + } + return out +} + +export async function runProfileCommand(args: string[], deps: HistoryDeps, seams: ProfileSeams = defaultSeams): Promise { + const json = args.includes('--json') + try { + const { values, positionals } = parseHistoryArgs(args, { + name: { type: 'string' }, + description: { type: 'string' }, + image: { type: 'string' }, + operator: { type: 'boolean' }, + yes: { type: 'boolean' }, + 'no-open': { type: 'boolean' }, + }, PROFILE_USAGE) + if (values.help) { + await deps.io.out(HELP) + return 0 + } + if (positionals.length > 0) throw new HistoryError(2, `unexpected argument: ${positionals[0]}`, `usage: ${PROFILE_USAGE}`) + const { config, identity } = await requireIdentity(deps) + const current = readProfile(identity) + const editing = values.name !== undefined || values.description !== undefined || values.image !== undefined + if (!editing) { + if (json) await emitJson(deps.io, { agentId: identity.agentId ?? null, ...current }) + else await deps.io.out(`${current.name}\n${current.description || '(no description)'}\nimage: ${current.image ?? 'none'}\nagent card: ${current.agentCard ?? 'not published'}\n`) + return 0 + } + if (!identity.agentId) throw new HistoryError(1, 'This identity has no agent token ID yet.', 'Create or restore it first.') + const registry = resolveRegistryForIdentity(identity, config) + if (!registry) throw new HistoryError(1, 'No agent registry is configured for this identity.') + + const updates: ProfileUpdates = {} + const changes: Array<{ field: string; from: string | null; to: string | null }> = [] + if (typeof values.name === 'string') { + const name = values.name.trim() + if (name.length < 2) throw new HistoryError(2, '--name needs at least 2 characters') + if (name !== current.name) { + updates.name = name + changes.push({ field: 'name', from: current.name, to: name }) + } + } + if (typeof values.description === 'string') { + const description = values.description.trim() + if (description !== current.description.trim()) { + updates.description = description + changes.push({ field: 'description', from: current.description || null, to: description || null }) + } + } + if (typeof values.image === 'string') { + const raw = values.image.trim() + if (raw === 'none') { + if (current.image) { + updates.imagePath = 'delete' + changes.push({ field: 'image', from: current.image, to: null }) + } + } else { + const problem = validateAgentIconReference(raw) + if (problem) throw new HistoryError(2, `--image: ${problem}`) + if (!isAgentIconUrl(raw) && !await seams.imageExists(resolveAgentIconPath(raw))) { + throw new HistoryError(2, `--image: no file at ${resolveAgentIconPath(raw)}`) + } + if (raw !== current.image) { + updates.imagePath = isAgentIconUrl(raw) ? raw : resolveAgentIconPath(raw) + changes.push({ field: 'image', from: current.image, to: isAgentIconUrl(raw) ? raw : `${resolveAgentIconPath(raw)} (pinned to IPFS on save)` }) + } + } + } + + const operator = values.operator ? requireOperatorKey(deps, 'profile') : undefined + if (operator && changes.length > 0) { + const blockers = operatorBlockers(identity, updates, operator.address) + if (blockers.length > 0) { + throw new HistoryError(1, `The operator key cannot publish this profile change: ${blockers.join('; ')}.`, 'Run without --operator to sign with the owner wallet. Nothing was pinned or sent.') + } + } + const summary = { + changes, + signer: operator ? { kind: 'operator', address: operator.address } : { kind: 'browser', wallet: 'owner' }, + } + if (changes.length === 0) { + if (json) await emitJson(deps.io, { applied: false, ...summary, reason: 'no-changes' }) + else await deps.io.out('Nothing to change: the profile already matches.\n') + return 0 + } + if (!values.yes) { + if (json) { + await emitJson(deps.io, { applied: false, ...summary }) + } else { + const lines = [`Preview (nothing pinned or sent). Signer: ${operator ? `operator key ${operator.address}` : 'owner wallet, in the browser'}.`] + for (const change of changes) lines.push(` ${change.field}: ${change.from ?? '(none)'} -> ${change.to ?? '(none)'}`) + lines.push('Publishing saves a new snapshot of the agent. Run again with --yes to publish.') + await deps.io.out(`${lines.join('\n')}\n`) + } + return 0 + } + + const vault = await seams.vaultStatus(identity).catch(() => ({ ready: false })) + if (!vault.ready) throw new HistoryError(1, 'Local continuity files are not restored.', 'Run `ethagent restore` first. Nothing was sent.') + const jwt = await requireStorage(seams.resolveJwt) + await seams.pullHarness(identity).catch(() => []) + + const vaultAddress = resolveVaultAddress(identity, config.erc8004?.operatorVaults) + const step: Extract = { + kind: 'rebackup-signing', + identity, + registry, + pinataJwt: jwt, + profileUpdates: updates, + returnTo: { kind: 'menu' }, + ...(vaultAddress ? { vaultAddress } : {}), + } + let saved: EthagentIdentity | undefined + const callbacks = quietCallbacks({ + onIdentityComplete: async next => { + await seams.saveConfig({ ...config, identity: next }) + saved = next + }, + }) + const tab = new WalletTab(seams.openSession, deps.io, json, Boolean(values['no-open']), seams.openExternal) + try { + if (operator) { + await seams.publishOperator({ + step, + callbacks, + walletPurpose: 'update-profile-operator', + deriveAgentName, + signAndTransaction: seams.operatorRunner({ privateKey: operator.key, rpcUrl: registry.rpcUrl, chainId: registry.chainId }), + }) + } else { + await seams.publishOwner(step, callbacks, await tab.get()) + } + } catch (err: unknown) { + const cancelled = walletCancelled(err, []) + if (cancelled) throw cancelled + throw err + } finally { + await tab.close() + } + if (!saved) throw new HistoryError(1, 'The profile save did not complete; nothing was recorded.') + const result = { applied: true, ...summary, cid: saved.backup?.cid ?? null, txHash: saved.backup?.txHash ?? null, owner: getAddress(saved.ownerAddress ?? saved.address) } + if (json) await emitJson(deps.io, result) + else await deps.io.out(`Profile published${result.txHash ? ` (tx ${result.txHash})` : ''}.\n`) + return 0 + } catch (err) { + return failFrom(deps.io, json, err) + } +} diff --git a/src/cli/onchain/restore.ts b/src/cli/onchain/restore.ts new file mode 100644 index 0000000..9735f6e --- /dev/null +++ b/src/cli/onchain/restore.ts @@ -0,0 +1,322 @@ +import { getAddress, isAddress, type Address } from 'viem' +import type { EthagentConfig, EthagentIdentity } from '../../storage/config.js' +import { saveConfig } from '../../storage/config.js' +import { DEFAULT_IPFS_API_URL } from '../../identity/storage/ipfs.js' +import { + chainIdForNetwork, + discoverOwnedAgentBackupByTokenId, + discoverOwnedAgentBackups, + discoverOwnedAgentBackupsAcrossSupportedNetworks, + erc8004ConfigForSupportedChain, + supportedErc8004ChainForId, + type Erc8004AgentCandidate, + type Erc8004RegistryConfig, +} from '../../identity/registry/erc8004.js' +import { resolveRegistryForIdentity } from '../../identity/registry/registryConfig.js' +import { parseAgentTokenReference, readEthagentTextRecords } from '../../identity/ens/ensLookup.js' +import { AGENT_TOKEN_RECORD_KEY } from '../../identity/ens/agentRecords.js' +import { envelopeChallengeFor, localKeySigner } from '../../identity/continuity/localKeyDecrypt.js' +import { listPublishedContinuitySnapshots } from '../../identity/continuity/snapshots.js' +import { continuityWorkingTreeStatus } from '../../identity/continuity/storage/status.js' +import { hasPendingPublish } from '../../identity/manager/continuity/state.js' +import { resolveAgentEnsToCandidate, resolveAgentTokenIdToCandidate } from '../../identity/manager/restore/resolve.js' +import { canRestoreCandidate } from '../../identity/manager/restore/discover.js' +import { runRestoreFetch } from '../../identity/manager/restore/fetch.js' +import { runRestoreAuthorize } from '../../identity/manager/restore/apply.js' +import { runRecoveryRefetch } from '../../identity/manager/restore/recovery.js' +import { isContinuitySnapshotEnvelope } from '../../identity/manager/restore/envelopes.js' +import type { RestoreSigner } from '../../identity/manager/restore/signer.js' +import type { Step } from '../../identity/manager/reducer.js' +import { openBrowserWalletSession, type BrowserWalletSession, type BrowserWalletReady } from '../../identity/wallet/browserWallet.js' +import { openExternalUrl } from '../../utils/openExternal.js' +import { emitJson, failFrom, HistoryError, parseHistoryArgs, type HistoryDeps } from '../history/shared.js' +import { + networkOfChain, + parseNetwork, + persistIdentity, + quietCallbacks, + registryForNetwork, + requireOperatorKey, + walletCancelled, + WalletTab, +} from './shared.js' + +export const RESTORE_USAGE = 'ethagent restore [ | | --owner ] [--network mainnet|base] [--operator] [--yes] [--no-open] [--json]' + +const HELP = [ + `usage: ${RESTORE_USAGE}`, + '', + ' ethagent restore rebuild that agent on this machine (needs --network', + ' unless this machine already has an identity on it)', + ' ethagent restore rebuild the agent an ENS name points at', + ' ethagent restore --owner ', + ' list the agents that wallet holds or operates. Read-only.', + ' ethagent restore with an identity here: pull the newest onchain snapshot', + ' into the vault (Refetch Latest)', + '', + 'Previews until --yes. The browser wallet signs the decrypt challenge in one tab, or with', + '--operator the operator key decrypts locally and nothing opens. Whatever the vault held is', + 'checkpointed first; `ethagent rollback --undo --yes` puts it back. Nothing is sent onchain.', + '', +].join('\n') + +type AuthorizingStep = Extract + +export type RestoreSeams = { + resolveTokenId: typeof resolveAgentTokenIdToCandidate + resolveEns: typeof resolveAgentEnsToCandidate + ensTokenChain: (name: string) => Promise + discoverOwner: (owner: string, registry?: Erc8004RegistryConfig) => Promise + latestCandidate: (identity: EthagentIdentity, registry: Erc8004RegistryConfig) => Promise + fetchEnvelope: (candidate: Erc8004AgentCandidate, requester?: Address) => Promise + authorize: typeof runRestoreAuthorize + refetch: typeof runRecoveryRefetch + localChanges: (identity: EthagentIdentity) => Promise + openSession: (onReady: (ready: BrowserWalletReady) => void) => Promise + openExternal: (url: string) => void + saveConfig: (config: EthagentConfig) => Promise +} + +const defaultSeams: RestoreSeams = { + resolveTokenId: resolveAgentTokenIdToCandidate, + resolveEns: resolveAgentEnsToCandidate, + ensTokenChain: async name => { + const records = await readEthagentTextRecords(name, [AGENT_TOKEN_RECORD_KEY]) + const value = records[AGENT_TOKEN_RECORD_KEY] + return value ? parseAgentTokenReference(value)?.chainId ?? null : null + }, + discoverOwner: (owner, registry) => registry + ? discoverOwnedAgentBackups({ ...registry, ownerHandle: owner, ipfsApiUrl: DEFAULT_IPFS_API_URL }) + : discoverOwnedAgentBackupsAcrossSupportedNetworks({ ownerHandle: owner, ipfsApiUrl: DEFAULT_IPFS_API_URL }), + latestCandidate: (identity, registry) => discoverOwnedAgentBackupByTokenId({ + ...registry, + ownerHandle: getAddress(identity.ownerAddress ?? identity.address), + tokenId: BigInt(identity.agentId ?? '0'), + ipfsApiUrl: identity.backup?.ipfsApiUrl ?? DEFAULT_IPFS_API_URL, + }), + fetchEnvelope: async (candidate, requester) => { + let next: AuthorizingStep | undefined + await runRestoreFetch({ + kind: 'restore-fetching', + cid: candidate.backup!.cid, + apiUrl: DEFAULT_IPFS_API_URL, + candidate, + ...(requester ? { requesterAddress: requester } : {}), + }, quietCallbacks({ onStep: step => { if (step.kind === 'restore-authorizing') next = step } })) + if (!next) throw new Error('The snapshot did not download.') + return next + }, + authorize: runRestoreAuthorize, + refetch: runRecoveryRefetch, + localChanges: async identity => { + const [latest] = await listPublishedContinuitySnapshots(identity, 1) + const tree = await continuityWorkingTreeStatus(identity, latest) + return tree.ready && tree.localChangedAfterBackup + }, + openSession: onReady => openBrowserWalletSession({ title: 'ethagent restore', onReady }), + openExternal: url => openExternalUrl(url), + saveConfig, +} + +function candidateJson(candidate: Erc8004AgentCandidate): Record { + return { + agentId: candidate.agentId.toString(), + network: networkOfChain(candidate.chainId), + chainId: candidate.chainId, + name: candidate.name ?? null, + owner: candidate.ownerAddress, + heldBy: candidate.tokenOwnerAddress ?? candidate.ownerAddress, + snapshot: candidate.backup?.cid ?? null, + savedAt: candidate.backup?.createdAt ?? null, + readable: !candidate.metadataError, + ...(candidate.metadataError ? { problem: candidate.metadataError } : {}), + } +} + +function sameAgent(identity: EthagentIdentity | undefined, candidate: Erc8004AgentCandidate): boolean { + if (!identity?.agentId) return false + return identity.agentId === candidate.agentId.toString() + && identity.chainId === candidate.chainId + && (identity.identityRegistryAddress ?? '').toLowerCase() === candidate.identityRegistryAddress.toLowerCase() +} + +// Which wallet the snapshot will ask, in words, for a preview. +function decryptPlan(step: AuthorizingStep, operator: Address | undefined): { signer: string; opens: boolean | null } { + if (operator) { + if (!isContinuitySnapshotEnvelope(step.envelope)) { + return { signer: `operator key ${operator}`, opens: step.envelope.ownerAddress.toLowerCase() === operator.toLowerCase() } + } + return { signer: `operator key ${operator}`, opens: envelopeChallengeFor(step.envelope, operator) !== null } + } + return { signer: 'the owner wallet or an approved operator wallet, in the browser', opens: null } +} + +async function resolveTarget( + target: string, + network: ReturnType, + identity: EthagentIdentity | undefined, + config: EthagentConfig | null, + seams: RestoreSeams, +): Promise { + if (/^\d+$/.test(target)) { + const registry = network + ? registryForNetwork(network) + : identity ? resolveRegistryForIdentity(identity, config ?? undefined) : null + if (!registry) throw new HistoryError(2, `Which network holds token #${target}?`, 'Pass --network mainnet or --network base.') + const result = await seams.resolveTokenId(target, registry) + if (!result.ok) throw new HistoryError(1, result.message) + return result.candidate + } + if (/^([a-z0-9-]+\.)+eth$/i.test(target)) { + const chainId = network ? chainIdForNetwork(network) : await seams.ensTokenChain(target) + if (!chainId) throw new HistoryError(1, `${target} has no agent token record.`, 'Restore by token id instead: `ethagent restore --network `.') + if (!supportedErc8004ChainForId(chainId)) throw new HistoryError(1, `${target} points at chain ${chainId}, which ethagent does not support.`) + const registry = network ? registryForNetwork(network) : erc8004ConfigForSupportedChain(chainId) + const result = await seams.resolveEns(target, registry) + if (!result.ok) throw new HistoryError(1, result.message) + return result.candidate + } + throw new HistoryError(2, `${target} is neither a token id nor a .eth name.`, `usage: ${RESTORE_USAGE}`) +} + +export async function runRestoreCommand(args: string[], deps: HistoryDeps, seams: RestoreSeams = defaultSeams): Promise { + const json = args.includes('--json') + try { + const { values, positionals } = parseHistoryArgs(args, { + owner: { type: 'string' }, + network: { type: 'string' }, + operator: { type: 'boolean' }, + yes: { type: 'boolean' }, + 'no-open': { type: 'boolean' }, + }, RESTORE_USAGE) + if (values.help) { + await deps.io.out(HELP) + return 0 + } + if (positionals.length > 1) throw new HistoryError(2, `unexpected argument: ${positionals[1]}`, `usage: ${RESTORE_USAGE}`) + const network = parseNetwork(values.network) + const target = positionals[0] + const config = await deps.loadConfig().catch(() => null) + const identity = config?.identity + + // Listing what a wallet holds needs no key and no wallet. + if (typeof values.owner === 'string') { + if (target) throw new HistoryError(2, 'choose either a target or --owner', `usage: ${RESTORE_USAGE}`) + const owner = values.owner.trim() + if (!isAddress(owner) && !/^([a-z0-9-]+\.)+eth$/i.test(owner)) throw new HistoryError(2, '--owner expects an address or a .eth name') + const candidates = await seams.discoverOwner(owner, network ? registryForNetwork(network) : undefined) + const rows = candidates.map(candidateJson) + if (json) { + await emitJson(deps.io, { owner, agents: rows }) + } else if (rows.length === 0) { + await deps.io.out(`No agents found for ${owner}${network ? ` on ${network}` : ''}.\n`) + } else { + const lines = rows.map(row => `#${String(row.agentId)} on ${String(row.network)}${row.name ? ` · ${String(row.name)}` : ''}${row.snapshot ? ` · saved ${String(row.savedAt ?? '')}` : ' · no snapshot'}${row.readable ? '' : ' · profile unreadable'}`) + await deps.io.out(`${lines.join('\n')}\nRestore one with \`ethagent restore --network \`.\n`) + } + return 0 + } + + const operator = values.operator ? requireOperatorKey(deps, 'restore') : undefined + const refetch = !target + if (refetch && !identity?.agentId) { + throw new HistoryError(2, 'No agent identity on this machine yet.', `Pass with --network, an agent's , or --owner to list agents. usage: ${RESTORE_USAGE}`) + } + + let candidate: Erc8004AgentCandidate + let registry: Erc8004RegistryConfig | null = null + if (refetch) { + registry = resolveRegistryForIdentity(identity!, config ?? undefined) + if (!registry) throw new HistoryError(1, 'No agent registry is configured for this identity.') + candidate = await seams.latestCandidate(identity!, registry) + } else { + candidate = await resolveTarget(target, network, identity, config, seams) + } + if (!candidate.backup?.cid) { + throw new HistoryError(1, `Agent #${candidate.agentId.toString()} has no encrypted snapshot to restore.`, candidate.metadataError ? `Its profile did not load: ${candidate.metadataError}` : undefined) + } + if (operator && !canRestoreCandidate(candidate, operator.address)) { + throw new HistoryError(1, `The operator key ${operator.address} is neither the owner of agent #${candidate.agentId.toString()} nor one of its approved operators.`) + } + + const authorizing = await seams.fetchEnvelope(candidate, operator?.address) + const plan = decryptPlan(authorizing, operator?.address) + if (plan.opens === false) { + throw new HistoryError(3, `The operator key ${operator!.address} has no restore slot in this snapshot.`, 'Save once with the owner wallet after approving this operator, then retry. Nothing was written.') + } + const replacing = identity && !sameAgent(identity, candidate) + const warnings: string[] = [] + if (identity && sameAgent(identity, candidate) && await seams.localChanges(identity).catch(() => false)) { + warnings.push('the vault has local changes that are not in any saved snapshot; they are checkpointed first') + } + if (identity && sameAgent(identity, candidate) && hasPendingPublish(identity)) { + warnings.push('a pinned snapshot is waiting for the owner to publish it') + } + if (replacing) { + warnings.push(`this machine's identity switches from agent #${identity!.agentId ?? '?'} to #${candidate.agentId.toString()}; the old vault stays on disk`) + } + const summary = { + action: refetch ? 'refetch' : 'restore', + agent: candidateJson(candidate), + snapshot: { cid: candidate.backup.cid, createdAt: authorizing.envelope.createdAt }, + signer: operator ? { kind: 'operator', address: operator.address } : { kind: 'browser' }, + decryptsWith: plan.signer, + warnings, + } + + if (!values.yes) { + if (json) { + await emitJson(deps.io, { applied: false, ...summary }) + } else { + const lines = [ + `Preview (nothing written). ${refetch ? 'Pull the newest onchain snapshot of' : 'Restore'} agent #${candidate.agentId.toString()} on ${networkOfChain(candidate.chainId)}${candidate.name ? ` (${candidate.name})` : ''}.`, + ` snapshot ${candidate.backup.cid}, saved ${authorizing.envelope.createdAt}`, + ` decrypts with ${plan.signer}`, + ...warnings.map(warning => ` note: ${warning}`), + 'Run again with --yes to restore. The vault is checkpointed first.', + ] + await deps.io.out(`${lines.join('\n')}\n`) + } + return 0 + } + + const tab = new WalletTab(seams.openSession, deps.io, json, Boolean(values['no-open']), seams.openExternal) + let restored: EthagentIdentity | undefined + const signer: RestoreSigner = operator + ? { kind: 'local', signer: localKeySigner(operator.key) } + : { kind: 'browser', requestSignature: async req => (await tab.get()).requestSignature(req) } + const callbacks = quietCallbacks({ + onIdentityComplete: async next => { + await persistIdentity(next, { loadConfig: deps.loadConfig, saveConfig: seams.saveConfig }) + restored = next + }, + }) + try { + if (refetch) { + await seams.refetch(identity!, registry!, callbacks, { signer }) + } else { + await seams.authorize(authorizing, callbacks, { signer }) + } + } catch (err: unknown) { + const cancelled = walletCancelled(err, []) + if (cancelled) throw cancelled + throw err + } finally { + await tab.close() + } + if (!restored) throw new HistoryError(1, 'The restore did not complete; nothing was saved to config.') + // Only a vault that already held this agent was checkpointed, so only then is + // there anything to undo. + const undo = identity && sameAgent(identity, candidate) ? 'ethagent rollback --undo --yes' : null + const result = { applied: true, ...summary, snapshotRestored: restored.backup?.cid ?? null, undo } + if (json) { + await emitJson(deps.io, result) + } else { + await deps.io.out(`Restored agent #${candidate.agentId.toString()} from ${candidate.backup.cid}.\n${undo ? `The previous vault was checkpointed; \`${undo}\` puts it back.\n` : ''}`) + } + return 0 + } catch (err) { + if (err instanceof Error && err.name === 'RestoreLockedError') return failFrom(deps.io, json, new HistoryError(3, err.message)) + return failFrom(deps.io, json, err) + } +} diff --git a/src/cli/onchain/shared.ts b/src/cli/onchain/shared.ts new file mode 100644 index 0000000..08fe1d4 --- /dev/null +++ b/src/cli/onchain/shared.ts @@ -0,0 +1,127 @@ +import type { Address, Hex } from 'viem' +import { getAddress } from 'viem' +import { loadConfig, saveConfig, type EthagentConfig, type EthagentIdentity, type SelectableNetwork } from '../../storage/config.js' +import type { BrowserWalletReady, BrowserWalletSession } from '../../identity/wallet/browserWallet.js' +import type { EffectCallbacks } from '../../identity/manager/shared/effects/types.js' +import { isWalletCancelled } from '../../identity/manager/shared/utils.js' +import { INVALID_OPERATOR_KEY_MESSAGE, OPERATOR_KEY_ENV } from '../operatorKey.js' +import { HistoryError, type HistoryDeps, type HistoryIo } from '../history/shared.js' +import { registryConfigFromConfig } from '../../identity/registry/registryConfig.js' +import { networkForChainId, type Erc8004RegistryConfig } from '../../identity/registry/erc8004.js' + +export const NETWORKS: readonly SelectableNetwork[] = ['mainnet', 'base'] + +export function parseNetwork(value: unknown): SelectableNetwork | undefined { + if (value === undefined) return undefined + if (typeof value === 'string' && (NETWORKS as readonly string[]).includes(value)) return value as SelectableNetwork + throw new HistoryError(2, `--network must be one of ${NETWORKS.join(', ')}`) +} + +// The injected operator key, or the exit code the history and onchain commands use: +// 3 when no key was injected, 2 when it is not a valid key. +export function requireOperatorKey(deps: HistoryDeps, command: string): { key: Hex; address: Address } { + 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 ${command} --operator\`, which injects ${OPERATOR_KEY_ENV}.`) + } + if (!key.ok) throw new HistoryError(2, INVALID_OPERATOR_KEY_MESSAGE) + return { key: key.key, address: getAddress(key.address) } +} + +// A working storage credential, or exit 3 before anything is signed or sent. +export async function requireStorage(resolveJwt: () => Promise): Promise { + let jwt: string | undefined + try { + jwt = await resolveJwt() + } catch (err) { + throw new HistoryError(3, `The configured Pinata JWT is invalid or unreachable (${err instanceof Error ? err.message : String(err)}). Nothing was signed or sent.`, 'Replace it with `ethagent storage --set`.') + } + if (!jwt) { + throw new HistoryError(3, 'No IPFS storage credential is configured. Nothing was signed or sent.', 'Save one with `ethagent storage --set` (reads the JWT from stdin), or export PINATA_JWT.') + } + return jwt +} + +export type OpenSession = (onReady: (ready: BrowserWalletReady) => void) => Promise + +// One browser tab for a whole command, opened only when the first prompt needs it. +// Every prompt the command makes goes through it, and it is closed when the command +// ends, whatever the outcome. +export class WalletTab { + private session: BrowserWalletSession | null = null + constructor( + private readonly open: OpenSession, + private readonly io: HistoryIo, + private readonly json: boolean, + private readonly noOpen: boolean, + private readonly openExternal: (url: string) => void, + ) {} + + async get(): Promise { + if (this.session) return this.session + this.session = await this.open(ready => { + const sink = this.json ? this.io.err : this.io.out + void sink(`Approve in your browser wallet tab: ${ready.url}\nKeep it open; every step of this command appears there. This waits until you approve or cancel.\n`) + if (!this.noOpen) this.openExternal(ready.url) + }) + return this.session + } + + async close(): Promise { + const open = this.session + this.session = null + if (open) await open.close().catch(() => {}) + } +} + +export function quietCallbacks(overrides: Partial = {}): EffectCallbacks { + return { + onStep: () => {}, + onWalletReady: () => {}, + onIdentityComplete: async () => {}, + ...overrides, + } +} + +// Turns a cancelled wallet prompt into exit 3, naming what had already landed so a +// re-run knows where it stands. +export function walletCancelled(err: unknown, done: string[]): HistoryError | null { + if (!isWalletCancelled(err)) return null + return new HistoryError(3, `Wallet approval was cancelled.${done.length ? ` Already confirmed: ${done.join('; ')}. Run the same command again to finish.` : ' Nothing was sent.'}`) +} + +// Saves the identity the way the manager does when a restore, refetch, or create +// completes: into the existing config, or into a fresh one seeded with its registry. +// Called only after the files have landed, so config never points at a vault that +// was not written. +export async function persistIdentity( + identity: EthagentIdentity, + io: { loadConfig?: () => Promise; saveConfig?: (config: EthagentConfig) => Promise } = {}, +): Promise { + if (!identity.address || !identity.agentId || !identity.agentUri || !identity.ownerAddress) { + throw new Error('Token identity is missing ERC-8004 metadata') + } + const current = await (io.loadConfig ?? loadConfig)().catch(() => null) + const registry = identity.chainId && identity.rpcUrl && identity.identityRegistryAddress + ? { chainId: identity.chainId, rpcUrl: identity.rpcUrl, identityRegistryAddress: identity.identityRegistryAddress } + : undefined + const base: EthagentConfig = current ?? { version: 2, firstSeenAt: new Date().toISOString() } + const next: EthagentConfig = { + ...base, + identity: { ...identity, source: 'erc8004' }, + ...(!base.erc8004 && registry ? { erc8004: registry } : {}), + } + await (io.saveConfig ?? saveConfig)(next) + return next +} + +// The registry for a network, honouring ETHAGENT_RPC_URL the way the manager does. +export function registryForNetwork(network: SelectableNetwork): Erc8004RegistryConfig { + const resolved = registryConfigFromConfig({ version: 2, firstSeenAt: '', selectedNetwork: network } as EthagentConfig).config + if (!resolved) throw new HistoryError(1, `No agent registry is known for ${network}.`) + return resolved +} + +export function networkOfChain(chainId: number): string { + return networkForChainId(chainId) ?? `chain ${chainId}` +} diff --git a/src/cli/onchain/storage.ts b/src/cli/onchain/storage.ts new file mode 100644 index 0000000..cbbbe1f --- /dev/null +++ b/src/cli/onchain/storage.ts @@ -0,0 +1,122 @@ +import { clearPinataJwt, hasPinataJwt, savePinataJwt } from '../../identity/storage/pinataJwt.js' +import { DEFAULT_IPFS_API_URL, isPinataUploadUrl } from '../../identity/storage/ipfs.js' +import { emitJson, failFrom, HistoryError, parseHistoryArgs, type HistoryDeps } from '../history/shared.js' + +export const STORAGE_USAGE = 'ethagent storage [--set | --forget [--yes]] [--json]' + +const HELP = [ + `usage: ${STORAGE_USAGE}`, + '', + ' ethagent storage where snapshots are pinned and whether a credential is set.', + ' Read-only.', + ' ethagent storage --set read a Pinata JWT from stdin, check it with Pinata, and save', + ' it encrypted. Never pass it as an argument.', + ' ethagent storage --forget remove the saved credential (preview, then --yes).', + '', + 'PINATA_JWT in the environment works too. ETHAGENT_IPFS_API_URL replaces Pinata entirely.', + '', +].join('\n') + +export type StorageSeams = { + has: () => Promise + save: (input: string) => Promise<{ backend: string }> + clear: () => Promise + readStdin: () => Promise + apiUrl: string +} + +async function readAllStdin(): Promise { + if (process.stdin.isTTY) { + throw new HistoryError(2, 'Pipe the JWT on stdin.', 'For example from your keychain tooling; never type it into a command line.') + } + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk as Uint8Array)) + return Buffer.concat(chunks).toString('utf8') +} + +const defaultSeams: StorageSeams = { + has: hasPinataJwt, + save: async input => { + const saved = await savePinataJwt(input) + return { backend: String(saved.backend) } + }, + clear: clearPinataJwt, + readStdin: readAllStdin, + apiUrl: DEFAULT_IPFS_API_URL, +} + +export async function runStorageCommand(args: string[], deps: HistoryDeps, seams: StorageSeams = defaultSeams): Promise { + const json = args.includes('--json') + try { + const { values, positionals } = parseHistoryArgs(args, { + set: { type: 'boolean' }, + forget: { type: 'boolean' }, + yes: { type: 'boolean' }, + }, STORAGE_USAGE) + if (values.help) { + await deps.io.out(HELP) + return 0 + } + if (positionals.length > 0) { + throw new HistoryError(2, 'The JWT is read from stdin, never from an argument.', `usage: ${STORAGE_USAGE}`) + } + if (values.set && values.forget) throw new HistoryError(2, 'choose either --set or --forget', `usage: ${STORAGE_USAGE}`) + const pinata = isPinataUploadUrl(seams.apiUrl) + + if (values.set) { + if (!pinata) throw new HistoryError(1, `Snapshots go to ${seams.apiUrl} (ETHAGENT_IPFS_API_URL), which needs no Pinata credential.`) + const input = (await seams.readStdin()).trim() + if (!input) throw new HistoryError(2, 'No JWT arrived on stdin.') + let saved: { backend: string } + try { + saved = await seams.save(input) + } catch (err: unknown) { + throw new HistoryError(1, err instanceof Error ? err.message : String(err), 'Nothing was saved.') + } + if (json) await emitJson(deps.io, { saved: true, backend: saved.backend }) + else await deps.io.out(`Pinata credential checked and saved (${saved.backend}).\n`) + return 0 + } + + const saved = await seams.has() + if (values.forget) { + if (!saved) { + if (json) await emitJson(deps.io, { applied: false, reason: 'nothing-saved' }) + else await deps.io.out('No credential is saved; nothing to forget.\n') + return 0 + } + if (!values.yes) { + if (json) await emitJson(deps.io, { applied: false, action: 'forget' }) + else await deps.io.out('Preview: removes the saved Pinata credential. PINATA_JWT, if set, still works. Run again with --yes.\n') + return 0 + } + await seams.clear() + if (json) await emitJson(deps.io, { applied: true, action: 'forget' }) + else await deps.io.out('Saved Pinata credential removed.\n') + return 0 + } + if (values.yes) throw new HistoryError(2, '--yes only applies to --forget', `usage: ${STORAGE_USAGE}`) + + const env = Boolean(deps.env.PINATA_JWT?.trim()) + const view = { + uploadsTo: seams.apiUrl, + provider: pinata ? 'pinata' : 'custom', + savedCredential: saved, + envCredential: env, + ready: !pinata || saved || env, + } + if (json) { + await emitJson(deps.io, view) + } else { + const lines = [`uploads to ${view.uploadsTo}`] + if (pinata) { + lines.push(`saved credential: ${saved ? 'yes' : 'no'} · PINATA_JWT: ${env ? 'set' : 'not set'}`) + if (!view.ready) lines.push('Not ready: save one with `ethagent storage --set` (reads the JWT from stdin).') + } + await deps.io.out(`${lines.join('\n')}\n`) + } + return 0 + } catch (err) { + return failFrom(deps.io, json, err) + } +} diff --git a/src/cli/onchain/transfer.ts b/src/cli/onchain/transfer.ts new file mode 100644 index 0000000..dce0396 --- /dev/null +++ b/src/cli/onchain/transfer.ts @@ -0,0 +1,147 @@ +import { getAddress, type Address } from 'viem' +import type { EthagentConfig } from '../../storage/config.js' +import { saveConfig } from '../../storage/config.js' +import { resolveRegistryForIdentity } from '../../identity/registry/registryConfig.js' +import { continuityVaultStatus } from '../../identity/continuity/storage/status.js' +import { resolveValidatedPinataJwt } from '../../identity/storage/pinataJwt.js' +import { assertTokenNotInVault, TokenInVaultError } from '../../identity/manager/custody/preflight.js' +import { resolveTransferTargetAddress, runTokenTransferSigning } from '../../identity/manager/transfer/effects.js' +import { humanOwnerAddress } from '../../identity/manager/custody/helpers.js' +import { openExternalUrl } from '../../utils/openExternal.js' +import { pullHarnessSoulMemoryIntoVault } from '../sync.js' +import { emitJson, failFrom, HistoryError, parseHistoryArgs, requireIdentity, type HistoryDeps } from '../history/shared.js' +import { quietCallbacks, requireStorage, walletCancelled } from './shared.js' + +export const TRANSFER_USAGE = 'ethagent transfer [--yes] [--no-open] [--json]' + +const HELP = [ + `usage: ${TRANSFER_USAGE}`, + '', + 'Prepares the agent for a new owner: the current owner and the receiver both sign in one', + 'browser tab, which re-encrypts soul, memory, and skills for the receiver, then the owner', + 'publishes that snapshot. Both wallets must be available in this browser. Previews until --yes.', + '', + 'ethagent never moves the token itself. After this, send the token to the receiver from your', + 'wallet; the receiver then runs `ethagent restore ` on their machine.', + 'A token held in a Vault must come out first: `ethagent custody --simple`.', + '', +].join('\n') + +export type TransferSeams = { + resolveTarget: (handle: string) => Promise
+ assertNotInVault: typeof assertTokenNotInVault + sign: typeof runTokenTransferSigning + resolveJwt: typeof resolveValidatedPinataJwt + vaultStatus: typeof continuityVaultStatus + pullHarness: typeof pullHarnessSoulMemoryIntoVault + openExternal: (url: string) => void + saveConfig: (config: EthagentConfig) => Promise +} + +const defaultSeams: TransferSeams = { + resolveTarget: handle => resolveTransferTargetAddress(handle), + assertNotInVault: assertTokenNotInVault, + sign: runTokenTransferSigning, + resolveJwt: resolveValidatedPinataJwt, + vaultStatus: continuityVaultStatus, + pullHarness: pullHarnessSoulMemoryIntoVault, + openExternal: url => openExternalUrl(url), + saveConfig, +} + +export async function runTransferCommand(args: string[], deps: HistoryDeps, seams: TransferSeams = defaultSeams): Promise { + const json = args.includes('--json') + try { + const { values, positionals } = parseHistoryArgs(args, { + yes: { type: 'boolean' }, + 'no-open': { type: 'boolean' }, + }, TRANSFER_USAGE) + if (values.help) { + await deps.io.out(HELP) + return 0 + } + if (positionals.length !== 1) throw new HistoryError(2, positionals.length ? `unexpected argument: ${positionals[1]}` : 'Who receives the agent?', `usage: ${TRANSFER_USAGE}`) + const handle = positionals[0]!.trim() + const { config, identity } = await requireIdentity(deps) + if (!identity.agentId) throw new HistoryError(1, 'This identity has no agent token ID yet.') + const registry = resolveRegistryForIdentity(identity, config) + if (!registry) throw new HistoryError(1, 'No agent registry is configured for this identity.') + const owner = getAddress(humanOwnerAddress(identity)) + let target: Address + try { + target = await seams.resolveTarget(handle) + } catch (err: unknown) { + throw new HistoryError(1, err instanceof Error ? err.message : String(err)) + } + if (target.toLowerCase() === owner.toLowerCase()) throw new HistoryError(1, 'The receiver must be a different wallet from the owner.') + try { + await seams.assertNotInVault({ identity, registry, operatorVaults: config.erc8004?.operatorVaults }) + } catch (err: unknown) { + if (err instanceof TokenInVaultError) { + throw new HistoryError(1, err.message, 'Withdraw it first with `ethagent custody --simple`, then retry. Nothing was signed.') + } + throw err + } + const summary = { + action: 'transfer', + agentId: identity.agentId, + owner, + receiver: target, + ...(handle.toLowerCase() !== target.toLowerCase() ? { receiverName: handle } : {}), + steps: [ + { step: 'sender-sign', signer: `owner wallet ${owner}`, description: 'sign the transfer snapshot as the sender' }, + { step: 'receiver-sign', signer: `receiver wallet ${target}`, description: 'sign as the receiver, in the same browser' }, + { step: 'publish', signer: `owner wallet ${owner}`, description: 'publish the snapshot the receiver can open' }, + ], + afterwards: `send token #${identity.agentId} to ${target} from your wallet; ethagent does not move it`, + } + if (!values.yes) { + if (json) { + await emitJson(deps.io, { applied: false, ...summary }) + } else { + const lines = [`Preview (nothing signed or sent). Prepare agent #${identity.agentId} for ${handle}${handle.toLowerCase() !== target.toLowerCase() ? ` (${target})` : ''}.`] + summary.steps.forEach((step, index) => lines.push(` ${index + 1}. ${step.description} [${step.signer}]`)) + lines.push(`Afterwards: ${summary.afterwards}.`) + lines.push('Run again with --yes. Both wallets must be available in this browser.') + await deps.io.out(`${lines.join('\n')}\n`) + } + return 0 + } + + const vault = await seams.vaultStatus(identity).catch(() => ({ ready: false })) + if (!vault.ready) throw new HistoryError(1, 'Local continuity files are not restored.', 'Run `ethagent restore` first. Nothing was signed.') + const jwt = await requireStorage(seams.resolveJwt) + await seams.pullHarness(identity).catch(() => []) + const noOpen = Boolean(values['no-open']) + let result + try { + result = await seams.sign({ + kind: 'token-transfer-signing', + identity, + registry, + targetHandle: handle, + targetAddress: target, + pinataJwt: jwt, + returnTo: { kind: 'menu' }, + }, quietCallbacks({ + onWalletReady: ready => { + if (!ready) return + const sink = json ? deps.io.err : deps.io.out + void sink(`Approve in your browser wallet tab: ${ready.url}\nSign with the owner wallet, switch to the receiver wallet when asked, then approve the publish with the owner.\n`) + if (!noOpen) seams.openExternal(ready.url) + }, + })) + } catch (err: unknown) { + const cancelled = walletCancelled(err, []) + if (cancelled) throw cancelled + throw err + } + await seams.saveConfig({ ...config, identity: result.identity }) + const output = { applied: true, ...summary, snapshot: result.snapshotCid, txHash: result.txHash } + if (json) await emitJson(deps.io, output) + else await deps.io.out(`Transfer snapshot published (${result.txHash}).\nNow ${summary.afterwards}.\n`) + return 0 + } catch (err) { + return failFrom(deps.io, json, err) + } +} diff --git a/src/cli/sessionStart.ts b/src/cli/sessionStart.ts index 7f9c3c8..77a4652 100644 --- a/src/cli/sessionStart.ts +++ b/src/cli/sessionStart.ts @@ -19,8 +19,9 @@ export function buildSessionStartContext(identity?: EthagentIdentity | null): st '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 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.' + '`npx ethagent custody`, `ens`, `profile`, and `storage` (with `--json`) show the onchain identity and are safe to run. ' + + '`create`, `restore`, `profile`, `custody`, `ens`, and `transfer` preview their changes without `--yes`; anything with ' + + '`--yes` stays the user\'s to run, except `restore --operator --yes` when the user asks you to restore or refresh the agent.' ) } diff --git a/src/identity/continuity/localKeyDecrypt.ts b/src/identity/continuity/localKeyDecrypt.ts index 0a388a5..d1d4e73 100644 --- a/src/identity/continuity/localKeyDecrypt.ts +++ b/src/identity/continuity/localKeyDecrypt.ts @@ -92,3 +92,16 @@ export async function decryptWithSigner(envelope: ContinuitySnapshotEnvelope, si } return { ok: false, reason: 'signature-mismatch' } } + +// The challenge this address would sign to open the snapshot, or null when the snapshot +// has no slot for it. Restore previews use it to say whether a key can decrypt. +export function envelopeChallengeFor(envelope: ContinuitySnapshotEnvelope, address: string): string | null { + const me = address.toLowerCase() + if (isWalletContinuitySnapshotEnvelope(envelope)) { + return envelope.slots.find(slot => slot.address.toLowerCase() === me)?.challenge ?? null + } + if (isTransferContinuitySnapshotEnvelope(envelope)) { + return [envelope.slots.owner, envelope.slots.target].find(slot => slot.address.toLowerCase() === me)?.challenge ?? null + } + return envelope.ownerAddress.toLowerCase() === me ? envelope.challenge : null +} diff --git a/src/identity/manager/continuity/effects.ts b/src/identity/manager/continuity/effects.ts index 405303b..04a01a9 100644 --- a/src/identity/manager/continuity/effects.ts +++ b/src/identity/manager/continuity/effects.ts @@ -402,6 +402,7 @@ async function runRebackupSigningInner( ...(step.vaultAddress ? { vaultAddress: step.vaultAddress } : {}), callbacks, afterDeposit: step.profileUpdates?.custodyPhase === 'switch-advanced', + ...(opts?.session ? { session: opts.session } : {}), }).then(() => null).catch(err => operatorSyncWarningMessage(err)) const completionMessage = appendOperatorSyncWarning( rebackupCompletionMessage( diff --git a/src/identity/manager/create/effects.ts b/src/identity/manager/create/effects.ts index 1b20730..fd74f9d 100644 --- a/src/identity/manager/create/effects.ts +++ b/src/identity/manager/create/effects.ts @@ -36,6 +36,8 @@ import { setOwnerAddressField } from '../../identityCompat.js' import { prepareTransactionGasFee, requestBrowserWalletSignatureAndTransaction, + type BrowserWalletSession, + type SignAndTransactionRequest, } from '../../wallet/browserWallet.js' import { initialAgentState, PREFLIGHT_AGENT_URI } from '../profile/identity.js' import { mergeImportedNotes } from './importScan.js' @@ -119,12 +121,11 @@ function registryResolutionForNetwork(network: SelectableNetwork): RegistryResol export async function runCreateSigning( step: Extract, callbacks: EffectCallbacks, + opts: { session?: BrowserWalletSession } = {}, ): Promise { - const result = await requestBrowserWalletSignatureAndTransaction({ + const request: SignAndTransactionRequest = { chainId: step.registry.chainId, messageForAccount: account => createContinuitySnapshotChallenge(account), - onReady: callbacks.onWalletReady, - ...(callbacks.signal ? { signal: callbacks.signal } : {}), purpose: 'create-agent', prepareTransaction: async wallet => { await preflightRegisterAgent({ @@ -219,7 +220,14 @@ export async function runCreateSigning( }, } }, - }) + } + const result = opts.session + ? await opts.session.requestSignatureAndTransaction(request) + : await requestBrowserWalletSignatureAndTransaction({ + ...request, + onReady: callbacks.onWalletReady, + ...(callbacks.signal ? { signal: callbacks.signal } : {}), + }) callbacks.onWalletReady(null) callbacks.onCreateProgress?.({ phase: 'confirming', label: 'Confirming the transaction…' }) const client = createErc8004PublicClient(step.registry) diff --git a/src/identity/manager/custody/helpers.ts b/src/identity/manager/custody/helpers.ts index d68ee3f..342b2a2 100644 --- a/src/identity/manager/custody/helpers.ts +++ b/src/identity/manager/custody/helpers.ts @@ -1,5 +1,7 @@ import type { EthagentIdentity } from '../../../storage/config.js' +import { getAddress } from 'viem' import { readOwnerAddressField } from '../../identityCompat.js' +import { normalizeApprovedOperatorWallets } from '../shared/operatorWallets.js' import { supportedErc8004ChainForId } from '../../registry/erc8004.js' export function chainLabel(chainId: number): string { @@ -13,3 +15,16 @@ export function humanOwnerAddress(identity: EthagentIdentity): `0x${string}` { } return (identity.ownerAddress ?? identity.address) as `0x${string}` } + +// Every operator local state knows of for this identity: the approved list and the +// active one, checksummed and without repeats. +export function localOperatorAddresses(identity: EthagentIdentity): `0x${string}`[] { + const state = (identity.state ?? {}) as Record + const out = new Map() + for (const record of normalizeApprovedOperatorWallets(state.approvedOperatorWallets)) { + out.set(record.address.toLowerCase(), getAddress(record.address)) + } + const active = typeof state.activeOperatorAddress === 'string' ? state.activeOperatorAddress.trim() : '' + if (/^0x[0-9a-fA-F]{40}$/.test(active)) out.set(active.toLowerCase(), getAddress(active)) + return [...out.values()] +} diff --git a/src/identity/manager/custody/transactions.ts b/src/identity/manager/custody/transactions.ts index 64317bb..16bb65b 100644 --- a/src/identity/manager/custody/transactions.ts +++ b/src/identity/manager/custody/transactions.ts @@ -18,7 +18,13 @@ import { 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 { + prepareTransactionGasFee, + sendBrowserWalletTransaction, + type BrowserWalletSession, + type BrowserWalletTransaction, + type TransactionRequest, +} 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' @@ -82,6 +88,20 @@ export class VaultRefusedDepositError extends Error { } } +// Sends one custody transaction: through the caller's wallet session when it has one, +// otherwise in a tab of its own. +async function sendCustodyTransaction( + args: { session?: BrowserWalletSession; flowStep?: number }, + req: TransactionRequest, +): Promise { + const withStep: TransactionRequest = typeof args.flowStep === 'number' ? { ...req, flowStep: args.flowStep } : req + if (args.session) { + const { onReady: _onReady, signal: _signal, ...rest } = withStep + return args.session.sendTransaction(rest) + } + return sendBrowserWalletTransaction(withStep) +} + async function withTxGuard(kind: TxGuardKind, fn: () => Promise): Promise { acquireTxGuard(kind) try { @@ -97,6 +117,9 @@ export async function runVaultDeployTransaction(args: { walletAddress: Address agentId: bigint callbacks: EffectCallbacks + // When set, the wallet prompts go through this one tab instead of a tab each. + session?: BrowserWalletSession + flowStep?: number publicClient?: Pick flowId?: string // Called as soon as the deploy receipt names the new Vault, before its code is @@ -111,6 +134,9 @@ async function runVaultDeployTransactionInner(args: { walletAddress: Address agentId: bigint callbacks: EffectCallbacks + // When set, the wallet prompts go through this one tab instead of a tab each. + session?: BrowserWalletSession + flowStep?: number publicClient?: Pick flowId?: string onDeployed?: (vaultAddress: Address) => Promise | void @@ -128,7 +154,7 @@ async function runVaultDeployTransactionInner(args: { account: walletAddress, data: deployData, }) - const result = await sendBrowserWalletTransaction({ + const result = await sendCustodyTransaction(args, { chainId: args.registry.chainId, expectedAccount: walletAddress, data: deployData, @@ -157,6 +183,9 @@ export async function runVaultDepositTransaction(args: { registry: Erc8004RegistryConfig vaultAddress: Address callbacks: EffectCallbacks + // When set, the wallet prompts go through this one tab instead of a tab each. + session?: BrowserWalletSession + flowStep?: number flowId?: string }): Promise<{ txHash: string; receiptBlock: bigint; build: VaultBuild }> { return withTxGuard('vault-deposit', () => runVaultDepositTransactionInner(args)) @@ -167,6 +196,9 @@ async function runVaultDepositTransactionInner(args: { registry: Erc8004RegistryConfig vaultAddress: Address callbacks: EffectCallbacks + // When set, the wallet prompts go through this one tab instead of a tab each. + session?: BrowserWalletSession + flowStep?: number flowId?: string }): Promise<{ txHash: string; receiptBlock: bigint; build: VaultBuild }> { const { identity, registry, vaultAddress } = args @@ -199,7 +231,7 @@ async function runVaultDepositTransactionInner(args: { to: encoded.to, data: encoded.data, }) - const result = await sendBrowserWalletTransaction({ + const result = await sendCustodyTransaction(args, { chainId: registry.chainId, expectedAccount: tokenOwner, to: encoded.to, @@ -314,6 +346,9 @@ export async function runVaultUnwrapTransaction(args: { registry: Erc8004RegistryConfig vaultAddress: Address callbacks: EffectCallbacks + // When set, the wallet prompts go through this one tab instead of a tab each. + session?: BrowserWalletSession + flowStep?: number flowId?: string agentId?: bigint }): Promise<{ txHash: string } | null> { @@ -325,6 +360,9 @@ async function runVaultUnwrapTransactionInner(args: { registry: Erc8004RegistryConfig vaultAddress: Address callbacks: EffectCallbacks + // When set, the wallet prompts go through this one tab instead of a tab each. + session?: BrowserWalletSession + flowStep?: number flowId?: string agentId?: bigint }): Promise<{ txHash: string } | null> { @@ -358,7 +396,7 @@ async function runVaultUnwrapTransactionInner(args: { to: encoded.to, data: encoded.data, }) - const result = await sendBrowserWalletTransaction({ + const result = await sendCustodyTransaction(args, { chainId: registry.chainId, expectedAccount: ownerAddress, to: encoded.to, @@ -392,6 +430,9 @@ export async function runVaultWithdrawTransaction(args: { registry: Erc8004RegistryConfig vaultAddress: Address callbacks: EffectCallbacks + // When set, the wallet prompts go through this one tab instead of a tab each. + session?: BrowserWalletSession + flowStep?: number agentId?: bigint }): Promise<{ txHash: string; recipient: Address }> { return withTxGuard('vault-withdraw', () => runVaultWithdrawTransactionInner(args)) @@ -402,6 +443,9 @@ async function runVaultWithdrawTransactionInner(args: { registry: Erc8004RegistryConfig vaultAddress: Address callbacks: EffectCallbacks + // When set, the wallet prompts go through this one tab instead of a tab each. + session?: BrowserWalletSession + flowStep?: number agentId?: bigint }): Promise<{ txHash: string; recipient: Address }> { const { identity, registry, vaultAddress } = args @@ -435,7 +479,7 @@ async function runVaultWithdrawTransactionInner(args: { to: encoded.to, data: encoded.data, }) - const result = await sendBrowserWalletTransaction({ + const result = await sendCustodyTransaction(args, { chainId: registry.chainId, expectedAccount: recipient, to: encoded.to, diff --git a/src/identity/manager/custody/useCustodyEffects.ts b/src/identity/manager/custody/useCustodyEffects.ts index 6e489fc..85859aa 100644 --- a/src/identity/manager/custody/useCustodyEffects.ts +++ b/src/identity/manager/custody/useCustodyEffects.ts @@ -5,7 +5,9 @@ import { invalidateOwnershipCache } from '../shared/reconciliation/index.js' import type { ProfileUpdates } from '../reducer.js' import type { CustodyFlowDeps } from './types.js' import { scopeCallbacks } from '../shared/effects/types.js' -import { humanOwnerAddress } from './helpers.js' +import { humanOwnerAddress, localOperatorAddresses } from './helpers.js' +import { revokeVaultOperatorsBeforeWithdraw } from '../shared/effects/sync.js' +import { getAddress } from 'viem' import { receiptPacing, recordDeployedVault, @@ -152,6 +154,14 @@ export function useCustodyTransactionEffects({ : (step.identity.agentId ? [step.identity.agentId] : []) ;(async () => { try { + await revokeVaultOperatorsBeforeWithdraw({ + identity: step.identity, + registry: step.registry, + vaultAddress: step.vaultAddress, + ownerAddress: getAddress(humanOwnerAddress(step.identity)), + candidates: localOperatorAddresses(step.identity), + callbacks: scope.callbacks, + }) for (const agentIdStr of agentIds) { if (cancelled) return await runVaultUnwrapTransaction({ diff --git a/src/identity/manager/ens/EnsOperatorWalletsScreen.tsx b/src/identity/manager/ens/EnsOperatorWalletsScreen.tsx index 2817789..ca20038 100644 --- a/src/identity/manager/ens/EnsOperatorWalletsScreen.tsx +++ b/src/identity/manager/ens/EnsOperatorWalletsScreen.tsx @@ -10,10 +10,7 @@ import { openExternalUrl } from '../../../utils/openExternal.js' import type { EthagentIdentity } from '../../../storage/config.js' import { readOwnerAddressField } from '../../identityCompat.js' import type { Erc8004RegistryConfig } from '../../registry/erc8004.js' -import { - createWalletRestoreAccessChallenge, - createWalletRestoreAccessKey, -} from '../../continuity/envelope.js' +import { operatorAccessContext, operatorProofChallenge, operatorRecordFromProof } from '../shared/operatorAccess.js' import { requestBrowserWalletSignature, type BrowserWalletReady } from '../../wallet/browserWallet.js' import { FlowTimeline } from '../shared/components/FlowTimeline.js' import { FieldList } from '../shared/components/FieldRow.js' @@ -113,8 +110,7 @@ export const OperatorWalletsScreen: React.FC = ({ setPhase({ kind: 'main', error: 'agent token ID is required before authorizing a wallet' }) return } - const token = restoreAccessToken(registry, identity.agentId) - const nextEpoch = restoreAccessEpoch + 1 + const access = operatorAccessContext(identity, registry) setPhase({ kind: 'signing' }) signingRef.current?.abort() const signing = new AbortController() @@ -123,31 +119,11 @@ export const OperatorWalletsScreen: React.FC = ({ chainId: registry.chainId, purpose: 'operator-proof', signal: signing.signal, - messageForAccount: account => createWalletRestoreAccessChallenge({ - token, - ownerAddress: ownerAddress, - walletAddress: account, - accessEpoch: nextEpoch, - purpose: 'restore-operator', - }), + messageForAccount: account => operatorProofChallenge(access, account), onReady: onWalletReady, }).then(wallet => { onWalletReady(null) - const restoreAccessKey = createWalletRestoreAccessKey({ - token, - ownerAddress: ownerAddress, - walletAddress: wallet.account, - walletSignature: wallet.signature, - accessEpoch: nextEpoch, - createdAt: new Date().toISOString(), - purpose: 'restore-operator', - }) - addRecord({ - address: wallet.account, - challenge: wallet.message, - verifiedAt: restoreAccessKey.createdAt, - restoreAccessKey, - }) + addRecord(operatorRecordFromProof(access, wallet)) }).catch((err: unknown) => { if (signing.signal.aborted) return onWalletReady(null) @@ -334,14 +310,6 @@ function readStateNumber(state: Record, key: string): number | return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined } -function restoreAccessToken(registry: Erc8004RegistryConfig, agentId: string) { - return { - chainId: registry.chainId, - identityRegistryAddress: registry.identityRegistryAddress, - agentId, - } -} - function readStateAddress(state: Record, key: string): Address | undefined { const value = state[key] if (typeof value !== 'string' || !isAddress(value, { strict: false })) return undefined diff --git a/src/identity/manager/restore/apply.ts b/src/identity/manager/restore/apply.ts index 44204b9..f984c15 100644 --- a/src/identity/manager/restore/apply.ts +++ b/src/identity/manager/restore/apply.ts @@ -1,4 +1,4 @@ -import { getAddress } from 'viem' +import { getAddress, type Address } from 'viem' import type { EthagentIdentity } from '../../../storage/config.js' import { restoreAgentStateBackupEnvelope } from '../../crypto/backupEnvelope.js' import { @@ -13,46 +13,88 @@ import { import { syncAgentCardManifest } from '../../continuity/skills/publicSkillsSync.js' import { recordPublishedContinuitySnapshot } from '../../continuity/snapshots.js' import { captureSnapshot, checkpointBeforeRestore } from '../../continuity/snapshotCapture.js' -import { requestBrowserWalletSignature } from '../../wallet/browserWallet.js' +import { requestBrowserWalletSignature, type SignatureRequest } from '../../wallet/browserWallet.js' +import type { ContinuitySnapshotEnvelope } from '../../continuity/envelope.js' +import { decryptContinuityWithLocalSigner, signLegacyChallengeLocally, type RestoreSigner } from './signer.js' +import { canRestoreCandidate } from './discover.js' import { setVaultAddressField } from '../../identityCompat.js' import type { Step } from '../reducer.js' import type { EffectCallbacks } from '../shared/effects/types.js' import { isContinuitySnapshotEnvelope } from './envelopes.js' -import { restoreSignatureRequestForStep } from './auth.js' +import { restoreMessageForWallet, restoreSignatureRequestForStep } from './auth.js' import { type BackupMetadata, operatorStateFromCandidate, restorePublishedAgentCard } from './helpers.js' export async function runRestoreAuthorize( step: Extract, callbacks: EffectCallbacks, + opts: { signer?: RestoreSigner } = {}, ): Promise { - const signatureRequest = restoreSignatureRequestForStep(step) - const wallet = await requestBrowserWalletSignature({ - chainId: step.candidate.chainId, - expectedAccount: signatureRequest.expectedAccount, - message: signatureRequest.message, - purpose: signatureRequest.purpose, - onReady: callbacks.onWalletReady, - ...(callbacks.signal ? { signal: callbacks.signal } : {}), - }) - callbacks.onWalletReady(null) - callbacks.onRestoreProgress?.({ phase: 'decrypting', label: 'Decrypting the snapshot…' }) let restored: ReturnType | ReturnType let continuityFiles: ReturnType['files'] | undefined let continuitySkills: ReturnType['skills'] - if (isContinuitySnapshotEnvelope(step.envelope)) { - const payload = restoreContinuitySnapshotEnvelope({ - envelope: step.envelope, - walletSignature: wallet.signature, - currentOwnerAddress: wallet.account, - }) - restored = payload - continuityFiles = payload.files - continuitySkills = payload.skills + let signerAccount: Address + if (opts.signer?.kind === 'local') { + // The operator key answers the challenge locally: no browser, and the snapshot + // opens only if it carries a slot for that key. + if (isContinuitySnapshotEnvelope(step.envelope)) { + const opened = await decryptContinuityWithLocalSigner(step.envelope, opts.signer.signer) + restored = opened.payload + continuityFiles = opened.payload.files + continuitySkills = opened.payload.skills + signerAccount = opened.account + } else { + const envelope = step.envelope + const opened = await signLegacyChallengeLocally(opts.signer.signer, envelope.challenge, signature => + restoreAgentStateBackupEnvelope({ envelope, walletSignature: signature })) + restored = opened.value + signerAccount = opened.account + } + callbacks.onRestoreProgress?.({ phase: 'decrypting', label: 'Decrypting the snapshot…' }) } else { - restored = restoreAgentStateBackupEnvelope({ - envelope: step.envelope, - walletSignature: wallet.signature, + const requestSignature = opts.signer?.kind === 'browser' + ? opts.signer.requestSignature + : (req: SignatureRequest) => requestBrowserWalletSignature({ + ...req, + onReady: callbacks.onWalletReady, + ...(callbacks.signal ? { signal: callbacks.signal } : {}), + }) + // Without a known requester and with a continuity snapshot, whichever wallet + // connects is asked for its own slot's challenge and checked afterwards. + const openRequest = !step.requesterAddress && isContinuitySnapshotEnvelope(step.envelope) + const envelopeForRequest = step.envelope + const signatureRequest = openRequest ? null : restoreSignatureRequestForStep(step) + const wallet = await requestSignature({ + chainId: step.candidate.chainId, + ...(signatureRequest + ? { expectedAccount: signatureRequest.expectedAccount, message: signatureRequest.message, purpose: signatureRequest.purpose } + : { + purpose: 'restore-owner-wallet', + messageForAccount: (account: Address) => { + if (!canRestoreCandidate(step.candidate, account)) { + throw new Error(`${account} is not this agent's owner or an approved operator wallet. Connect one of those.`) + } + return restoreMessageForWallet(envelopeForRequest as ContinuitySnapshotEnvelope, account) + }, + }), }) + callbacks.onWalletReady(null) + signerAccount = getAddress(wallet.account) + callbacks.onRestoreProgress?.({ phase: 'decrypting', label: 'Decrypting the snapshot…' }) + if (isContinuitySnapshotEnvelope(step.envelope)) { + const payload = restoreContinuitySnapshotEnvelope({ + envelope: step.envelope, + walletSignature: wallet.signature, + currentOwnerAddress: wallet.account, + }) + restored = payload + continuityFiles = payload.files + continuitySkills = payload.skills + } else { + restored = restoreAgentStateBackupEnvelope({ + envelope: step.envelope, + walletSignature: wallet.signature, + }) + } } callbacks.onRestoreProgress?.({ phase: 'writing', label: 'Writing soul, memory, and skills…' }) const transferSnapshot = isContinuitySnapshotEnvelope(step.envelope) @@ -75,7 +117,7 @@ export async function runRestoreAuthorize( } const restoreRequester = step.requesterAddress && /^0x[a-fA-F0-9]{40}$/.test(step.requesterAddress) ? getAddress(step.requesterAddress) - : step.candidate.ownerAddress + : signerAccount const tokenOwnerAddress = step.candidate.tokenOwnerAddress ?? step.candidate.ownerAddress const restoredState: Record = { ...restored.state, diff --git a/src/identity/manager/restore/recovery.ts b/src/identity/manager/restore/recovery.ts index bb65ac4..06b0590 100644 --- a/src/identity/manager/restore/recovery.ts +++ b/src/identity/manager/restore/recovery.ts @@ -20,7 +20,8 @@ import { discoverOwnedAgentBackupByTokenId, type Erc8004RegistryConfig, } from '../../registry/erc8004.js' -import { requestBrowserWalletSignature } from '../../wallet/browserWallet.js' +import { requestBrowserWalletSignature, type SignatureRequest } from '../../wallet/browserWallet.js' +import { decryptContinuityWithLocalSigner, type RestoreSigner } from './signer.js' import { setVaultAddressField } from '../../identityCompat.js' import type { EffectCallbacks } from '../shared/effects/types.js' import { isContinuitySnapshotEnvelope, parseRestorableEnvelope } from './envelopes.js' @@ -32,6 +33,7 @@ export async function runRecoveryRefetch( identity: EthagentIdentity, registry: Erc8004RegistryConfig, callbacks: EffectCallbacks, + opts: { signer?: RestoreSigner; fetchImpl?: typeof fetch } = {}, ): Promise { if (!identity.agentId) throw new Error('Cannot refetch: identity is missing an agent token ID') const ownerAddress = getAddress(identity.ownerAddress ?? identity.address) @@ -46,7 +48,7 @@ export async function runRecoveryRefetch( throw new Error('The published agent does not have a recoverable encrypted snapshot') } const apiUrl = identity.backup?.ipfsApiUrl ?? DEFAULT_IPFS_API_URL - const raw = await catFromIpfs(apiUrl, candidate.backup.cid, fetch, { + const raw = await catFromIpfs(apiUrl, candidate.backup.cid, opts.fetchImpl ?? fetch, { ...(callbacks.signal ? { signal: callbacks.signal } : {}), onProgress: progress => callbacks.onRestoreProgress?.(downloadProgress(progress)), }) @@ -66,26 +68,37 @@ export async function runRecoveryRefetch( } else { assertContinuitySnapshotOwner(envelope, ownerAddress) } - const wallet = await requestBrowserWalletSignature({ - chainId: candidate.chainId, - purpose: 'refetch-snapshot', - messageForAccount: account => { - const matched = eligibleAddresses.find(a => a.toLowerCase() === account.toLowerCase()) - if (!matched) { - throw new Error(`Operator Wallet Required: ${account} is not authorized for this agent. Connect the owner wallet or an authorized operator wallet.`) - } - return restoreMessageForWallet(envelope, matched) - }, - onReady: callbacks.onWalletReady, - ...(callbacks.signal ? { signal: callbacks.signal } : {}), - }) - callbacks.onWalletReady(null) - callbacks.onRestoreProgress?.({ phase: 'decrypting', label: 'Decrypting the snapshot…' }) - const payload = restoreContinuitySnapshotEnvelope({ - envelope, - walletSignature: wallet.signature, - currentOwnerAddress: getAddress(wallet.account), - }) + let payload: ReturnType + if (opts.signer?.kind === 'local') { + payload = (await decryptContinuityWithLocalSigner(envelope, opts.signer.signer)).payload + callbacks.onRestoreProgress?.({ phase: 'decrypting', label: 'Decrypting the snapshot…' }) + } else { + const request: SignatureRequest = { + chainId: candidate.chainId, + purpose: 'refetch-snapshot', + messageForAccount: account => { + const matched = eligibleAddresses.find(a => a.toLowerCase() === account.toLowerCase()) + if (!matched) { + throw new Error(`Operator Wallet Required: ${account} is not authorized for this agent. Connect the owner wallet or an authorized operator wallet.`) + } + return restoreMessageForWallet(envelope, matched) + }, + } + const wallet = opts.signer?.kind === 'browser' + ? await opts.signer.requestSignature(request) + : await requestBrowserWalletSignature({ + ...request, + onReady: callbacks.onWalletReady, + ...(callbacks.signal ? { signal: callbacks.signal } : {}), + }) + callbacks.onWalletReady(null) + callbacks.onRestoreProgress?.({ phase: 'decrypting', label: 'Decrypting the snapshot…' }) + payload = restoreContinuitySnapshotEnvelope({ + envelope, + walletSignature: wallet.signature, + currentOwnerAddress: getAddress(wallet.account), + }) + } callbacks.onRestoreProgress?.({ phase: 'writing', label: 'Writing soul, memory, and skills…' }) const transferSnapshot = transferSnapshotMetadataFromEnvelope(envelope) const refreshedBackup: BackupMetadata = { diff --git a/src/identity/manager/restore/signer.ts b/src/identity/manager/restore/signer.ts new file mode 100644 index 0000000..a42c30d --- /dev/null +++ b/src/identity/manager/restore/signer.ts @@ -0,0 +1,55 @@ +import { getAddress, type Address, type Hex } from 'viem' +import type { ContinuitySnapshotEnvelope, ContinuitySnapshotPayload } from '../../continuity/envelope.js' +import { decryptWithSigner, signatureVariants, type ChallengeSigner } from '../../continuity/localKeyDecrypt.js' +import type { BrowserWalletSignature, SignatureRequest } from '../../wallet/browserWallet.js' + +// Who answers a restore's decrypt challenge. The manager asks the browser wallet in a +// tab of its own; headless commands pass one wallet session for the whole command, or +// the operator key, which signs locally and never opens a browser. +export type RestoreSigner = + | { kind: 'browser'; requestSignature: (req: SignatureRequest) => Promise } + | { kind: 'local'; signer: ChallengeSigner } + +const LOCK_WORDS: Record = { + 'no-slot': 'this snapshot has no restore slot for the operator key', + 'signature-mismatch': 'the operator key did not open this snapshot', + 'transfer-parties-only': 'this transfer snapshot opens only for its sender and receiver', + 'owner-only': 'this snapshot opens only with the owner wallet', +} + +export class RestoreLockedError extends Error { + constructor(readonly reason: string, readonly signer: Address, readonly slots?: string[]) { + super(`${LOCK_WORDS[reason] ?? reason} (${signer})${slots?.length ? `; it opens for ${slots.join(', ')}` : ''}.`) + this.name = 'RestoreLockedError' + } +} + +// Opens a continuity snapshot with the operator key, trying both signature v-byte +// forms as the history fetch does. +export async function decryptContinuityWithLocalSigner( + envelope: ContinuitySnapshotEnvelope, + signer: ChallengeSigner, +): Promise<{ account: Address; payload: ContinuitySnapshotPayload }> { + const result = await decryptWithSigner(envelope, signer) + if (!result.ok) throw new RestoreLockedError(result.reason, getAddress(signer.address), result.slots) + return { account: getAddress(signer.address), payload: result.payload } +} + +// Signs a legacy owner-only challenge locally, returning the first v-byte form the +// caller's check accepts. +export async function signLegacyChallengeLocally( + signer: ChallengeSigner, + challenge: string, + open: (signature: Hex) => T, +): Promise<{ account: Address; value: T }> { + const signature = await signer.sign(challenge) + let lastError: unknown + for (const variant of signatureVariants(signature)) { + try { + return { account: getAddress(signer.address), value: open(variant as Hex) } + } catch (err: unknown) { + lastError = err + } + } + throw lastError ?? new RestoreLockedError('signature-mismatch', getAddress(signer.address)) +} diff --git a/src/identity/manager/shared/effects/sync.ts b/src/identity/manager/shared/effects/sync.ts index 4a1a752..efb8ec0 100644 --- a/src/identity/manager/shared/effects/sync.ts +++ b/src/identity/manager/shared/effects/sync.ts @@ -11,7 +11,7 @@ import { encodeSetMetadataOperator, readMetadataOperators, } from '../../../registry/vault.js' -import { prepareTransactionGasFee, sendBrowserWalletTransaction } from '../../../wallet/browserWallet.js' +import { prepareTransactionGasFee, sendBrowserWalletTransaction, type BrowserWalletSession } from '../../../wallet/browserWallet.js' import { computeApprovalDiff, type ApprovalDiff, @@ -47,6 +47,7 @@ export async function syncVaultOperatorsAfterOwnerSave(args: { // which wipes every approval, so operators that local state still lists are approved // again even though the local diff is empty. afterDeposit?: boolean + session?: BrowserWalletSession }): Promise { const beforeState = (args.beforeIdentity.state ?? {}) as Record const afterState = (args.afterIdentity.state ?? {}) as Record @@ -79,6 +80,7 @@ export async function syncVaultOperatorsAfterOwnerSave(args: { diff, ownerAddress, callbacks: args.callbacks, + ...(args.session ? { session: args.session } : {}), }) } @@ -89,6 +91,7 @@ export async function syncVaultMetadataOperatorsAfterOwnerSave(args: { diff: ApprovalDiff ownerAddress: Address callbacks: EffectCallbacks + session?: BrowserWalletSession }): Promise { if (!args.vaultAddress) return const agentIdRaw = args.afterIdentity.agentId @@ -126,16 +129,21 @@ export async function syncVaultMetadataOperatorsAfterOwnerSave(args: { to: encoded.to, data: encoded.data, }) - const tx = await sendBrowserWalletTransaction({ + const request = { chainId: args.registry.chainId, expectedAccount: args.ownerAddress, to: encoded.to, data: encoded.data, ...gasFee, - onReady: args.callbacks.onWalletReady, - ...(args.callbacks.signal ? { signal: args.callbacks.signal } : {}), - purpose: 'sync-operator-vault', - }) + purpose: 'sync-operator-vault' as const, + } + const tx = args.session + ? await args.session.sendTransaction(request) + : await sendBrowserWalletTransaction({ + ...request, + onReady: args.callbacks.onWalletReady, + ...(args.callbacks.signal ? { signal: args.callbacks.signal } : {}), + }) args.callbacks.onWalletReady(null) await awaitConfirmedReceipt(probeClient, tx.txHash, 'Vault operator sync') } @@ -200,3 +208,38 @@ export async function markCurrentContinuityFilesPublished( }) } } + +// Revokes every operator the Vault still approves for this token, before the token +// leaves it. Some Vault builds keep approvals across a withdraw, so leftover operators +// could otherwise rotate the URI of whoever deposits the token next. +export async function revokeVaultOperatorsBeforeWithdraw(args: { + identity: EthagentIdentity + registry: Erc8004RegistryConfig + vaultAddress: Address + ownerAddress: Address + candidates: readonly Address[] + callbacks: EffectCallbacks + session?: BrowserWalletSession +}): Promise { + const agentIdRaw = args.identity.agentId + if (!agentIdRaw || args.candidates.length === 0) return [] + const approved = await readMetadataOperators({ + client: createErc8004PublicClient(args.registry), + vaultAddress: getAddress(args.vaultAddress), + registry: getAddress(args.registry.identityRegistryAddress), + agentId: BigInt(agentIdRaw), + candidates: args.candidates.map(candidate => getAddress(candidate)), + }) + const removed = Object.entries(approved).filter(([, on]) => on).map(([address]) => getAddress(address)) + if (removed.length === 0) return [] + await syncVaultMetadataOperatorsAfterOwnerSave({ + afterIdentity: args.identity, + registry: args.registry, + vaultAddress: args.vaultAddress, + diff: { added: [], removed }, + ownerAddress: args.ownerAddress, + callbacks: args.callbacks, + ...(args.session ? { session: args.session } : {}), + }) + return removed +} diff --git a/src/identity/manager/shared/operatorAccess.ts b/src/identity/manager/shared/operatorAccess.ts new file mode 100644 index 0000000..1f0dffb --- /dev/null +++ b/src/identity/manager/shared/operatorAccess.ts @@ -0,0 +1,109 @@ +import { getAddress, isAddress, type Address } from 'viem' +import type { EthagentIdentity } from '../../../storage/config.js' +import { createWalletRestoreAccessChallenge, createWalletRestoreAccessKey } from '../../continuity/envelope.js' +import { readOwnerAddressField } from '../../identityCompat.js' +import type { Erc8004RegistryConfig } from '../../registry/erc8004.js' +import type { ProfileUpdates } from '../reducer.js' +import { + normalizeApprovedOperatorWallets, + removeApprovedOperatorWallet, + upsertApprovedOperatorWallet, + type ApprovedOperatorWalletRecord, +} from './operatorWallets.js' + +// What adding, removing, or activating an operator reads from local state. Every +// operator change starts a new restore-access epoch, so the change is signed for +// epoch + 1, as the manager's Operator Wallets screen does. +export type OperatorAccessContext = { + ownerAddress: Address + records: ApprovedOperatorWalletRecord[] + activeOperatorAddress: Address | undefined + nextEpoch: number + token: { chainId: number; identityRegistryAddress: Address; agentId: string } +} + +export function operatorAccessContext(identity: EthagentIdentity, registry: Erc8004RegistryConfig): OperatorAccessContext { + const state = (identity.state ?? {}) as Record + const ownerRaw = readOwnerAddressField(state) + if (!ownerRaw || !isAddress(ownerRaw, { strict: false })) { + throw new Error('Advanced custody needs an owner wallet before managing operator wallets.') + } + if (!identity.agentId) throw new Error('The agent token ID is required before authorizing a wallet.') + const epochRaw = state.restoreAccessEpoch + const epoch = typeof epochRaw === 'number' && Number.isSafeInteger(epochRaw) && epochRaw >= 0 ? epochRaw : 0 + const activeRaw = state.activeOperatorAddress + return { + ownerAddress: getAddress(ownerRaw), + records: normalizeApprovedOperatorWallets(state.approvedOperatorWallets), + activeOperatorAddress: typeof activeRaw === 'string' && isAddress(activeRaw, { strict: false }) ? getAddress(activeRaw) : undefined, + nextEpoch: epoch + 1, + token: { chainId: registry.chainId, identityRegistryAddress: registry.identityRegistryAddress, agentId: identity.agentId }, + } +} + +// The challenge an operator signs to prove it holds its wallet and to derive its +// restore-access key for the next epoch. +export function operatorProofChallenge(ctx: OperatorAccessContext, operator: Address): string { + return createWalletRestoreAccessChallenge({ + token: ctx.token, + ownerAddress: ctx.ownerAddress, + walletAddress: operator, + accessEpoch: ctx.nextEpoch, + purpose: 'restore-operator', + }) +} + +export function operatorRecordFromProof( + ctx: OperatorAccessContext, + proof: { account: Address; message: string; signature: string }, +): ApprovedOperatorWalletRecord { + if (proof.account.toLowerCase() === ctx.ownerAddress.toLowerCase()) { + throw new Error('The operator wallet must differ from the owner wallet.') + } + const restoreAccessKey = createWalletRestoreAccessKey({ + token: ctx.token, + ownerAddress: ctx.ownerAddress, + walletAddress: proof.account, + walletSignature: proof.signature, + accessEpoch: ctx.nextEpoch, + createdAt: new Date().toISOString(), + purpose: 'restore-operator', + }) + return { + address: getAddress(proof.account), + challenge: proof.message, + verifiedAt: restoreAccessKey.createdAt, + restoreAccessKey, + } +} + +function operatorUpdates(ctx: OperatorAccessContext, records: ApprovedOperatorWalletRecord[], active: Address | '' | undefined): ProfileUpdates { + return { + custodyMode: 'advanced', + ownerAddress: ctx.ownerAddress, + approvedOperatorWallets: records, + restoreAccessEpoch: ctx.nextEpoch, + ...(active !== undefined ? { activeOperatorAddress: active } : {}), + } +} + +// The new operator becomes active when none is. +export function addOperatorUpdates(ctx: OperatorAccessContext, record: ApprovedOperatorWalletRecord): ProfileUpdates { + return operatorUpdates(ctx, upsertApprovedOperatorWallet(ctx.records, record), ctx.activeOperatorAddress ?? record.address) +} + +// Removing the active operator leaves none active. +export function removeOperatorUpdates(ctx: OperatorAccessContext, operator: Address): ProfileUpdates { + if (!ctx.records.some(record => record.address.toLowerCase() === operator.toLowerCase())) { + throw new Error(`${operator} is not an approved operator.`) + } + const records = removeApprovedOperatorWallet(ctx.records, operator) + const active = ctx.activeOperatorAddress?.toLowerCase() === operator.toLowerCase() ? '' : ctx.activeOperatorAddress + return operatorUpdates(ctx, records, active) +} + +export function activateOperatorUpdates(ctx: OperatorAccessContext, operator: Address): ProfileUpdates { + const record = ctx.records.find(item => item.address.toLowerCase() === operator.toLowerCase()) + if (!record) throw new Error(`${operator} is not an approved operator.`) + return operatorUpdates(ctx, ctx.records, getAddress(record.address)) +} diff --git a/src/identity/manager/transfer/effects.ts b/src/identity/manager/transfer/effects.ts index a96a652..1660bb9 100644 --- a/src/identity/manager/transfer/effects.ts +++ b/src/identity/manager/transfer/effects.ts @@ -286,7 +286,7 @@ export async function runTokenTransferSigning( } } -async function resolveTransferTargetAddress(value: string, options: { signal?: AbortSignal } = {}): Promise
{ +export async function resolveTransferTargetAddress(value: string, options: { signal?: AbortSignal } = {}): Promise
{ const trimmed = value.trim() throwIfTransferAborted(options.signal) if (isAddress(trimmed, { strict: false })) return getAddress(trimmed) diff --git a/src/identity/wallet/browserWallet/session.ts b/src/identity/wallet/browserWallet/session.ts index bf1c318..6e11127 100644 --- a/src/identity/wallet/browserWallet/session.ts +++ b/src/identity/wallet/browserWallet/session.ts @@ -244,7 +244,14 @@ export async function openBrowserWalletSession(args: { kind: 'transaction', chainIdHex: chainIdHex(req.chainId), expectedAccount: req.expectedAccount, - tx: { to: req.to, data: req.data, ...(req.value ? { value: req.value } : {}) }, + tx: { + ...(req.to ? { to: req.to } : {}), + data: req.data, + ...(req.value ? { value: req.value } : {}), + ...(req.gas ? { gas: req.gas } : {}), + ...(req.maxFeePerGas ? { maxFeePerGas: req.maxFeePerGas } : {}), + ...(req.maxPriorityFeePerGas ? { maxPriorityFeePerGas: req.maxPriorityFeePerGas } : {}), + }, ...(req.purpose ? { purpose: req.purpose } : {}), ...(req.flowId ? { flowId: req.flowId } : {}), ...(typeof req.flowStep === 'number' ? { flowStep: req.flowStep } : {}), @@ -266,7 +273,14 @@ export async function openBrowserWalletSession(args: { account: `0x${string}` message: string signature: `0x${string}` - tx: { to: `0x${string}`; data: `0x${string}`; value?: `0x${string}` } + tx: { + to: `0x${string}` + data: `0x${string}` + value?: `0x${string}` + gas?: `0x${string}` + maxFeePerGas?: `0x${string}` + maxPriorityFeePerGas?: `0x${string}` + } prepared: TPrepared } | null = null @@ -303,6 +317,9 @@ export async function openBrowserWalletSession(args: { to: next.to, data: next.data, ...(next.value ? { value: next.value } : {}), + ...(next.gas ? { gas: next.gas } : {}), + ...(next.maxFeePerGas ? { maxFeePerGas: next.maxFeePerGas } : {}), + ...(next.maxPriorityFeePerGas ? { maxPriorityFeePerGas: next.maxPriorityFeePerGas } : {}), }, prepared: next.prepared, } diff --git a/src/identity/wallet/page/timeline.ts b/src/identity/wallet/page/timeline.ts index 48ab5d4..6afdb0e 100644 --- a/src/identity/wallet/page/timeline.ts +++ b/src/identity/wallet/page/timeline.ts @@ -11,7 +11,7 @@ const LIFECYCLE_DEFINITIONS: Record = { "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"] }, - "custody-switch": { steps: ["Deploy Vault", "Deposit Token", "Reconcile Operators"] }, + "custody-switch": { steps: ["Deploy Vault", "Deposit Token", "Save Through Vault"] }, "public-profile-vault": { steps: ["Sign Profile", "Save Through Vault"] }, }; diff --git a/test/cli/custodyWrite.test.ts b/test/cli/custodyWrite.test.ts new file mode 100644 index 0000000..c90df4d --- /dev/null +++ b/test/cli/custodyWrite.test.ts @@ -0,0 +1,160 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { getAddress, type Address } from 'viem' +import { runCustodyCommand } from '../../src/cli/onchain/custody.js' +import type { CustodyWriteSeams } from '../../src/cli/onchain/custodyWrite.js' +import type { HistoryDeps } from '../../src/cli/history/shared.js' +import type { EthagentConfig, EthagentIdentity } from '../../src/storage/config.js' +import type { ProfileUpdates } from '../../src/identity/manager/reducer.js' +import { captureIo, type CapturedIo } from '../support/home.js' + +const REGISTRY = getAddress('0x8004A169FB4a3325136EB29fA0ceB6D2e539a432') +const OWNER = getAddress('0xA1E9000000000000000000000000000000000001') +const VAULT = getAddress('0x6bdC0000000000000000000000000000000051d7') +const OPERATOR = getAddress('0x70997970C51812dc3A010C7d01b50e0d17dc79C8') +const OPERATOR_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as const +const ZERO = '0x0000000000000000000000000000000000000000' + +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', agentUri: 'ipfs://x', state: { custodyMode: 'simple', ...state }, + } as EthagentIdentity +} + +function deps(id: EthagentIdentity, operatorKey?: HistoryDeps['operatorKey']): HistoryDeps & { io: CapturedIo } { + return { + io: captureIo(), + env: {}, + now: () => new Date(), + loadConfig: async () => ({ version: 2, firstSeenAt: id.createdAt, identity: id } as EthagentConfig), + listLedger: async () => [], + ...(operatorKey ? { operatorKey } : {}), + } +} + +type Chain = { tokenOwner: Address; vaultOwner: Address | null; approved: Address[] } +type Calls = { order: string[]; updates?: ProfileUpdates; sessions: Set; publishVault?: string } + +function writeSeams(chain: Chain, calls: Calls, overrides: Partial = {}): CustodyWriteSeams { + const session = { close: async () => {}, requestSignature: async () => { throw new Error('no browser proof expected') } } + const track = (name: string, s?: unknown) => { calls.order.push(name); if (s) calls.sessions.add(s) } + return { + client: () => ({ + readContract: async (call: { functionName: string; args?: readonly unknown[] }) => { + if (call.functionName === 'ownerOf') return chain.tokenOwner + if (call.functionName === 'agentOwner') return chain.vaultOwner ?? ZERO + if (call.functionName === 'metadataOperators') return chain.approved.some(a => a.toLowerCase() === String(call.args?.[2]).toLowerCase()) + throw new Error(`unexpected read ${call.functionName}`) + }, + getBytecode: async () => '0x00', + simulateContract: async () => ({}), + getBlockNumber: async () => 1n, + }) as never, + priorVault: async () => ({ found: false }), + reusableVault: async () => undefined, + deploy: async args => { track('deploy', args.session); return { txHash: '0xd1', vaultAddress: VAULT } }, + deposit: async args => { track('deposit', args.session); chain.vaultOwner = OWNER; return { txHash: '0xd2', receiptBlock: 1n, build: {} as never } }, + confirmDeposit: async () => ({ inVault: true, ownerAddress: OWNER }), + unwrap: async args => { track('withdraw', args.session); chain.vaultOwner = null; return { txHash: '0xd3' } }, + revoke: async args => { track('revoke', args.session); return [...args.candidates] }, + recordVault: async () => {}, + publish: async (step, callbacks, s) => { + track('save', s) + calls.updates = step.profileUpdates + calls.publishVault = step.vaultAddress + await callbacks.onIdentityComplete({ ...step.identity, backup: { cid: 'bafy', txHash: '0xs' } as never }, 'saved') + }, + resolveJwt: async () => 'jwt', + vaultStatus: async () => ({ ready: true }) as never, + pullHarness: async () => [], + openSession: async () => session as never, + openExternal: () => {}, + saveConfig: async () => {}, + ...overrides, + } +} + +const readSeams = { client: () => { throw new Error('read path not expected') } } as never + +test('custody --advanced previews deploy, deposit, and save when no Vault exists yet', async () => { + const calls: Calls = { order: [], sessions: new Set() } + const d = deps(identity()) + assert.equal(await runCustodyCommand(['--advanced', '--json'], d, readSeams, writeSeams({ tokenOwner: OWNER, vaultOwner: null, approved: [] }, calls)), 0) + const out = d.io.json() as Record + assert.equal(out.applied, false) + assert.deepEqual(out.steps.map((step: any) => step.step), ['deploy', 'deposit', 'save']) + assert.deepEqual(calls.order, []) +}) + +test('custody --advanced --yes runs every step in one wallet tab and saves with the new Vault', async () => { + const calls: Calls = { order: [], sessions: new Set() } + const d = deps(identity()) + assert.equal(await runCustodyCommand(['--advanced', '--yes', '--json'], d, readSeams, writeSeams({ tokenOwner: OWNER, vaultOwner: null, approved: [] }, calls)), 0) + assert.deepEqual(calls.order, ['deploy', 'deposit', 'save']) + assert.equal(calls.sessions.size, 1) + assert.deepEqual(calls.updates, { custodyMode: 'advanced', ownerAddress: OWNER, bumpRestoreAccessEpoch: true, custodyPhase: 'switch-advanced', operatorVaultAddress: VAULT }) + assert.equal(calls.publishVault, VAULT) +}) + +test('custody --advanced resumes: a Vault that already holds the token skips straight to the save', async () => { + const calls: Calls = { order: [], sessions: new Set() } + const id = identity({ operatorVaultAddress: VAULT }) + const d = deps(id) + assert.equal(await runCustodyCommand(['--advanced', '--yes', '--json'], d, readSeams, writeSeams({ tokenOwner: VAULT, vaultOwner: OWNER, approved: [] }, calls)), 0) + assert.deepEqual(calls.order, ['save']) +}) + +test('custody --advanced reports what landed and exits 3 when the wallet is cancelled part way', async () => { + const calls: Calls = { order: [], sessions: new Set() } + const d = deps(identity()) + const seams = writeSeams({ tokenOwner: OWNER, vaultOwner: null, approved: [] }, calls, { + deposit: async () => { throw new Error('wallet request was cancelled') }, + }) + assert.equal(await runCustodyCommand(['--advanced', '--yes', '--json'], d, readSeams, seams), 3) + assert.match(String(d.io.json().error), /Already confirmed: Vault 0x6bdc.* deployed/i) + assert.match(String(d.io.json().error), /Run the same command again/) +}) + +test('custody --simple revokes approved operators, withdraws, then saves Simple', async () => { + const calls: Calls = { order: [], sessions: new Set() } + const id = identity({ custodyMode: 'advanced', ownerAddress: OWNER, operatorVaultAddress: VAULT, approvedOperatorWallets: [{ address: OPERATOR }], activeOperatorAddress: OPERATOR }) + const d = deps(id) + const chain = { tokenOwner: VAULT, vaultOwner: OWNER, approved: [OPERATOR] } + assert.equal(await runCustodyCommand(['--simple', '--json'], d, readSeams, writeSeams(chain, calls)), 0) + assert.deepEqual((d.io.json().steps as any[]).map(step => step.step), ['revoke', 'withdraw', 'save']) + const run = deps(id) + assert.equal(await runCustodyCommand(['--simple', '--yes', '--json'], run, readSeams, writeSeams(chain, calls)), 0) + assert.deepEqual(calls.order, ['revoke', 'withdraw', 'save']) + assert.equal(calls.updates?.custodyMode, 'simple') + assert.deepEqual(calls.updates?.approvedOperatorWallets, []) + assert.equal(calls.publishVault, undefined) +}) + +test('custody --add-operator --operator signs the proof with the injected key and saves the new list', async () => { + const calls: Calls = { order: [], sessions: new Set() } + const id = identity({ custodyMode: 'advanced', ownerAddress: OWNER, operatorVaultAddress: VAULT, restoreAccessEpoch: 4 }) + const d = deps(id, { ok: true, key: OPERATOR_KEY, address: OPERATOR }) + assert.equal(await runCustodyCommand(['--add-operator', '--operator', '--yes', '--json'], d, readSeams, writeSeams({ tokenOwner: VAULT, vaultOwner: OWNER, approved: [] }, calls)), 0, d.io.stdout()) + assert.deepEqual(calls.order, ['save']) + const records = calls.updates?.approvedOperatorWallets as Array<{ address: string; restoreAccessKey?: unknown }> + assert.equal(records[0]!.address, OPERATOR) + assert.ok(records[0]!.restoreAccessKey) + assert.equal(calls.updates?.restoreAccessEpoch, 5) + assert.equal(calls.updates?.activeOperatorAddress, OPERATOR) +}) + +test('custody operator flags refuse what does not apply', async () => { + const simple = deps(identity()) + assert.equal(await runCustodyCommand(['--add-operator', '--json'], simple, readSeams, writeSeams({ tokenOwner: OWNER, vaultOwner: null, approved: [] }, { order: [], sessions: new Set() })), 1) + assert.match(String(simple.io.json().hint), /custody --advanced/) + const advanced = identity({ custodyMode: 'advanced', ownerAddress: OWNER }) + const unknown = deps(advanced) + assert.equal(await runCustodyCommand(['--remove-operator', OPERATOR, '--json'], unknown, readSeams, writeSeams({ tokenOwner: VAULT, vaultOwner: OWNER, approved: [] }, { order: [], sessions: new Set() })), 1) + assert.match(String(unknown.io.json().error), /not an approved operator/) + const both = deps(advanced) + assert.equal(await runCustodyCommand(['--advanced', '--simple', '--json'], both, readSeams, writeSeams({ tokenOwner: VAULT, vaultOwner: OWNER, approved: [] }, { order: [], sessions: new Set() })), 2) + const yesAlone = deps(advanced) + assert.equal(await runCustodyCommand(['--yes', '--json'], yesAlone, readSeams), 2) +}) diff --git a/test/cli/profileCommand.test.ts b/test/cli/profileCommand.test.ts new file mode 100644 index 0000000..49636af --- /dev/null +++ b/test/cli/profileCommand.test.ts @@ -0,0 +1,116 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { getAddress } from 'viem' +import { runProfileCommand, type ProfileSeams } from '../../src/cli/onchain/profile.js' +import type { HistoryDeps } from '../../src/cli/history/shared.js' +import type { EthagentConfig, EthagentIdentity } from '../../src/storage/config.js' +import type { ProfileUpdates } from '../../src/identity/manager/reducer.js' +import { captureIo, type CapturedIo } from '../support/home.js' + +const OWNER = getAddress('0xA1E9000000000000000000000000000000000001') +const OPERATOR = getAddress('0x70997970C51812dc3A010C7d01b50e0d17dc79C8') +const KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as const + +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: '0x8004A169FB4a3325136EB29fA0ceB6D2e539a432', + agentId: '45744', agentUri: 'ipfs://x', + state: { name: 'meow', description: 'a cat', ...state }, + } as EthagentIdentity +} + +function deps(id: EthagentIdentity, operatorKey?: HistoryDeps['operatorKey']): HistoryDeps & { io: CapturedIo } { + return { + io: captureIo(), + env: {}, + now: () => new Date(), + loadConfig: async () => ({ version: 2, firstSeenAt: id.createdAt, identity: id } as EthagentConfig), + listLedger: async () => [], + ...(operatorKey ? { operatorKey } : {}), + } +} + +function seams(record: { updates?: ProfileUpdates; via?: string; saved?: EthagentConfig } = {}): ProfileSeams { + const finish = async (step: { identity: EthagentIdentity; profileUpdates?: ProfileUpdates }, callbacks: { onIdentityComplete: (i: EthagentIdentity, m: string) => Promise }) => { + record.updates = step.profileUpdates + await callbacks.onIdentityComplete({ ...step.identity, state: { ...(step.identity.state ?? {}), ...step.profileUpdates } }, 'Profile published.') + } + return { + publishOwner: async (step, callbacks) => { record.via = 'owner'; await finish(step, callbacks) }, + publishOperator: async args => { record.via = 'operator'; await finish(args.step, args.callbacks) }, + operatorRunner: (() => (async () => { throw new Error('unused') })) as never, + resolveJwt: async () => 'jwt', + vaultStatus: async () => ({ ready: true }) as never, + pullHarness: async () => [], + imageExists: async () => true, + openSession: async () => ({ close: async () => {} }) as never, + openExternal: () => {}, + saveConfig: async config => { record.saved = config }, + } +} + +test('profile shows the public fields read-only', async () => { + const d = deps(identity({ imageUrl: 'ipfs://bafyimage.png' })) + assert.equal(await runProfileCommand(['--json'], d, seams()), 0) + const out = d.io.json() + assert.equal(out.name, 'meow') + assert.equal(out.image, 'ipfs://bafyimage.png') +}) + +test('profile edits preview a field diff and send nothing without --yes', async () => { + const record: { via?: string } = {} + const d = deps(identity()) + assert.equal(await runProfileCommand(['--name', 'purr', '--description', 'a cat', '--json'], d, seams(record)), 0) + const out = d.io.json() as Record + assert.equal(out.applied, false) + assert.deepEqual(out.changes, [{ field: 'name', from: 'meow', to: 'purr' }]) + assert.equal(record.via, undefined) +}) + +test('profile --yes publishes through the owner wallet and saves the new identity', async () => { + const record: { via?: string; updates?: ProfileUpdates; saved?: EthagentConfig } = {} + const d = deps(identity()) + assert.equal(await runProfileCommand(['--name', 'purr', '--image', 'none', '--yes', '--json'], d, seams(record)), 0) + assert.equal(record.via, 'owner') + assert.deepEqual(record.updates, { name: 'purr' }) + assert.equal((record.saved?.identity?.state as Record).name, 'purr') +}) + +test('profile with nothing changed sends nothing', async () => { + const record: { via?: string } = {} + const d = deps(identity()) + assert.equal(await runProfileCommand(['--name', 'meow', '--yes', '--json'], d, seams(record)), 0) + assert.equal(d.io.json().reason, 'no-changes') + assert.equal(record.via, undefined) +}) + +test('profile validates the name and the image before anything is pinned', async () => { + const short = deps(identity()) + assert.equal(await runProfileCommand(['--name', 'x', '--json'], short, seams()), 2) + const badType = deps(identity()) + assert.equal(await runProfileCommand(['--image', 'avatar.bmp', '--json'], badType, seams()), 2) + const missing = deps(identity()) + assert.equal(await runProfileCommand(['--image', 'avatar.png', '--json'], missing, { ...seams(), imageExists: async () => false }), 2) + assert.match(String(missing.io.json().error), /no file at/) +}) + +test('profile --operator refuses with the reasons when the operator key cannot sign', async () => { + const d = deps(identity(), { ok: true, key: KEY, address: OPERATOR }) + assert.equal(await runProfileCommand(['--name', 'purr', '--operator', '--json'], d, seams()), 1) + const error = String(d.io.json().error) + assert.match(error, /custody is not advanced/) + assert.match(error, /no ENS name is linked/) + assert.match(error, /not an approved operator/) +}) + +test('profile --operator needs an injected key', async () => { + const missing = deps(identity(), { ok: false, reason: 'missing' }) + assert.equal(await runProfileCommand(['--name', 'purr', '--operator', '--json'], missing, seams()), 3) +}) + +test('profile --yes needs a storage credential and exits 3 without one', async () => { + const d = deps(identity()) + assert.equal(await runProfileCommand(['--name', 'purr', '--yes', '--json'], d, { ...seams(), resolveJwt: async () => undefined }), 3) + assert.match(String(d.io.json().hint), /ethagent storage --set/) +}) diff --git a/test/cli/restoreCommand.test.ts b/test/cli/restoreCommand.test.ts new file mode 100644 index 0000000..db07706 --- /dev/null +++ b/test/cli/restoreCommand.test.ts @@ -0,0 +1,186 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import { getAddress, type Address, type Hex } from 'viem' +import { runRestoreCommand, type RestoreSeams } from '../../src/cli/onchain/restore.js' +import type { HistoryDeps } from '../../src/cli/history/shared.js' +import type { EthagentConfig, EthagentIdentity } from '../../src/storage/config.js' +import type { Erc8004AgentCandidate } from '../../src/identity/registry/erc8004.js' +import { createContinuitySnapshotChallenge, createContinuitySnapshotEnvelope } from '../../src/identity/continuity/envelope.js' +import { addressFromPrivateKey, generatePrivateKey, signMessage } from '../../src/identity/crypto/eth.js' +import { runRestoreAuthorize } from '../../src/identity/manager/restore/apply.js' +import { continuityVaultRef } from '../../src/identity/continuity/storage/paths.js' +import { captureIo, withHome, type CapturedIo } from '../support/home.js' + +const REGISTRY = getAddress('0x8004A169FB4a3325136EB29fA0ceB6D2e539a432') +const CID = 'bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1' + +function candidateFor(owner: Address, extra: Partial = {}): Erc8004AgentCandidate { + return { + ownerAddress: owner, + chainId: 8453, + rpcUrl: 'https://mainnet.base.org', + identityRegistryAddress: REGISTRY, + agentId: 45744n, + agentUri: 'ipfs://bafymetadata', + name: 'meow', + backup: { cid: CID, createdAt: '2026-10-01T00:00:00.000Z' } as Erc8004AgentCandidate['backup'], + registration: null, + ...extra, + } +} + +function envelopeFor(key: Hex) { + const owner = addressFromPrivateKey(key) + return createContinuitySnapshotEnvelope({ + ownerAddress: owner, + walletSignature: signMessage(key, createContinuitySnapshotChallenge(owner)), + payload: { + createdAt: '2026-10-01T00:00:00.000Z', + agent: { chainId: 8453, agentId: '45744' }, + files: { 'SOUL.md': '# SOUL.md\n- Voice: calm\n', 'MEMORY.md': '# MEMORY.md\n- restored rule\n' }, + transcript: [], + state: {}, + }, + }) +} + +function deps(config: EthagentConfig | null, operatorKey?: HistoryDeps['operatorKey']): HistoryDeps & { io: CapturedIo } { + return { + io: captureIo(), + env: {}, + now: () => new Date('2026-10-06T00:00:00.000Z'), + loadConfig: async () => config, + listLedger: async () => [], + ...(operatorKey ? { operatorKey } : {}), + } +} + +function seams(candidate: Erc8004AgentCandidate, envelope: ReturnType, saved: EthagentConfig[] = []): RestoreSeams { + return { + resolveTokenId: async () => ({ ok: true, candidate }), + resolveEns: async () => ({ ok: true, candidate }), + ensTokenChain: async () => 8453, + discoverOwner: async () => [candidate], + latestCandidate: async () => candidate, + fetchEnvelope: async () => ({ kind: 'restore-authorizing', cid: CID, apiUrl: 'https://uploads.pinata.cloud/v3/files', envelope, candidate }), + authorize: runRestoreAuthorize, + refetch: async () => { throw new Error('refetch must not run here') }, + localChanges: async () => false, + openSession: async () => { throw new Error('the operator key must never open a browser') }, + openExternal: () => {}, + saveConfig: async config => { saved.push(config) }, + } +} + +test('restore previews without writing and names the signer', async () => { + await withHome(async () => { + const key = generatePrivateKey() as Hex + const owner = getAddress(addressFromPrivateKey(key)) + const d = deps(null, { ok: true, key, address: owner }) + const saved: EthagentConfig[] = [] + const code = await runRestoreCommand(['45744', '--network', 'base', '--operator', '--json'], d, seams(candidateFor(owner), envelopeFor(key), saved)) + assert.equal(code, 0) + const out = d.io.json() as Record + assert.equal(out.applied, false) + assert.equal(out.action, 'restore') + assert.equal(out.agent.agentId, '45744') + assert.deepEqual(out.signer, { kind: 'operator', address: owner }) + assert.equal(saved.length, 0) + }) +}) + +test('restore --operator --yes decrypts locally, writes the vault, and saves config last', async () => { + await withHome(async () => { + const key = generatePrivateKey() as Hex + const owner = getAddress(addressFromPrivateKey(key)) + const d = deps(null, { ok: true, key, address: owner }) + const saved: EthagentConfig[] = [] + const candidate = candidateFor(owner) + const code = await runRestoreCommand(['45744', '--network', 'base', '--operator', '--yes', '--json'], d, seams(candidate, envelopeFor(key), saved)) + assert.equal(code, 0, d.io.stdout() + d.io.stderr()) + assert.equal(d.io.json().applied, true) + assert.equal(saved.length, 1) + const identity = saved[0]!.identity as EthagentIdentity + assert.equal(identity.agentId, '45744') + assert.equal(saved[0]!.erc8004?.chainId, 8453) + const memory = await fs.readFile(continuityVaultRef(identity).memoryPath, 'utf8') + assert.match(memory, /restored rule/) + }) +}) + +test('restore --operator refuses with exit 3 when the snapshot has no slot for the key', async () => { + await withHome(async () => { + const ownerKey = generatePrivateKey() as Hex + const operatorKey = generatePrivateKey() as Hex + const owner = getAddress(addressFromPrivateKey(ownerKey)) + const operator = getAddress(addressFromPrivateKey(operatorKey)) + const candidate = candidateFor(owner, { + operators: { approvedOperatorWallets: [{ address: operator }], activeOperatorAddress: operator } as Erc8004AgentCandidate['operators'], + }) + const d = deps(null, { ok: true, key: operatorKey, address: operator }) + const code = await runRestoreCommand(['45744', '--network', 'base', '--operator', '--yes', '--json'], d, seams(candidate, envelopeFor(ownerKey))) + assert.equal(code, 3) + assert.match(String(d.io.json().error), /no restore slot/) + }) +}) + +test('restore refuses an operator key that is neither owner nor approved', async () => { + await withHome(async () => { + const ownerKey = generatePrivateKey() as Hex + const strangerKey = generatePrivateKey() as Hex + const owner = getAddress(addressFromPrivateKey(ownerKey)) + const stranger = getAddress(addressFromPrivateKey(strangerKey)) + const d = deps(null, { ok: true, key: strangerKey, address: stranger }) + assert.equal(await runRestoreCommand(['45744', '--network', 'base', '--operator', '--json'], d, seams(candidateFor(owner), envelopeFor(ownerKey))), 1) + assert.match(String(d.io.json().error), /neither the owner/) + }) +}) + +test('restore usage: a token id needs a network, no identity needs a target, keys need injecting', async () => { + await withHome(async () => { + const key = generatePrivateKey() as Hex + const owner = getAddress(addressFromPrivateKey(key)) + const s = seams(candidateFor(owner), envelopeFor(key)) + const noNetwork = deps(null) + assert.equal(await runRestoreCommand(['45744', '--json'], noNetwork, s), 2) + assert.match(String(noNetwork.io.json().hint), /--network/) + const noTarget = deps(null) + assert.equal(await runRestoreCommand(['--json'], noTarget, s), 2) + const missingKey = deps(null, { ok: false, reason: 'missing' }) + assert.equal(await runRestoreCommand(['45744', '--network', 'base', '--operator', '--json'], missingKey, s), 3) + const badKey = deps(null, { ok: false, reason: 'invalid' }) + assert.equal(await runRestoreCommand(['45744', '--network', 'base', '--operator', '--json'], badKey, s), 2) + const badNetwork = deps(null) + assert.equal(await runRestoreCommand(['45744', '--network', 'optimism', '--json'], badNetwork, s), 2) + }) +}) + +test('restore --owner lists agents read-only', async () => { + await withHome(async () => { + const key = generatePrivateKey() as Hex + const owner = getAddress(addressFromPrivateKey(key)) + const d = deps(null) + assert.equal(await runRestoreCommand(['--owner', owner, '--json'], d, seams(candidateFor(owner), envelopeFor(key))), 0) + const agents = d.io.json().agents as Array> + assert.equal(agents[0]!.agentId, '45744') + assert.equal(agents[0]!.network, 'base') + }) +}) + +test('restore with an identity previews a refetch and warns about local changes', async () => { + await withHome(async () => { + const key = generatePrivateKey() as Hex + const owner = getAddress(addressFromPrivateKey(key)) + const identity = { + source: 'erc8004', address: owner, ownerAddress: owner, createdAt: '2026-01-01T00:00:00.000Z', + chainId: 8453, rpcUrl: 'https://mainnet.base.org', identityRegistryAddress: REGISTRY, agentId: '45744', agentUri: 'ipfs://x', state: {}, + } as EthagentIdentity + const d = deps({ version: 2, firstSeenAt: identity.createdAt, identity } as EthagentConfig) + const s = { ...seams(candidateFor(owner), envelopeFor(key)), localChanges: async () => true } + assert.equal(await runRestoreCommand(['--json'], d, s), 0) + const out = d.io.json() as Record + assert.equal(out.action, 'refetch') + assert.match(out.warnings.join('\n'), /local changes/) + }) +}) diff --git a/test/cli/setupCommands.test.ts b/test/cli/setupCommands.test.ts new file mode 100644 index 0000000..e34996e --- /dev/null +++ b/test/cli/setupCommands.test.ts @@ -0,0 +1,195 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { getAddress } from 'viem' +import { runCreateCommand, type CreateSeams } from '../../src/cli/onchain/create.js' +import { runStorageCommand, type StorageSeams } from '../../src/cli/onchain/storage.js' +import { runTransferCommand, type TransferSeams } from '../../src/cli/onchain/transfer.js' +import type { HistoryDeps } from '../../src/cli/history/shared.js' +import type { EthagentConfig, EthagentIdentity } from '../../src/storage/config.js' +import { TokenInVaultError } from '../../src/identity/manager/custody/preflight.js' +import { captureIo, type CapturedIo } from '../support/home.js' + +const OWNER = getAddress('0xA1E9000000000000000000000000000000000001') +const RECEIVER = getAddress('0x000000000000000000000000000000000000bEEF') +const REGISTRY = getAddress('0x8004A169FB4a3325136EB29fA0ceB6D2e539a432') + +function identity(): 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', agentUri: 'ipfs://x', state: {}, + } as EthagentIdentity +} + +function deps(config: EthagentConfig | null, env: NodeJS.ProcessEnv = {}): HistoryDeps & { io: CapturedIo } { + return { io: captureIo(), env, now: () => new Date(), loadConfig: async () => config, listLedger: async () => [] } +} + +// --- create ----------------------------------------------------------------------- + +function createSeams(record: { minted?: boolean; saved?: EthagentConfig; custody?: string[] } = {}): CreateSeams { + return { + sign: async (step, callbacks, opts) => { + assert.ok(opts?.session, 'the mint goes through the command\'s wallet tab') + record.minted = true + await callbacks.onIdentityComplete({ + ...identity(), + agentId: '50000', + state: { name: step.name, custodyMode: step.custodyMode }, + backup: { cid: 'bafy', txHash: '0xmint' } as never, + }, 'created', 'create') + }, + scanImports: async () => [{ source: 'Claude Code', raw: 'notes', contentLines: 12 }], + resolveJwt: async () => 'jwt', + openSession: async () => ({ close: async () => {} }) as never, + openExternal: () => {}, + saveConfig: async config => { record.saved = config }, + custody: { + client: () => ({ readContract: async () => OWNER, getBytecode: async () => '0x', simulateContract: async () => ({}), getBlockNumber: async () => 1n }) as never, + priorVault: async () => ({ found: false }), + reusableVault: async () => undefined, + deploy: async () => { record.custody?.push('deploy'); return { txHash: '0xd', vaultAddress: '0x6bdC0000000000000000000000000000000051d7' } }, + deposit: async () => { record.custody?.push('deposit'); return { txHash: '0xe', receiptBlock: 1n, build: {} as never } }, + confirmDeposit: async () => ({ inVault: true, ownerAddress: OWNER }), + unwrap: async () => null, + revoke: async () => [], + recordVault: async () => {}, + publish: async (step, callbacks) => { record.custody?.push('save'); await callbacks.onIdentityComplete(step.identity, 'saved') }, + resolveJwt: async () => 'jwt', + vaultStatus: async () => ({ ready: true }) as never, + pullHarness: async () => [], + openSession: async () => { throw new Error('create must share its own tab') }, + openExternal: () => {}, + saveConfig: async () => {}, + }, + } +} + +test('create previews the mint and needs a network and a name', async () => { + const noNetwork = deps(null) + assert.equal(await runCreateCommand(['--name', 'meow', '--json'], noNetwork, createSeams()), 2) + const shortName = deps(null) + assert.equal(await runCreateCommand(['--name', 'm', '--network', 'base', '--json'], shortName, createSeams()), 2) + const preview = deps(null) + const record: { minted?: boolean } = {} + assert.equal(await runCreateCommand(['--name', 'meow', '--network', 'base', '--import', '--json'], preview, createSeams(record)), 0) + const out = preview.io.json() as Record + assert.equal(out.applied, false) + assert.equal(out.chainId, 8453) + assert.deepEqual(out.imports, [{ source: 'Claude Code', lines: 12 }]) + assert.equal(record.minted, undefined) +}) + +test('create refuses when this machine has an agent unless --replace', async () => { + const config = { version: 2, firstSeenAt: '', identity: identity() } as EthagentConfig + const refused = deps(config) + assert.equal(await runCreateCommand(['--name', 'meow', '--network', 'base', '--json'], refused, createSeams()), 1) + assert.match(String(refused.io.json().hint), /--replace/) + const allowed = deps(config) + assert.equal(await runCreateCommand(['--name', 'meow', '--network', 'base', '--replace', '--json'], allowed, createSeams()), 0) +}) + +test('create --yes mints and saves; --advanced continues into custody in the same tab', async () => { + const record: { minted?: boolean; saved?: EthagentConfig; custody: string[] } = { custody: [] } + const simple = deps(null) + assert.equal(await runCreateCommand(['--name', 'meow', '--network', 'base', '--yes', '--json'], simple, createSeams(record)), 0) + assert.equal(record.saved?.identity?.agentId, '50000') + assert.deepEqual(record.custody, []) + const advanced = deps(null) + assert.equal(await runCreateCommand(['--name', 'meow', '--network', 'base', '--advanced', '--yes', '--json'], advanced, createSeams(record)), 0, advanced.io.stdout()) + assert.deepEqual(record.custody, ['deploy', 'deposit', 'save']) + assert.ok((advanced.io.json() as Record).custodyResult) +}) + +test('create --yes exits 3 without a storage credential, before any wallet prompt', async () => { + const record: { minted?: boolean } = {} + const d = deps(null) + assert.equal(await runCreateCommand(['--name', 'meow', '--network', 'base', '--yes', '--json'], d, { ...createSeams(record), resolveJwt: async () => undefined }), 3) + assert.equal(record.minted, undefined) +}) + +// --- storage ---------------------------------------------------------------------- + +function storageSeams(state: { saved: boolean; stdin?: string; received?: string }): StorageSeams { + return { + has: async () => state.saved, + save: async input => { state.received = input; state.saved = true; return { backend: 'file' } }, + clear: async () => { state.saved = false }, + readStdin: async () => state.stdin ?? '', + apiUrl: 'https://uploads.pinata.cloud/v3/files', + } +} + +test('storage reports readiness, reads the JWT only from stdin, and previews a forget', async () => { + const state = { saved: false, stdin: 'eyJ.jwt.value\n' } as { saved: boolean; stdin?: string; received?: string } + const view = deps(null, { PINATA_JWT: '' }) + assert.equal(await runStorageCommand(['--json'], view, storageSeams(state)), 0) + assert.equal(view.io.json().ready, false) + const arg = deps(null) + assert.equal(await runStorageCommand(['--set', 'eyJ.jwt.value', '--json'], arg, storageSeams(state)), 2) + const set = deps(null) + assert.equal(await runStorageCommand(['--set', '--json'], set, storageSeams(state)), 0) + assert.equal(state.received, 'eyJ.jwt.value') + const preview = deps(null) + assert.equal(await runStorageCommand(['--forget', '--json'], preview, storageSeams(state)), 0) + assert.equal(state.saved, true) + const forget = deps(null) + assert.equal(await runStorageCommand(['--forget', '--yes', '--json'], forget, storageSeams(state)), 0) + assert.equal(state.saved, false) +}) + +// --- transfer --------------------------------------------------------------------- + +function transferSeams(record: { signed?: boolean; saved?: EthagentConfig } = {}): TransferSeams { + return { + resolveTarget: async handle => { + if (handle.endsWith('.eth')) return RECEIVER + return getAddress(handle) + }, + assertNotInVault: async () => {}, + sign: async step => { + record.signed = true + return { identity: { ...step.identity, backup: { cid: 'bafytransfer' } as never }, snapshotCid: 'bafytransfer', txHash: '0xpublish' } + }, + resolveJwt: async () => 'jwt', + vaultStatus: async () => ({ ready: true }) as never, + pullHarness: async () => [], + openExternal: () => {}, + saveConfig: async config => { record.saved = config }, + } +} + +test('transfer previews both signatures and says ethagent never moves the token', async () => { + const config = { version: 2, firstSeenAt: '', identity: identity() } as EthagentConfig + const d = deps(config) + const record: { signed?: boolean } = {} + assert.equal(await runTransferCommand(['receiver.eth', '--json'], d, transferSeams(record)), 0) + const out = d.io.json() as Record + assert.equal(out.receiver, RECEIVER) + assert.equal(out.receiverName, 'receiver.eth') + assert.deepEqual(out.steps.map((step: any) => step.step), ['sender-sign', 'receiver-sign', 'publish']) + assert.match(out.afterwards, /ethagent does not move it/) + assert.equal(record.signed, undefined) +}) + +test('transfer --yes publishes the snapshot and saves the identity', async () => { + const config = { version: 2, firstSeenAt: '', identity: identity() } as EthagentConfig + const record: { signed?: boolean; saved?: EthagentConfig } = {} + const d = deps(config) + assert.equal(await runTransferCommand([RECEIVER, '--yes', '--json'], d, transferSeams(record)), 0) + assert.equal(record.signed, true) + assert.equal(record.saved?.identity?.backup?.cid, 'bafytransfer') +}) + +test('transfer refuses the owner itself and a token held in a Vault', async () => { + const config = { version: 2, firstSeenAt: '', identity: identity() } as EthagentConfig + const self = deps(config) + assert.equal(await runTransferCommand([OWNER, '--json'], self, transferSeams()), 1) + const inVault = deps(config) + assert.equal(await runTransferCommand([RECEIVER, '--json'], inVault, { + ...transferSeams(), + assertNotInVault: async () => { throw new TokenInVaultError('0x6bdC0000000000000000000000000000000051d7') }, + }), 1) + assert.match(String(inVault.io.json().hint), /custody --simple/) + const none = deps(config) + assert.equal(await runTransferCommand(['--json'], none, transferSeams()), 2) +}) diff --git a/test/identity/wallet/browserWallet.test.ts b/test/identity/wallet/browserWallet.test.ts index 07b1118..51cd6a7 100644 --- a/test/identity/wallet/browserWallet.test.ts +++ b/test/identity/wallet/browserWallet.test.ts @@ -689,3 +689,42 @@ test('browser wallet session.requestSignatureAndTransaction completes through on await session.close() } }) + +test('browser wallet session.sendTransaction carries the prepared gas and fees to the page', async () => { + const session = await openBrowserWalletSession({}) + try { + const cancelled = session.sendTransaction({ + chainId: 1, + expectedAccount: '0x0000000000000000000000000000000000000002', + to: '0x0000000000000000000000000000000000000001', + data: '0xabcd', + gas: '0x5208', + maxFeePerGas: '0x77359400', + maxPriorityFeePerGas: '0x3b9aca00', + timeoutMs: 3_000, + }).then(() => null, err => err as Error) + + const events = await fetch(new URL('/events', session.url)) + const reader = events.body!.getReader() + const decoder = new TextDecoder() + let buf = '' + while (!buf.includes('event: prompt')) { + const { value, done } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + } + void reader.cancel() + assert.match(buf, /"gas":"0x5208"/) + assert.match(buf, /"maxFeePerGas":"0x77359400"/) + assert.match(buf, /"maxPriorityFeePerGas":"0x3b9aca00"/) + const sessionToken = buf.match(/"sessionToken":"([^"]+)"/)![1] + await fetch(new URL('/cancel', session.url), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionToken }), + }) + assert.match((await cancelled)?.message ?? '', /cancelled/) + } finally { + await session.close() + } +}) diff --git a/test/run.ts b/test/run.ts index 26f4e54..a3a2a67 100644 --- a/test/run.ts +++ b/test/run.ts @@ -17,6 +17,10 @@ import './cli/resetMirrors.test.js' import './cli/save.test.js' import './cli/historyCommands.test.js' import './cli/onchainCommands.test.js' +import './cli/restoreCommand.test.js' +import './cli/profileCommand.test.js' +import './cli/custodyWrite.test.js' +import './cli/setupCommands.test.js' import './storage/config.test.js' import './storage/gitignore.test.js' import './storage/reset.test.js'