@agenticprimitives/contracts 0.1.0-alpha.2

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 (84) hide show
  1. package/AUDIT.md +67 -0
  2. package/CLAUDE.md +40 -0
  3. package/LICENSE +21 -0
  4. package/README.md +45 -0
  5. package/deployments-anvil.json +1 -0
  6. package/deployments-base-sepolia.json +1 -0
  7. package/dist/abi/AgentNameAttributeResolver.json +798 -0
  8. package/dist/abi/AgentNamePredicates.json +1 -0
  9. package/dist/abi/AgentNameRegistry.json +826 -0
  10. package/dist/abi/AgentNameUniversalResolver.json +222 -0
  11. package/dist/abi/AgentProfilePredicates.json +1 -0
  12. package/dist/abi/AgentProfileResolver.json +1044 -0
  13. package/dist/abi/AgentRelationship.json +583 -0
  14. package/dist/abi/AgentRelationshipPredicates.json +1 -0
  15. package/dist/abi/AgenticGovernance.json +259 -0
  16. package/dist/abi/AllowedMethodsEnforcer.json +108 -0
  17. package/dist/abi/AllowedTargetsEnforcer.json +103 -0
  18. package/dist/abi/ApprovedHashRegistry.json +114 -0
  19. package/dist/abi/AttributeStorage.json +557 -0
  20. package/dist/abi/CaveatEnforcerBase.json +130 -0
  21. package/dist/abi/GovernanceManaged.json +43 -0
  22. package/dist/abi/IAttributeReader.json +98 -0
  23. package/dist/abi/ICaveatEnforcer.json +98 -0
  24. package/dist/abi/IDelegationManager.json +211 -0
  25. package/dist/abi/IERC7579Module.json +34 -0
  26. package/dist/abi/IERC7579ModuleLifecycle.json +60 -0
  27. package/dist/abi/IGovernanceView.json +34 -0
  28. package/dist/abi/MultiSendCallOnly.json +29 -0
  29. package/dist/abi/MultiSendCallOnlyHarness.json +42 -0
  30. package/dist/abi/OntologyTermRegistry.json +397 -0
  31. package/dist/abi/P256Verifier.json +1 -0
  32. package/dist/abi/PermissionlessSubregistry.json +207 -0
  33. package/dist/abi/RelationshipTypeRegistry.json +455 -0
  34. package/dist/abi/ShapeRegistry.json +627 -0
  35. package/dist/abi/SmartAgentModuleTypes.json +1 -0
  36. package/dist/abi/TimestampEnforcer.json +108 -0
  37. package/dist/abi/ValueEnforcer.json +103 -0
  38. package/dist/abi/WebAuthnLib.json +1 -0
  39. package/dist/abi/index.d.ts +35 -0
  40. package/dist/abi/index.js +35 -0
  41. package/package.json +48 -0
  42. package/spec.md +52 -0
  43. package/src/AgentAccount.sol +1374 -0
  44. package/src/AgentAccountFactory.sol +274 -0
  45. package/src/ApprovedHashRegistry.sol +57 -0
  46. package/src/IAgentAccount.sol +138 -0
  47. package/src/SmartAgentPaymaster.sol +281 -0
  48. package/src/UniversalSignatureValidator.sol +136 -0
  49. package/src/agency/DelegationManager.sol +374 -0
  50. package/src/agency/ICaveatEnforcer.sol +62 -0
  51. package/src/agency/IDelegationManager.sol +69 -0
  52. package/src/custody/CustodyPolicy.sol +892 -0
  53. package/src/custody/IERC7579Module.sol +60 -0
  54. package/src/enforcers/AllowedMethodsEnforcer.AUDIT.md +51 -0
  55. package/src/enforcers/AllowedMethodsEnforcer.sol +48 -0
  56. package/src/enforcers/AllowedTargetsEnforcer.AUDIT.md +49 -0
  57. package/src/enforcers/AllowedTargetsEnforcer.sol +44 -0
  58. package/src/enforcers/CaveatEnforcerBase.sol +19 -0
  59. package/src/enforcers/QuorumEnforcer.AUDIT.md +71 -0
  60. package/src/enforcers/QuorumEnforcer.sol +191 -0
  61. package/src/enforcers/TimestampEnforcer.AUDIT.md +50 -0
  62. package/src/enforcers/TimestampEnforcer.sol +43 -0
  63. package/src/enforcers/ValueEnforcer.AUDIT.md +51 -0
  64. package/src/enforcers/ValueEnforcer.sol +41 -0
  65. package/src/governance/AgenticGovernance.sol +140 -0
  66. package/src/governance/GovernanceManaged.sol +75 -0
  67. package/src/governance/IGovernance.sol +15 -0
  68. package/src/identity/AgentProfilePredicates.sol +40 -0
  69. package/src/identity/AgentProfileResolver.sol +194 -0
  70. package/src/libraries/MultiSendCallOnly.sol +95 -0
  71. package/src/libraries/P256Verifier.sol +47 -0
  72. package/src/libraries/SignatureSlotRecovery.sol +196 -0
  73. package/src/libraries/WebAuthnLib.sol +164 -0
  74. package/src/naming/AgentNameAttributeResolver.sol +95 -0
  75. package/src/naming/AgentNamePredicates.sol +74 -0
  76. package/src/naming/AgentNameRegistry.sol +362 -0
  77. package/src/naming/AgentNameUniversalResolver.sol +210 -0
  78. package/src/naming/PermissionlessSubregistry.sol +98 -0
  79. package/src/ontology/AttributeStorage.sol +289 -0
  80. package/src/ontology/OntologyTermRegistry.sol +146 -0
  81. package/src/ontology/ShapeRegistry.sol +240 -0
  82. package/src/relationships/AgentRelationship.sol +289 -0
  83. package/src/relationships/AgentRelationshipPredicates.sol +44 -0
  84. package/src/relationships/RelationshipTypeRegistry.sol +143 -0
