@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.
- package/AUDIT.md +5 -0
- package/README.md +2 -0
- package/deployments-faithchain.json +2 -1
- package/dist/abi/AgentAccount.json +127 -0
- package/dist/abi/AgentAccountFactory.json +18 -0
- package/dist/abi/ApprovedHashRegistry.json +47 -2
- package/dist/abi/CustodyPolicy.json +35 -0
- package/dist/abi/DigestBindingEnforcer.json +125 -3
- package/dist/abi/EstateProjectionRegistry.json +293 -0
- package/dist/abi/IApprovedHashRegistryWrite.json +35 -0
- package/dist/abi/ICustodyPolicyAccountModuleView.json +31 -0
- package/dist/abi/PayloadClassesEnforcer.json +117 -0
- package/dist/abi/{ContentCorpusRegistry.json → ReceiptAnchorRegistry.json} +61 -82
- package/dist/abi/TreasurySpendPolicy.json +369 -0
- package/dist/abi/index.d.ts +6 -0
- package/dist/abi/index.js +6 -0
- package/dist/deployments/faithchain.d.ts +1 -0
- package/dist/deployments/faithchain.js +1 -0
- package/package.json +16 -5
- package/src/AgentAccount.sol +137 -7
- package/src/AgentAccountFactory.sol +17 -10
- package/src/ApprovedHashRegistry.sol +41 -6
- package/src/IAgentAccount.sol +23 -0
- package/src/custody/CustodyPolicy.sol +72 -2
- package/src/custody/TreasurySpendPolicy.sol +166 -0
- package/src/enforcers/DigestBindingEnforcer.sol +45 -10
- package/src/enforcers/PayloadClassesEnforcer.sol +30 -0
- package/src/provenance/ReceiptAnchorRegistry.sol +55 -0
- package/src/registry/EstateProjectionRegistry.sol +109 -0
- package/deployments-anvil.json +0 -1
- package/dist/abi/AgentSkillClaimRegistry.json +0 -735
- package/dist/abi/IERC1271.json +0 -26
- package/dist/abi/ValidationAttestationRegistry.json +0 -205
package/src/AgentAccount.sol
CHANGED
|
@@ -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[
|
|
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
|
-
//
|
|
361
|
-
// so a demo can drive an admin custody action's
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
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
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
+
_approvedAt[msg.sender][hash] = 0;
|
|
50
63
|
emit HashRevoked(msg.sender, hash);
|
|
51
64
|
}
|
|
52
65
|
|
|
53
|
-
/// @notice
|
|
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
|
-
|
|
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
|
}
|
package/src/IAgentAccount.sol
CHANGED
|
@@ -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
|
|
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;
|