@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.
- package/AUDIT.md +67 -0
- package/CLAUDE.md +40 -0
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/deployments-anvil.json +1 -0
- package/deployments-base-sepolia.json +1 -0
- package/dist/abi/AgentNameAttributeResolver.json +798 -0
- package/dist/abi/AgentNamePredicates.json +1 -0
- package/dist/abi/AgentNameRegistry.json +826 -0
- package/dist/abi/AgentNameUniversalResolver.json +222 -0
- package/dist/abi/AgentProfilePredicates.json +1 -0
- package/dist/abi/AgentProfileResolver.json +1044 -0
- package/dist/abi/AgentRelationship.json +583 -0
- package/dist/abi/AgentRelationshipPredicates.json +1 -0
- package/dist/abi/AgenticGovernance.json +259 -0
- package/dist/abi/AllowedMethodsEnforcer.json +108 -0
- package/dist/abi/AllowedTargetsEnforcer.json +103 -0
- package/dist/abi/ApprovedHashRegistry.json +114 -0
- package/dist/abi/AttributeStorage.json +557 -0
- package/dist/abi/CaveatEnforcerBase.json +130 -0
- package/dist/abi/GovernanceManaged.json +43 -0
- package/dist/abi/IAttributeReader.json +98 -0
- package/dist/abi/ICaveatEnforcer.json +98 -0
- package/dist/abi/IDelegationManager.json +211 -0
- package/dist/abi/IERC7579Module.json +34 -0
- package/dist/abi/IERC7579ModuleLifecycle.json +60 -0
- package/dist/abi/IGovernanceView.json +34 -0
- package/dist/abi/MultiSendCallOnly.json +29 -0
- package/dist/abi/MultiSendCallOnlyHarness.json +42 -0
- package/dist/abi/OntologyTermRegistry.json +397 -0
- package/dist/abi/P256Verifier.json +1 -0
- package/dist/abi/PermissionlessSubregistry.json +207 -0
- package/dist/abi/RelationshipTypeRegistry.json +455 -0
- package/dist/abi/ShapeRegistry.json +627 -0
- package/dist/abi/SmartAgentModuleTypes.json +1 -0
- package/dist/abi/TimestampEnforcer.json +108 -0
- package/dist/abi/ValueEnforcer.json +103 -0
- package/dist/abi/WebAuthnLib.json +1 -0
- package/dist/abi/index.d.ts +35 -0
- package/dist/abi/index.js +35 -0
- package/package.json +48 -0
- package/spec.md +52 -0
- package/src/AgentAccount.sol +1374 -0
- package/src/AgentAccountFactory.sol +274 -0
- package/src/ApprovedHashRegistry.sol +57 -0
- package/src/IAgentAccount.sol +138 -0
- package/src/SmartAgentPaymaster.sol +281 -0
- package/src/UniversalSignatureValidator.sol +136 -0
- package/src/agency/DelegationManager.sol +374 -0
- package/src/agency/ICaveatEnforcer.sol +62 -0
- package/src/agency/IDelegationManager.sol +69 -0
- package/src/custody/CustodyPolicy.sol +892 -0
- package/src/custody/IERC7579Module.sol +60 -0
- package/src/enforcers/AllowedMethodsEnforcer.AUDIT.md +51 -0
- package/src/enforcers/AllowedMethodsEnforcer.sol +48 -0
- package/src/enforcers/AllowedTargetsEnforcer.AUDIT.md +49 -0
- package/src/enforcers/AllowedTargetsEnforcer.sol +44 -0
- package/src/enforcers/CaveatEnforcerBase.sol +19 -0
- package/src/enforcers/QuorumEnforcer.AUDIT.md +71 -0
- package/src/enforcers/QuorumEnforcer.sol +191 -0
- package/src/enforcers/TimestampEnforcer.AUDIT.md +50 -0
- package/src/enforcers/TimestampEnforcer.sol +43 -0
- package/src/enforcers/ValueEnforcer.AUDIT.md +51 -0
- package/src/enforcers/ValueEnforcer.sol +41 -0
- package/src/governance/AgenticGovernance.sol +140 -0
- package/src/governance/GovernanceManaged.sol +75 -0
- package/src/governance/IGovernance.sol +15 -0
- package/src/identity/AgentProfilePredicates.sol +40 -0
- package/src/identity/AgentProfileResolver.sol +194 -0
- package/src/libraries/MultiSendCallOnly.sol +95 -0
- package/src/libraries/P256Verifier.sol +47 -0
- package/src/libraries/SignatureSlotRecovery.sol +196 -0
- package/src/libraries/WebAuthnLib.sol +164 -0
- package/src/naming/AgentNameAttributeResolver.sol +95 -0
- package/src/naming/AgentNamePredicates.sol +74 -0
- package/src/naming/AgentNameRegistry.sol +362 -0
- package/src/naming/AgentNameUniversalResolver.sol +210 -0
- package/src/naming/PermissionlessSubregistry.sol +98 -0
- package/src/ontology/AttributeStorage.sol +289 -0
- package/src/ontology/OntologyTermRegistry.sol +146 -0
- package/src/ontology/ShapeRegistry.sol +240 -0
- package/src/relationships/AgentRelationship.sol +289 -0
- package/src/relationships/AgentRelationshipPredicates.sol +44 -0
- 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
|
+
}
|