@agenticprimitives/contracts 1.0.0-alpha.25 → 1.0.0-alpha.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/AUDIT.md +5 -0
  2. package/README.md +2 -0
  3. package/deployments-faithchain.json +2 -1
  4. package/dist/abi/AgentAccount.json +127 -0
  5. package/dist/abi/AgentAccountFactory.json +18 -0
  6. package/dist/abi/ApprovedHashRegistry.json +47 -2
  7. package/dist/abi/CustodyPolicy.json +35 -0
  8. package/dist/abi/DigestBindingEnforcer.json +125 -3
  9. package/dist/abi/EstateProjectionRegistry.json +293 -0
  10. package/dist/abi/IApprovedHashRegistryWrite.json +35 -0
  11. package/dist/abi/ICustodyPolicyAccountModuleView.json +31 -0
  12. package/dist/abi/PayloadClassesEnforcer.json +117 -0
  13. package/dist/abi/{ContentCorpusRegistry.json → ReceiptAnchorRegistry.json} +61 -82
  14. package/dist/abi/TreasurySpendPolicy.json +369 -0
  15. package/dist/abi/index.d.ts +6 -0
  16. package/dist/abi/index.js +6 -0
  17. package/dist/deployments/faithchain.d.ts +1 -0
  18. package/dist/deployments/faithchain.js +1 -0
  19. package/package.json +16 -5
  20. package/src/AgentAccount.sol +137 -7
  21. package/src/AgentAccountFactory.sol +17 -10
  22. package/src/ApprovedHashRegistry.sol +41 -6
  23. package/src/IAgentAccount.sol +23 -0
  24. package/src/custody/CustodyPolicy.sol +72 -2
  25. package/src/custody/TreasurySpendPolicy.sol +166 -0
  26. package/src/enforcers/DigestBindingEnforcer.sol +45 -10
  27. package/src/enforcers/PayloadClassesEnforcer.sol +30 -0
  28. package/src/provenance/ReceiptAnchorRegistry.sol +55 -0
  29. package/src/registry/EstateProjectionRegistry.sol +109 -0
  30. package/deployments-anvil.json +0 -1
  31. package/dist/abi/AgentSkillClaimRegistry.json +0 -735
  32. package/dist/abi/IERC1271.json +0 -26
  33. package/dist/abi/ValidationAttestationRegistry.json +0 -205
@@ -56,6 +56,16 @@ interface IApprovedHashRegistryView {
56
56
  function isApproved(address signer, bytes32 hash) external view returns (bool);
57
57
  }
58
58
 
