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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,20 @@ jobs:
working-directory: contracts
- run: forge test -vv
working-directory: contracts

chain:
name: Chain suite (anvil)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
submodules: recursive
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- uses: foundry-rs/foundry-toolchain@v1
with:
version: v1.5.1
- run: npm ci
- run: npm run test:chain
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,8 @@ With `--json`, every history and onchain command prints one line of ASCII-only J
| --- | --- |
| `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. |
| `ETHAGENT_ENS_RPC_URL` | An Ethereum Mainnet RPC endpoint asked first for every ENS read and write. |
| `ETHAGENT_IPFS_API_URL` | Where snapshots are uploaded, in place of Pinata: an IPFS HTTP API (`/api/v0/add`). No Pinata credential is needed then. |
| `PINATA_JWT` | IPFS storage credential, if you haven't set one up in the manager. |
| `PINATA_GATEWAY_URL` | Your own IPFS gateway. Downloads try it before any other source. |
| `ETHAGENT_IPFS_GATEWAYS` | Gateways to download from, separated by commas, in place of the built-in ones. |
Expand All @@ -465,6 +466,17 @@ With `--json`, every history and onchain command prints one line of ASCII-only J
| `ETHAGENT_HARNESS_FILES` | Extra instruction files to keep in sync, separated by commas. |
| `ETHAGENT_NO_DAEMON` | Set to `1` to turn off background sync. |

## Development

```bash
npm ci
npm run typecheck
npm test # unit tests, no network
npm run test:chain # every command that sends, on local chains (needs Foundry)
```

`npm run test:chain` starts two local Anvil chains (one for ENS on Ethereum Mainnet, one for the agent registry on Base) and serves IPFS from the test runner. Stand-ins for the registry and the ENS contracts, in `contracts/test/chain`, sit at the real addresses, and the real Vault is deployed by ethagent itself. It then walks one agent's whole life with `--yes`: create, profile, ENS link and records, Advanced custody, the operator key, an operator save, `check`, restoring on a fresh machine, ENS delete, Simple custody, and a transfer the receiver restores. A test wallet signs in place of the browser tab, and nothing leaves the machine. CI runs it on every pull request.

## Updating

ethagent updates itself: `npx ethagent` always fetches the latest version, so new releases reach you with nothing to install. Check your version with `ethagent --version`.
Expand Down
202 changes: 202 additions & 0 deletions contracts/test/chain/ChainEns.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

// Stand-ins for the ENS contracts, for the local test chain only. The chain suite places
// their runtime code at the real mainnet addresses, so none keeps constructor state, and
// each reaches the others at those fixed addresses. They cover what ethagent and viem
// call: the registry's owner, resolver and subnode writes; the public resolver's text,
// addr, multicall and delegation; and the universal resolver's resolveWithGateways.

address constant ENS_REGISTRY = 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e;

interface IChainEnsRegistry {
function owner(bytes32 node) external view returns (address);
function resolver(bytes32 node) external view returns (address);
function isApprovedForAll(address owner, address operator) external view returns (bool);
}

/// @notice The registry, as ENSRegistry minus the root bootstrap: `testSetOwner` hands
/// out top-level names, which only the test chain can do.
contract ChainEnsRegistry {
struct Record {
address owner;
address resolver;
uint64 ttl;
}

mapping(bytes32 => Record) private _records;
mapping(address => mapping(address => bool)) private _operators;

event NewOwner(bytes32 indexed node, bytes32 indexed label, address owner);
event Transfer(bytes32 indexed node, address owner);
event NewResolver(bytes32 indexed node, address resolver);
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);

error NotAuthorized();

modifier authorised(bytes32 node) {
address current = _records[node].owner;
if (current != msg.sender && !_operators[current][msg.sender]) revert NotAuthorized();
_;
}

function testSetOwner(bytes32 node, address newOwner) external {
_records[node].owner = newOwner;
emit Transfer(node, newOwner);
}

function setOwner(bytes32 node, address newOwner) external authorised(node) {
_records[node].owner = newOwner;
emit Transfer(node, newOwner);
}

function setSubnodeOwner(bytes32 node, bytes32 label, address newOwner)
public
authorised(node)
returns (bytes32 subnode)
{
subnode = keccak256(abi.encodePacked(node, label));
_records[subnode].owner = newOwner;
emit NewOwner(node, label, newOwner);
}

function setSubnodeRecord(bytes32 node, bytes32 label, address newOwner, address newResolver, uint64 newTtl)
external
{
bytes32 subnode = setSubnodeOwner(node, label, newOwner);
if (_records[subnode].resolver != newResolver) {
_records[subnode].resolver = newResolver;
emit NewResolver(subnode, newResolver);
}
_records[subnode].ttl = newTtl;
}

function setResolver(bytes32 node, address newResolver) external authorised(node) {
_records[node].resolver = newResolver;
emit NewResolver(node, newResolver);
}

function setApprovalForAll(address operator, bool approved) external {
_operators[msg.sender][operator] = approved;
emit ApprovalForAll(msg.sender, operator, approved);
}

function owner(bytes32 node) external view returns (address) {
return _records[node].owner;
}

function resolver(bytes32 node) external view returns (address) {
return _records[node].resolver;
}

function ttl(bytes32 node) external view returns (uint64) {
return _records[node].ttl;
}

function recordExists(bytes32 node) external view returns (bool) {
return _records[node].owner != address(0);
}

function isApprovedForAll(address owner_, address operator) external view returns (bool) {
return _operators[owner_][operator];
}
}

/// @notice The public resolver: text and addr records, delegation, and multicall, with
/// the registry's owner, its approved operators, or a delegate allowed to write.
contract ChainPublicResolver {
mapping(bytes32 => mapping(string => string)) private _texts;
mapping(bytes32 => address) private _addrs;
mapping(address => mapping(bytes32 => mapping(address => bool))) private _delegates;

event TextChanged(bytes32 indexed node, string indexed indexedKey, string key, string value);
event AddrChanged(bytes32 indexed node, address a);
event Approved(address owner, bytes32 indexed node, address indexed delegate, bool indexed approved);

error NotAuthorized();

modifier authorised(bytes32 node) {
if (!_isAuthorised(node)) revert NotAuthorized();
_;
}

function setText(bytes32 node, string calldata key, string calldata value) external authorised(node) {
_texts[node][key] = value;
emit TextChanged(node, key, key, value);
}

function text(bytes32 node, string calldata key) external view returns (string memory) {
return _texts[node][key];
}

function setAddr(bytes32 node, address a) external authorised(node) {
_addrs[node] = a;
emit AddrChanged(node, a);
}

function addr(bytes32 node) external view returns (address payable) {
return payable(_addrs[node]);
}

function addr(bytes32 node, uint256 coinType) external view returns (bytes memory) {
if (coinType != 60 || _addrs[node] == address(0)) return "";
return abi.encodePacked(_addrs[node]);
}

function approve(bytes32 node, address delegate, bool approved) external {
_delegates[msg.sender][node][delegate] = approved;
emit Approved(msg.sender, node, delegate, approved);
}

function isApprovedFor(address owner, bytes32 node, address delegate) external view returns (bool) {
return _delegates[owner][node][delegate];
}

function multicall(bytes[] calldata data) external returns (bytes[] memory results) {
results = new bytes[](data.length);
for (uint256 i = 0; i < data.length; i++) {
(bool ok, bytes memory result) = address(this).delegatecall(data[i]);
if (!ok) {
assembly {
revert(add(result, 32), mload(result))
}
}
results[i] = result;
}
}

function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return interfaceId == 0x01ffc9a7 || interfaceId == 0x3b3b57de || interfaceId == 0x59d1d43c
|| interfaceId == 0xf1cb7e06;
}

function _isAuthorised(bytes32 node) private view returns (bool) {
address owner = IChainEnsRegistry(ENS_REGISTRY).owner(node);
return owner == msg.sender || IChainEnsRegistry(ENS_REGISTRY).isApprovedForAll(owner, msg.sender)
|| _delegates[owner][node][msg.sender];
}
}

/// @notice The universal resolver viem's getEnsAddress goes through: it hashes the
/// DNS-encoded name, finds its resolver in the registry, and forwards the call.
contract ChainUniversalResolver {
function resolveWithGateways(bytes calldata name, bytes calldata data, string[] calldata)
external
view
returns (bytes memory result, address resolverAddress)
{
bytes32 node = _namehash(name, 0);
resolverAddress = IChainEnsRegistry(ENS_REGISTRY).resolver(node);
if (resolverAddress == address(0)) return ("", address(0));
(bool ok, bytes memory answer) = resolverAddress.staticcall(data);
if (!ok) return ("", resolverAddress);
return (answer, resolverAddress);
}

function _namehash(bytes calldata name, uint256 offset) private pure returns (bytes32) {
uint256 length = uint8(name[offset]);
if (length == 0) return bytes32(0);
bytes32 label = keccak256(name[offset + 1:offset + 1 + length]);
return keccak256(abi.encodePacked(_namehash(name, offset + 1 + length), label));
}
}
116 changes: 116 additions & 0 deletions contracts/test/chain/ChainIdentityRegistry.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

interface IERC721ReceiverChain {
function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data)
external
returns (bytes4);
}

/// @notice Stand-in for the ERC-8004 identity registry, for the local test chain only.
/// The chain suite places its runtime code at the real registry address, so it keeps
/// no constructor state: token IDs start at 1 because unset storage reads as zero.
/// It covers what ethagent calls: register, tokenURI, setAgentURI, and the ERC-721
/// transfers a Vault deposit and unwrap make. It is not enumerable, like the real one,
/// so owner lookups go through Transfer logs.
contract ChainIdentityRegistry {
uint256 private _lastId;
mapping(uint256 => address) private _owners;
mapping(address => uint256) private _balances;
mapping(uint256 => string) private _uris;
mapping(uint256 => address) private _approved;
mapping(address => mapping(address => bool)) private _operators;

event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
event Registered(uint256 indexed agentId, address indexed owner, string agentURI);
event URIUpdated(uint256 indexed agentId, string newURI, address indexed updatedBy);

error NotAuthorized();
error NonexistentToken();
error UnsafeRecipient();

function register(string calldata agentURI) external returns (uint256 agentId) {
agentId = ++_lastId;
_owners[agentId] = msg.sender;
_balances[msg.sender] += 1;
_uris[agentId] = agentURI;
emit Transfer(address(0), msg.sender, agentId);
emit Registered(agentId, msg.sender, agentURI);
}

function setAgentURI(uint256 agentId, string calldata newURI) external {
if (!_isApprovedOrOwner(msg.sender, agentId)) revert NotAuthorized();
_uris[agentId] = newURI;
emit URIUpdated(agentId, newURI, msg.sender);
}

function tokenURI(uint256 tokenId) external view returns (string memory) {
if (_owners[tokenId] == address(0)) revert NonexistentToken();
return _uris[tokenId];
}

function getMetadata(uint256, string calldata) external pure returns (bytes memory) {
return "";
}

function ownerOf(uint256 tokenId) public view returns (address owner) {
owner = _owners[tokenId];
if (owner == address(0)) revert NonexistentToken();
}

function balanceOf(address owner) external view returns (uint256) {
return _balances[owner];
}

function approve(address to, uint256 tokenId) external {
address owner = ownerOf(tokenId);
if (msg.sender != owner && !_operators[owner][msg.sender]) revert NotAuthorized();
_approved[tokenId] = to;
emit Approval(owner, to, tokenId);
}

function getApproved(uint256 tokenId) external view returns (address) {
return _approved[tokenId];
}

function setApprovalForAll(address operator, bool approved) external {
_operators[msg.sender][operator] = approved;
emit ApprovalForAll(msg.sender, operator, approved);
}

function isApprovedForAll(address owner, address operator) external view returns (bool) {
return _operators[owner][operator];
}

function transferFrom(address from, address to, uint256 tokenId) public {
if (!_isApprovedOrOwner(msg.sender, tokenId) || ownerOf(tokenId) != from) revert NotAuthorized();
delete _approved[tokenId];
_balances[from] -= 1;
_balances[to] += 1;
_owners[tokenId] = to;
emit Transfer(from, to, tokenId);
}

function safeTransferFrom(address from, address to, uint256 tokenId) external {
safeTransferFrom(from, to, tokenId, "");
}

function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public {
transferFrom(from, to, tokenId);
if (to.code.length > 0) {
bytes4 answer = IERC721ReceiverChain(to).onERC721Received(msg.sender, from, tokenId, data);
if (answer != IERC721ReceiverChain.onERC721Received.selector) revert UnsafeRecipient();
}
}

function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return interfaceId == 0x80ac58cd || interfaceId == 0x5b5e139f || interfaceId == 0x01ffc9a7;
}

function _isApprovedOrOwner(address spender, uint256 tokenId) private view returns (bool) {
address owner = ownerOf(tokenId);
return spender == owner || _approved[tokenId] == spender || _operators[owner][spender];
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"typecheck": "tsc --noEmit",
"test": "node test/run-tests.mjs",
"test:coverage": "node test/run-tests.mjs --coverage",
"test:chain": "node test/run-chain.mjs",
"previews": "tsx scripts/render-previews.ts",
"prepack": "npm run build",
"contracts:build": "cd contracts && forge build",
Expand Down
Loading
Loading