Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7f78d04
agentkeys: ERC-4337 P-256 master plan (#164) + correct stale London→C…
hanwencheng Jun 2, 2026
7dfe55e
agentkeys: ERC-4337 P256Account + CREATE2 factory (#164 E1/E2) — WebA…
hanwencheng Jun 2, 2026
2ebd1e3
agentkeys: ERC-4337 threat-model delta (#164 E0) + plan cross-link
hanwencheng Jun 2, 2026
461320e
agentkeys: P256Account map verifier/decode reverts to SIG_VALIDATION_…
hanwencheng Jun 2, 2026
fc84107
agentkeys: #164 docs — ED/Sybil funding model + E3 design + build status
hanwencheng Jun 2, 2026
5e3bfb6
agentkeys: ERC-4337 E3 — thin AgentKeysScope to account-auth (retire …
hanwencheng Jun 2, 2026
ccf9af0
agentkeys: record #164 E1 live addresses (EntryPoint v0.7 + factory) …
hanwencheng Jun 2, 2026
b66f4d2
agentkeys: consolidate deployed addresses into docs/contracts.md + ar…
hanwencheng Jun 2, 2026
1673218
agentkeys: ERC-4337 E5 — guardian M-of-N social recovery (generation …
hanwencheng Jun 2, 2026
2c0fef3
agentkeys: ERC-4337 E6 — VerifyingPaymaster (broker-co-signed sponsor…
hanwencheng Jun 2, 2026
2099bc8
agentkeys: ERC-4337 E7+E8 — WebAuthn UserOp signer + passkey-only mas…
hanwencheng Jun 2, 2026
5aeedac
agentkeys: #164 plan — E4-E8 complete (E8 ran green on mainnet); cuto…
hanwencheng Jun 2, 2026
55924e0
docs(runbook): document Phase 6 ERC-4337 passkey-master (#164 E8) in …
hanwencheng Jun 2, 2026
1d54a42
agentkeys: make #164 E8 (Phase 6) default in --real wire demo (skip i…
hanwencheng Jun 2, 2026
8a832f4
agentkeys: E8 fresh-per-run (local) / reuse-one-account (CI auto) + P…
hanwencheng Jun 2, 2026
4f24efe
agentkeys: harness Phase-0 builds the host agentkeys CLI (self-heal t…
hanwencheng Jun 2, 2026
36be513
fix(harness): brace ${BEFORE}/${AFTER} in E8 assert — UTF-8 locale ab…
hanwencheng Jun 2, 2026
256a460
agentkeys: unhook E8 from the wire demo — it's a standalone mechanism…
hanwencheng Jun 2, 2026
bbb1e66
agentkeys: address codex adversarial review (#164)
hanwencheng Jun 2, 2026
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ Determine the real opcode level any time by *executing* a probe on a dev chain (

## Deployed contract registry

Live v2 stage-1 contract addresses on each chain are kept in [`docs/spec/deployed-contracts.md`](docs/spec/deployed-contracts.md). The same addresses are also written to `scripts/operator-workstation.env` (via `env_set` in `scripts/heima-bring-up.sh` step 6) for shell-script consumption — those env-file entries are the operational source of truth and `deployed-contracts.md` is the human-readable canonical record (deployer, deploy date, block, explorer links, ABI summary).
Live contract addresses on each chain (Heima mainnet v2 set, the ERC-4337 master infra #164, historical v1) are kept in [`docs/contracts.md`](docs/contracts.md) — the single canonical registry, indexed from `arch.md` §5. (The old `docs/spec/deployed-contracts.md` is now a redirect to it.) The same addresses are also written to `scripts/operator-workstation.env` (via `env_set` in `scripts/heima-bring-up.sh` step 6) for shell-script consumption — those env-file entries are the operational source of truth and `docs/contracts.md` is the human-readable canonical record (deployer, deploy date, block, explorer links, ABI summary).

Verify all contracts are live + functional any time:

Expand Down
5 changes: 4 additions & 1 deletion crates/agentkeys-chain/script/DeployAgentKeysV1.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ contract DeployAgentKeysV1 is Script {
P256Verifier p256 = new P256Verifier();
K11Verifier k11 = new K11Verifier(address(p256));
SidecarRegistry registry = new SidecarRegistry(address(k11));
AgentKeysScope scope = new AgentKeysScope(address(registry), address(k11));
// #164 E3: AgentKeysScope no longer verifies K11 itself (scope writes are
// authorized by the operator's ERC-4337 master account). Constructor takes
// only the registry now.
AgentKeysScope scope = new AgentKeysScope(address(registry));
K3EpochCounter epoch = new K3EpochCounter(signerGov);
// Audit appendRoot gates on operator-master via the registry (codex M1).
CredentialAudit audit = new CredentialAudit(address(registry));
Expand Down
149 changes: 25 additions & 124 deletions crates/agentkeys-chain/src/AgentKeysScope.sol
Original file line number Diff line number Diff line change
@@ -1,47 +1,34 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.20;

import {K11Verifier} from "./K11Verifier.sol";

/// @notice Minimal SidecarRegistry surface AgentKeysScope needs for K11 auth.
/// @notice Minimal SidecarRegistry surface AgentKeysScope needs.
interface ISidecarRegistry {
struct DeviceEntry {
bytes32 operatorOmni;
bytes32 actorOmni;
bytes32 k11CredId;
bytes32 k11RpIdHash;
uint256 k11PubX;
uint256 k11PubY;
uint8 tier;
uint8 roles;
uint64 registeredAt;
uint32 lastSignCount;
bool revoked;
}

function operatorMasterWallet(bytes32 operatorOmni) external view returns (address);
function operatorNonce(bytes32 operatorOmni) external view returns (uint256);
function getDevice(bytes32 deviceKeyHash) external view returns (DeviceEntry memory);
function ROLE_SCOPE_MGMT() external view returns (uint8);
function TIER_MASTER() external view returns (uint8);
}

/// @title AgentKeysScope — per-(operator, agent) scope state
/// @notice "Which services can this agent use, with what spend limits?"
/// Read by the broker on cap-mint AND by workers on cap-verify
/// (arch.md §12.4, §13.1, §19).
///
/// @dev Stage-2 (#90) hardening: scope mutations are K11-bound via on-chain
/// P-256 verify against the asserting master's registered K11 pubkey.
/// K11 challenge commits to (operation || operator || agent || services
/// hash || chainid || scopeNonce[op][agent]) so a captured sig cannot
/// be replayed for a different scope target.
/// @dev ⚠ DEPLOYMENT ORDER (codex #2): deploy this thinned scope ONLY together
/// with the registry cutover that stores the operator's 4337 ACCOUNT as
/// `operatorMasterWallet`. If deployed while a master is still a raw EOA,
/// that EOA key alone could setScope/revokeScope with NO biometric (the
/// in-contract K11 gate is gone). Never deploy E3 before the cutover.
///
/// @dev #164 E3 (Solution A — ERC-4337 P-256 master). Scope mutations are
/// authorized by `msg.sender == operatorMasterWallet(operator)`, where
/// the master is now an ERC-4337 P-256 smart account. The passkey check
/// happens ONCE, upstream, in the account's `validateUserOp` over the
/// `userOpHash` — which commits the entire setScope/revokeScope calldata,
/// so the signature is a provably-complete full-intent authorization.
/// Consequently the per-op on-chain K11 challenge, `scopeNonce`, and the
/// K11Verifier dependency are RETIRED here (they lived in the pre-#164
/// EOA model). Replay is the EntryPoint 2D nonce. See
/// docs/plan/chain/erc4337-master-account.md §3.2.
contract AgentKeysScope {
ISidecarRegistry public immutable registry;
K11Verifier public immutable k11Verifier;

bytes32 public constant OP_SET_SCOPE = keccak256("agentkeys:v1:set-scope");
bytes32 public constant OP_REVOKE_SCOPE = keccak256("agentkeys:v1:revoke-scope");

struct Scope {
bytes32[] services;
Expand All @@ -54,19 +41,8 @@ contract AgentKeysScope {
bool exists;
}

struct K11Assertion {
bytes32 attestingDeviceKeyHash;
bytes authenticatorData;
bytes clientDataJSON;
uint256 challengeLocation;
uint256 r;
uint256 s;
}

/// @notice operator_omni → agent_omni → Scope
mapping(bytes32 => mapping(bytes32 => Scope)) private scopes;
/// @notice per-(operator, agent) monotonic nonce for anti-replay of K11
mapping(bytes32 => mapping(bytes32 => uint256)) public scopeNonce;

event ScopeUpdated(
bytes32 indexed operatorOmni,
Expand All @@ -82,51 +58,29 @@ contract AgentKeysScope {

error OperatorNotRegistered(bytes32 operatorOmni);
error NotAuthorized(address caller, address expected);
error InvalidAttestingDevice(bytes32 deviceKeyHash);
error K11VerificationFailed();
error K11RoleMissing(uint8 required);
error ScopeNotSet(bytes32 operatorOmni, bytes32 agentOmni);

constructor(address registryAddr, address k11VerifierAddr) {
constructor(address registryAddr) {
registry = ISidecarRegistry(registryAddr);
k11Verifier = K11Verifier(k11VerifierAddr);
}

/// @notice Grant or replace an agent's scope. Master-mutation, K11-gated.
function setScopeWithWebauthn(
/// @notice Grant or replace an agent's scope. Master-mutation: authorized by
/// the operator's master account (passkey-gated upstream in the
/// account's validateUserOp; see the contract-level notes).
function setScope(
bytes32 operatorOmni,
bytes32 agentOmni,
bytes32[] calldata services,
bool readOnly,
uint128 maxPerCall,
uint128 maxPerPeriod,
uint128 maxTotal,
uint32 periodSeconds,
K11Assertion calldata assertion
uint32 periodSeconds
) external {
address master = registry.operatorMasterWallet(operatorOmni);
if (master == address(0)) revert OperatorNotRegistered(operatorOmni);
if (msg.sender != master) revert NotAuthorized(msg.sender, master);

bytes32 servicesDigest = keccak256(abi.encode(services));
bytes32 expectedChallenge = keccak256(
abi.encode(
OP_SET_SCOPE,
operatorOmni,
agentOmni,
servicesDigest,
readOnly,
maxPerCall,
maxPerPeriod,
maxTotal,
periodSeconds,
block.chainid,
scopeNonce[operatorOmni][agentOmni]
)
);
_verifyK11(expectedChallenge, operatorOmni, assertion);
scopeNonce[operatorOmni][agentOmni] += 1;

scopes[operatorOmni][agentOmni] = Scope({
services: services,
readOnly: readOnly,
Expand All @@ -150,31 +104,15 @@ contract AgentKeysScope {
);
}

/// @notice Revoke an agent's entire scope. Master-mutation, K11-gated.
function revokeScope(
bytes32 operatorOmni,
bytes32 agentOmni,
K11Assertion calldata assertion
) external {
/// @notice Revoke an agent's entire scope. Master-mutation (see setScope).
function revokeScope(bytes32 operatorOmni, bytes32 agentOmni) external {
address master = registry.operatorMasterWallet(operatorOmni);
if (master == address(0)) revert OperatorNotRegistered(operatorOmni);
if (msg.sender != master) revert NotAuthorized(msg.sender, master);
if (!scopes[operatorOmni][agentOmni].exists) {
revert ScopeNotSet(operatorOmni, agentOmni);
}

bytes32 expectedChallenge = keccak256(
abi.encode(
OP_REVOKE_SCOPE,
operatorOmni,
agentOmni,
block.chainid,
scopeNonce[operatorOmni][agentOmni]
)
);
_verifyK11(expectedChallenge, operatorOmni, assertion);
scopeNonce[operatorOmni][agentOmni] += 1;

delete scopes[operatorOmni][agentOmni];
emit ScopeRevoked(operatorOmni, agentOmni);
}
Expand All @@ -199,41 +137,4 @@ contract AgentKeysScope {
}
return false;
}

/// @dev Verify K11 assertion against an asserting MASTER device with the
/// SCOPE_MGMT role. Caller is responsible for incrementing the per-
/// (operator, agent) scopeNonce after this returns.
function _verifyK11(
bytes32 expectedChallenge,
bytes32 expectedOperatorOmni,
K11Assertion calldata a
) internal view {
ISidecarRegistry.DeviceEntry memory entry = registry.getDevice(a.attestingDeviceKeyHash);
if (entry.registeredAt == 0 || entry.revoked) {
revert InvalidAttestingDevice(a.attestingDeviceKeyHash);
}
if (entry.tier != registry.TIER_MASTER()) {
revert InvalidAttestingDevice(a.attestingDeviceKeyHash);
}
if (entry.operatorOmni != expectedOperatorOmni) {
revert InvalidAttestingDevice(a.attestingDeviceKeyHash);
}
uint8 requiredRole = registry.ROLE_SCOPE_MGMT();
if ((entry.roles & requiredRole) == 0) {
revert K11RoleMissing(requiredRole);
}

bool ok = k11Verifier.verifyAssertion(
expectedChallenge,
entry.k11RpIdHash,
a.authenticatorData,
a.clientDataJSON,
a.challengeLocation,
a.r,
a.s,
entry.k11PubX,
entry.k11PubY
);
if (!ok) revert K11VerificationFailed();
}
}
29 changes: 29 additions & 0 deletions crates/agentkeys-chain/src/IERC4337.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.20;

/// @notice The two ERC-4337 v0.7 surfaces our account implements, vendored from
/// eth-infinitism/account-abstraction@v0.7.0. The full EntryPoint is
/// deployed separately and verified live on Heima mainnet — see
/// docs/plan/chain/erc4337-master-account.md §1. Vendoring keeps the
/// chain crate dependency-free (solc 0.8.20 pin) while staying ABI-exact
/// with the canonical v0.7 EntryPoint (field order/types are consensus).
struct PackedUserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
bytes32 accountGasLimits;
uint256 preVerificationGas;
bytes32 gasFees;
bytes paymasterAndData;
bytes signature;
}

interface IAccount {
/// @return validationData 0 = signature valid; 1 = SIG_VALIDATION_FAILED.
function validateUserOp(
PackedUserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external returns (uint256 validationData);
}
Loading
Loading