59
+ /// @dev Spec 410 §1 (THESIS-1a) — the ONLY write path from this account to its registry. The account
60
+ /// computes the epoch-bound key itself (`approveDigest`); `execute` / `executeBatch` /
61
+ /// `executeFromModule` refuse the registry as a target unconditionally, so no caller — custodian,
62
+ /// module or delegate — can approve a key under an epoch the account is not in.
63
+ interface IApprovedHashRegistryWrite {
64
+ function approveHash(bytes32 hash) external;
65
+ function revokeHash(bytes32 hash) external;
66
+ function revokeAll() external;
67
+ }
68
+
59
69
  /**
60
70
  * @title AgentAccount
61
71
  * @notice ERC-4337 + UUPS-upgradeable smart account — agent identity anchor.
@@ -189,12 +199,23 @@ contract AgentAccount is
189
199
  /// large delay that bricks future migrations.
190
200
  uint256 internal constant MAX_UPGRADE_TIMELOCK = 30 days;
191
201
 
202
+ /// @dev R917-C-2 (spec 408 §1.2) — THE CUSTODY EPOCH. Incremented by every path that RETIRES a
203
+ /// credential (`removeCustodian`, `removePasskey` — T6 recovery and a full rotation remove through
204
+ /// these). The `0x03` approved-hash sentinel keys every approval to the epoch it was made under
205
+ /// (`approvedHashKey`), so an approval planted by a custodian who is later removed is void the
206
+ /// moment the custody set no longer contains that custodian — an ECDSA signature dies with its key,
207
+ /// and an approved hash now dies with its custody set. Adding a credential does not bump it: an
208
+ /// added key cannot launder an approval a still-present key planted, and voiding a person's
209
+ /// standing wires every time she enrols a device would be the wrong invariant. Takes one slot
210
+ /// from the gap (49 remain).
211
+ uint64 private _custodyEpoch;
212
+
192
213
  /// @dev Spec 007 Phase A.5 (SC7 § 3.1) — storage gap reserves 50
193
214
  /// slots after the last linear-layout state variable. Future
194
215
  /// additive upgrades shrink the gap by however many slots they
195
216
  /// add. ERC-7201 namespaced state (passkeys, modules) lives at
196
217
  /// keccak-computed slots and is NOT in this linear layout.
197
- uint256[50] private __gap;
218
+ uint256[49] private __gap;
198
219
 
199
220
  // ─── Errors ─────────────────────────────────────────────────────
200
221
 
@@ -257,6 +278,9 @@ contract AgentAccount is
257
278
  /// @notice A delegation-originated execution stack tried to self-call an
258
279
  /// account-governance selector. Delegations may never reach governance.
259
280
  error DelegatedGovernanceForbidden();
281
+ /// @dev Spec 410 §1 (THESIS-1a): the approved-hash registry is reachable only through
282
+ /// `approveDigest` / `revokeDigest` / `revokeAllApprovedDigests`, never as an execute target.
283
+ error ApprovedHashRegistryOnlyViaAccount();
260
284
 
261
285
  /// @notice Emitted when `setDelegationManager` rotates the DM via a
262
286
  /// self-call (the only authorized path post-Wave-2A).
@@ -943,6 +967,13 @@ contract AgentAccount is
943
967
  revert ModuleNotInstalled(moduleTypeId, module);
944
968
  }
945
969
 
970
+ // R917-C-1: effects BEFORE the interaction. The module's `onUninstall` may verify that this account
971
+ // no longer lists it (CustodyPolicy does — that is how it tells `uninstallModule` apart from a
972
+ // custodian's `execute(custodyPolicy, onUninstall)`), so the flag is cleared first. A revert below
973
+ // still rolls the whole call back.
974
+ $.installed[moduleTypeId][module] = false;
975
+ _removeFromList($.installedList[moduleTypeId], module);
976
+
946
977
  // Loud uninstall — if the module reverts in onUninstall, we revert
947
978
  // too so the caller sees the failure. (Owner can force-uninstall
948
979
  // by passing deInitData the module can handle, or by re-deploying
@@ -953,9 +984,6 @@ contract AgentAccount is
953
984
  revert ModuleOnUninstallFailed(reason);
954
985
  }
955
986
 
956
- $.installed[moduleTypeId][module] = false;
957
- _removeFromList($.installedList[moduleTypeId], module);
958
-
959
987
  emit ModuleUninstalled(moduleTypeId, module);
960
988
  }
961
989
 
@@ -1042,6 +1070,7 @@ contract AgentAccount is
1042
1070
  revert NotInstalledExecutor(msg.sender);
1043
1071
  }
1044
1072
  emit ModuleExecuted(msg.sender, target, value);
1073
+ _rejectDirectRegistryCall(target);
1045
1074
  // CA-1 + Wave-1/ACCT-C1 (spec 300): a module action from the CANONICAL CustodyPolicy is a
1046
1075
  // custody-governed context (gated upstream by its own T5 quorum + timelock). `_custodyGovCtx`
1047
1076
  // authorizes every governance mutator this call dispatches (the ONLY way past
@@ -1131,6 +1160,7 @@ contract AgentAccount is
1131
1160
  // executeBatch(...)) nest) and forbid it from self-calling governance.
1132
1161
  bool setDelCtx = (msg.sender == _delegationManager && !_delegationExecCtx);
1133
1162
  if (setDelCtx) _delegationExecCtx = true;
1163
+ _rejectDirectRegistryCall(target);
1134
1164
  _rejectDelegatedGovernance(target, data);
1135
1165
 
1136
1166
  ModulesStorage storage $ = _modulesStorage();
@@ -1217,6 +1247,7 @@ contract AgentAccount is
1217
1247
  }
1218
1248
 
1219
1249
  for (uint256 i = 0; i < calls.length; i++) {
1250
+ _rejectDirectRegistryCall(calls[i].target);
1220
1251
  _rejectDelegatedGovernance(calls[i].target, calls[i].data);
1221
1252
  (bool ok, bytes memory ret) = calls[i].target.call{ value: calls[i].value }(calls[i].data);
1222
1253
  if (!ok) {
@@ -1297,14 +1328,42 @@ contract AgentAccount is
1297
1328
  /// `DM → execute(self, executeBatch(...))` nest, so a nested governance
1298
1329
  /// self-call is caught regardless of mode.
1299
1330
  function _rejectDelegatedGovernance(address target, bytes calldata data) internal view {
1331
+ if (!_delegationExecCtx) return;
1300
1332
  if (
1301
- _delegationExecCtx &&
1302
1333
  target == address(this) &&
1303
1334
  data.length >= 4 &&
1304
1335
  _isGovernanceSelector(bytes4(data[0:4]))
1305
1336
  ) {
1306
1337
  revert DelegatedGovernanceForbidden();
1307
1338
  }
1339
+ // R917-C-1/C-2 (spec 408 §1.1): a delegation stack may not speak AS THE ACCOUNT to the account's own
1340
+ // custody-tier contracts. `CustodyPolicy` and `ApprovedHashRegistry` treat `msg.sender == account` as
1341
+ // the principal — a delegate reaching them through `execute` would uninstall the custody policy
1342
+ // (bricking the account) or plant approved-hash root delegations that outlive the delegate's own
1343
+ // caveats. Any selector: there is no legitimate delegated call to either.
1344
+ if (_isAccountTrustedTarget(target)) revert DelegatedGovernanceForbidden();
1345
+ }
1346
+
1347
+ /// @dev Spec 410 §1 (THESIS-1a). `ApprovedHashRegistry.approveHash` stores whatever 32 bytes the account
1348
+ /// sends, and the custody epoch is public — so a custodian who could reach the registry through
1349
+ /// `execute` could approve `keccak256(abi.encode(h, epoch + 1))`: inert now, live the moment a
1350
+ /// recovery bumps the epoch to expel that very custodian. Closing it: the registry is never an
1351
+ /// execute target; the account derives the key on chain in `approveDigest`, under the epoch it is
1352
+ /// in, and nowhere else. Unconditional — not only under the delegation context (R917-C-1/C-2).
1353
+ function _rejectDirectRegistryCall(address target) internal view {
1354
+ if (target == _approvedHashRegistry && target != address(0)) revert ApprovedHashRegistryOnlyViaAccount();
1355
+ }
1356
+
1357
+ /// @dev The contracts that take THIS ACCOUNT as principal when it is `msg.sender` — reachable only from
1358
+ /// the account's own governance paths (`uninstallModule`, a custodian-signed userOp), never from a
1359
+ /// delegation. The custody policy is read from the factory (the canonical one; a legacy direct
1360
+ /// deploy with no factory has none); the registry is the immutable this account was built with.
1361
+ function _isAccountTrustedTarget(address target) internal view returns (bool) {
1362
+ if (target == _approvedHashRegistry && target != address(0)) return true;
1363
+ address f = _factory;
1364
+ if (f == address(0)) return false;
1365
+ address cp = IAgentAccountFactoryView(f).custodyPolicy();
1366
+ return cp != address(0) && target == cp;
1308
1367
  }
1309
1368
 
1310
1369
  /// @dev The account-governance selector set — mutators that change custody,
@@ -1324,7 +1383,10 @@ contract AgentAccount is
1324
1383
  s == IAgentAccount.removeCustodian.selector ||
1325
1384
  s == this.addPasskey.selector ||
1326
1385
  s == this.removePasskey.selector ||
1327
- s == this.acceptSessionDelegation.selector;
1386
+ s == this.acceptSessionDelegation.selector ||
1387
+ s == this.approveDigest.selector ||
1388
+ s == this.revokeDigest.selector ||
1389
+ s == this.revokeAllApprovedDigests.selector;
1328
1390
  }
1329
1391
 
1330
1392
  // ─── ERC-1271 ───────────────────────────────────────────────────
@@ -1349,9 +1411,11 @@ contract AgentAccount is
1349
1411
  // branch is unreachable from `_validateSignature`/`_validateSig`
1350
1412
  // (the userOp-auth path), where 0x03 is an unknown type → false.
1351
1413
  // Fails CLOSED if the registry is unset/not-a-contract (ADR-0013).
1414
+ // R917-C-2: the registry is consulted for the EPOCH-BOUND key, never the raw hash — an approval
1415
+ // made under a custody set that has since retired a credential is not this account's approval.
1352
1416
  if (signature.length == 1 && uint8(signature[0]) == SIG_TYPE_APPROVED_HASH) {
1353
1417
  if (_approvedHashRegistry.code.length == 0) return bytes4(0xffffffff);
1354
- return IApprovedHashRegistryView(_approvedHashRegistry).isApproved(address(this), hash)
1418
+ return IApprovedHashRegistryView(_approvedHashRegistry).isApproved(address(this), approvedHashKey(hash))
1355
1419
  ? ERC1271_MAGIC_VALUE
1356
1420
  : bytes4(0xffffffff);
1357
1421
  }
@@ -1611,6 +1675,7 @@ contract AgentAccount is
1611
1675
  _externalCustodians[owner] = false;
1612
1676
  _externalCustodianCount--;
1613
1677
  emit CustodianRemoved(owner);
1678
+ _bumpCustodyEpoch();
1614
1679
  }
1615
1680
 
1616
1681
  // ─── Passkey (WebAuthn P-256) management ──────────────────────
@@ -1659,6 +1724,13 @@ contract AgentAccount is
1659
1724
  /// (ADR-0012/0013; INV-302-3).
1660
1725
  event PasskeyAdded(bytes32 indexed credentialIdDigest);
1661
1726
  event PasskeyRemoved(bytes32 indexed credentialIdDigest);
1727
+ /// @dev R917-C-2 — a credential was retired; every approved hash keyed to the previous epoch is void.
1728
+ event CustodyEpochBumped(uint64 epoch);
1729
+ /// @dev Spec 410 §1 — `digest` approved under `epoch` through the account's own key derivation.
1730
+ event DigestApproved(bytes32 indexed digest, uint64 epoch);
1731
+ event DigestRevoked(bytes32 indexed digest, uint64 epoch);
1732
+ /// @dev Spec 410 §1 — every approval this account ever made is void (registry-side epoch bump).
1733
+ event AllDigestsRevoked();
1662
1734
 
1663
1735
  /// @notice Register a new WebAuthn credential bound to a specific RP.
1664
1736
  /// `onlySelf` — callable via a UserOp signed by any existing signer
@@ -1715,6 +1787,7 @@ contract AgentAccount is
1715
1787
  $.count -= 1;
1716
1788
  emit PasskeyRemoved(credentialIdDigest);
1717
1789
  emit CustodianRemoved(pia);
1790
+ _bumpCustodyEpoch();
1718
1791
  }
1719
1792
 
1720
1793
  /// @notice Whether a passkey is registered on this account.
@@ -1741,6 +1814,63 @@ contract AgentAccount is
1741
1814
  return _passkeyStorage().count;
1742
1815
  }
1743
1816
 
1817
+ // ─── Custody epoch (R917-C-2, spec 408 §1.2) ─────────────────────
1818
+
1819
+ /// @notice The current custody epoch — bumped whenever a credential is retired.
1820
+ function custodyEpoch() external view returns (uint64) {
1821
+ return _custodyEpoch;
1822
+ }
1823
+
1824
+ /// @notice THE KEY an SDK approves in the `ApprovedHashRegistry` for `hash` to validate through this
1825
+ /// account's `0x03` sentinel NOW: `keccak256(abi.encode(hash, custodyEpoch))`. A counterfactual
1826
+ /// account (not yet deployed) is at epoch 0, so its key is computable before deploy — which is
1827
+ /// what spec 253's batched deploy-and-approve relies on.
1828
+ function approvedHashKey(bytes32 hash) public view returns (bytes32) {
1829
+ return keccak256(abi.encode(hash, _custodyEpoch));
1830
+ }
1831
+
1832
+ /// @dev Retire-a-credential paths call this; see `_custodyEpoch`.
1833
+ function _bumpCustodyEpoch() internal {
1834
+ uint64 next = _custodyEpoch + 1;
1835
+ _custodyEpoch = next;
1836
+ emit CustodyEpochBumped(next);
1837
+ }
1838
+
1839
+ // ─── Approved digests (spec 410 §1, THESIS-1a) ───────────────────
1840
+
1841
+ /// @notice Approve `digest` for this account's `0x03` sentinel under the epoch the account is IN. The
1842
+ /// only path to the registry (`execute` refuses it as a target), so the key is never computed
1843
+ /// by a caller and never for an epoch other than the current one. `onlySelf`, not
1844
+ /// custody-gated: spec 253's one-prompt org-create batches it into the deploy userOp under the
1845
+ /// creating custodian, and the epoch (R917-C-2) plus `revokeAllApprovedDigests` on every
1846
+ /// recovery path (spec 410 §1.2) close the persistence risk instead. A delegation stack cannot
1847
+ /// reach it (`_isGovernanceSelector`).
1848
+ function approveDigest(bytes32 digest) external onlySelf {
1849
+ if (_approvedHashRegistry == address(0)) revert ApprovedHashRegistryOnlyViaAccount();
1850
+ IApprovedHashRegistryWrite(_approvedHashRegistry).approveHash(approvedHashKey(digest));
1851
+ emit DigestApproved(digest, _custodyEpoch);
1852
+ }
1853
+
1854
+ /// @notice Revoke one approval made under the current epoch.
1855
+ function revokeDigest(bytes32 digest) external onlySelf {
1856
+ if (_approvedHashRegistry == address(0)) revert ApprovedHashRegistryOnlyViaAccount();
1857
+ IApprovedHashRegistryWrite(_approvedHashRegistry).revokeHash(approvedHashKey(digest));
1858
+ emit DigestRevoked(digest, _custodyEpoch);
1859
+ }
1860
+
1861
+ /// @notice Void EVERY approval this account ever made, under any epoch — including one planted for a
1862
+ /// future epoch before this contract generation refused direct registry calls. `CustodyPolicy`
1863
+ /// calls it as the last step of every recovery path; a custody-governed account reaches it
1864
+ /// only through the module (`_requireCustodyGoverned`), a mode-0 person through her own userOp.
1865
+ /// Recovery re-approves nothing: the person re-issues from a reviewed list (spec 410 §1.2).
1866
+ function revokeAllApprovedDigests() external onlySelf {
1867
+ _requireCustodyGoverned();
1868
+ // No registry wired: nothing was ever approvable (the sentinel fails closed, l. ~1388), so there is
1869
+ // nothing to void — a no-op, never a revert, so a recovery on such an account still completes.
1870
+ if (_approvedHashRegistry != address(0)) IApprovedHashRegistryWrite(_approvedHashRegistry).revokeAll();
1871
+ emit AllDigestsRevoked();
1872
+ }
1873
+
1744
1874
  // ─── Custody policy + scheduled changes ───────────────────────────
1745
1875
  //
1746
1876
  // The schedule / apply / cancel custody-change surface, CustodyAction
@@ -55,6 +55,14 @@ contract AgentAccountFactory is GovernanceManaged {
55
55
  /// sentinel consults it; it is NOT a mutable factory-view.
56
56
  address public immutable approvedHashRegistry;
57
57
 
58
+ /// @notice R917-C-5 (spec 408 §1.3) — whether the T4 (admin) safety-delay floor is LIFTED to zero for
59
+ /// accounts this factory deploys. A DEPLOYMENT decision, stated by the deployer in the
60
+ /// constructor and recorded in the deployments JSON, never a `block.chainid` list in bytecode:
61
+ /// the previous carve-out named the estate's own chain (34348) and silently switched the
62
+ /// EXT-TL-FLOOR-1 invariant off where it mattered. `check:production-deploy` refuses a
63
+ /// production/testnet record with this `true`. T5 (24 h) / T6 (48 h) are never relaxed.
64
+ bool public immutable relaxedT4Floor;
65
+
58
66
  address public bundlerSigner;
59
67
  address public sessionIssuer;
60
68
 
@@ -127,11 +135,13 @@ contract AgentAccountFactory is GovernanceManaged {
127
135
  address bundlerSigner_,
128
136
  address sessionIssuer_,
129
137
  address governance_,
130
- address approvedHashRegistry_
138
+ address approvedHashRegistry_,
139
+ bool relaxedT4Floor_
131
140
  ) GovernanceManaged(governance_) {
132
141
  if (custodyPolicy_ == address(0)) revert ZeroAddress();
133
142
  accountImplementation = new AgentAccount(entryPoint_, approvedHashRegistry_);
134
143
  approvedHashRegistry = approvedHashRegistry_;
144
+ relaxedT4Floor = relaxedT4Floor_;
135
145
  delegationManager = delegationManager_;
136
146
  custodyPolicy = custodyPolicy_;
137
147
  bundlerSigner = bundlerSigner_;
@@ -357,15 +367,12 @@ contract AgentAccountFactory is GovernanceManaged {
357
367
  if (thresholds[5] > sodCap) thresholds[5] = sodCap;
358
368
  }
359
369
 
360
- // DEV/TEST-CHAIN CARVE-OUT (local demo only): on a local development chain the T4 floor is lifted
361
- // so a demo can drive an admin custody action's schedule→apply back-to-back without a real-time
362
- // wait, and a zero override yields a ZERO T4 safety delay instead of the 1-hour production default.
363
- // Gated strictly by `block.chainid` — anvil (31337/1337) and the local faithchain (34348). Every
364
- // PRODUCTION and public TESTNET chain id (Base Sepolia 84532, mainnet, …) keeps the audited
365
- // EXT-TL-FLOOR-1 invariant. T5 (24h) and T6 (recovery, 48h) are NOT relaxed even on dev — the
366
- // CustodyPolicy requires a non-zero timelock for those tiers, and the demo only exercises T4.
367
- bool devChain = block.chainid == 31337 || block.chainid == 1337 || block.chainid == 34348;
368
- uint32 floor4 = devChain ? 0 : uint32(1 hours);
370
+ // R917-C-5 (spec 408 §1.3): the T4 floor is lifted ONLY when the deployer said so in the constructor
371
+ // (`relaxedT4Floor`) — a local development posture so a demo can drive an admin custody action's
372
+ // schedule→apply back-to-back. It is not inferred from `block.chainid`: the estate's own chain was
373
+ // once on that list, and the audited EXT-TL-FLOOR-1 invariant was off where it mattered. T5 (24h)
374
+ // and T6 (recovery, 48h) are NOT relaxed under any posture.
375
+ uint32 floor4 = relaxedT4Floor ? 0 : uint32(1 hours);
369
376
 
370
377
  uint32[7] memory timelocks;
371
378
  timelocks[4] = timelockOverrides[4] == 0 ? floor4 : timelockOverrides[4];
@@ -26,13 +26,26 @@ pragma solidity ^0.8.28;
26
26
  * Pairs with `MultiSendCallOnly` (atomic batch) so a passkey
27
27
  * smart account can `approveHash` + perform the delegated action
28
28
  * in one userOp.
29
+ *
30
+ * R917-C-2 (spec 408 §1.2): an `AgentAccount`'s `0x03` sentinel does NOT approve the raw hash —
31
+ * it approves `AgentAccount.approvedHashKey(hash)` = `keccak256(abi.encode(hash, custodyEpoch))`,
32
+ * so an approval planted by a custodian who is later removed is void once the account's custody
33
+ * epoch moves. This registry is agnostic to that: it stores whatever 32 bytes the signer approves.
29
34
  */