@@ -0,0 +1,1374 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import "account-abstraction/core/BaseAccount.sol";
5
+ import "account-abstraction/interfaces/IEntryPoint.sol";
6
+ import "account-abstraction/interfaces/PackedUserOperation.sol";
7
+ import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
8
+ import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
9
+ import "@openzeppelin/contracts/proxy/utils/Initializable.sol";
10
+ import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
11
+ import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
12
+ import "@openzeppelin/contracts/interfaces/IERC1271.sol";
13
+ import "@openzeppelin/contracts/utils/introspection/IERC165.sol";
14
+ import "./IAgentAccount.sol";
15
+ import "./libraries/SignatureSlotRecovery.sol";
16
+ import "./libraries/WebAuthnLib.sol";
17
+
18
+ /// @dev Minimal subset of AgentAccountFactory's surface used by the
19
+ /// account to look up factory-scoped capability roles. We avoid a
20
+ /// hard import of AgentAccountFactory to prevent a circular type
21
+ /// dependency at compile time.
22
+ interface IAgentAccountFactoryView {
23
+ function bundlerSigner() external view returns (address);
24
+ function sessionIssuer() external view returns (address);
25
+ }
26
+
27
+ /**
28
+ * @title AgentAccount
29
+ * @notice ERC-4337 + UUPS-upgradeable smart account — agent identity anchor.
30
+ *
31
+ * The agent address IS the identity (did:ethr:<chainId>:<address>).
32
+ * UUPS upgradeability means the implementation can evolve without
33
+ * changing the proxy address or losing state.
34
+ *
35
+ * Upgrade authorization: only the account itself (via UserOp or self-call).
36
+ * This follows the MetaMask DeleGator pattern for upgradeable smart accounts.
37
+ *
38
+ * Supports:
39
+ * - Multi-owner with ERC-1271 signature validation
40
+ * - ERC-4337 UserOp validation
41
+ * - ERC-7710 delegated execution via DelegationManager
42
+ * - UUPS upgrades (ERC-1822)
43
+ */
44
+ contract AgentAccount is
45
+ BaseAccount,
46
+ Initializable,
47
+ UUPSUpgradeable,
48
+ ReentrancyGuard,
49
+ IAgentAccount,
50
+ IERC1271,
51
+ IERC165,
52
+ IAgenticPrimitivesAgentAccount
53
+ {
54
+ using ECDSA for bytes32;
55
+ using MessageHashUtils for bytes32;
56
+
57
+ /// @dev ERC-1271 magic value for valid signature
58
+ bytes4 internal constant ERC1271_MAGIC_VALUE = 0x1626ba7e;
59
+
60
+ /// @dev The ERC-4337 EntryPoint contract
61
+ IEntryPoint private immutable _entryPoint;
62
+
63
+ /// @dev Authorized DelegationManager (ERC-7710 executor)
64
+ address private _delegationManager;
65
+
66
+ /// @dev External custodian set — addresses outside our own
67
+ /// AgentAccount system that can sign for this account. Holds
68
+ /// EOAs (SIWE) and third-party smart wallets (Safe, Argent,
69
+ /// Privy, …). Per spec 211 § 3 / spec 212 § 2.2, an
70
+ /// agenticprimitives AgentAccount must NEVER appear here —
71
+ /// enforced at runtime by `addCustodian` via the ERC-165
72
+ /// `IAgenticPrimitivesAgentAccount` marker. Passkey custodians
73
+ /// live in the namespaced `_passkeyStorage().piaToCredentialId`
74
+ /// mapping; the unified view is exposed via `isCustodian` and
75
+ /// `custodianCount`.
76
+ /// @dev internal (not private) so test harnesses can subclass + seed
77
+ /// this state for policy tests. Storage layout is unaffected
78
+ /// by visibility.
79
+ mapping(address => bool) internal _externalCustodians;
80
+ uint256 internal _externalCustodianCount;
81
+
82
+ /// @dev Spec 007 Phase A — the factory that deployed this account.
83
+ /// `bundlerSigner()` and `sessionIssuer()` are read off this
84
+ /// address on each capability check, so a future factory
85
+ /// upgrade can rotate either role without per-account
86
+ /// migration. Set once during `initialize`. Zero is tolerated
87
+ /// for legacy / direct-deploy paths (no bundler envelope path
88
+ /// enabled).
89
+ address private _factory;
90
+
91
+ /// @dev Spec 007 Phase A (Variant B) — set of session-delegation
92
+ /// hashes the owner has pre-authorized on chain. The
93
+ /// DelegationManager consults this set at redeem time when a
94
+ /// high-risk session is in play; this is the on-chain
95
+ /// counterpart to the off-chain caveated delegation used in
96
+ /// Variant A.
97
+ mapping(bytes32 => bool) private _acceptedSessionDelegations;
98
+
99
+ /// @dev Spec 007 Phase A.5 — optional per-account upgrade delay
100
+ /// (seconds). 0 (default) keeps backward-compat: upgrades fire
101
+ /// immediately on owner sig. Owners can opt in to a delay via
102
+ /// `setUpgradeTimelock`.
103
+ uint256 private _upgradeTimelock;
104
+
105
+ struct PendingUpgrade {
106
+ address newImplementation;
107
+ uint64 readyAt; // UNIX seconds; 0 = no pending upgrade
108
+ }
109
+
110
+ /// @dev Spec 007 Phase A.5 — current pending upgrade (set by
111
+ /// `upgradeToWithAuthorization` when `_upgradeTimelock > 0`).
112
+ PendingUpgrade private _pendingUpgrade;
113
+
114
+ /// @dev Spec 007 Phase A.5 — maximum permitted upgrade timelock to
115
+ /// protect against an owner accidentally setting an absurdly
116
+ /// large delay that bricks future migrations.
117
+ uint256 internal constant MAX_UPGRADE_TIMELOCK = 30 days;
118
+
119
+ /// @dev Spec 007 Phase A.5 (SC7 § 3.1) — storage gap reserves 50
120
+ /// slots after the last linear-layout state variable. Future
121
+ /// additive upgrades shrink the gap by however many slots they
122
+ /// add. ERC-7201 namespaced state (passkeys, modules) lives at
123
+ /// keccak-computed slots and is NOT in this linear layout.
124
+ uint256[50] private __gap;
125
+
126
+ // ─── Errors ─────────────────────────────────────────────────────
127
+
128
+ error NotFromSelf();
129
+ error NotCustodianOrSelf();
130
+ error CustodianAlreadyExists(address owner);
131
+ error CustodianDoesNotExist(address owner);
132
+ error CannotRemoveLastCustodian();
133
+ error ZeroAddress();
134
+ error PasskeyAlreadyRegistered(bytes32 credentialIdDigest);
135
+ error PasskeyNotRegistered(bytes32 credentialIdDigest);
136
+ /// @dev Contract audit C-6: `bytes32(0)` is the absence sentinel in
137
+ /// `piaToCredentialId`. Registering a passkey with a zero
138
+ /// digest poisons the mapping — count++ but isCustodian(pia)
139
+ /// returns false because `piaToCredentialId[pia] == 0`. Reject
140
+ /// at every entry point that writes the digest.
141
+ error InvalidCredentialIdDigest();
142
+ error InvalidPasskeyPublicKey();
143
+ /// @notice H7-C.1 / CON-WEBAUTHN-001 — addPasskey was called with rpIdHash == bytes32(0).
144
+ error InvalidRpIdHash();
145
+ error CannotRemoveLastSigner();
146
+ error UnknownSignatureType(uint8 sigType);
147
+ error AgenticPrimitivesAgentNotAllowedAsCustodian(address candidate);
148
+
149
+ // Phase A errors (spec 007).
150
+ error NotEntryPoint();
151
+ error NotBundler();
152
+ error NotOwnerSig();
153
+ error InvalidInnerSignature();
154
+ error FactoryNotSet();
155
+ error SessionDelegationAlreadyAccepted(bytes32 hash);
156
+
157
+ // Phase A.5 errors (spec 007).
158
+ error UpgradeTimelockTooLong(uint256 secs, uint256 max);
159
+ error NoPendingUpgrade();
160
+ error UpgradeNotReady(uint64 readyAt, uint256 nowTs);
161
+ error PendingUpgradeMismatch(address pending, address attempted);
162
+ error UpgradePending();
163
+
164
+ // Wave 2A — on-chain authority closure (contract audit C-1..C-3).
165
+ error ValidatorRequired();
166
+ error LegacyUpgradePathDisabled();
167
+ error ModuleOperationNotAllowed();
168
+
169
+ /// @notice Emitted when `setDelegationManager` rotates the DM via a
170
+ /// self-call (the only authorized path post-Wave-2A).
171
+ event DelegationManagerRotated(address indexed newDelegationManager);
172
+
173
+ // ─── Phase A events ─────────────────────────────────────────────
174
+
175
+ /// @notice Emitted by `acceptSessionDelegation` — the owner has
176
+ /// registered the given session-delegation hash on chain.
177
+ event SessionDelegationAccepted(bytes32 indexed sessionDelegationHash);
178
+
179
+ /// @notice Emitted by `upgradeToWithAuthorization` after the owner
180
+ /// signature is verified, before the underlying UUPS
181
+ /// upgrade fires. Provides an auditable on-chain witness
182
+ /// that THIS specific owner authorized the upgrade.
183
+ event UpgradeAuthorized(address indexed newImplementation);
184
+
185
+ /// @notice Emitted when an owner queues an upgrade that has to wait
186
+ /// out the per-account timelock (Phase A.5).
187
+ event UpgradeProposed(address indexed newImplementation, uint64 readyAt);
188
+
189
+ /// @notice Emitted when the queued upgrade is cancelled by the owner
190
+ /// during the wait window.
191
+ event UpgradeCancelled(address indexed newImplementation);
192
+
193
+ /// @notice Emitted when the per-account upgrade timelock is changed.
194
+ event UpgradeTimelockChanged(uint256 oldValue, uint256 newValue);
195
+
196
+ // ─── Modifiers ──────────────────────────────────────────────────
197
+
198
+ modifier onlySelf() {
199
+ if (msg.sender != address(this)) revert NotFromSelf();
200
+ _;
201
+ }
202
+
203
+ // ─── Constructor / Initializer ──────────────────────────────────
204
+
205
+ constructor(IEntryPoint entryPoint_) {
206
+ _entryPoint = entryPoint_;
207
+ _disableInitializers();
208
+ }
209
+
210
+ /**
211
+ * @notice Unified initializer (phase 6f.4 pivot).
212
+ *
213
+ * Bootstraps an AgentAccount with any combination of
214
+ * external custodians (EOAs / SIWE / third-party smart
215
+ * wallets) and an initial passkey credential. At least one
216
+ * must be supplied — an account with no signer would be
217
+ * bricked.
218
+ *
219
+ * Each external custodian is checked against the ERC-165
220
+ * `IAgenticPrimitivesAgentAccount` marker: any address that
221
+ * responds positively is rejected. This enforces spec 211
222
+ * § 3 / spec 212 § 2.2 — an agenticprimitives AgentAccount
223
+ * can never be a custodian of another. Smart-agent ↔
224
+ * smart-agent relationships move into stewardship /
225
+ * delegation territory.
226
+ *
227
+ * When `passkeyX != 0 && passkeyY != 0`, the passkey is
228
+ * registered and its PIA (derived from the pubkey) becomes
229
+ * a first-class custodian via the namespaced passkey
230
+ * storage. The PIA's "membership" is unified with
231
+ * `_externalCustodians` via `isCustodian` / `custodianCount`.
232
+ *
233
+ * @param externalCustodians Initial set of external custodian
234
+ * addresses (zero or more EOAs / third-party smart wallets).
235
+ * @param passkeyCredentialIdDigest keccak256(credentialId) of the
236
+ * initial passkey (or `bytes32(0)` to skip).
237
+ * @param passkeyX P-256 X coordinate (or 0 to skip the passkey).
238
+ * @param passkeyY P-256 Y coordinate (or 0 to skip).
239
+ * @param passkeyRpIdHash `sha256(rpId)` for the initial passkey (or
240
+ * `bytes32(0)` to skip — required to be non-zero when the
241
+ * passkey is present; H7-C.1 / CON-WEBAUTHN-001).
242
+ * @param dm DelegationManager address (or `address(0)`).
243
+ * @param factory_ The factory that deployed this account.
244
+ */
245
+ function initialize(
246
+ address[] calldata externalCustodians,
247
+ bytes32 passkeyCredentialIdDigest,
248
+ uint256 passkeyX,
249
+ uint256 passkeyY,
250
+ bytes32 passkeyRpIdHash,
251
+ address dm,
252
+ address factory_
253
+ ) external initializer {
254
+ // H7-C.5: passkey setup factored into `_setupInitialPasskey` to keep
255
+ // `initialize`'s local-variable count below the `via_ir`-off stack
256
+ // limit. `forge coverage` builds without `via_ir`; this lets the
257
+ // coverage build compile (closes XCON-002).
258
+ bool withPasskey = passkeyX != 0 && passkeyY != 0;
259
+ if (externalCustodians.length == 0 && !withPasskey) {
260
+ revert ZeroAddress();
261
+ }
262
+
263
+ for (uint256 i; i < externalCustodians.length; i++) {
264
+ address c = externalCustodians[i];
265
+ if (c == address(0)) revert ZeroAddress();
266
+ if (_externalCustodians[c]) revert CustodianAlreadyExists(c);
267
+ if (_isAgenticPrimitivesAgent(c)) {
268
+ revert AgenticPrimitivesAgentNotAllowedAsCustodian(c);
269
+ }
270
+ _externalCustodians[c] = true;
271
+ emit CustodianAdded(c);
272
+ }
273
+ _externalCustodianCount = externalCustodians.length;
274
+
275
+ if (withPasskey) {
276
+ _setupInitialPasskey(passkeyCredentialIdDigest, passkeyX, passkeyY, passkeyRpIdHash);
277
+ }
278
+
279
+ _delegationManager = dm;
280
+ _factory = factory_;
281
+ }
282
+
283
+ /// @dev H7-C.5 — passkey-init body extracted so `initialize` stays below
284
+ /// the via-IR-off stack limit (lets `forge coverage` compile).
285
+ /// Behavior unchanged from the prior inline block.
286
+ function _setupInitialPasskey(
287
+ bytes32 credIdDigest,
288
+ uint256 x,
289
+ uint256 y,
290
+ bytes32 rpIdHash
291
+ ) internal {
292
+ // Audit C-6: reject zero digest at every passkey-write site.
293
+ if (credIdDigest == bytes32(0)) revert InvalidCredentialIdDigest();
294
+ // H7-C.1 / CON-WEBAUTHN-001: zero rpIdHash would let the verifier
295
+ // accept any RP (kills the pin).
296
+ if (rpIdHash == bytes32(0)) revert InvalidRpIdHash();
297
+ PasskeyStorage storage $ = _passkeyStorage();
298
+ address pia = _passkeyIdentity(x, y);
299
+ // Architectural invariant: a PIA never appears in both the
300
+ // external-custodian set AND the passkey set.
301
+ if (_externalCustodians[pia]) {
302
+ revert CustodianAlreadyExists(pia);
303
+ }
304
+ $.keys[credIdDigest] = PasskeyEntry(x, y);
305
+ $.registered[credIdDigest] = true;
306
+ $.piaToCredentialId[pia] = credIdDigest;
307
+ $.rpIdHashOf[credIdDigest] = rpIdHash;
308
+ $.count = 1;
309
+ emit PasskeyAdded(credIdDigest, x, y, rpIdHash);
310
+ emit CustodianAdded(pia);
311
+ }
312
+
313
+ // ─── Custody-policy initializer (relocated) ──────────────────────
314
+ //
315
+ // `initializeWithThresholdPolicy` + the default-approvals matrix
316
+ // were relocated in phase 6c.5-d.1 and renamed in phase 6g.1. The
317
+ // CustodyPolicy module's `onInstall` is now the per-account init
318
+ // path: factory deploys account → installs the policy with
319
+ // ABI-encoded init data (mode, trustees, approvalsRequiredByTier,
320
+ // safetyDelayByTier, T3 ceiling, ApprovedHashRegistry address). The
321
+ // default-approvals matrix from spec § 5.1 is exposed by
322
+ // `CustodyPolicy.defaultApprovals`.
323
+ // ─── UUPS Upgrade ──────────────────────────────────────────────
324
+
325
+ /**
326
+ * @dev Spec 007 Phase A — `_authorizeUpgrade` requires `msg.sender
327
+ * == address(this)`. The ONLY path that satisfies this is a
328
+ * re-entrant call from `upgradeToWithAuthorization` (below),
329
+ * which verifies an explicit owner signature first. Direct
330
+ * callers of `upgradeToAndCall` cannot satisfy `onlySelf` and
331
+ * will revert here. Master / bundler / session-issuer
332
+ * therefore cannot upgrade even by submitting the tx.
333
+ */
334
+ function _authorizeUpgrade(address) internal view override onlySelf {}
335
+
336
+ /**
337
+ * @notice DEPRECATED — single-signature upgrade path (contract audit C-3).
338
+ *
339
+ * @dev The legacy behavior verified ONE ECDSA signature against any
340
+ * external custodian. For multi-custodian / treasury / org
341
+ * accounts that meant a single compromised key could swap the
342
+ * implementation out from under the entire custody quorum —
343
+ * catastrophic. Removed entirely.
344
+ *
345
+ * Per-account upgrade paths post-Wave-2A:
346
+ * - Person accounts (single custodian, no CustodyPolicy):
347
+ * submit an owner-signed UserOp that calls
348
+ * `upgradeToAndCall(newImpl, "")` from `address(this)`.
349
+ * - Multi-custodian accounts with CustodyPolicy installed:
350
+ * route through `CustodyPolicy.ApplySystemUpdate` which
351
+ * requires the full T4/T5 quorum + timelock + audit and
352
+ * dispatches a self-call into `upgradeToAndCall`.
353
+ *
354
+ * The function is retained for ABI compat (tooling that probes
355
+ * the selector won't break) but always reverts.
356
+ */
357
+ function upgradeToWithAuthorization(address /* newImpl */, bytes calldata /* ownerSig */) external pure {
358
+ revert LegacyUpgradePathDisabled();
359
+ }
360
+
361
+ /// @notice Execute a previously-queued upgrade. Permissionless once
362
+ /// the timelock has expired — anyone can pay the gas, but
363
+ /// the implementation address was bound at queue time.
364
+ function executePendingUpgrade() external {
365
+ PendingUpgrade memory p = _pendingUpgrade;
366
+ if (p.readyAt == 0) revert NoPendingUpgrade();
367
+ if (block.timestamp < p.readyAt) revert UpgradeNotReady(p.readyAt, block.timestamp);
368
+ // Clear pending BEFORE the upgrade fires so a misbehaving new
369
+ // impl can't replay this state.
370
+ delete _pendingUpgrade;
371
+ emit UpgradeAuthorized(p.newImplementation);
372
+ this.upgradeToAndCall(p.newImplementation, "");
373
+ }
374
+
375
+ /// @notice Cancel a queued upgrade during the timelock window. Owner
376
+ /// signs a digest that binds to the pending implementation
377
+ /// AND `address(this)` + chain id, preventing cross-account
378
+ /// replay of a cancel-grant.
379
+ /// @param ownerSig Owner ECDSA signature over `keccak256(abi.encode(
380
+ /// "UPGRADE_CANCEL", _pendingUpgrade.newImplementation,
381
+ /// address(this), block.chainid))`.
382
+ function cancelPendingUpgrade(bytes calldata ownerSig) external {
383
+ PendingUpgrade memory p = _pendingUpgrade;
384
+ if (p.readyAt == 0) revert NoPendingUpgrade();
385
+ bytes32 digest = keccak256(
386
+ abi.encode(
387
+ bytes32("UPGRADE_CANCEL"),
388
+ p.newImplementation,
389
+ address(this),
390
+ block.chainid
391
+ )
392
+ );
393
+ if (!_verifyEcdsa(digest, ownerSig)) revert NotOwnerSig();
394
+ delete _pendingUpgrade;
395
+ emit UpgradeCancelled(p.newImplementation);
396
+ }
397
+
398
+ /// @notice Configure the per-account upgrade timelock (seconds).
399
+ /// Settable only via a userOp the owner signed
400
+ /// (`onlySelf`). 0 == immediate upgrades (default,
401
+ /// backward-compat).
402
+ function setUpgradeTimelock(uint256 secs) external onlySelf {
403
+ if (secs > MAX_UPGRADE_TIMELOCK) {
404
+ revert UpgradeTimelockTooLong(secs, MAX_UPGRADE_TIMELOCK);
405
+ }
406
+ uint256 old = _upgradeTimelock;
407
+ _upgradeTimelock = secs;
408
+ emit UpgradeTimelockChanged(old, secs);
409
+ }
410
+
411
+ /// @notice Current per-account upgrade timelock (seconds).
412
+ function upgradeTimelock() external view returns (uint256) {
413
+ return _upgradeTimelock;
414
+ }
415
+
416
+ /// @notice Current pending-upgrade state.
417
+ function pendingUpgrade() external view returns (address newImpl, uint64 readyAt) {
418
+ PendingUpgrade memory p = _pendingUpgrade;
419
+ return (p.newImplementation, p.readyAt);
420
+ }
421
+
422
+ /// @notice Returns the current implementation version.
423
+ function version() external pure returns (string memory) {
424
+ return "2.2.0";
425
+ }
426
+
427
+ // ─── Delegation Manager (ERC-7710) ─────────────────────────────
428
+
429
+ /**
430
+ * @notice Set the DelegationManager authorized to execute on behalf of this account.
431
+ * Per contract audit C-1: this MUST be `onlySelf`. Previously any
432
+ * external custodian could call this directly, then point at an
433
+ * attacker-controlled manager that calls back through
434
+ * `account.execute(...)` — bypassing CustodyPolicy quorum + timelock
435
+ * entirely. A single custodian on a multi-sig account was a
436
+ * catastrophic escape hatch.
437
+ *
438
+ * For initial wiring the factory routes through the unified
439
+ * initializer (which sets `_delegationManager` directly). For
440
+ * post-deploy rotation, route through
441
+ * `CustodyPolicy.RotateDelegationManager`, which requires the
442
+ * account's full T4 quorum + timelock + audit, then dispatches
443
+ * a self-call into this function.
444
+ */
445
+ function setDelegationManager(address dm) external onlySelf {
446
+ if (dm.code.length == 0) revert ValidatorRequired();
447
+ _delegationManager = dm;
448
+ emit DelegationManagerRotated(dm);
449
+ }
450
+
451
+ /// @notice Get the currently authorized DelegationManager.
452
+ function delegationManager() external view returns (address) {
453
+ return _delegationManager;
454
+ }
455
+
456
+ // ─── Phase A — capability roles (factory-scoped) ───────────────
457
+
458
+ /// @notice Address of the factory that deployed this account.
459
+ function factory() external view returns (address) {
460
+ return _factory;
461
+ }
462
+
463
+ /// @notice Bundler signer address. Resolved through the factory so
464
+ /// a factory upgrade can rotate this without per-account
465
+ /// migration. Returns address(0) if no factory is set
466
+ /// (legacy / direct-deploy path).
467
+ function bundlerSigner() public view returns (address) {
468
+ if (_factory == address(0)) return address(0);
469
+ return IAgentAccountFactoryView(_factory).bundlerSigner();
470
+ }
471
+
472
+ /// @notice Session-issuer address. Same factory-indirect pattern.
473
+ function sessionIssuer() public view returns (address) {
474
+ if (_factory == address(0)) return address(0);
475
+ return IAgentAccountFactoryView(_factory).sessionIssuer();
476
+ }
477
+
478
+ /// @notice True iff the owner has pre-authorized the given
479
+ /// session-delegation hash on chain (Variant B).
480
+ function hasAcceptedSessionDelegation(bytes32 sessionDelegationHash) external view returns (bool) {
481
+ return _acceptedSessionDelegations[sessionDelegationHash];
482
+ }
483
+
484
+ /**
485
+ * @notice Pre-authorize an on-chain session delegation (Variant B).
486
+ *
487
+ * For high-risk sessions (per spec 007 § D2), the user
488
+ * signs an EIP-712 message authorising a specific session
489
+ * delegation hash and submits a userOp that calls this
490
+ * function. Subsequent session userOps recover their
491
+ * authority by consulting this set.
492
+ *
493
+ * The hash itself is opaque to AgentAccount; it MUST
494
+ * encode the session key, scope, expiry and any other
495
+ * binding fields. Off-chain layer (DelegationManager)
496
+ * validates the shape.
497
+ *
498
+ * Authorization gate: `msg.sender == address(this)`. The
499
+ * only way to reach this is a userOp signed by an owner
500
+ * and routed through `execute` → self-call. Master /
501
+ * bundler / session-issuer cannot register a session by
502
+ * themselves; they can only submit a userOp the user
503
+ * already signed.
504
+ *
505
+ * @param sessionDelegationHash The keccak256 hash of the session
506
+ * delegation the owner has authorized.
507
+ */
508
+ function acceptSessionDelegation(bytes32 sessionDelegationHash) external onlySelf {
509
+ if (_acceptedSessionDelegations[sessionDelegationHash]) {
510
+ revert SessionDelegationAlreadyAccepted(sessionDelegationHash);
511
+ }
512
+ _acceptedSessionDelegations[sessionDelegationHash] = true;
513
+ emit SessionDelegationAccepted(sessionDelegationHash);
514
+ }
515
+
516
+ /**
517
+ * @notice Defense-in-depth wrapper: verify a bundler-envelope
518
+ * signature AT THE CONTRACT LAYER, then re-enter the
519
+ * standard ERC-4337 validation path.
520
+ *
521
+ * Spec 007 Phase A D3 — `executeFromBundler` is an
522
+ * ADDITIONAL layer ALONGSIDE the standard EntryPoint flow,
523
+ * not a replacement. It re-checks at the contract layer
524
+ * what `apps/a2a-agent/src/routes/onchain-redeem.ts`
525
+ * already checked off-chain.
526
+ *
527
+ * Authorization gates:
528
+ * - `bundlerSig` recovers to `bundlerSigner()` over a
529
+ * digest binding `userOpHash`, `address(this)`, and
530
+ * `block.chainid`. Master / random callers cannot
531
+ * impersonate the bundler.
532
+ * - The inner `op.signature` is validated against
533
+ * `_externalCustodians` (or the WebAuthn passkey set) via the
534
+ * standard `_validateSig` path. The bundler envelope
535
+ * alone is insufficient.
536
+ *
537
+ * This function is purely a verification hook: it does
538
+ * NOT execute the userOp. EntryPoint.handleOps drives
539
+ * execution as usual. Off-chain bundler-relay code calls
540
+ * this view first as a sanity gate before submitting to
541
+ * EntryPoint, and on-chain tooling can call it to assert
542
+ * the bundler envelope at the contract layer.
543
+ *
544
+ * @param op The packed userOp envelope being submitted.
545
+ * @param userOpHash The hash EntryPoint will compute for `op`.
546
+ * Passed by the caller so the contract doesn't
547
+ * need to know EntryPoint version semantics; the
548
+ * off-chain relay computes it identically.
549
+ * @param bundlerSig The bundler's signature over
550
+ * `keccak256(abi.encode("BUNDLER_ENVELOPE",
551
+ * userOpHash, address(this), block.chainid))`.
552
+ * @return true if both layers (bundler envelope + inner signature)
553
+ * validate. Reverts otherwise.
554
+ */
555
+ function executeFromBundler(
556
+ PackedUserOperation calldata op,
557
+ bytes32 userOpHash,
558
+ bytes calldata bundlerSig
559
+ ) external view returns (bool) {
560
+ address bundler = bundlerSigner();
561
+ if (bundler == address(0)) revert FactoryNotSet();
562
+
563
+ bytes32 envelopeDigest = keccak256(
564
+ abi.encode(
565
+ bytes32("BUNDLER_ENVELOPE"),
566
+ userOpHash,
567
+ address(this),
568
+ block.chainid
569
+ )
570
+ );
571
+ if (!_verifySignerEcdsa(envelopeDigest, bundlerSig, bundler)) {
572
+ revert NotBundler();
573
+ }
574
+ // Re-verify the inner userOp signature against the owner set.
575
+ // This is what `_validateSignature` does at EntryPoint time;
576
+ // we re-run it here so a misbehaving off-chain relay can't
577
+ // forge a payload past the bundler check.
578
+ if (!_validateSig(userOpHash, op.signature)) {
579
+ revert InvalidInnerSignature();
580
+ }
581
+ return true;
582
+ }
583
+
584
+ // ─── ERC-7579 module config (install/uninstall + introspection) ───
585
+ //
586
+ // Phase 3 of the delegation refactor adds first-party module support
587
+ // for stateful policy (spend caps, rate limits, target allowlists,
588
+ // session validators). Modules are isolated in ERC-7201 namespaced
589
+ // storage so future upgrades can extend the layout without clobbering
590
+ // existing state (owners, passkeys, delegationManager).
591
+ //
592
+ // First-party only at v1 — no third-party module registry. Install
593
+ // and uninstall are owner-gated (or self-gated via UserOp). The
594
+ // DelegationManager cannot install modules; module changes are too
595
+ // sensitive to delegate.
596
+
597
+ /// @dev ERC-7579 module type IDs (canonical).
598
+ uint256 internal constant MODULE_TYPE_VALIDATOR = 1;
599
+ uint256 internal constant MODULE_TYPE_EXECUTOR = 2;
600
+ uint256 internal constant MODULE_TYPE_FALLBACK = 3;
601
+ uint256 internal constant MODULE_TYPE_HOOK = 4;
602
+
603
+ /// @dev Gas-protection cap: a single account can carry at most this many
604
+ /// hook modules before installModule reverts. Hooks loop per call so
605
+ /// an unbounded list would let a malicious owner brick their account.
606
+ uint256 internal constant MAX_HOOKS = 8;
607
+
608
+ /// @dev ERC-7201 namespaced storage slot for module state.
609
+ /// slot = keccak256(abi.encode(uint256(keccak256("smart-agent.account.modules.v1")) - 1)) & ~bytes32(uint256(0xff))
610
+ bytes32 private constant MODULES_STORAGE_SLOT =
611
+ 0x1f14a6accceab237b8ab0463623403008b2dec742c79d1d0e63a7729f8c11c00;
612
+
613
+ struct ModulesStorage {
614
+ // moduleTypeId => module address => installed flag
615
+ mapping(uint256 => mapping(address => bool)) installed;
616
+ // moduleTypeId => ordered list of installed module addresses (for enumeration + hook iteration)
617
+ mapping(uint256 => address[]) installedList;
618
+ }
619
+
620
+ function _modulesStorage() private pure returns (ModulesStorage storage $) {
621
+ bytes32 slot = MODULES_STORAGE_SLOT;
622
+ assembly { $.slot := slot }
623
+ }
624
+
625
+ // ─── Events ───────────────────────────────────────────────────────
626
+
627
+ /// @notice ERC-7579 ModuleInstalled.
628
+ event ModuleInstalled(uint256 moduleTypeId, address module);
629
+ /// @notice ERC-7579 ModuleUninstalled.
630
+ event ModuleUninstalled(uint256 moduleTypeId, address module);
631
+
632
+ // ─── Errors ───────────────────────────────────────────────────────
633
+
634
+ error UnsupportedModuleType(uint256 moduleTypeId);
635
+ error ModuleAlreadyInstalled(uint256 moduleTypeId, address module);
636
+ error ModuleNotInstalled(uint256 moduleTypeId, address module);
637
+ error TooManyHooks();
638
+ error ModuleOnInstallFailed(bytes reason);
639
+ error ModuleOnUninstallFailed(bytes reason);
640
+
641
+ // ─── Auth modifier (post Wave 2A) ─────────────────────────────────
642
+ //
643
+ // Per contract audit C-2: module install/uninstall MUST NOT be
644
+ // callable by an external custodian. Previously any single
645
+ // custodian on a multi-sig account could:
646
+ // 1. Call `installModule(EXECUTOR, attackerContract, data)`.
647
+ // 2. Have the attacker module call `executeFromModule(account, ...)`
648
+ // against any privileged self-only function (addCustodian,
649
+ // addPasskey, upgradeToAndCall, …).
650
+ // 3. Drain the account or replace the custodian set.
651
+ //
652
+ // The new gate: only `address(this)` (self-calls routed through the
653
+ // custody quorum + CustodyPolicy.execute) OR the factory ONCE during
654
+ // initial deployment. After the first factory-driven install, the
655
+ // factory exception is consumed and post-deploy module changes can
656
+ // only land via a full self-call.
657
+
658
+ /**
659
+ * @notice True once the factory has consumed its one-time module-install
660
+ * exception. After this flips, the factory is treated like any
661
+ * non-self caller — `onlySelf` is the only path in.
662
+ */
663
+ bool private _factoryInitConsumed;
664
+
665
+ /// @dev Factory's narrow init window: a single install call per
666
+ /// account, used during the unified deploy tx. After this slot
667
+ /// flips true, future factory calls are rejected.
668
+ modifier onlySelfOrFactoryInit() {
669
+ if (msg.sender == address(this)) {
670
+ _;
671
+ return;
672
+ }
673
+ if (msg.sender == _factory && !_factoryInitConsumed) {
674
+ _factoryInitConsumed = true;
675
+ _;
676
+ return;
677
+ }
678
+ revert ModuleOperationNotAllowed();
679
+ }
680
+
681
+ /**
682
+ * @notice Install an ERC-7579 module of the given type.
683
+ * @dev Owner-gated (or self via UserOp). Calls `onInstall(initData)` on
684
+ * the module after marking it installed; if the module reverts in
685
+ * `onInstall`, the install is aborted — but failure is wrapped in
686
+ * a typed error so the caller can distinguish from auth failures.
687
+ */
688
+ function installModule(
689
+ uint256 moduleTypeId,
690
+ address module,
691
+ bytes calldata initData
692
+ ) external onlySelfOrFactoryInit {
693
+ if (module == address(0)) revert ZeroAddress();
694
+ if (!_isSupportedModuleType(moduleTypeId)) revert UnsupportedModuleType(moduleTypeId);
695
+
696
+ ModulesStorage storage $ = _modulesStorage();
697
+ if ($.installed[moduleTypeId][module]) {
698
+ revert ModuleAlreadyInstalled(moduleTypeId, module);
699
+ }
700
+ if (moduleTypeId == MODULE_TYPE_HOOK && $.installedList[moduleTypeId].length >= MAX_HOOKS) {
701
+ revert TooManyHooks();
702
+ }
703
+
704
+ $.installed[moduleTypeId][module] = true;
705
+ $.installedList[moduleTypeId].push(module);
706
+
707
+ // Notify the module — best-effort wrapped so a misbehaving module
708
+ // produces a typed error instead of bubbling raw bytes. We still
709
+ // revert the install on failure (leaving the storage flag set would
710
+ // create an inconsistent module/`onInstall` state).
711
+ try IERC7579ModuleLike(module).onInstall(initData) {
712
+ // ok
713
+ } catch (bytes memory reason) {
714
+ // Roll back the storage write before reverting.
715
+ $.installed[moduleTypeId][module] = false;
716
+ address[] storage list = $.installedList[moduleTypeId];
717
+ list.pop();
718
+ revert ModuleOnInstallFailed(reason);
719
+ }
720
+
721
+ emit ModuleInstalled(moduleTypeId, module);
722
+ }
723
+
724
+ /**
725
+ * @notice Uninstall a previously installed ERC-7579 module.
726
+ * @dev Owner-gated. `onUninstall` failure is loud — it reverts. Loud
727
+ * failure is better than orphan state for security-sensitive
728
+ * modules (e.g., a spend-cap hook with budget state shouldn't
729
+ * be removed silently if it can't clean up).
730
+ */
731
+ function uninstallModule(
732
+ uint256 moduleTypeId,
733
+ address module,
734
+ bytes calldata deInitData
735
+ ) external {
736
+ // Post-Wave-2A: uninstall is ALWAYS `onlySelf`. The factory's
737
+ // narrow init exception is install-only — uninstall would be
738
+ // an exit ramp out of the policy stack the factory wired in
739
+ // at deploy time, and there's never a legitimate reason for
740
+ // the factory to do that.
741
+ if (msg.sender != address(this)) revert ModuleOperationNotAllowed();
742
+ if (!_isSupportedModuleType(moduleTypeId)) revert UnsupportedModuleType(moduleTypeId);
743
+
744
+ ModulesStorage storage $ = _modulesStorage();
745
+ if (!$.installed[moduleTypeId][module]) {
746
+ revert ModuleNotInstalled(moduleTypeId, module);
747
+ }
748
+
749
+ // Loud uninstall — if the module reverts in onUninstall, we revert
750
+ // too so the caller sees the failure. (Owner can force-uninstall
751
+ // by passing deInitData the module can handle, or by re-deploying
752
+ // the account proxy — UUPS is available.)
753
+ try IERC7579ModuleLike(module).onUninstall(deInitData) {
754
+ // ok
755
+ } catch (bytes memory reason) {
756
+ revert ModuleOnUninstallFailed(reason);
757
+ }
758
+
759
+ $.installed[moduleTypeId][module] = false;
760
+ _removeFromList($.installedList[moduleTypeId], module);
761
+
762
+ emit ModuleUninstalled(moduleTypeId, module);
763
+ }
764
+
765
+ /// @notice Returns true iff the given module is installed for the given type.
766
+ /// @dev `additionalContext` accepted for ERC-7579 conformance; unused here.
767
+ function isModuleInstalled(
768
+ uint256 moduleTypeId,
769
+ address module,
770
+ bytes calldata /* additionalContext */
771
+ ) external view returns (bool) {
772
+ return _modulesStorage().installed[moduleTypeId][module];
773
+ }
774
+
775
+ /// @notice ERC-7579 supportsModule (introspection).
776
+ function supportsModule(uint256 moduleTypeId) external pure returns (bool) {
777
+ return _isSupportedModuleType(moduleTypeId);
778
+ }
779
+
780
+ /// @notice ERC-7579 supportsExecutionMode (introspection).
781
+ /// @dev We support the canonical single-call mode (CALLTYPE_SINGLE, EXECTYPE_DEFAULT).
782
+ /// We don't expose `execute(bytes32 mode, bytes execData)` (the new ERC-7579
783
+ /// execution surface) — BaseAccount.execute is the canonical entry. We return
784
+ /// true here for the encoded form of CALLTYPE_SINGLE so 7579-aware tooling
785
+ /// can introspect the account before routing through our existing path.
786
+ function supportsExecutionMode(bytes32 /* mode */) external pure returns (bool) {
787
+ // Phase 3 surface — we don't support the multiplexed ERC-7579 execute()
788
+ // entry yet; routing remains via BaseAccount.execute. Return false for
789
+ // any encoded mode to avoid misadvertising capability.
790
+ return false;
791
+ }
792
+
793
+ /// @notice Enumerate the installed modules for a given type.
794
+ function getInstalledModules(uint256 moduleTypeId) external view returns (address[] memory) {
795
+ return _modulesStorage().installedList[moduleTypeId];
796
+ }
797
+
798
+ /**
799
+ * @notice Stable account-implementation identifier.
800
+ * @dev Bumped to `.3` to signal ERC-7579 `executeFromModule` callback
801
+ * support shipped in phase 6c.5-d.0 (spec 209).
802
+ */
803
+ function accountId() external pure returns (string memory) {
804
+ return "smart-agent.agent-account.3";
805
+ }
806
+
807
+ // ─── ERC-7579 executor callback (spec 209 phase 6c.5-d.0) ─────────
808
+
809
+ /// @notice Emitted when an installed executor module successfully
810
+ /// calls `executeFromModule`.
811
+ event ModuleExecuted(address indexed module, address indexed target, uint256 value);
812
+
813
+ error NotInstalledExecutor(address caller);
814
+
815
+ /**
816
+ * @notice ERC-7579 executor callback. An installed
817
+ * `MODULE_TYPE_EXECUTOR` calls this to act on the account's
818
+ * behalf. When `target == address(this)` the EVM-level call
819
+ * becomes a self-call (`msg.sender == account` at the
820
+ * callee), so inner `onlySelf` gates pass without special
821
+ * dispatch. For external targets the call goes through
822
+ * directly.
823
+ *
824
+ * @dev Only an installed executor module may call. The
825
+ * install-permission model IS the gate: install is
826
+ * `onlyOwnerOrSelf` today and migrates to T5 (quorum + timelock)
827
+ * in phase 6c.5-d.1 once `CustodyPolicy` lands.
828
+ *
829
+ * `nonReentrant` guards against an executor calling this
830
+ * function twice in a single call frame. It shares the
831
+ * ReentrancyGuard slot with `execute` / `executeBatch`, so a
832
+ * module cannot route through the account's `execute` — the
833
+ * module IS the executor; it must call its target directly.
834
+ *
835
+ * Bubble-revert preserves the inner error selector.
836
+ */
837
+ function executeFromModule(address target, uint256 value, bytes calldata data)
838
+ external
839
+ nonReentrant
840
+ returns (bytes memory)
841
+ {
842
+ ModulesStorage storage $ = _modulesStorage();
843
+ if (!$.installed[MODULE_TYPE_EXECUTOR][msg.sender]) {
844
+ revert NotInstalledExecutor(msg.sender);
845
+ }
846
+ emit ModuleExecuted(msg.sender, target, value);
847
+ (bool ok, bytes memory ret) = target.call{value: value}(data);
848
+ if (!ok) {
849
+ assembly { revert(add(ret, 32), mload(ret)) }
850
+ }
851
+ return ret;
852
+ }
853
+
854
+ function _isSupportedModuleType(uint256 moduleTypeId) internal pure returns (bool) {
855
+ return moduleTypeId == MODULE_TYPE_VALIDATOR
856
+ || moduleTypeId == MODULE_TYPE_EXECUTOR
857
+ || moduleTypeId == MODULE_TYPE_HOOK;
858
+ // Fallback (type 3) intentionally unsupported in v1 — would require
859
+ // a fallback dispatcher we don't ship yet.
860
+ }
861
+
862
+ function _removeFromList(address[] storage list, address module) private {
863
+ uint256 len = list.length;
864
+ for (uint256 i = 0; i < len; i++) {
865
+ if (list[i] == module) {
866
+ if (i != len - 1) list[i] = list[len - 1];
867
+ list.pop();
868
+ return;
869
+ }
870
+ }
871
+ // unreachable — installed flag guarantees presence
872
+ }
873
+
874
+ // ─── Hook execution wrapper ───────────────────────────────────────
875
+ //
876
+ // Override BaseAccount.execute to run pre/postCheck on installed hook
877
+ // modules. Authorization (entryPoint / self / delegationManager) is
878
+ // enforced by `_requireForExecute` from BaseAccount unchanged.
879
+ //
880
+ // Hook semantics:
881
+ // - preCheck runs in install order; each receives (msg.sender, value, msgData).
882
+ // - The hookData returned by preCheck is fed into postCheck after the call.
883
+ // - If preCheck reverts the whole execute reverts.
884
+ // - postCheck only runs on success (the call already reverts on failure).
885
+
886
+ /// @inheritdoc BaseAccount
887
+ /// @dev Spec 007 Phase A.5 (SC5 § 6.1) — `nonReentrant` blocks the
888
+ /// "execute -> target -> execute on the same account" class of
889
+ /// bugs that downstream stateful enforcers / hooks could enable.
890
+ /// `_requireForExecute` already restricts callers to
891
+ /// EntryPoint / self / DelegationManager; the guard hardens
892
+ /// against a malicious target re-entering through one of those
893
+ /// callers.
894
+ function execute(address target, uint256 value, bytes calldata data) external override nonReentrant {
895
+ _requireForExecute();
896
+
897
+ ModulesStorage storage $ = _modulesStorage();
898
+ address[] memory hooks = $.installedList[MODULE_TYPE_HOOK];
899
+ bytes[] memory hookData = new bytes[](hooks.length);
900
+
901
+ // Compose msgData = abi.encodeWithSignature("execute(address,uint256,bytes)", ...)
902
+ // so hook policy can decode the inner call. Easier and cheaper than
903
+ // forwarding msg.data which includes the selector + ABI tail; we
904
+ // rebuild the encoded inner call directly here.
905
+ bytes memory hookMsgData = abi.encode(target, value, data);
906
+
907
+ for (uint256 i = 0; i < hooks.length; i++) {
908
+ hookData[i] = IERC7579HookLike(hooks[i]).preCheck(msg.sender, value, hookMsgData);
909
+ }
910
+
911
+ // Perform the actual call (mirrors BaseAccount.execute body).
912
+ (bool ok, bytes memory ret) = target.call{value: value}(data);
913
+ if (!ok) {
914
+ // bubble the revert reason
915
+ assembly {
916
+ let len := mload(ret)
917
+ revert(add(ret, 0x20), len)
918
+ }
919
+ }
920
+
921
+ for (uint256 i = 0; i < hooks.length; i++) {
922
+ IERC7579HookLike(hooks[i]).postCheck(hookData[i]);
923
+ }
924
+ }
925
+
926
+ /// @notice Atomically execute multiple calls from this account.
927
+ /// @dev Overrides BaseAccount.executeBatch to layer the module-hook
928
+ /// pre/post-check semantics already used by `execute`. Spec 005 —
929
+ /// pledge honor needs `USDC.transfer(pool, amount)` +
930
+ /// `PledgeRegistry.recordHonor(...)` in one atomic operation,
931
+ /// pinned by a `CallDataHashEnforcer` on the redeem path so the
932
+ /// donor's sub-delegation authorises exactly this calldata.
933
+ ///
934
+ /// Auth: identical to `execute` — EntryPoint / self / DelegationManager.
935
+ /// Inner calls run with `msg.sender == address(this)`.
936
+ /// All-or-nothing: any inner revert bubbles and reverts the batch.
937
+ /// Pre-hooks run once on the whole batch; post-hooks run on success.
938
+ ///
939
+ /// Spec 007 Phase A.5 — intentionally NOT `nonReentrant`. The
940
+ /// DM redeem flow legitimately calls `account.execute(target=self,
941
+ /// data=executeBatch_calldata)`, which then self-calls into
942
+ /// `executeBatch`. If both functions held the guard, that
943
+ /// pattern would revert with `ReentrancyGuardReentrantCall`. The
944
+ /// OUTER `execute` already holds the guard, so external re-entry
945
+ /// is blocked; `_requireForExecute` restricts entry to
946
+ /// EntryPoint / self / DM (DM has its own `nonReentrant`).
947
+ function executeBatch(Call[] calldata calls) external override {
948
+ _requireForExecute();
949
+
950
+ ModulesStorage storage $ = _modulesStorage();
951
+ address[] memory hooks = $.installedList[MODULE_TYPE_HOOK];
952
+ bytes[] memory hookData = new bytes[](hooks.length);
953
+
954
+ // hookMsgData encodes the full batch so hook policy can inspect
955
+ // every inner call.
956
+ bytes memory hookMsgData = abi.encode(calls);
957
+
958
+ for (uint256 i = 0; i < hooks.length; i++) {
959
+ hookData[i] = IERC7579HookLike(hooks[i]).preCheck(msg.sender, 0, hookMsgData);
960
+ }
961
+
962
+ for (uint256 i = 0; i < calls.length; i++) {
963
+ (bool ok, bytes memory ret) = calls[i].target.call{ value: calls[i].value }(calls[i].data);
964
+ if (!ok) {
965
+ assembly {
966
+ let len := mload(ret)
967
+ revert(add(ret, 0x20), len)
968
+ }
969
+ }
970
+ }
971
+
972
+ for (uint256 i = 0; i < hooks.length; i++) {
973
+ IERC7579HookLike(hooks[i]).postCheck(hookData[i]);
974
+ }
975
+ }
976
+
977
+ // ─── ERC-4337 ───────────────────────────────────────────────────
978
+
979
+ /// @inheritdoc BaseAccount
980
+ function entryPoint() public view override returns (IEntryPoint) {
981
+ return _entryPoint;
982
+ }
983
+
984
+ /// @inheritdoc BaseAccount
985
+ /// @dev Routes on the leading signature-type byte:
986
+ /// 0x00 or bare 65-byte sig → ECDSA (backward-compatible default)
987
+ /// 0x01 → WebAuthn (abi.encoded Assertion follows)
988
+ /// Unknown types return SIG_VALIDATION_FAILED rather than reverting
989
+ /// so the ERC-4337 validation phase stays bundler-friendly.
990
+ function _validateSignature(
991
+ PackedUserOperation calldata userOp,
992
+ bytes32 userOpHash
993
+ ) internal view override returns (uint256 validationData) {
994
+ return _validateSig(userOpHash, userOp.signature) ? 0 : 1;
995
+ }
996
+
997
+ /// @dev Allow execution from EntryPoint, account itself (via UserOp), or DelegationManager (ERC-7710)
998
+ function _requireForExecute() internal view override {
999
+ if (
1000
+ msg.sender != address(entryPoint()) &&
1001
+ msg.sender != address(this) &&
1002
+ msg.sender != _delegationManager
1003
+ ) {
1004
+ revert NotFromEntryPoint(msg.sender, address(this), address(entryPoint()));
1005
+ }
1006
+ }
1007
+
1008
+ // ─── ERC-1271 ───────────────────────────────────────────────────
1009
+
1010
+ /// @dev 32-byte ERC-6492 magic suffix — `0x6492…6492` repeated.
1011
+ bytes32 private constant ERC6492_MAGIC =
1012
+ 0x6492649264926492649264926492649264926492649264926492649264926492;
1013
+
1014
+ /// @inheritdoc IERC1271
1015
+ /// @dev Tolerates ERC-6492 envelope (stripped first), then routes on the
1016
+ /// leading signature-type byte like _validateSignature does.
1017
+ function isValidSignature(
1018
+ bytes32 hash,
1019
+ bytes calldata signature
1020
+ ) external view override(IAgentAccount, IERC1271) returns (bytes4) {
1021
+ bytes memory inner = signature;
1022
+ if (signature.length >= 32 && bytes32(signature[signature.length - 32:]) == ERC6492_MAGIC) {
1023
+ (, , bytes memory unwrapped) = abi.decode(
1024
+ signature[:signature.length - 32],
1025
+ (address, bytes, bytes)
1026
+ );
1027
+ inner = unwrapped;
1028
+ }
1029
+ return _validateSig(hash, inner) ? ERC1271_MAGIC_VALUE : bytes4(0xffffffff);
1030
+ }
1031
+
1032
+ // ─── Signature routing ─────────────────────────────────────────
1033
+
1034
+ uint8 internal constant SIG_TYPE_ECDSA = 0x00;
1035
+ uint8 internal constant SIG_TYPE_WEBAUTHN = 0x01;
1036
+
1037
+ /// @dev Internal dispatcher. Accepts plain 65-byte ECDSA sigs as legacy
1038
+ /// form AND type-prefixed sigs (first byte = SIG_TYPE_*).
1039
+ function _validateSig(bytes32 hash, bytes memory sig) internal view returns (bool) {
1040
+ // Legacy fast path: bare 65-byte ECDSA sig (no type byte).
1041
+ if (sig.length == 65) {
1042
+ return _verifyEcdsa(hash, sig);
1043
+ }
1044
+ if (sig.length < 1) return false;
1045
+ uint8 sigType = uint8(sig[0]);
1046
+ if (sigType == SIG_TYPE_ECDSA) {
1047
+ // 0x00 || <65-byte sig>
1048
+ if (sig.length != 66) return false;
1049
+ bytes memory inner = new bytes(65);
1050
+ for (uint256 i; i < 65; i++) inner[i] = sig[i + 1];
1051
+ return _verifyEcdsa(hash, inner);
1052
+ }
1053
+ if (sigType == SIG_TYPE_WEBAUTHN) {
1054
+ // 0x01 || abi.encode(WebAuthnLib.Assertion)
1055
+ bytes memory payload = new bytes(sig.length - 1);
1056
+ for (uint256 i; i < payload.length; i++) payload[i] = sig[i + 1];
1057
+ return _verifyWebAuthn(hash, payload);
1058
+ }
1059
+ return false;
1060
+ }
1061
+
1062
+ function _verifyEcdsa(bytes32 hash, bytes memory sig) internal view returns (bool) {
1063
+ // Try raw hash first — matches EntryPoint v0.8 (EIP-712 userOpHash signed directly).
1064
+ (address recovered, ECDSA.RecoverError err,) = ECDSA.tryRecover(hash, sig);
1065
+ if (err == ECDSA.RecoverError.NoError && _externalCustodians[recovered]) return true;
1066
+ // Fall back to eth-signed-message wrap — matches v0.7 and legacy ERC-1271
1067
+ // callers that pre-prefix the digest.
1068
+ bytes32 ethSigned = hash.toEthSignedMessageHash();
1069
+ (recovered, err,) = ECDSA.tryRecover(ethSigned, sig);
1070
+ return err == ECDSA.RecoverError.NoError && _externalCustodians[recovered];
1071
+ }
1072
+
1073
+ /// @dev Verify a signature recovers to a SPECIFIC expected signer
1074
+ /// (used by `executeFromBundler` and any future capability
1075
+ /// check that targets a system key, NOT the owner set). Try
1076
+ /// raw hash first then eth-signed wrap, like `_verifyEcdsa`.
1077
+ function _verifySignerEcdsa(
1078
+ bytes32 hash,
1079
+ bytes memory sig,
1080
+ address expected
1081
+ ) internal pure returns (bool) {
1082
+ (address recovered, ECDSA.RecoverError err,) = ECDSA.tryRecover(hash, sig);
1083
+ if (err == ECDSA.RecoverError.NoError && recovered == expected) return true;
1084
+ bytes32 ethSigned = MessageHashUtils.toEthSignedMessageHash(hash);
1085
+ (recovered, err,) = ECDSA.tryRecover(ethSigned, sig);
1086
+ return err == ECDSA.RecoverError.NoError && recovered == expected;
1087
+ }
1088
+
1089
+ /**
1090
+ * @dev Audit C-7: malformed `0x01||abi.encode(Assertion)` payloads
1091
+ * previously reverted inside this function during `abi.decode`,
1092
+ * which propagated out of `validateUserOp` instead of returning
1093
+ * `SIG_VALIDATION_FAILED` (= 1) per ERC-4337. Bundlers treat a
1094
+ * revert during validation as a banned account — much worse
1095
+ * than a clean "this sig is invalid" return.
1096
+ *
1097
+ * Solidity can't try/catch an internal `abi.decode`, so we
1098
+ * route the decode through an external self-call (which CAN
1099
+ * be try/catch'd). Gas overhead is ~700 in the success path;
1100
+ * the security win is bounded reverts.
1101
+ */
1102
+ function _verifyWebAuthn(bytes32 hash, bytes memory payload) internal view returns (bool) {
1103
+ try this.decodeWebAuthnAssertion(payload) returns (WebAuthnLib.Assertion memory a) {
1104
+ PasskeyStorage storage $ = _passkeyStorage();
1105
+ PasskeyEntry storage key = $.keys[a.credentialIdDigest];
1106
+ if (key.x == 0 && key.y == 0) return false;
1107
+ // H7-C.1 / CON-WEBAUTHN-001: pin per-credential rpIdHash + require UP.
1108
+ // UV is not required at the library layer; account policy can layer
1109
+ // UV requirement in via a future policy module if needed.
1110
+ bytes32 rpIdHash = $.rpIdHashOf[a.credentialIdDigest];
1111
+ if (rpIdHash == bytes32(0)) return false;
1112
+ return WebAuthnLib.verify(a, hash, key.x, key.y, rpIdHash, false);
1113
+ } catch {
1114
+ return false;
1115
+ }
1116
+ }
1117
+
1118
+ /**
1119
+ * @notice External decoder wrapper used by `_verifyWebAuthn` to
1120
+ * bound `abi.decode` reverts. MUST stay `external` so the
1121
+ * caller can wrap it in `try { … } catch { … }`. Marked
1122
+ * pure — no state access, just decode.
1123
+ */
1124
+ function decodeWebAuthnAssertion(bytes calldata payload)
1125
+ external
1126
+ pure
1127
+ returns (WebAuthnLib.Assertion memory)
1128
+ {
1129
+ return abi.decode(payload, (WebAuthnLib.Assertion));
1130
+ }
1131
+
1132
+ // ─── Owner Management ───────────────────────────────────────────
1133
+
1134
+ /// @inheritdoc IAgentAccount
1135
+ /// @dev An account is implicitly an owner of itself: when a delegation
1136
+ /// chain bottoms out at this AgentAccount as the rootDelegator,
1137
+ /// DelegationManager calls `this.execute(...)` and the resulting
1138
+ /// external call has `msg.sender == address(this)`. Downstream
1139
+ /// `isCustodian(msg.sender)` checks (e.g. FundRegistry.onlyFundOwner)
1140
+ /// should pass — the account IS the actor making the call.
1141
+ ///
1142
+ /// Also resolves passkey-identity addresses (PIAs): the
1143
+ /// deterministic address derived from a registered passkey's
1144
+ /// (x, y) is a custodian first-class, so multi-passkey accounts
1145
+ /// can put each user's PIA into quorum slots without nesting
1146
+ /// through a separate Person Smart Agent.
1147
+ function isCustodian(address account) external view override returns (bool) {
1148
+ if (account == address(this)) return true;
1149
+ if (_externalCustodians[account]) return true;
1150
+ return _passkeyStorage().piaToCredentialId[account] != bytes32(0);
1151
+ }
1152
+
1153
+ /// @inheritdoc IAgentAccount
1154
+ /// @dev Counts EOA/contract custodians PLUS registered passkeys —
1155
+ /// each passkey contributes one PIA-custodian. Matches the
1156
+ /// isCustodian semantics above so `defaultApprovals(N, t)` at
1157
+ /// install time and ChangeApprovalsRequired bounds at apply
1158
+ /// time both see the same N.
1159
+ function custodianCount() external view override returns (uint256) {
1160
+ return _externalCustodianCount + _passkeyStorage().count;
1161
+ }
1162
+
1163
+ /// @notice Deterministically derive the Passkey-Identity-Address for
1164
+ /// a P-256 public key. Exposed as `pure` so off-chain code +
1165
+ /// other contracts (CustodyPolicy, factory) can recompute it
1166
+ /// without reading account state.
1167
+ function passkeyIdentity(uint256 x, uint256 y) public pure returns (address) {
1168
+ return _passkeyIdentity(x, y);
1169
+ }
1170
+
1171
+ function _passkeyIdentity(uint256 x, uint256 y) internal pure returns (address) {
1172
+ return address(uint160(uint256(keccak256(abi.encode(x, y)))));
1173
+ }
1174
+
1175
+ /// @inheritdoc IAgentAccount
1176
+ /// @dev `owner` here is an EXTERNAL custodian (EOA / SIWE wallet /
1177
+ /// third-party smart wallet like Safe/Argent). Adding an
1178
+ /// agenticprimitives AgentAccount is forbidden — enforced via
1179
+ /// the ERC-165 marker. Passkey custodians are not added here;
1180
+ /// use `addPasskey` instead.
1181
+ function addCustodian(address owner) external override onlySelf {
1182
+ if (owner == address(0)) revert ZeroAddress();
1183
+ if (_externalCustodians[owner]) revert CustodianAlreadyExists(owner);
1184
+ if (_isAgenticPrimitivesAgent(owner)) {
1185
+ revert AgenticPrimitivesAgentNotAllowedAsCustodian(owner);
1186
+ }
1187
+ _externalCustodians[owner] = true;
1188
+ _externalCustodianCount++;
1189
+ emit CustodianAdded(owner);
1190
+ }
1191
+
1192
+ /// @dev ERC-165 query — true iff `addr` is a deployed contract that
1193
+ /// advertises `IAgenticPrimitivesAgentAccount`. Safe-style
1194
+ /// try/catch so non-contracts and non-ERC-165 contracts return
1195
+ /// false without reverting.
1196
+ function _isAgenticPrimitivesAgent(address addr) internal view returns (bool) {
1197
+ if (addr.code.length == 0) return false;
1198
+ try IERC165(addr).supportsInterface(
1199
+ type(IAgenticPrimitivesAgentAccount).interfaceId
1200
+ ) returns (bool ok) {
1201
+ return ok;
1202
+ } catch {
1203
+ return false;
1204
+ }
1205
+ }
1206
+
1207
+ /// @inheritdoc IAgenticPrimitivesAgentAccount
1208
+ function isAgenticPrimitivesAgentAccount() external pure override returns (bool) {
1209
+ return true;
1210
+ }
1211
+
1212
+ /// @inheritdoc IERC165
1213
+ function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
1214
+ return interfaceId == type(IERC165).interfaceId
1215
+ || interfaceId == type(IERC1271).interfaceId
1216
+ || interfaceId == type(IAgentAccount).interfaceId
1217
+ || interfaceId == type(IAgenticPrimitivesAgentAccount).interfaceId;
1218
+ }
1219
+
1220
+ /// @inheritdoc IAgentAccount
1221
+ /// @dev Enforces a multi-signer-safe invariant: can't remove the last
1222
+ /// owner if there are also no registered passkeys. A passkey-only
1223
+ /// account is allowed, but a zero-signer account is not.
1224
+ function removeCustodian(address owner) external override onlySelf {
1225
+ if (!_externalCustodians[owner]) revert CustodianDoesNotExist(owner);
1226
+ if (_externalCustodianCount == 1 && _passkeyStorage().count == 0) revert CannotRemoveLastCustodian();
1227
+ _externalCustodians[owner] = false;
1228
+ _externalCustodianCount--;
1229
+ emit CustodianRemoved(owner);
1230
+ }
1231
+
1232
+ // ─── Passkey (WebAuthn P-256) management ──────────────────────
1233
+
1234
+ /// @dev ERC-7201 namespaced storage slot — isolates passkey state so
1235
+ /// future upgrades can add more signer types without clobbering.
1236
+ /// slot = keccak256(abi.encode(uint256(keccak256("smart-agent.agent-account.passkey.v1")) - 1)) & ~bytes32(uint256(0xff))
1237
+ bytes32 private constant PASSKEY_STORAGE_SLOT =
1238
+ 0x3b3ffcf51a0a9bcb2764532549426e303b6d219fffb988d3d097bfc22ad32d00;
1239
+
1240
+ struct PasskeyEntry {
1241
+ uint256 x;
1242
+ uint256 y;
1243
+ }
1244
+
1245
+ struct PasskeyStorage {
1246
+ mapping(bytes32 => PasskeyEntry) keys;
1247
+ mapping(bytes32 => bool) registered;
1248
+ uint256 count;
1249
+ // Passkey-Identity-Address → credentialIdDigest. PIA is the
1250
+ // deterministic address derived from a passkey's (x, y) pubkey
1251
+ // via `_passkeyIdentity(x, y)`. Lets `isCustodian(pia)` resolve
1252
+ // a passkey without iterating the credentialId set, and lets
1253
+ // CustodyPolicy `_verifyQuorum` count passkey signers as
1254
+ // first-class quorum members (v=2 slots in
1255
+ // SignatureSlotRecovery).
1256
+ mapping(address => bytes32) piaToCredentialId;
1257
+ // H7-C.1 / CON-WEBAUTHN-001: per-credential RP ID hash, captured
1258
+ // at registration and pinned at every verify. Stored separately
1259
+ // from PasskeyEntry to preserve the existing PasskeyEntry storage
1260
+ // layout for upgrade safety. Required (non-zero) on add.
1261
+ mapping(bytes32 => bytes32) rpIdHashOf;
1262
+ }
1263
+
1264
+ function _passkeyStorage() private pure returns (PasskeyStorage storage $) {
1265
+ bytes32 slot = PASSKEY_STORAGE_SLOT;
1266
+ assembly { $.slot := slot }
1267
+ }
1268
+
1269
+ event PasskeyAdded(bytes32 indexed credentialIdDigest, uint256 x, uint256 y, bytes32 rpIdHash);
1270
+ event PasskeyRemoved(bytes32 indexed credentialIdDigest);
1271
+
1272
+ /// @notice Register a new WebAuthn credential bound to a specific RP.
1273
+ /// `onlySelf` — callable via a UserOp signed by any existing signer
1274
+ /// (owner or another passkey). Also registers the credential's
1275
+ /// Passkey-Identity-Address (PIA) so `isCustodian(pia)` returns
1276
+ /// true and v=2 quorum slots can count this passkey as a distinct
1277
+ /// signer.
1278
+ /// @param rpIdHash `sha256(rpId)` — the RP this credential was registered
1279
+ /// against. PINNED on every verify (H7-C.1 / CON-WEBAUTHN-001
1280
+ /// closure). Must be non-zero. Each credential carries its
1281
+ /// own rpIdHash so an account that adopts credentials across
1282
+ /// multiple RPs gets correct per-credential origin scoping.
1283
+ function addPasskey(
1284
+ bytes32 credentialIdDigest,
1285
+ uint256 x,
1286
+ uint256 y,
1287
+ bytes32 rpIdHash
1288
+ ) external onlySelf {
1289
+ if (x == 0 || y == 0) revert InvalidPasskeyPublicKey();
1290
+ // Audit C-6: zero digest poisons piaToCredentialId mapping.
1291
+ if (credentialIdDigest == bytes32(0)) revert InvalidCredentialIdDigest();
1292
+ // H7-C.1: zero rpIdHash would let the verifier accept any RP (kills the pin).
1293
+ if (rpIdHash == bytes32(0)) revert InvalidRpIdHash();
1294
+ PasskeyStorage storage $ = _passkeyStorage();
1295
+ if ($.registered[credentialIdDigest]) revert PasskeyAlreadyRegistered(credentialIdDigest);
1296
+ address pia = _passkeyIdentity(x, y);
1297
+ if ($.piaToCredentialId[pia] != bytes32(0)) revert PasskeyAlreadyRegistered(credentialIdDigest);
1298
+ if (_externalCustodians[pia]) revert CustodianAlreadyExists(pia);
1299
+ $.keys[credentialIdDigest] = PasskeyEntry(x, y);
1300
+ $.registered[credentialIdDigest] = true;
1301
+ $.piaToCredentialId[pia] = credentialIdDigest;
1302
+ $.rpIdHashOf[credentialIdDigest] = rpIdHash;
1303
+ $.count += 1;
1304
+ emit PasskeyAdded(credentialIdDigest, x, y, rpIdHash);
1305
+ emit CustodianAdded(pia);
1306
+ }
1307
+
1308
+ /// @notice Remove a registered WebAuthn credential. onlySelf, with a
1309
+ /// "must leave at least one signer" invariant that counts owners
1310
+ /// AND passkeys together. Also clears the credential's PIA
1311
+ /// entry so `isCustodian(pia)` flips back to false.
1312
+ function removePasskey(bytes32 credentialIdDigest) external onlySelf {
1313
+ PasskeyStorage storage $ = _passkeyStorage();
1314
+ if (!$.registered[credentialIdDigest]) revert PasskeyNotRegistered(credentialIdDigest);
1315
+ if (_externalCustodianCount + $.count == 1) revert CannotRemoveLastSigner();
1316
+ PasskeyEntry storage key = $.keys[credentialIdDigest];
1317
+ address pia = _passkeyIdentity(key.x, key.y);
1318
+ delete $.keys[credentialIdDigest];
1319
+ $.registered[credentialIdDigest] = false;
1320
+ delete $.piaToCredentialId[pia];
1321
+ delete $.rpIdHashOf[credentialIdDigest];
1322
+ $.count -= 1;
1323
+ emit PasskeyRemoved(credentialIdDigest);
1324
+ emit CustodianRemoved(pia);
1325
+ }
1326
+
1327
+ /// @notice Whether a passkey is registered on this account.
1328
+ function hasPasskey(bytes32 credentialIdDigest) external view returns (bool) {
1329
+ return _passkeyStorage().registered[credentialIdDigest];
1330
+ }
1331
+
1332
+ /// @notice Read the registered passkey public key.
1333
+ function getPasskey(bytes32 credentialIdDigest) external view returns (uint256 x, uint256 y) {
1334
+ PasskeyEntry storage k = _passkeyStorage().keys[credentialIdDigest];
1335
+ return (k.x, k.y);
1336
+ }
1337
+
1338
+ /// @notice Total count of registered passkeys.
1339
+ function passkeyCount() external view returns (uint256) {
1340
+ return _passkeyStorage().count;
1341
+ }
1342
+
1343
+ // ─── Custody policy + scheduled changes ───────────────────────────
1344
+ //
1345
+ // The schedule / apply / cancel custody-change surface, CustodyAction
1346
+ // enum, per-account Config struct, _verifyQuorum, and action handlers
1347
+ // live in `src/custody/CustodyPolicy.sol` — an ERC-7579 module
1348
+ // installed as MODULE_TYPE_EXECUTOR. Per spec 209 (ERC-7579 module
1349
+ // taxonomy) AgentAccount is the thin core; per spec 213 the module
1350
+ // owns the custody-layer surface. The policy calls back via
1351
+ // `executeFromModule` to apply changes.
1352
+ //
1353
+ // Views (custodyMode / approvalsRequired / recoveryApprovals /
1354
+ // trusteeCount / scheduledChangeCount / etc.) live on the policy
1355
+ // module and are queried through it: `CustodyPolicy(modAddr)
1356
+ // .custodyMode(account)` etc.
1357
+ // ─── Receive ETH ────────────────────────────────────────────────
1358
+
1359
+ receive() external payable {}
1360
+ }
1361
+
1362
+ /// @dev Minimal subset of the ERC-7579 module interface we call into.
1363
+ /// We import the OpenZeppelin draft-IERC7579 only when needed; an inline
1364
+ /// type-erased shape here avoids pulling the full file at this layer.
1365
+ interface IERC7579ModuleLike {
1366
+ function onInstall(bytes calldata data) external;
1367
+ function onUninstall(bytes calldata data) external;
1368
+ }
1369
+
1370
+ interface IERC7579HookLike {
1371
+ function preCheck(address msgSender, uint256 value, bytes calldata msgData)
1372
+ external returns (bytes memory);
1373
+ function postCheck(bytes calldata hookData) external;
1374
+ }