30
35
  contract ApprovedHashRegistry {
31
- /// signer => hash => approved
32
- mapping(address => mapping(bytes32 => bool)) public approved;
36
+ /// @dev R917-C-2 (spec 408 §1.2): an approval is recorded with the signer's epoch at the time
37
+ /// (`epoch + 1`, so a set slot is never zero); `revokeAll()` bumps the epoch and voids every
38
+ /// approval the signer ever made in one call. `isApproved` is the ONE read: the `0x03` sentinel
39
+ /// and `QuorumEnforcer`'s `v == 1` slot both consult it.
40
+ /// signer => hash => (epoch at approval + 1), 0 = never approved
41
+ mapping(address => mapping(bytes32 => uint64)) private _approvedAt;
42
+ /// signer => current epoch
43
+ mapping(address => uint64) private _epoch;
33
44
 
34
45
  event HashApproved(address indexed signer, bytes32 indexed hash);
35
46
  event HashRevoked(address indexed signer, bytes32 indexed hash);
47
+ /// @dev R917-C-2 — every approval `signer` made before this event is void.
48
+ event AllHashesRevoked(address indexed signer, uint64 epoch);
36
49
 
37
50
  /**
38
51
  * @notice Pre-approve a hash. `msg.sender` is the signer; only
@@ -40,18 +53,40 @@ contract ApprovedHashRegistry {
40
53
  * for this hash.
41
54
  */
42
55
  function approveHash(bytes32 hash) external {
43
- approved[msg.sender][hash] = true;
56
+ _approvedAt[msg.sender][hash] = _epoch[msg.sender] + 1;
44
57
  emit HashApproved(msg.sender, hash);
45
58
  }
46
59
 
47
60
  /// @notice Revoke a previously-approved hash before redemption.
48
61
  function revokeHash(bytes32 hash) external {
49
- approved[msg.sender][hash] = false;
62
+ _approvedAt[msg.sender][hash] = 0;
50
63
  emit HashRevoked(msg.sender, hash);
51
64
  }
52
65
 
53
- /// @notice External view used by `QuorumEnforcer`'s `v == 1` path.
66
+ /// @notice R917-C-2 — revoke EVERY hash `msg.sender` ever approved, in one call. The per-hash
67
+ /// approvals are unenumerable on chain; this is how a signer that suspects a planted approval
68
+ /// voids all of them without knowing which.
69
+ function revokeAll() external {
70
+ uint64 next = _epoch[msg.sender] + 1;
71
+ _epoch[msg.sender] = next;
72
+ emit AllHashesRevoked(msg.sender, next);
73
+ }
74
+
75
+ /// @notice The signer's current epoch (informational; `isApproved` already accounts for it).
76
+ function epochOf(address signer) external view returns (uint64) {
77
+ return _epoch[signer];
78
+ }
79
+
80
+ /// @notice Whether `signer` approved `hash` under its CURRENT epoch. External view used by the
81
+ /// `0x03` sentinel and by `QuorumEnforcer`'s `v == 1` path.
54
82
  function isApproved(address signer, bytes32 hash) external view returns (bool) {
55
- return approved[signer][hash];
83
+ uint64 at = _approvedAt[signer][hash];
84
+ return at != 0 && at == _epoch[signer] + 1;
85
+ }
86
+
87
+ /// @notice Back-compat read shape of the former public mapping: true iff currently approved.
88
+ function approved(address signer, bytes32 hash) external view returns (bool) {
89
+ uint64 at = _approvedAt[signer][hash];
90
+ return at != 0 && at == _epoch[signer] + 1;
56
91
  }
57
92
  }
@@ -90,6 +90,29 @@ struct AgentAccountRecoveryArgs {
90
90
  bytes32[] removePasskeyCredentialIdDigests;
91
91
  }
92
92
 
93
+ /**
94
+ * @notice Payload for `CustodyAction.RotateCredential` (T4) — spec 410 §1.2. A PLANNED rotation: the
95
+ * old credential is still controlled, the person (or an org's quorum) registers the new one,
96
+ * retires the old one, and re-approves every standing approved-digest wire under the new epoch
97
+ * — in ONE applied change, so no wire is dead between the retirement and the re-approval.
98
+ *
99
+ * Exactly one credential is added (`addOwner` XOR `addPasskey.x != 0`) and exactly one retired
100
+ * (`removeOwner` XOR `removePasskeyCredentialIdDigest != 0`). `reapprove` lists the delegation
101
+ * digests to approve after the retirement; the module calls `AgentAccount.approveDigest` for
102
+ * each, so the key is derived on chain under the post-rotation epoch.
103
+ *
104
+ * This is the ONLY custody action that carries a re-approval list. `AgentAccountRecoveryArgs`
105
+ * has no such field by construction: a recovery is the moment a planted grant would otherwise
106
+ * ride through, so recovery re-approves nothing and ends with `revokeAllApprovedDigests`.
107
+ */
108
+ struct AgentAccountRotateCredentialArgs {
109
+ address addOwner;
110
+ AgentAccountRecoveryPasskeyAdd addPasskey;
111
+ address removeOwner;
112
+ bytes32 removePasskeyCredentialIdDigest;
113
+ bytes32[] reapprove;
114
+ }
115
+
93
116
  /**
94
117
  * @title IAgentAccount
95
118
  * @notice Interface for an agent-native ERC-4337 smart account.
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity ^0.8.28;
3
3
 
4
- import {IAgentAccount, AgentAccountRecoveryArgs, AgentAccountRecoveryPasskeyAdd} from "../IAgentAccount.sol";
4
+ import {IAgentAccount, AgentAccountRecoveryArgs, AgentAccountRecoveryPasskeyAdd, AgentAccountRotateCredentialArgs} from "../IAgentAccount.sol";
5
5
  import {SignatureSlotRecovery} from "../libraries/SignatureSlotRecovery.sol";
6
6
 
7
7
  /// @dev R6.6 — minimal interface for the `account → factory → governance.isPaused()`
@@ -20,6 +20,13 @@ interface ICustodyPolicyPauseView {
20
20
  function isPaused() external view returns (bool);
21
21
  }
22
22
 
23
+ /// @dev R917-C-1 — the account's ERC-7579 module view. `onUninstall` refuses while the account still lists
24
+ /// this module as installed, so the only path to it is `AgentAccount.uninstallModule` (onlySelf +
25
+ /// custody-quorum), which clears the flag BEFORE calling out.
26
+ interface ICustodyPolicyAccountModuleView {
27
+ function isModuleInstalled(uint256 moduleTypeId, address module, bytes calldata additionalContext) external view returns (bool);
28
+ }
29
+
23
30
  /**
24
31
  * @title CustodyPolicy
25
32
  * @notice ERC-7579 module that owns the schedule / apply / cancel
@@ -73,7 +80,8 @@ contract CustodyPolicy {
73
80
  ChangeValueCeiling, // 12 — T4
74
81
  SetRecoveryApprovals, // 13 — T4
75
82
  RecoverAccount, // 14 — T6
76
- ChangeApprovalsRequired // 15 — T4 (per-tier custody quorum threshold)
83
+ ChangeApprovalsRequired, // 15 — T4 (per-tier custody quorum threshold)
84
+ RotateCredential // 16 — T4 (spec 410 §1.2: add + retire + re-approve standing wires, atomically)
77
85
  }
78
86
 
79
87
  struct ScheduledChange {
@@ -289,6 +297,9 @@ contract CustodyPolicy {
289
297
  event T3CeilingChanged(address indexed account, uint256 oldCeiling, uint256 newCeiling);
290
298
  event RecoveryThresholdChanged(address indexed account, uint8 oldValue, uint8 newValue);
291
299
  event OwnersRotated(address indexed account, uint256 newOwnerCount);
300
+ /// @dev Spec 410 §1.2 — a planned rotation applied: one credential in, one out, `reapproved` wires
301
+ /// re-approved under the new custody epoch in the same transaction.
302
+ event CredentialRotated(address indexed account, uint256 reapproved);
292
303
  /// @notice Audit C-10: per-rotation count of custodians removed.
293
304
  event CustodiansRemovedDuringRotation(address indexed account, uint256 removedCount);
294
305
  /// @notice Audit C-11: CustodyPolicy was uninstalled; the account
@@ -336,6 +347,8 @@ contract CustodyPolicy {
336
347
  error UnconfiguredTier(uint8 tier);
337
348
  error RecoveryRequiresGuardians();
338
349
  error EmptyOwnerSet();
350
+ /// @dev Spec 410 §1.2 — `RotateCredential` needs exactly one credential added and exactly one retired.
351
+ error RotationNeedsOneAddAndOneRemove();
339
352
  error CustodyActionNotYetImplemented(uint8 action);
340
353
  error CannotDowngradeWithTrustees();
341
354
  error TimelockRequiredForTier(uint8 tier);
@@ -346,6 +359,11 @@ contract CustodyPolicy {
346
359
  /// @dev Audit C-11: reinstall over a previously-uninstalled config
347
360
  /// is forbidden — stale mappings would compose unpredictably.
348
361
  error ReinstallForbidden(address account);
362
+ /// @dev R917-C-1: `onUninstall` was called while the account still has this module installed — i.e. by
363
+ /// `execute(custodyPolicy, onUninstall)` under a single custodian or a delegation, not by
364
+ /// `uninstallModule`. Accepting it would leave the account governed by a policy that refuses every
365
+ /// change (installed on the account, uninstalled here, reinstall forbidden) — bricked forever.
366
+ error StillInstalledOn(address account);
349
367
 
350
368
  // ─── ERC-7579 lifecycle ───────────────────────────────────────────
351
369
 
@@ -452,6 +470,14 @@ contract CustodyPolicy {
452
470
  address account = msg.sender;
453
471
  Config storage c = _configs[account];
454
472
  if (!c.installed) revert NotInstalledOn(account);
473
+ // R917-C-1: the account must have ALREADY dropped this module. `AgentAccount.uninstallModule` (onlySelf
474
+ // + `_requireCustodyGoverned`) clears its installed flag before this call; any other caller that is the
475
+ // account — a 1-of-N custodian userOp `execute(custodyPolicy, onUninstall)`, a delegation redeemed
476
+ // through the DM — still lists the module and is refused here. Fail closed on the read as well: a
477
+ // caller that cannot answer the view is not an account that uninstalled us.
478
+ if (ICustodyPolicyAccountModuleView(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, address(this), "")) {
479
+ revert StillInstalledOn(account);
480
+ }
455
481
  c.installed = false;
456
482
  // Audit C-11: lock against ever re-installing on this account.
457
483
  // The mapping state (trustees, thresholds, proposals) is left
@@ -838,6 +864,9 @@ contract CustodyPolicy {
838
864
  } else if (action == CustodyAction.RecoverAccount) {
839
865
  AgentAccountRecoveryArgs memory r = abi.decode(args, (AgentAccountRecoveryArgs));
840
866
  _applyRecoverAccount(account, r);
867
+ } else if (action == CustodyAction.RotateCredential) {
868
+ AgentAccountRotateCredentialArgs memory r = abi.decode(args, (AgentAccountRotateCredentialArgs));
869
+ _applyRotateCredential(account, r);
841
870
  } else if (action == CustodyAction.ApplySystemUpdate) {
842
871
  (address newImpl) = abi.decode(args, (address));
843
872
  _execute(account, abi.encodeWithSignature("upgradeToAndCall(address,bytes)", newImpl, ""));
@@ -957,6 +986,9 @@ contract CustodyPolicy {
957
986
  removed++;
958
987
  }
959
988
  }
989
+ // Spec 410 §1.2: a bulk custodian rotation carries no re-approval list; like recovery it voids every
990
+ // approval so nothing planted for a future epoch survives it. Re-approve through `RotateCredential`.
991
+ _revokeAllApprovedDigests(account);
960
992
  emit OwnersRotated(account, added);
961
993
  // Note: OwnersRotated only carries `added` for ABI compat. A
962
994
  // future revision should add a `removed` field; that's a
@@ -1050,9 +1082,47 @@ contract CustodyPolicy {
1050
1082
  removedPasskeys++;
1051
1083
  }
1052
1084
 
1085
+ // Spec 410 §1.2 (THESIS-1a): recovery re-approves nothing and voids every approval the account
1086
+ // ever made — including one planted for a future epoch. The person re-issues from a reviewed list.
1087
+ _revokeAllApprovedDigests(account);
1053
1088
  emit AccountRecovered(account, addedOwners, removedOwners, addedPasskeys, removedPasskeys);
1054
1089
  }
1055
1090
 
1091
+ /// @dev Spec 410 §1.2 — the planned rotation: add → retire → re-approve, one transaction. The account
1092
+ /// derives each key under the post-retirement epoch (`approveDigest`); this module never computes
1093
+ /// a key. Exactly one credential in and one out, so the account is never credential-less and the
1094
+ /// action cannot be bent into a bulk custody change at the rotation tier.
1095
+ function _applyRotateCredential(address account, AgentAccountRotateCredentialArgs memory r) internal {
1096
+ bool addsOwner = r.addOwner != address(0);
1097
+ bool addsPasskey = r.addPasskey.x != 0 || r.addPasskey.y != 0 || r.addPasskey.credentialIdDigest != bytes32(0);
1098
+ bool removesOwner = r.removeOwner != address(0);
1099
+ bool removesPasskey = r.removePasskeyCredentialIdDigest != bytes32(0);
1100
+ if (addsOwner == addsPasskey || removesOwner == removesPasskey) revert RotationNeedsOneAddAndOneRemove();
1101
+
1102
+ if (addsOwner) {
1103
+ _execute(account, abi.encodeCall(IAgentAccount.addCustodian, (r.addOwner)));
1104
+ } else {
1105
+ _execute(account, abi.encodeWithSignature(
1106
+ "addPasskey(bytes32,uint256,uint256,bytes32)",
1107
+ r.addPasskey.credentialIdDigest, r.addPasskey.x, r.addPasskey.y, r.addPasskey.rpIdHash
1108
+ ));
1109
+ }
1110
+ if (removesOwner) {
1111
+ _execute(account, abi.encodeCall(IAgentAccount.removeCustodian, (r.removeOwner)));
1112
+ } else {
1113
+ _execute(account, abi.encodeWithSignature("removePasskey(bytes32)", r.removePasskeyCredentialIdDigest));
1114
+ }
1115
+ for (uint256 i; i < r.reapprove.length; i++) {
1116
+ _execute(account, abi.encodeWithSignature("approveDigest(bytes32)", r.reapprove[i]));
1117
+ }
1118
+ emit CredentialRotated(account, r.reapprove.length);
1119
+ }
1120
+
1121
+ /// @dev Spec 410 §1.2 — `AgentAccount.revokeAllApprovedDigests()` as the account (module context).
1122
+ function _revokeAllApprovedDigests(address account) internal {
1123
+ _execute(account, abi.encodeWithSignature("revokeAllApprovedDigests()"));
1124
+ }
1125
+
1056
1126
  /// @dev Spec § 5.1 default-threshold matrix.
1057
1127
  function _defaultApprovals(uint8 nCustodians, uint8 tier) internal pure returns (uint8) {
1058
1128
  if (nCustodians == 0) return 0;