diff --git a/src/Keystore.sol b/src/Keystore.sol index 37719c8..0c15135 100644 --- a/src/Keystore.sol +++ b/src/Keystore.sol @@ -58,10 +58,10 @@ contract Keystore { /// @notice The replay domain a {SignedAccountChanges} batch is bound to. /// - /// @dev Replaces the prior `uint256 chainId` argument. {Local} binds `block.chainid` and carries the full local - /// epoch machinery (see {SignedAccountChanges.sequence}); {Multichain} binds chainId 0 and keeps a plain - /// monotonic counter with no epochs and no unsequenced (JIT) mode. {Lock} and {Unlock} are rejected on the - /// Multichain channel; {IncrementLocalEpoch} is allowed on either channel. + /// @dev {AccountChangeChannel.Local} binds `block.chainid` and carries the full local + /// epoch machinery (see {SignedAccountChanges.sequence}); {AccountChangeChannel.Multichain} binds chainId 0 and keeps a plain + /// monotonic counter with no epochs and no unsequenced (JIT) mode. {ChangeType.Lock} and {ChangeType.Unlock} are rejected on the + /// Multichain channel; {ChangeType.IncrementLocalEpoch} is allowed on either channel. enum AccountChangeChannel { Local, Multichain @@ -161,6 +161,12 @@ contract Keystore { /// @notice Typehash used to structurally hash each AccountChange within a SignedAccountChanges batch. bytes32 public constant ACCOUNT_CHANGE_TYPEHASH = keccak256("AccountChange(uint8 changeType,bytes payload)"); + /// @notice Typehash binding a user signature to its account and chainId. + /// @dev NOT compliant with EIP-712, to mitigate eth_signTypedData phishing, consistent with the other 8130 + /// signed-message typehashes. First byte 0x9d: provably not a transaction encoding. + bytes32 public constant SIGNED_MESSAGE_TYPEHASH = + keccak256("EIP8130SignedMessage(address account,uint256 chainId,bytes32 hash)"); + /// @notice Local-channel sequence low-half sentinel marking an unsequenced (JIT) batch. A {SignedAccountChanges} /// whose low 32 bits equal this value does not consume a sequence, so it stays replayable until the local /// epoch moves. Any op may use it, but Lock and Unlock must remain standalone. Sequenced batches may run up @@ -308,7 +314,9 @@ contract Keystore { error StaleEpoch(); /// @notice A sequenced batch's sequence did not match the account's current (local or multichain) counter. - error BadSequence(); + /// @param expected The account's current channel counter the batch had to match. + /// @param provided The sequence the batch carried. + error BadSequence(uint64 expected, uint64 provided); /// @notice The channel's sequence counter is at its terminal value and cannot advance. error SequenceSaturated(); @@ -384,6 +392,12 @@ contract Keystore { /// EIP-8130 configuration is left behind. error AccountDeploymentFailed(); + /// @notice The signature envelope's leading type byte is not a recognized {SignatureType} value. + error UnknownSignatureType(uint8 sigType); + + /// @notice The signature envelope is empty (missing its leading type byte). + error EmptySignatureEnvelope(); + // ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡ // STORAGE // ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡ @@ -393,7 +407,7 @@ contract Keystore { mapping(bytes32 actorId => mapping(address account => ActorConfig)) internal _actorConfig; /// @notice Per-actor signed policy commitment. Set when the actor's scope carries Scopes.POLICY. - /// @dev Read only during execution (via getPolicyCommitment / getActor), never during signature validity checks. + /// @dev Read only during execution (via getPolicyCommitment / getActorWithPolicy), never during signature validity checks. mapping(bytes32 actorId => mapping(address account => bytes32)) internal _policyCommitment; /// @notice Per-actor policy manager address. Set when the actor's scope carries Scopes.POLICY. @@ -563,42 +577,40 @@ contract Keystore { /// comes entirely from the signature. /// /// @param account The account whose configuration is changed. - /// @param s The signed batch (channel, ordered changes, sequence word, signature). - function applySignedAccountChanges(address account, SignedAccountChanges calldata s) + /// @param batch The signed batch (channel, ordered changes, sequence word, signature). + function applySignedAccountChanges(address account, SignedAccountChanges calldata batch) external nonZeroAccount(account) { AccountState storage st = _accountState[account]; - bool isLocal = s.channel == AccountChangeChannel.Local; + bool isLocal = batch.channel == AccountChangeChannel.Local; // A JIT (unsequenced) batch — local channel with the low sequence half == UNSEQUENCED — is the only replayable // form (it consumes no counter). This gates the AuthorizeActor expiry fail-fast: only a replayable grant needs // its expiry to bound replay. Multichain is never unsequenced, so this short-circuits false there. - bool isUnsequenced = isLocal && uint32(s.sequence) == UNSEQUENCED; + bool isUnsequenced = isLocal && uint32(batch.sequence) == UNSEQUENCED; // Reject an empty batch: a no-op signed change would otherwise consume a sequence (or initialize a fresh // account, below) without altering any configuration. - if (s.changes.length == 0) { + if (batch.changes.length == 0) { revert EmptyChangeSet(); } // Epoch / sequence gate. An unsequenced (JIT) batch exists only on the local channel (low half == // UNSEQUENCED) and does not consume a counter; every other batch consumes its channel's counter. if (isLocal) { - uint32 epoch = uint32(s.sequence >> 32); - uint32 seq = uint32(s.sequence); + uint32 epoch = uint32(batch.sequence >> 32); + uint32 seq = uint32(batch.sequence); if (epoch != st.localEpoch) { revert StaleEpoch(); } if (seq != UNSEQUENCED) { if (seq != st.localSequence) { - revert BadSequence(); + revert BadSequence(st.localSequence, seq); } if (seq >= UNSEQUENCED - 1) { revert SequenceSaturated(); } - // Advance the local sequence before apply. A trailing IncrementLocalEpoch in the same batch - // overwrites this to 0, but that second write lands on an already-warm slot (~100 gas), so the - // combo isn't worth special-casing here. + // Advance the local sequence before apply. st.localSequence = seq + 1; } else if (!_isInitialized(account)) { // Mark a fresh account initialized and invalidate outstanding sequence-0 signatures. The unsequenced @@ -607,9 +619,9 @@ contract Keystore { } } else { // Multichain: a plain monotonic counter, never epoch-bearing or UNSEQUENCED. - uint64 seq = s.sequence; + uint64 seq = batch.sequence; if (seq != st.multichainSequence) { - revert BadSequence(); + revert BadSequence(st.multichainSequence, seq); } if (seq == type(uint64).max) { revert SequenceSaturated(); @@ -619,8 +631,8 @@ contract Keystore { // Authenticate over the digest. Authorization is flat: every signed account change is // admin-only, so a single scope check up front replaces any per-op authorization. - bytes32 digest = _changesDigest(account, s.channel, s.sequence, s.changes); - (, uint16 scope) = authenticateActor(account, digest, s.signature); + bytes32 digest = _changesDigest(account, batch.channel, batch.sequence, batch.changes); + (, uint16 scope) = authenticateActor(account, digest, batch.signature); if (scope != 0) { revert UnauthorizedAccountChange(); } @@ -631,9 +643,9 @@ contract Keystore { // lock a Lock earlier in the same batch just set (or a stale-unlocked one). bool locked = _isLocked(account); - uint256 n = s.changes.length; + uint256 n = batch.changes.length; for (uint256 i; i < n; i++) { - ChangeType t = s.changes[i].changeType; + ChangeType t = batch.changes[i].changeType; // Preconditions: freeze non-exempt ops on a locked account, and hold Lock/Unlock to a standalone local batch. if (locked && t != ChangeType.Unlock && t != ChangeType.IncrementLocalEpoch) { @@ -650,19 +662,18 @@ contract Keystore { // Apply: dispatch the op to its handler. if (t == ChangeType.AuthorizeActor) { - _applyAuthorize(account, s.changes[i].payload, isUnsequenced); + _applyAuthorize(account, batch.changes[i].payload, isUnsequenced); } else if (t == ChangeType.RevokeActor) { - _applyRevoke(account, s.changes[i].payload); + _applyRevoke(account, batch.changes[i].payload); } else if (t == ChangeType.IncrementLocalEpoch) { - _applyIncrementLocalEpoch(account, s.changes[i].payload); + _applyIncrementLocalEpoch(account, batch.changes[i].payload); } else if (t == ChangeType.Lock) { - _applyLock(account, s.changes[i].payload); + _applyLock(account, batch.changes[i].payload); } else if (t == ChangeType.Unlock) { - _applyUnlock(account, s.changes[i].payload); + _applyUnlock(account, batch.changes[i].payload); } else { - // Defensive guard: every ChangeType must be dispatched explicitly. Unreachable today — out-of-range - // wire values are rejected by the enum decoder while ABI-decoding the calldata — so this forces any - // future ChangeType to be wired in here rather than silently falling through. + // Unreachable at runtime (the enum decoder rejects out-of-range values). Kept to force any future + // ChangeType to be dispatched here rather than silently no-op'ing. revert UnknownChangeType(); } } @@ -672,24 +683,23 @@ contract Keystore { // SIGNED-CHANGE OP HANDLERS // ---------------------------------------------------------------------------------------------------------------- - /// @dev AuthorizeActor. `payload = abi.encode(bytes32 actorId, ActorConfig cfg, bytes policyData)`; `cfg.expiry` - /// is the granted expiry (or never, if `cfg.expiry == 0`). A plain upsert. An already-expired grant never - /// reverts — whether a signed change applies never depends on the granted expiry vs. onchain time — but it is - /// handled by sequencing: + /// @dev AuthorizeActor. `payload = abi.encode(bytes32 actorId, ActorConfig cfg, bytes policyData)`. Normally a plain + /// upsert: write `cfg`/`policyData` into the actor's slot, granting authority until `cfg.expiry` (`cfg.expiry + /// == 0` = no expiry). An expired grant never reverts the batch — expiry vs. onchain time is never an + /// acceptance check — but it does change what the write does, per channel: /// - /// - Unsequenced (JIT, `isUnsequenced`): SKIPPED (not applied). A JIT grant consumes no counter and is - /// replayable, so skipping a lapsed one keeps it from ever clobbering its slot — a renewed lease cannot be - /// overwritten by replaying the old, expired one. Skip is per-change, so live siblings in the same batch - /// still apply (durable/dependent ops belong on the sequenced channel). - /// - Sequenced (local or multichain): installed INERT — the actor is dead on arrival ({_isExpired} yields - /// ActorExpired at authentication) so it grants no authority, but the slot is written and the sequence is - /// consumed. The write keeps a replayed history consistent (a later RevokeActor still finds the slot) and, - /// on multichain, lets a chain catching up replay a historical expiring grant (e.g. a yearly-renewed - /// operator) to reach the current live grant, rather than stranding its counter behind the expired slot. + /// For an already-expired grant (non-zero `cfg.expiry <= block.timestamp`): + /// - Unsequenced (JIT, `isUnsequenced`): SKIPPED (no write). A JIT batch consumes no counter and stays + /// replayable, so writing a lapsed grant would let an old replay clobber a since-renewed lease. Skip is + /// per-change, so live siblings in the same batch still apply. + /// - Sequenced (local or multichain): written anyway but INERT — it authenticates as ActorExpired ({_isExpired}) + /// so it grants no authority. Sequenced batches are single-consume (not replayable), so there is no clobber + /// risk; the write is still made so replayed history stays consistent (a later RevokeActor finds the slot) and + /// a catching-up chain can step its counter through historical expiring grants to reach the live one. /// - /// A JIT grant is otherwise last-write-wins on its slot until the epoch is incremented; durable reduction - /// (revoke, shorter expiry, narrower scope) is a wallet responsibility — batch the reducing op with - /// {IncrementLocalEpoch} to retire outstanding grants. + /// A JIT grant is last-write-wins on its slot until the epoch is incremented; durable reduction (revoke, shorter + /// expiry, narrower scope) is a wallet responsibility — batch it with {IncrementLocalEpoch} to retire outstanding + /// grants. function _applyAuthorize(address account, bytes calldata payload, bool isUnsequenced) private { (bytes32 actorId, ActorConfig memory cfg, bytes memory policyData) = abi.decode(payload, (bytes32, ActorConfig, bytes)); @@ -775,12 +785,6 @@ contract Keystore { // VIEW FUNCTIONS // ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡ - /// @notice Typehash binding a user signature to its account and chainId. - /// @dev NOT compliant with EIP-712, to mitigate eth_signTypedData phishing, consistent with the other 8130 - /// signed-message typehashes. First byte 0x9d: provably not a transaction encoding. - bytes32 public constant SIGNED_MESSAGE_TYPEHASH = - keccak256("EIP8130SignedMessage(address account,uint256 chainId,bytes32 hash)"); - /// @notice The chain-scoping channel a signature envelope's leading type byte selects. /// /// @dev Read from the envelope's leading byte (uint8), not ABI-decoded: Local = 0x01 (block.chainid), @@ -789,24 +793,17 @@ contract Keystore { /// out-of-range byte with {UnknownSignatureType}. /// /// Multichain binds chainId = 0, so the signature is replayable on EVERY chain; there is no strict - /// per-chain-list channel. This is intentional and mirrors the applySignedActorChanges all-chains channel. - /// A signer who wants single-chain binding uses Local; scoping to an arbitrary subset of chains is a broader - /// protocol change (a chainId-list binding across all signed-message typehashes) deliberately left out here. + /// per-chain-list channel. This is intentional and mirrors the applySignedAccountChanges all-chains channel. + /// A signer who wants single-chain binding uses Local; scoping to an arbitrary subset of chains is unsupported. enum SignatureType { Invalid, Local, Multichain } - /// @notice The signature envelope's leading type byte is not a recognized {SignatureType} value. - error UnknownSignatureType(uint8 sigType); - - /// @notice The signature envelope is empty (missing its leading type byte). - error EmptySignatureEnvelope(); - /// @notice Envelope digest to sign for `hash` to be accepted for `account` on `chainId`. /// @dev Pass `block.chainid` for a chain-local signature ({SignatureType.Local}) or `0` for an all-chains signature - /// ({SignatureType.Multichain}, mirroring the applySignedActorChanges multichain channel). + /// ({SignatureType.Multichain}, mirroring the applySignedAccountChanges multichain channel). /// @param account Account the signature is bound to. /// @param chainId Chain the signature is bound to (0 = all chains). /// @param hash Raw message digest. @@ -935,7 +932,7 @@ contract Keystore { /// @notice Returns an actor's config, policy manager, and policy commitment in one read. The manager and /// commitment are non-zero only for a live actor with scope & Scopes.POLICY set; a non-live actor /// returns the empty config and a zero manager/commitment. - function getActor(address account, bytes32 actorId) + function getActorWithPolicy(address account, bytes32 actorId) external view returns (ActorConfig memory config, address policyManager, bytes32 policyCommitment) @@ -948,7 +945,7 @@ contract Keystore { } } - /// @dev The single liveness resolver behind every read surface ({getActorConfig}, {getActor}, + /// @dev The single liveness resolver behind every read surface ({getActorConfig}, {getActorWithPolicy}, /// {getPolicyCommitment}, {getPolicyManager}). A populated _actorConfig entry returns verbatim unless expired; /// the k1 self (inline in AccountState) resolves to a native ecrecover owner unless disabled or expired; /// anything unknown/revoked/disabled/expired resolves to the all-zero (empty) config. Centralizing this is @@ -1071,7 +1068,7 @@ contract Keystore { // INTERNAL FUNCTIONS // ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡ - /// @dev Returns whether the account's configuration is currently frozen without mutating storage. + /// @dev Returns whether the account's configuration is currently frozen. /// @dev An elapsed unlock reads as unlocked; a later {_applyLock} overwrites the stale lock fields. function _isLocked(address account) private view returns (bool) { AccountState storage st = _accountState[account]; @@ -1143,7 +1140,7 @@ contract Keystore { // Only reject the zero authenticator (the empty-slot sentinel). A non-zero authenticator with no code is // accepted deliberately: authenticators may be counterfactual (deployed later) and some are intentionally // codeless sentinels (e.g. EXTERNAL_POLICY_AUTHENTICATOR). A bad authenticator simply fails fail-closed at - // authentication time, mirroring the reference PolicyManager's treatment of a zero-commitment policy actor. + // authentication time. if (config.authenticator < K1_AUTHENTICATOR) { revert InvalidAuthenticator(); } diff --git a/src/accounts/CanonicalHighRatePayerAccount.sol b/src/accounts/CanonicalHighRatePayerAccount.sol index 18b5008..018239b 100644 --- a/src/accounts/CanonicalHighRatePayerAccount.sol +++ b/src/accounts/CanonicalHighRatePayerAccount.sol @@ -42,6 +42,7 @@ contract CanonicalHighRatePayerAccount is DefaultAccount { /// @notice Executes a single call from the account, blocking an outbound value transfer while locked. /// + /// @dev Equivalent to a one-element {executeBatch}. Included for selector-compatibility with common existing wallet implementations. /// @dev Reverts with UnauthorizedCaller when the caller is neither the account nor a TRUSTED_EXECUTOR actor. /// @dev Reverts with AccountLocked when the call carries non-zero value and the account is locked. /// @dev Bubbles up the inner call's revert reason verbatim (a reason-less revert propagates as an empty revert). diff --git a/src/accounts/DefaultAccount.sol b/src/accounts/DefaultAccount.sol index 4e891b6..a4e98eb 100644 --- a/src/accounts/DefaultAccount.sol +++ b/src/accounts/DefaultAccount.sol @@ -38,8 +38,7 @@ address constant TRUSTED_EXECUTOR = address(uint160(uint256(keccak256("trustedEx /// example ERC-4337 on a chain without native EIP-8130 support) should delegate or deploy to a purpose-built /// account, not to this one. If this bytecode is deployed anyway, it MUST sit behind an upgradeable (UUPS) /// proxy: adopting those features later means swapping in different bytecode, which is only possible if the -/// deployment is upgradeable. An upgradeable (UUPS) variant is provided as an unaudited example in a separate -/// repository. +/// deployment is upgradeable. /// /// @author Coinbase contract DefaultAccount is Receiver { @@ -82,9 +81,7 @@ contract DefaultAccount is Receiver { /// @notice Executes a single call from the account. /// - /// @dev Equivalent to a one-element {executeBatch}. Selector-compatible with the widely deployed - /// CoinbaseSmartWallet V1 `execute(address,uint256,bytes)` (0xb61d27f6), so integrations that call that ABI - /// directly (e.g. SpendPermissionManager) keep working against this account. + /// @dev Equivalent to a one-element {executeBatch}. Included for selector-compatibility with common existing wallet implementations. /// @dev Reverts with UnauthorizedCaller when the caller is neither the account nor a TRUSTED_EXECUTOR actor. /// @dev Bubbles up the inner call's revert reason verbatim (a reason-less revert propagates as an empty revert). /// diff --git a/src/interfaces/ITransactionContext.sol b/src/interfaces/ITransactionContext.sol index 08ec6b6..85ff91f 100644 --- a/src/interfaces/ITransactionContext.sol +++ b/src/interfaces/ITransactionContext.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; /// @dev Canonical address of the EIP-8130 Transaction Context precompile. Populated by the protocol only while it /// is dispatching a transaction's calls; STATICCALL returns zero/default values elsewhere (e.g. during /// validation, or on non-8130 chains where no code lives here). -// 8310 spec puts this at 0x813000000000000000000000000000000000aa02 +// 8130 spec puts this at 0x813000000000000000000000000000000000aa02 address constant TX_CONTEXT_ADDRESS = 0x813000000000000000000000000000000000aa02; /// @notice Reference interface for the EIP-8130 Transaction Context precompile at TX_CONTEXT_ADDRESS. diff --git a/src/policies/Policy.sol b/src/policies/Policy.sol index 390e4f0..ef65382 100644 --- a/src/policies/Policy.sol +++ b/src/policies/Policy.sol @@ -7,22 +7,11 @@ import {PolicyManager} from "./PolicyManager.sol"; /// /// @notice Minimal base hook interface for policies coordinated by {PolicyManager}. /// -/// @dev A policy turns an account-authorized *commitment* (bound to `policyConfig` via the signed actor change) -/// plus a per-use *action* (`executionData`) into an account call plan. The plan is ABI-encoded calldata that -/// the manager forwards to the account (e.g. `executeBatch`). -/// -/// Config delivery: the manager always forwards `policyConfig` to {onExecute} (it MAY be empty). Callers -/// supply the full {PolicyManager.PolicyBinding} at execute; the manager recomputes the commitment and -/// compares it to the live signed commitment in Keystore. That single check authenticates -/// config, validity window, and owning account — so policies MUST NOT store a config hash. There is no -/// separate install step; config validation belongs in {onExecute} (or a helper it calls). -/// -/// Mutable execution state (e.g. spend counters) is the exception that belongs in storage, keyed by -/// commitment. -/// -/// This is the example/reference shape for an EIP-8130 actor policy (`scope & Scopes.POLICY != 0`): the -/// manager is the single call target a restricted actor may reach, and policies express *what* that actor -/// may do. All hooks are callable only by the configured {PolicyManager}. +/// @dev A policy turns an account-authorized commitment plus a per-use action (`executionData`) into an +/// ABI-encoded account call plan that the manager forwards to the account. The manager verifies the +/// supplied `policyConfig` recomputes to the actor's signed commitment before calling {onExecute}, so a +/// policy MUST validate config in {onExecute} and MUST NOT store a config hash. Store only mutable execution +/// state (e.g. spend counters), keyed by commitment. All hooks are callable only by {POLICY_MANAGER}. abstract contract Policy { /// @notice The {PolicyManager} instance authorized to call this policy's hooks. PolicyManager public immutable POLICY_MANAGER; @@ -42,9 +31,10 @@ abstract contract Policy { /// @notice Authorize the execution and build the account call and optional post-call. /// - /// @dev MUST revert to refuse execution. `policyConfig` is the binding preimage; the manager has already - /// verified it recomputes to the actor's signed commitment. If `accountCallData` is empty, the manager - /// treats the call as a no-op (no account call, no {onPostExecute}, no event). + /// @dev Reverts with InvalidCaller when the caller is not {POLICY_MANAGER}. + /// @dev Implementations MUST revert to refuse execution. `policyConfig` is the binding preimage, already + /// verified by the manager to recompute to the actor's signed commitment. An empty `accountCallData` + /// return is treated as a no-op (no account call, no {onPostExecute}, no event). /// /// @param commitment Identifier of the authorized binding. /// @param account Account the plan will execute against. @@ -66,7 +56,8 @@ abstract contract Policy { /// @notice Hook invoked by the manager after a non-empty account call. /// - /// @dev Called whenever {onExecute} returns non-empty `accountCallData`. `postCallData` may be empty; + /// @dev Reverts with InvalidCaller when the caller is not {POLICY_MANAGER}. + /// @dev Invoked whenever {onExecute} returns non-empty `accountCallData`; `postCallData` may be empty, so /// implementations must handle that case. Default is a no-op. /// /// @param commitment Identifier of the authorized binding. diff --git a/src/policies/PolicyManager.sol b/src/policies/PolicyManager.sol index cec6f66..acb4030 100644 --- a/src/policies/PolicyManager.sol +++ b/src/policies/PolicyManager.sol @@ -9,16 +9,9 @@ import {ITransactionContext, TX_CONTEXT_ADDRESS} from "../interfaces/ITransactio import {ActorId} from "../libraries/ActorId.sol"; import {Policy} from "./Policy.sol"; -/// @dev Required `authenticator` for an actor that represents an *external caller* governed by a policy (e.g. a -/// subscription provider): an address that may act ONLY through its policy manager's external entrypoints, never -/// directly. Distinct from `TRUSTED_EXECUTOR` (which grants direct `executeBatch`): this is a -/// no-code, hash-derived sentinel, so the actor is recognized by Keystore (non-zero authenticator) -/// yet cannot drive the account directly and cannot authenticate an 8130 transaction (its `authenticate()` would -/// call into empty code and fail). `executeFor` / `executeForMany` require the acting actor's stored -/// authenticator to equal this sentinel: that restricts the external path to actors the account explicitly -/// provisioned as external-pull, so a native signing key gated to the same manager cannot be driven through the -/// auth-less external path. Authorization then also requires `actorId == ActorId.fromAddress(msg.sender)`, -/// `policy_manager == this`, and a matching binding commitment. +/// @dev Sentinel `authenticator` marking an actor as a policy-only external-pull caller (e.g. a subscription +/// provider): recognized by Keystore but unable to drive the account directly or authenticate an 8130 +/// transaction. Required by {PolicyManager.executeFor} and {PolicyManager.executeForMany}. address constant EXTERNAL_POLICY_AUTHENTICATOR = address(uint160(uint256(keccak256("externalPolicyCaller")))); /// @title PolicyManager @@ -28,45 +21,23 @@ address constant EXTERNAL_POLICY_AUTHENTICATOR = address(uint160(uint256(keccak2 /// precompile, so it is only usable on EIP-8130 chains; the external-caller {executeFor} / {executeForMany} /// paths derive identity from `msg.sender` and work on any chain. /// -/// @dev Role in the EIP-8130 flow: -/// - The manager is registered as an execution-enabled actor on the account (an actor whose authenticator is -/// `TRUSTED_EXECUTOR`), so it may drive the account via `executeBatch`. -/// - A restricted session-key actor is configured with `scope & Scopes.POLICY != 0` and `policy_manager = -/// address(this)`, so the protocol gate forces every call that actor makes to land on this manager. -/// - When the session key transacts, the protocol dispatches its call *as the account*, so `msg.sender` -/// here is the account itself. That is the authorization boundary: only a gated session-key transaction -/// (routed through the account) can invoke {execute}. +/// @dev The account registers this manager as an execution-enabled actor and gates a restricted session key with +/// `scope & Scopes.POLICY != 0` and `policy_manager = address(this)`, so the protocol routes that key's calls +/// here. Authorization is the account's signed commitment: the keccak256 of a {PolicyBinding}, stored in +/// Keystore. Every entrypoint recomputes the commitment from the supplied binding and requires it to match, so +/// config, validity window, and owning account are authenticated in one check with no config stored here. /// -/// Acting models and entrypoints: -/// - {execute}: the *account itself* acts — a policy-gated session key that the EIP-8130 protocol dispatches as -/// the account. The acting identity is read from the transaction-context precompile and `account == msg.sender`. -/// - {executeFor} / {executeForMany}: an *external caller* acts on behalf of one or more accounts that authorized -/// it (e.g. a subscription provider pulling from many accounts in one transaction). Identity is the caller -/// itself (`actorId == ActorId.fromAddress(msg.sender)`) and `account` comes from the supplied binding. -/// -/// Commitment binding: the account authorizes a {PolicyBinding}; its `keccak256` is the `commitment`. When the -/// account authorizes the session-key actor it stores `scope & Scopes.POLICY != 0`, `policy_manager = -/// address(this)`, and `policy_commitment = commitment` in Keystore. That signed actor change *is* -/// the authorization — there is no separate install step and no manager-side install bit. At every execute path -/// the manager recomputes the commitment from the supplied binding and requires it to equal the live signed -/// commitment — authenticating config, validity window, and owning account with zero config storage on the -/// manager or the policy. -/// -/// Shared {ReentrancyGuard}: beyond ordinary re-entrancy hygiene, a single status across {execute} / -/// {executeFor} / {executeForMany} is load-bearing for cross-account identity. The tx-context `actorId` is -/// global to the transaction; if the same session key is registered on two accounts (same derived actorId, -/// both gated here), a policy-approved call from account A's plan could land on a target that reenters -/// {execute} with that target's binding and would otherwise resolve identity. Do not "optimize" the -/// entrypoints onto separate reentrancy guards. -/// -/// Scope: account-acting {execute} (transaction-context precompile) and external-caller {executeFor} / -/// {executeForMany}. +/// All entrypoints deliberately share one {ReentrancyGuard} status; do not split it per entrypoint. The +/// transaction-context actorId is global to the transaction, so a shared guard is what stops a reentrant +/// {execute} from resolving a different account's identity mid-call. contract PolicyManager is ReentrancyGuard { using Address for address; /// @notice The EIP-8130 Keystore system contract used to resolve signed policy commitments. Keystore public immutable KEYSTORE; + /// @notice Deploys the manager bound to a Keystore instance. + /// @param keystore Address of the EIP-8130 Keystore system contract. constructor(address keystore) { KEYSTORE = Keystore(keystore); } @@ -87,35 +58,51 @@ contract PolicyManager is ReentrancyGuard { uint256 salt; } + /// @notice Emitted for each successful policy execution (a non-empty account call). + /// + /// @param account Account the plan executed against. + /// @param policy Policy contract that produced the plan. + /// @param commitment Binding commitment that authorized the execution. + /// @param caller Address that drove the execution (the account for {execute}, the external caller otherwise). event PolicyExecuted(address indexed account, address indexed policy, bytes32 indexed commitment, address caller); - /// @dev Emitted by {executeForMany} when one account in a best-effort batch is skipped because its per-account - /// enforcement reverted (e.g. revoked/expired binding, over budget, or a failing account call). + + /// @notice Emitted by {executeForMany} when one account is skipped because its per-account enforcement reverted + /// (e.g. revoked/expired binding, over budget, or a failing account call). + /// + /// @param account Account whose entry was skipped. + /// @param policy Policy contract for the skipped binding. + /// @param actorId Acting external caller's actorId. event ExecutionSkipped(address indexed account, address indexed policy, bytes32 indexed actorId); /// @notice The current time is outside the binding's `[validAfter, validUntil)` execution window. error OutsideValidityWindow(uint40 validAfter, uint40 validUntil, uint256 timestamp); + /// @notice The supplied binding does not recompute to the actor's live signed commitment. error BindingCommitmentMismatch(bytes32 expected, bytes32 actual); + /// @notice {execute} requires `binding.account == msg.sender` (the protocol-dispatched account). error InvalidBindingAccount(address expected, address actual); - /// @notice The acting actor is a live actor of the account but has no policy binding that routes here — either it - /// is not policy-gated, or (on the external path) the account did not gate this manager for it, or its - /// signed commitment is zero. + + /// @notice The acting actor has no policy binding that routes here: it is not policy-gated, or (on the external + /// path) the account did not gate this manager for it, or its signed commitment is zero. error NoActivePolicy(bytes32 actorId); - /// @notice The acting actor is not a live external-pull actor of the account: it is unknown, revoked, or expired, - /// or its stored authenticator is not {EXTERNAL_POLICY_AUTHENTICATOR}. Enforced on the external path - /// ({executeFor} / {executeForMany}), which has no protocol auth to reject it first. + + /// @notice The acting actor is not a live external-pull actor of the account (unknown, revoked, expired, or its + /// authenticator is not {EXTERNAL_POLICY_AUTHENTICATOR}). Enforced on the external path, which has no + /// protocol auth to reject it first. error InvalidActor(bytes32 actorId); - /// @notice {executeForMany} array length mismatch between `bindings` and `executionData`. + + /// @notice {executeForMany} was given `bindings` and `executionData` of differing lengths. error LengthMismatch(); - /// @notice The per-account self-call boundary used by {executeForMany} was invoked by someone other than this - /// contract. + + /// @notice The {executeForMany} self-call boundary was invoked by someone other than this contract. error OnlySelf(); /// @notice Computes the commitment (binding identifier) for a binding. /// - /// @dev This is the account-authorized, opaque commitment: `keccak256` over the binding fields, with the - /// config bound by its own hash. Portable by construction (no chain/domain mixed in). + /// @param binding Full policy binding to hash. + /// + /// @return The binding commitment: keccak256 over the binding fields, with the config bound by its own hash. function commitmentOf(PolicyBinding calldata binding) public pure returns (bytes32) { return _commitment(binding); } @@ -123,21 +110,16 @@ contract PolicyManager is ReentrancyGuard { /// @notice Exercises a policy authorized by the account's signed commitment and forwards the resulting call plan /// to the account. /// - /// @dev Identity comes from the protocol, not the caller. Reaching this function as a dispatched call proves this - /// manager is the acting key's configured gate. The manager reads the acting `actorId` from the - /// transaction-context precompile and takes the account from `msg.sender`. The caller supplies the full - /// {PolicyBinding}; recomputing its commitment and comparing to the live signed commitment authenticates - /// config, validity window, and owning account in one check. + /// @dev Reverts with InvalidBindingAccount when `binding.account != msg.sender`. + /// @dev Reverts with NoActivePolicy when the acting actor has no signed policy commitment. + /// @dev Reverts with BindingCommitmentMismatch when `binding` does not recompute to the signed commitment. + /// @dev Reverts with OutsideValidityWindow when the current time is outside `[validAfter, validUntil)`. + /// @dev Bubbles up the policy or account-call revert reason when the forwarded call reverts. + /// @dev Account-acting path only: identity is read from the transaction-context precompile and the account is + /// `msg.sender`, so it is usable only on EIP-8130 chains. Actor liveness is enforced by the protocol before + /// dispatch and not re-checked here. /// - /// Actor liveness is not re-checked here: protocol authentication already rejects expired (or otherwise - /// non-live) actors before dispatch. `Keystore._authenticate` reverts `ActorExpired`, so a protocol-dispatched - /// call's sender actor was liveness-checked at authentication in the same transaction (same `block.timestamp`, - /// so no gap), and any non-dispatched call yields `actorId == 0` → {NoActivePolicy}. This trades - /// defense-in-depth for one SLOAD; soundness rests on the spec-level invariant that every conforming - /// implementation enforces expiry at authentication. {executeFor} retains a local liveness check - /// ({InvalidActor}) because it has no protocol auth. - /// - /// @param binding Full account-authorized binding (config + window + salt). + /// @param binding Full account-authorized binding (config, window, salt). /// @param executionData Per-use action parameters interpreted by the policy. function execute(PolicyBinding calldata binding, bytes calldata executionData) external nonReentrant { address account = msg.sender; @@ -153,16 +135,18 @@ contract PolicyManager is ReentrancyGuard { _enforce(binding, commitment, executionData, account); } - /// @notice External-caller variant of {execute}: an external party drives a policy that an account authorized - /// for it. Used when the actor is not a key *on* the account but a separate party (e.g. a subscription - /// provider) the account opted into. + /// @notice External-caller variant of {execute}: an external party drives a policy an account authorized for it + /// (e.g. a subscription provider), rather than a key on the account. /// - /// @dev The acting identity is the caller itself — `actorId == ActorId.fromAddress(msg.sender)` — and `account` is - /// `binding.account`. There is no protocol routing on this path, so unlike {execute} this re-verifies that - /// the caller is a live external-pull actor (authenticator == {EXTERNAL_POLICY_AUTHENTICATOR}) and that the - /// account gated *this* manager for it. + /// @dev Reverts with InvalidActor when the caller is not a live external-pull actor of `binding.account`. + /// @dev Reverts with NoActivePolicy when the account has not gated this manager for the caller. + /// @dev Reverts with BindingCommitmentMismatch when `binding` does not recompute to the signed commitment. + /// @dev Reverts with OutsideValidityWindow when the current time is outside `[validAfter, validUntil)`. + /// @dev Bubbles up the policy or account-call revert reason when the forwarded call reverts. + /// @dev Identity is the caller (`ActorId.fromAddress(msg.sender)`); works on any chain. Unlike {execute}, it + /// re-verifies the actor and manager binding since there is no protocol routing. /// - /// @param binding Full account-authorized binding (config + window + salt). + /// @param binding Full account-authorized binding (config, window, salt). /// @param executionData Per-use action parameters interpreted by the policy. function executeFor(PolicyBinding calldata binding, bytes calldata executionData) external nonReentrant { _enforceExternal(binding, ActorId.fromAddress(msg.sender), executionData, msg.sender); @@ -170,8 +154,9 @@ contract PolicyManager is ReentrancyGuard { /// @notice Best-effort cross-account batch of {executeFor}: one external caller, many bindings, one transaction. /// - /// @dev Each binding is enforced in its own self-call so a single failure is isolated and skipped. Each entry - /// carries its own binding (different `account` → different commitment). Failures emit {ExecutionSkipped}. + /// @dev Reverts with LengthMismatch when `bindings` and `executionData` differ in length. + /// @dev Each binding is enforced in its own self-call; a per-account failure is isolated, skipped, and reported + /// via {ExecutionSkipped} rather than reverting the batch. /// /// @param bindings Per-account bindings, parallel to `executionData`. /// @param executionData Per-account action parameters, parallel to `bindings`. @@ -196,6 +181,15 @@ contract PolicyManager is ReentrancyGuard { } /// @notice Self-call boundary used by {executeForMany} for per-account revert isolation. Not for external use. + /// + /// @dev Reverts with OnlySelf when the caller is not this contract. + /// @dev Reverts via {_enforceExternal} with InvalidActor, NoActivePolicy, BindingCommitmentMismatch, or + /// OutsideValidityWindow, or bubbles the forwarded call's revert. + /// + /// @param binding Full account-authorized binding (config, window, salt). + /// @param actorId Acting external caller's actorId. + /// @param executionData Per-use action parameters interpreted by the policy. + /// @param caller External caller forwarded to the policy hooks. function enforceExternalSelf( PolicyBinding calldata binding, bytes32 actorId, @@ -206,11 +200,10 @@ contract PolicyManager is ReentrancyGuard { _enforceExternal(binding, actorId, executionData, caller); } - /// @dev External-path validation: live external-pull actor, manager-match, live commitment vs binding, then - /// enforce. The external path has no protocol auth, so the manager gates the caller itself with a single - /// liveness-resolved {Keystore.getActor} read: the actor must carry EXTERNAL_POLICY_AUTHENTICATOR (the - /// no-code sentinel marking an external-pull actor) and be gated to this manager. A non-live or ungated - /// actor resolves to a zero manager, failing the manager-match. + /// @dev External-path validation then {_enforce}. Reverts with InvalidActor when the caller's stored + /// authenticator is not EXTERNAL_POLICY_AUTHENTICATOR, NoActivePolicy when this manager is not gated for the + /// caller or the signed commitment is zero, or BindingCommitmentMismatch when `binding` does not recompute + /// to the signed commitment. function _enforceExternal( PolicyBinding calldata binding, bytes32 actorId, @@ -219,7 +212,8 @@ contract PolicyManager is ReentrancyGuard { ) internal { address account = binding.account; - (Keystore.ActorConfig memory config, address manager, bytes32 signed) = KEYSTORE.getActor(account, actorId); + (Keystore.ActorConfig memory config, address manager, bytes32 signed) = + KEYSTORE.getActorWithPolicy(account, actorId); if (config.authenticator != EXTERNAL_POLICY_AUTHENTICATOR) revert InvalidActor(actorId); if (manager != address(this)) revert NoActivePolicy(actorId); @@ -230,8 +224,9 @@ contract PolicyManager is ReentrancyGuard { _enforce(binding, commitment, executionData, caller); } - /// @dev Common enforcement: enforce the binding's validity window (authenticated by the commitment check at the - /// callsite), run the policy hooks, forward the account call, then post-execute. + /// @dev Enforces the binding's validity window, runs the policy hooks, forwards the account call, then + /// post-executes. Reverts with OutsideValidityWindow outside `[validAfter, validUntil)`; bubbles the policy + /// or account-call revert. Emits {PolicyExecuted} on a non-empty account call. function _enforce(PolicyBinding calldata binding, bytes32 commitment, bytes calldata executionData, address caller) internal { @@ -252,10 +247,9 @@ contract PolicyManager is ReentrancyGuard { emit PolicyExecuted(account, binding.policy, commitment, caller); } - /// @dev Reads the authenticated actor of the in-flight EIP-8130 transaction from the transaction-context - /// precompile. Outside a protocol-dispatched call (and on any chain without the precompile) the STATICCALL - /// yields no data and this returns bytes32(0); the manager has no other identity source, so such calls fail - /// as {NoActivePolicy}. Accepts the return only when it is exactly 32 bytes. + /// @dev Reads the in-flight transaction's authenticated actorId from the transaction-context precompile. + /// Returns bytes32(0) when not protocol-dispatched or the precompile is absent (accepts only a 32-byte + /// return). function _actingActorId() internal view returns (bytes32 actorId) { (bool ok, bytes memory ret) = TX_CONTEXT_ADDRESS.staticcall( abi.encodeWithSelector(ITransactionContext.getTransactionSenderActorId.selector) @@ -263,6 +257,7 @@ contract PolicyManager is ReentrancyGuard { if (ok && ret.length == 32) actorId = abi.decode(ret, (bytes32)); } + /// @dev Computes the binding commitment: keccak256 over the binding fields, with the config bound by its hash. function _commitment(PolicyBinding calldata binding) internal pure returns (bytes32) { return keccak256( abi.encode( diff --git a/src/policies/README.md b/src/policies/README.md index f59182e..ddc45cd 100644 --- a/src/policies/README.md +++ b/src/policies/README.md @@ -15,7 +15,7 @@ protocol-side, not enforced by this contract. 1. **Authorize + commit.** The account authorizes the session key with `scope = Scopes.POLICY`, `policy_manager = PolicyManager`, and `policy_commitment = keccak256` of an account-authorized [`PolicyBinding`](./PolicyManager.sol). The Keystore contract exposes this via - [`getActor(account, actorId)`](../Keystore.sol) (or the granular `getPolicyManager` / `getPolicyCommitment`). + [`getActorWithPolicy(account, actorId)`](../Keystore.sol) (or the granular `getPolicyManager` / `getPolicyCommitment`). That signed actor change *is* the authorization — there is no separate install step on the manager. 2. **Use.** When the session key transacts, the protocol gate resolves the key's allowed target (`policy_manager(account, actorId)`) and reverts any call whose `call.to` isn't that address before dispatch, so @@ -28,7 +28,7 @@ protocol-side, not enforced by this contract. revoked *or expired* key reads back a zero commitment and stops immediately: `getPolicyCommitment` (like every Keystore read accessor) is liveness-gated and resolves an expired actor to zero, identical to a revoked one. `execute` doesn't rely on this — protocol authentication already rejects expired actors before dispatch (the - external `executeFor` path enforces expiry itself, via a single `getActor` read) — but the gating means no off-chain + external `executeFor` path enforces expiry itself, via a single `getActorWithPolicy` read) — but the gating means no off-chain reader ever sees a live-looking commitment for a dead actor. The manager then invokes the policy, forwards a non-empty `executeBatch` plan to the account, and calls `onPostExecute` when applicable. diff --git a/src/policies/RecurringAllowance.sol b/src/policies/RecurringAllowance.sol index 001e480..d1f3ac4 100644 --- a/src/policies/RecurringAllowance.sol +++ b/src/policies/RecurringAllowance.sol @@ -5,8 +5,7 @@ pragma solidity ^0.8.30; /// /// @notice Reusable recurring-allowance accounting for policies (weekly/periodic spend limits). /// -/// @dev Keyed by `commitment` so the manager can remain fully stateless. Ported from -/// base/account-policies for the EIP-8130 reference example. +/// @dev Keyed by `commitment` so the manager can remain fully stateless. library RecurringAllowance { /// @notice Allowance bounds for a recurring spend window. struct Limit { @@ -59,12 +58,20 @@ library RecurringAllowance { /// @notice Validates and consumes allowance for `value`, updating stored usage for the current period. /// - /// @param state Allowance state storage. - /// @param commitment Binding identifier. - /// @param limit Allowance bounds. + /// @dev Reverts with ZeroValue when `value` is zero. + /// @dev Reverts with ZeroPeriod when `limit.period` is zero. + /// @dev Reverts with ZeroAllowance when `limit.allowance` is zero. + /// @dev Reverts with InvalidStartEnd when `limit.start >= limit.end`. + /// @dev Reverts with BeforeStart when the current timestamp is before `limit.start`. + /// @dev Reverts with AfterEnd when the current timestamp is at or past `limit.end`. + /// @dev Reverts with ExceededAllowance when cumulative period spend would exceed `limit.allowance`. + /// + /// @param state Allowance usage storage. + /// @param commitment Binding identifier the usage is keyed by. + /// @param limit Allowance bounds for the spend window. /// @param value Amount to spend. /// - /// @return current Updated current-period usage. + /// @return current Updated current-period usage after consuming `value`. function useLimit(State storage state, bytes32 commitment, Limit memory limit, uint256 value) internal returns (PeriodUsage memory current) @@ -82,16 +89,24 @@ library RecurringAllowance { state.lastUpdated[commitment] = current; } - /// @notice Return the most recent stored usage window for `commitment`. + /// @notice Returns the most recent stored usage window for `commitment`. + /// + /// @param state Allowance usage storage. + /// @param commitment Binding identifier the usage is keyed by. + /// + /// @return The most recent stored period usage (zeroed if none recorded). function getLastUpdated(State storage state, bytes32 commitment) internal view returns (PeriodUsage memory) { return state.lastUpdated[commitment]; } - /// @notice Compute the current period window and include stored spend if still active. + /// @notice Computes the current period window, including stored spend if the window is still active. + /// + /// @dev Reverts with BeforeStart when the current timestamp is before `limit.start`. + /// @dev Reverts with AfterEnd when the current timestamp is at or past `limit.end`. /// - /// @param state Allowance state storage. - /// @param commitment Binding identifier. - /// @param limit Allowance bounds. + /// @param state Allowance usage storage. + /// @param commitment Binding identifier the usage is keyed by. + /// @param limit Allowance bounds for the spend window. /// /// @return Current period usage snapshot (including stored spend if still active). function getCurrentPeriod(State storage state, bytes32 commitment, Limit memory limit) diff --git a/src/policies/SessionPolicy.sol b/src/policies/SessionPolicy.sol index 089c093..d3b0b6c 100644 --- a/src/policies/SessionPolicy.sol +++ b/src/policies/SessionPolicy.sol @@ -13,49 +13,19 @@ import {RecurringAllowance} from "./RecurringAllowance.sol"; /// check: a call-target allowlist, per-target function-selector rules, optional per-selector recipient /// allowlists, and per-token (and native-ETH) recurring/one-time spend limits. /// -/// @dev A single policy (rather than several composed) because {PolicyManager} validates one (policy, commitment) -/// per call; bundling every dimension here lets them all gate the same call atomically. +/// @dev Config is never stored: {onExecute} receives the config preimage via calldata, authenticated by the +/// manager's binding-commitment check, and {_validateConfig} re-checks its shape at execute. The only storage +/// is {_usage} (spend accounting). /// -/// Config model: no config storage. Every {onExecute} receives the config preimage via calldata; -/// {PolicyManager} authenticates it by recomputing the binding commitment against Keystore. -/// Config shape checks (ZeroLimit, LimitTooLarge, SelfTargetNotAllowed, AnySelectorOnLimitedToken, -/// duplicates, RecipientRuleUnsupportedSelector) run at execute via {_validateConfig}. -/// {_findTokenLimit} still defensively re-checks LimitTooLarge before the uint160 cast. The only storage is -/// {_usage} (mutable spend accounting). +/// Decoding limitation: only the standard ERC-20 selectors (`transfer`, `transferFrom`, `approve`) are decoded +/// for recipient allowlists and spend accounting. Any other selector on a limited token is gated by the +/// allowlist but NOT debited from the cap, so do not allowlist value-moving non-ERC-20 methods on a token you +/// intend to bound; `anySelector` on a limited token is rejected at execute. /// -/// Selector awareness (the one inherent limitation): recipient allowlists and spend-limit accounting require -/// decoding the call's arguments, which is only possible for selectors whose ABI layout is known. This policy -/// hardcodes the standard ERC-20 set — `transfer`, `transferFrom`, `approve`. Consequences: -/// - A recipient allowlist may only be attached to one of those selectors (enforced at execute). -/// - Spend limits are consumed only for those selectors when called on a limited token (`approve` included, -/// so an allowance grant cannot exceed the remaining budget). `anySelector` on a limited token is rejected -/// at execute; pin an explicit selector allowlist instead. -/// - WARNING: any *other* selector on a limited token is gated by the allowlist but NOT debited from the -/// cap. Value-moving methods this policy cannot decode — e.g. ERC-20 `increaseAllowance`/`decreaseAllowance`, -/// ERC-721 `safeTransferFrom`, any ERC-1155 transfer — therefore bypass the spend cap entirely if listed. -/// Do NOT allow such selectors on a token you are trying to bound; only `transfer`/`transferFrom`/`approve` -/// are tracked. -/// - Native-ETH limits are consumed from each call's `value`, independent of calldata. Native value fails -/// closed: a call carrying `value` reverts ({NativeValueNotAllowed}) unless the config pins a native -/// {TokenLimit} (`token == address(0)`). Absence means "no ETH", not "unlimited ETH" — so a grant can call a -/// value-accepting target while sending it zero ETH, and this does not depend on the target rejecting value. -/// Note the resulting asymmetry with ERC-20 defaults is structural, not an oversight: an ERC-20 must be -/// called at its own address to move, so the target allowlist already gates it (omit the token ⇒ "can't -/// touch it") and an absent cap can safely mean "unlimited amount". Native value has no such target to omit, -/// so it fails closed instead. Both assets share the same *expressible* states — forbidden, capped, and -/// unlimited (`limit == type(uint160).max`) — only the meaning of an omitted entry differs. -/// -/// Approvals vs. periods: `approve` is debited from the *current* period at grant time, but an ERC-20 allowance -/// is standing on-chain state that outlives the period and is pulled by a third party this policy never sees. A -/// grantee can therefore pull a still-live approval from an earlier period in the same wall-clock window as a -/// fresh-period `transfer`, so real token outflow across a period boundary can exceed a single period's cap. -/// Per-period accounting is exact for the key's own actions; it cannot bound reuse of standing allowances. -/// -/// Calldata length: empty calldata is allowed (a `receive()` / plain value transfer, gated by the target -/// allowlist and, when it carries `value`, a required native-ETH cap); calldata of 1–3 bytes is rejected -/// ({MissingSelector}) as it cannot carry a -/// 4-byte selector; 4+ bytes is treated as a selector (so a fallback reachable via 4+ byte data is gated by the -/// selector rules). +/// Native ETH fails closed: a call carrying `value` reverts unless the config pins a native {TokenLimit} +/// (`token == address(0)`). `approve` is debited at grant time, so a standing allowance pulled later can still +/// exceed a single period's cap. Empty calldata is allowed; 1-3 bytes revert ({MissingSelector}); 4+ bytes are +/// treated as a selector. contract SessionPolicy is Policy { using RecurringAllowance for RecurringAllowance.State; @@ -90,14 +60,19 @@ contract SessionPolicy is Policy { /// @notice The full committed configuration for a binding. struct Config { + /// @dev Per-token (and native-ETH) spend caps. TokenLimit[] tokenLimits; + /// @dev Allowed call targets and their selector rules. CallScope[] callScopes; } /// @notice Per-use action: a single call the session key wants the account to make. struct Action { + /// @dev Target contract the account will call. address target; + /// @dev Wei forwarded with the call. uint256 value; + /// @dev Calldata for the call (empty = plain value transfer). bytes data; } @@ -118,50 +93,65 @@ contract SessionPolicy is Policy { /// @notice The action's `target` is not in the committed call-target allowlist. error TargetNotAllowed(address target); + /// @notice `selector` is not permitted on `target` (the target pins an explicit selector allowlist that omits it). error SelectorNotAllowed(address target, bytes4 selector); + /// @notice `recipient` (decoded from the ERC-20 call) is not in the selector's committed recipient allowlist. error RecipientNotAllowed(address target, bytes4 selector, address recipient); + /// @notice The action carried 1–3 bytes of calldata: too short to hold a 4-byte selector, so it is rejected rather /// than guessed. error MissingSelector(); + /// @notice A configured spend `limit` exceeds uint160 and cannot be stored in the normalized allowance field. error LimitTooLarge(address token, uint256 limit); + /// @notice A configured spend limit was zero, which would be a no-op cap; reject to fail closed. error ZeroLimit(address token); + /// @notice The action carried native `value` but the config pins no native-ETH limit (`token == address(0)`). - /// Native value fails closed: unlike an ERC-20 (which requires calling an allowlisted token contract to - /// move), value rides on any call to any allowlisted target, so an absent native limit is treated as "no - /// ETH", not "unlimited ETH". To permit ETH, add a native {TokenLimit} with a positive cap. error NativeValueNotAllowed(uint256 value); + /// @notice A recipient allowlist was attached to a selector whose recipient argument this policy cannot decode /// (only the standard ERC-20 selectors are supported). error RecipientRuleUnsupportedSelector(bytes4 selector); + /// @notice A supported ERC-20 call's calldata was too short to decode its (recipient, amount) arguments. error MalformedTokenCall(bytes4 selector); - /// @notice A limited token must pin its allowed selectors: `anySelector` would let non-ERC20 methods move value - /// without debiting the spend cap. Native-ETH limits (`token == address(0)`) are unaffected — they gate - /// call `value`, not a call target. + + /// @notice A limited token was configured with `anySelector`, which would let non-ERC-20 methods move value + /// without debiting the spend cap. error AnySelectorOnLimitedToken(address token); - /// @notice A call scope targeted the account itself. The account is always an authorized caller of its own - /// `executeBatch`, so allowing a session key to call it would let the key re-enter with an arbitrary - /// batch that bypasses every policy check. Reject to fail closed. + + /// @notice A call scope targeted the account itself, which would let a session key re-enter `executeBatch` and + /// bypass every policy check. error SelfTargetNotAllowed(); + /// @notice A `transferFrom` moved funds from an address other than the account. A session key may only spend the /// account's own resources, not third-party allowances the account happens to hold. error TransferFromNotSelf(address from); + /// @notice Duplicate `TokenLimit.token` in the committed config (would silently widen / ambiguate grants). error DuplicateTokenLimit(address token); + /// @notice Duplicate `CallScope.target` in the committed config. error DuplicateCallScope(address target); + /// @notice Duplicate `SelectorRule.selector` within a call scope. error DuplicateSelectorRule(address target, bytes4 selector); constructor(address policyManager) Policy(policyManager) {} - // ── Views (pure over supplied config / explicit limit; no config storage to read) ── + // ── View functions ── /// @notice Returns whether `target` is allowed by `config`, and whether any selector is permitted on it. + /// + /// @param config Committed policy configuration. + /// @param target Call target to check. + /// + /// @return allowed True if `target` is in the call-target allowlist. + /// @return anySelector True if the target pins no selector allowlist (any selector permitted). function isTargetAllowed(Config calldata config, address target) external pure @@ -177,6 +167,13 @@ contract SessionPolicy is Policy { /// @notice Returns whether `selector` is allowed on `target` by `config`, and whether a recipient allowlist /// applies. + /// + /// @param config Committed policy configuration. + /// @param target Call target the selector is checked against. + /// @param selector Function selector to check. + /// + /// @return allowed True if `selector` is permitted on `target`. + /// @return recipientBound True if a recipient allowlist applies to the selector. function getSelectorRule(Config calldata config, address target, bytes4 selector) external pure @@ -197,6 +194,13 @@ contract SessionPolicy is Policy { } /// @notice Returns whether `recipient` is in the recipient allowlist for `(target, selector)` in `config`. + /// + /// @param config Committed policy configuration. + /// @param target Call target the selector belongs to. + /// @param selector Function selector the recipient allowlist is attached to. + /// @param recipient Recipient address to check. + /// + /// @return True if `recipient` is allowed (or the selector has no recipient allowlist). function isRecipientAllowed(Config calldata config, address target, bytes4 selector, address recipient) external pure @@ -219,6 +223,13 @@ contract SessionPolicy is Policy { } /// @notice Returns the spend cap for `token` from `config` (normalized period for one-time limits). + /// + /// @param config Committed policy configuration. + /// @param token Token address to look up (address(0) for native ETH). + /// + /// @return set True if `token` has a configured limit. + /// @return allowance Spend cap normalized to uint160. + /// @return period Period in seconds ({ONE_TIME_PERIOD} for one-time limits). function getTokenLimit(Config calldata config, address token) external pure @@ -235,9 +246,14 @@ contract SessionPolicy is Policy { /// @notice Returns the current-period spend usage for an explicit token limit under a binding. /// - /// @dev `limit` is unauthenticated calldata — results are only meaningful when `limit` is the committed - /// {TokenLimit} from the binding's config (same token/limit/period the account signed). - /// `limit.period == 0` is normalized to {ONE_TIME_PERIOD} so the accounting library never sees a zero period. + /// @dev Reverts with LimitTooLarge when `limit.limit` exceeds uint160. + /// @dev `limit` is unauthenticated calldata; results are only meaningful when it is the committed {TokenLimit} + /// from the binding's config. A zero `limit.period` is normalized to {ONE_TIME_PERIOD}. + /// + /// @param commitment Binding commitment the usage is keyed under. + /// @param limit Token limit to report usage for. + /// + /// @return Current-period usage snapshot (zeroed when `limit.limit` is zero). function getCurrentSpend(bytes32 commitment, TokenLimit calldata limit) external view @@ -256,9 +272,26 @@ contract SessionPolicy is Policy { // ── Hooks ── - /// @dev Validates config, enforces every configured dimension against a single decoded {Action} by linear scan, - /// then returns the account call plan (and empty postCallData). The manager has already authenticated - /// `policyConfig` via the binding commitment. + /// @dev Policy execute hook: validates config, gates the decoded {Action} by linear scan, and returns the + /// account call plan (empty postCallData). The manager has already authenticated `policyConfig`. + /// + /// @dev Reverts via {_validateConfig} when the committed config is malformed. + /// @dev Reverts with TargetNotAllowed when `action.target` is not in the call-target allowlist. + /// @dev Reverts with SelectorNotAllowed when the call's selector is not permitted on the target. + /// @dev Reverts with MalformedTokenCall when an ERC-20 call's calldata is too short to decode. + /// @dev Reverts with RecipientNotAllowed when the decoded ERC-20 recipient is not in the selector's allowlist. + /// @dev Reverts with TransferFromNotSelf when a `transferFrom` moves funds from an address other than `account`. + /// @dev Reverts with ExceededAllowance when a token or native-ETH spend exceeds its remaining cap. + /// @dev Reverts with MissingSelector when the action carries 1-3 bytes of calldata. + /// @dev Reverts with NativeValueNotAllowed when the action carries `value` but no native-ETH limit is configured. + /// + /// @param commitment Binding commitment authorizing this execution. + /// @param account Account the plan will execute against. + /// @param policyConfig ABI-encoded {Config} committed by the account. + /// @param executionData ABI-encoded {Action} for this call. + /// + /// @return accountCallData ABI-encoded {DefaultAccount.executeBatch} plan for the single action. + /// @return postCallData Always empty (no post-call hook). function _onExecute( bytes32 commitment, address account, @@ -289,15 +322,13 @@ contract SessionPolicy is Policy { } } - // 3. transferFrom source: a session key spends only the account's own resources, never a third-party - // allowance the account holds. Enforce `from == account` regardless of token limits or recipient rules. + // transferFrom may only move the account's own funds (from == account), never a third-party allowance. if (selector == TRANSFER_FROM) { address from = _decodeTransferFromSender(action.data); if (from != account) revert TransferFromNotSelf(from); } - // 3a. ERC-20 spend limit: consume the target token's cap for decodable spend selectors. Note `approve` - // debits at grant time; a standing allowance can still be reused across periods (see contract NatSpec). + // ERC-20 spend limit: debit the token's cap for decodable selectors (approve debits at grant time). if (_isErc20Selector(selector)) { (bool set, uint160 allowance, uint40 period) = _findTokenLimit(config, action.target); if (set) { @@ -310,9 +341,7 @@ contract SessionPolicy is Policy { revert MissingSelector(); } - // 3b. Native-ETH spend limit: consume from the call value, independent of calldata. Fail closed — value rides - // on any call to any allowlisted target (no token contract to allowlist as a gate), so an absent native limit - // means "no ETH", not "unlimited ETH". Permitting ETH requires an explicit native {TokenLimit}. + // Native-ETH spend limit: debit the call value; fail closed when no native limit is configured. if (action.value > 0) { (bool set, uint160 allowance, uint40 period) = _findTokenLimit(config, address(0)); if (!set) revert NativeValueNotAllowed(action.value); @@ -326,8 +355,19 @@ contract SessionPolicy is Policy { // ── Internal helpers ── - /// @dev Validates the committed {Config}. Rejects zero/oversized limits, self-targets, anySelector on limited - /// tokens, unsupported recipient rules, and duplicates (token / target / selector). + /// @dev Validates the committed {Config} shape and rejects duplicates. + /// + /// @dev Reverts with ZeroLimit when a token limit is zero. + /// @dev Reverts with LimitTooLarge when a token limit exceeds uint160. + /// @dev Reverts with DuplicateTokenLimit when a token appears twice in `tokenLimits`. + /// @dev Reverts with DuplicateCallScope when a target appears twice in `callScopes`. + /// @dev Reverts with SelfTargetNotAllowed when a call scope targets `account`. + /// @dev Reverts with AnySelectorOnLimitedToken when a limited token pins no selector allowlist. + /// @dev Reverts with DuplicateSelectorRule when a selector appears twice within a call scope. + /// @dev Reverts with RecipientRuleUnsupportedSelector when a recipient allowlist is attached to a non-ERC-20 selector. + /// + /// @param account Account the config is validated against (used for the self-target check). + /// @param config Committed policy configuration to validate. function _validateConfig(address account, Config memory config) internal pure { for (uint256 i; i < config.tokenLimits.length; i++) { TokenLimit memory tl = config.tokenLimits[i]; @@ -343,12 +383,10 @@ contract SessionPolicy is Policy { for (uint256 d; d < i; d++) { if (config.callScopes[d].target == scope.target) revert DuplicateCallScope(scope.target); } - // Fail closed: the account is always authorized to call its own executeBatch, so a session key allowed to - // target the account could re-enter with an arbitrary, unchecked batch and escape every policy dimension. + // The account can always call its own executeBatch; allowing it enables policy-bypassing re-entrancy. if (scope.target == account) revert SelfTargetNotAllowed(); bool anySelector = scope.selectorRules.length == 0; - // Fail closed: a TokenLimit on this target only tracks transfer/transferFrom/approve, so anySelector - // would let other methods move value untracked. Require an explicit selector allowlist instead. + // A TokenLimit only tracks ERC-20 transfer/transferFrom/approve; anySelector would move value untracked. if (anySelector && _hasTokenLimit(config, scope.target)) { revert AnySelectorOnLimitedToken(scope.target); } @@ -368,7 +406,8 @@ contract SessionPolicy is Policy { } } - /// @dev Consume `amount` against a token's cap. Skips zero amounts (the library rejects zero-value spends). + /// @dev Consumes `amount` against a token's cap; skips zero amounts. Reverts with ExceededAllowance when the + /// cumulative period spend exceeds the cap. function _consume(bytes32 commitment, address token, uint160 allowance, uint40 period, uint256 amount) internal { if (amount == 0) return; _usage.useLimit( @@ -383,6 +422,7 @@ contract SessionPolicy is Policy { return keccak256(abi.encode(commitment, token)); } + /// @dev True if `config` pins a spend limit for `token`. function _hasTokenLimit(Config memory config, address token) internal pure returns (bool) { for (uint256 i; i < config.tokenLimits.length; i++) { if (config.tokenLimits[i].token == token) return true; @@ -390,6 +430,8 @@ contract SessionPolicy is Policy { return false; } + /// @dev Finds the spend cap for `token` (period normalized). Reverts with LimitTooLarge if a limit exceeds + /// uint160 (defensive; {_validateConfig} already rejects it). function _findTokenLimit(Config memory config, address token) internal pure @@ -398,8 +440,7 @@ contract SessionPolicy is Policy { for (uint256 i; i < config.tokenLimits.length; i++) { TokenLimit memory tl = config.tokenLimits[i]; if (tl.token == token) { - // Defensive: {_validateConfig} already rejects LimitTooLarge, but never truncate a >uint160 limit - // into a smaller/arbitrary allowance if that invariant is ever bypassed. + // Defensive: never truncate a >uint160 limit if {_validateConfig}'s check is ever bypassed. if (tl.limit > type(uint160).max) revert LimitTooLarge(tl.token, tl.limit); return (true, uint160(tl.limit), tl.period == 0 ? ONE_TIME_PERIOD : tl.period); } @@ -407,6 +448,7 @@ contract SessionPolicy is Policy { return (false, 0, 0); } + /// @dev Finds the {CallScope} for `target`, reporting whether it was found and whether it pins no selectors. function _findScope(Config memory config, address target) internal pure @@ -421,6 +463,7 @@ contract SessionPolicy is Policy { return (false, false, scope); } + /// @dev Finds the {SelectorRule} for `selector` within `scope`, returning its recipient allowlist if any. function _findSelectorRule(CallScope memory scope, bytes4 selector) internal pure @@ -435,6 +478,7 @@ contract SessionPolicy is Policy { return (false, false, recipients); } + /// @dev True if `recipient` is in `recipients`. function _recipientIn(address[] memory recipients, address recipient) internal pure returns (bool) { for (uint256 i; i < recipients.length; i++) { if (recipients[i] == recipient) return true; @@ -447,16 +491,15 @@ contract SessionPolicy is Policy { return selector == TRANSFER || selector == TRANSFER_FROM || selector == APPROVE; } - /// @dev Reads the leading 4-byte selector from `data` (caller guarantees `data.length >= 4`). Masks to a clean - /// bytes4 so dirty low bytes cannot survive into equality comparisons. + /// @dev Reads the leading 4-byte selector from `data` (caller guarantees length >= 4), masked to a clean bytes4. function _selectorOf(bytes memory data) internal pure returns (bytes4 selector) { assembly ("memory-safe") { selector := and(mload(add(data, 0x20)), shl(224, 0xffffffff)) } } - /// @dev Decode (recipient, amount) for the supported ERC-20 selectors. `recipient` is `to` for transfer / - /// transferFrom and `spender` for approve. + /// @dev Decodes (recipient, amount) from a supported ERC-20 call (`recipient` is `to`, or `spender` for + /// approve). Reverts with MalformedTokenCall when the calldata is too short. function _decodeErc20(bytes4 selector, bytes memory data) internal pure @@ -482,7 +525,8 @@ contract SessionPolicy is Policy { recipient = address(uint160(recipientWord)); } - /// @dev Decode the `from` (source) address of a `transferFrom(address from, address to, uint256 amount)` call. + /// @dev Decodes the `from` (source) address of a `transferFrom` call. Reverts with MalformedTokenCall when the + /// calldata is too short. function _decodeTransferFromSender(bytes memory data) internal pure returns (address from) { if (data.length < 4 + 96) revert MalformedTokenCall(TRANSFER_FROM); uint256 fromWord; diff --git a/test/unit/Keystore/applyAccountChange.t.sol b/test/unit/Keystore/applyAccountChange.t.sol index 1f28518..c3beb64 100644 --- a/test/unit/Keystore/applyAccountChange.t.sol +++ b/test/unit/Keystore/applyAccountChange.t.sol @@ -561,7 +561,7 @@ contract AccountEnvironmentTest is KeystoreTest { assertEq(seq1, 1); // The pre-signed sequenced-at-0 batch no longer matches the advanced counter. - vm.expectRevert(Keystore.BadSequence.selector); + vm.expectRevert(abi.encodeWithSelector(Keystore.BadSequence.selector, uint64(seq1), uint64(0))); keystore.applySignedAccountChanges(account, seqZero); } diff --git a/test/unit/Keystore/applyKeyChange.t.sol b/test/unit/Keystore/applyKeyChange.t.sol index a35fac6..d2c528c 100644 --- a/test/unit/Keystore/applyKeyChange.t.sol +++ b/test/unit/Keystore/applyKeyChange.t.sol @@ -608,8 +608,10 @@ contract ApplySignedAccountChangesTest is KeystoreTest { _one(_authorizeChange(ACTOR_A, address(k1Authenticator), SENDER, _future(1 days), "")) ); + (, uint32 seqSigned) = _localEpochSeq(account); keystore.applySignedAccountChanges(account, s); - vm.expectRevert(Keystore.BadSequence.selector); + // The first apply advanced localSequence past the value the batch was signed at. + vm.expectRevert(abi.encodeWithSelector(Keystore.BadSequence.selector, uint64(seqSigned + 1), uint64(seqSigned))); keystore.applySignedAccountChanges(account, s); } diff --git a/test/unit/Keystore/createAccount.t.sol b/test/unit/Keystore/createAccount.t.sol index d47594c..850b624 100644 --- a/test/unit/Keystore/createAccount.t.sol +++ b/test/unit/Keystore/createAccount.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.30; import {Keystore} from "../../../src/Keystore.sol"; import {KeystoreTest} from "../../lib/KeystoreTest.sol"; +import {Scopes} from "../../../src/libraries/Scopes.sol"; /// @dev Fully fuzzed, branch-complete suite for Keystore.createAccount and the pure/view /// machinery it drives: computeAddress, _buildDeploymentCode, _computeEffectiveSalt / _computeActorsCommitment, @@ -53,6 +54,27 @@ contract CreateAccountTest is KeystoreTest { if (bc[0] == 0xEF) bc[0] = 0x00; // EIP-3541: leading 0xEF is rejected as runtime code } + /// @dev Test-side reproduction of _buildDeploymentCode from the documented loader opcodes (PUSH2 n; PUSH1 0x0e; + /// PUSH1 0x00; CODECOPY; PUSH2 n; PUSH1 0x00; RETURN), independent of the contract's implementation so drift + /// in either is caught. + function _reproduceInitCode(bytes memory bytecode) internal pure returns (bytes memory) { + uint16 n = uint16(bytecode.length); + return + abi.encodePacked(bytes1(0x61), bytes2(n), hex"600e600039", bytes1(0x61), bytes2(n), hex"6000f3", bytecode); + } + + /// @dev Test-side reproduction of _computeActorsCommitment from the documented client scheme: + /// leaf_i = keccak256(actorId || authenticator || scope || policyData); commitment = keccak256(leaf_0 || ...). + function _reproduceActorsCommitment(Keystore.InitialActor[] memory actors) internal pure returns (bytes32) { + bytes32[] memory leaves = new bytes32[](actors.length); + for (uint256 i; i < actors.length; i++) { + leaves[i] = keccak256( + abi.encodePacked(actors[i].actorId, actors[i].authenticator, actors[i].scope, actors[i].policyData) + ); + } + return keccak256(abi.encodePacked(leaves)); + } + // ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡ // REVERTS (source-execution order) // ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡ @@ -302,7 +324,9 @@ contract CreateAccountTest is KeystoreTest { /// @notice Verifies arbitrary valid runtime bytecode of a fuzzed length/content deploys successfully /// @dev The deployment header CODECOPY+RETURNs the bytes as runtime code without executing them, so any EIP-170- - /// sized, non-0xEF-leading payload deploys; the deployed code length matches the requested bytecode length + /// sized, non-0xEF-leading payload deploys. Asserting the deployed code equals the requested bytecode + /// exactly (not just its length) validates the loader's CODECOPY/RETURN offsets in _buildDeploymentCode: a + /// shifted offset could preserve the length while corrupting the returned bytes. function test_createAccount_success_arbitraryBytecode(uint256 lenSeed, bytes32 content, uint256 pk, bytes32 salt) public { @@ -312,7 +336,7 @@ contract CreateAccountTest is KeystoreTest { address account = keystore.createAccount(salt, bytecode, actors); - assertEq(account.code.length, bytecode.length); + assertEq(account.code, bytecode); } /// @notice Verifies bytecode of exactly 0xFFFF bytes is accepted (the encodable maximum is inclusive) @@ -525,4 +549,37 @@ contract CreateAccountTest is KeystoreTest { assertTrue(addrA != addrB); } + + /// @notice Verifies computeAddress matches an independent CREATE2 oracle for a mixed (ungated + policy-gated) actor + /// set, validating the full salt/commitment/initcode/formula chain against a reproduction that never calls + /// the contract's own helpers. + /// @dev The actor set mixes an ungated actor (scope 0, empty policyData) and a gated one (Scopes.POLICY, 52-byte + /// policyData = manager(20) || commitment(32)) so the packed leaf encoding is exercised for both scope and + /// policyData. The CREATE2 formula itself is checked via forge-std's vm.computeCreate2Address, an + /// implementation independent of _prepareDeployment. + function test_computeAddress_success_matchesIndependentOracle( + bytes32 userSalt, + uint256 lenSeed, + bytes32 content, + address manager, + bytes32 policyCommitment + ) public view { + Keystore.InitialActor[] memory actors = new Keystore.InitialActor[](2); + actors[0] = Keystore.InitialActor({ + actorId: bytes32(uint256(1)), authenticator: address(k1Authenticator), scope: 0, policyData: "" + }); + actors[1] = Keystore.InitialActor({ + actorId: bytes32(uint256(2)), + authenticator: address(k1Authenticator), + scope: Scopes.POLICY, + policyData: abi.encodePacked(manager, policyCommitment) // 20 + 32 = 52 bytes + }); + bytes memory bytecode = _validBytecode(lenSeed, content); + + bytes32 effectiveSalt = keccak256(abi.encodePacked(userSalt, _reproduceActorsCommitment(actors))); + bytes32 initCodeHash = keccak256(_reproduceInitCode(bytecode)); + address expected = vm.computeCreate2Address(effectiveSalt, initCodeHash, address(keystore)); + + assertEq(keystore.computeAddress(userSalt, bytecode, actors), expected); + } } diff --git a/test/unit/Keystore/policyAccessors.t.sol b/test/unit/Keystore/policyAccessors.t.sol index 174601d..2fa69a1 100644 --- a/test/unit/Keystore/policyAccessors.t.sol +++ b/test/unit/Keystore/policyAccessors.t.sol @@ -8,7 +8,7 @@ import {KeystoreTest} from "../../lib/KeystoreTest.sol"; /// @notice Fully-fuzzed unit tests for the policy accessors on `Keystore`: /// - `getPolicyCommitment(account, actorId)` — liveness-gated hot-path read /// - `getPolicyManager(account, actorId)` — liveness-gated hot-path read (the resolved policy target) -/// - `getActor(account, actorId)` — one-shot liveness-gated aggregate (config + manager + commitment) +/// - `getActorWithPolicy(account, actorId)` — one-shot liveness-gated aggregate (config + manager + commitment) /// /// All are `view`; there are no events to assert. Every test fuzzes its inputs (managers, commitments, /// actorIds, keys, scopes). Gating is determined by the SCOPE_POLICY bit, never by "slot non-zero": a @@ -472,7 +472,7 @@ contract PolicyAccessorsTest is KeystoreTest { // Live before expiry. assertEq(keystore.getPolicyManager(account, actorId), manager); assertEq(keystore.getPolicyCommitment(account, actorId), commitment); - (, address liveManager, bytes32 liveCommitment) = keystore.getActor(account, actorId); + (, address liveManager, bytes32 liveCommitment) = keystore.getActorWithPolicy(account, actorId); assertEq(liveManager, manager); assertEq(liveCommitment, commitment); @@ -482,7 +482,7 @@ contract PolicyAccessorsTest is KeystoreTest { // Every read surface now reports empty, identical to a revoked actor. assertEq(keystore.getPolicyManager(account, actorId), address(0)); assertEq(keystore.getPolicyCommitment(account, actorId), bytes32(0)); - (, address deadManager, bytes32 deadCommitment) = keystore.getActor(account, actorId); + (, address deadManager, bytes32 deadCommitment) = keystore.getActorWithPolicy(account, actorId); assertEq(deadManager, address(0)); assertEq(deadCommitment, bytes32(0)); // And the config resolver already treated it as empty — the accessors now agree with it. @@ -527,15 +527,15 @@ contract PolicyAccessorsTest is KeystoreTest { } // ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡ - // getActor — config + policy manager + commitment in one read + // getActorWithPolicy — config + policy manager + commitment in one read // ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡ // - // getActor returns the resolved (liveness-gated) config plus the policy gate as (manager, commitment): non-zero + // getActorWithPolicy returns the resolved (liveness-gated) config plus the policy gate as (manager, commitment): non-zero // for a live gated actor, zero for an ungated or non-live one. - /// @notice A gated explicit actor: getActor returns the config and the (manager, commitment) gate, agreeing with + /// @notice A gated explicit actor: getActorWithPolicy returns the config and the (manager, commitment) gate, agreeing with /// the granular accessors. - function test_getActor_success_gatedExplicitActor( + function test_getActorWithPolicy_success_gatedExplicitActor( uint256 rootSeed, bytes32 actorId, uint8 scopeSeed, @@ -552,15 +552,15 @@ contract PolicyAccessorsTest is KeystoreTest { _authorizePolicyActor(account, rootPk, actorId, scope, manager, commitment); (Keystore.ActorConfig memory config, address outManager, bytes32 outCommitment) = - keystore.getActor(account, actorId); + keystore.getActorWithPolicy(account, actorId); assertEq(config.authenticator, address(k1Authenticator)); assertEq(config.scope, scope); assertEq(outManager, manager); assertEq(outCommitment, commitment); } - /// @notice An ungated live actor: getActor returns the config with a zero gate (gating is by scope bit). - function test_getActor_success_ungatedActor_zeroGate(uint256 rootSeed, bytes32 actorId) public { + /// @notice An ungated live actor: getActorWithPolicy returns the config with a zero gate (gating is by scope bit). + function test_getActorWithPolicy_success_ungatedActor_zeroGate(uint256 rootSeed, bytes32 actorId) public { uint256 rootPk = _boundK1Pk(rootSeed); (address account,) = _createK1Account(rootPk); actorId = _boundExplicitActorId(account, rootPk, actorId); @@ -568,16 +568,16 @@ contract PolicyAccessorsTest is KeystoreTest { _authorizeUngatedActor(account, rootPk, actorId, address(k1Authenticator)); (Keystore.ActorConfig memory config, address outManager, bytes32 outCommitment) = - keystore.getActor(account, actorId); + keystore.getActorWithPolicy(account, actorId); assertEq(config.authenticator, address(k1Authenticator)); assertEq(config.scope, uint16(0)); assertEq(outManager, address(0)); assertEq(outCommitment, bytes32(0)); } - /// @notice An expired gated actor: getActor resolves to the all-zero config with a zero gate — identical to an + /// @notice An expired gated actor: getActorWithPolicy resolves to the all-zero config with a zero gate — identical to an /// unknown/revoked actor. - function test_getActor_success_expired_returnsEmpty( + function test_getActorWithPolicy_success_expired_returnsEmpty( uint256 rootSeed, bytes32 actorId, uint8 scopeSeed, @@ -603,7 +603,7 @@ contract PolicyAccessorsTest is KeystoreTest { vm.warp(uint256(expiry) + 1); (Keystore.ActorConfig memory config, address outManager, bytes32 outCommitment) = - keystore.getActor(account, actorId); + keystore.getActorWithPolicy(account, actorId); assertEq(config.authenticator, address(0)); assertEq(config.scope, uint16(0)); assertEq(config.expiry, uint48(0)); @@ -611,19 +611,19 @@ contract PolicyAccessorsTest is KeystoreTest { assertEq(outCommitment, bytes32(0)); } - /// @notice An unknown (never-authorized, non-self) actor: getActor returns an all-zero config with a zero gate. - function test_getActor_success_unknownActor_returnsEmpty(address account, bytes32 actorId) public view { + /// @notice An unknown (never-authorized, non-self) actor: getActorWithPolicy returns an all-zero config with a zero gate. + function test_getActorWithPolicy_success_unknownActor_returnsEmpty(address account, bytes32 actorId) public view { vm.assume(actorId != bytes32(uint256(uint160(account)))); // stay off the inline-self path (Keystore.ActorConfig memory config, address outManager, bytes32 outCommitment) = - keystore.getActor(account, actorId); + keystore.getActorWithPolicy(account, actorId); assertEq(config.authenticator, address(0)); assertEq(outManager, address(0)); assertEq(outCommitment, bytes32(0)); } - /// @notice A gated inline-k1 self: getActor resolves the inline config and the shared-keyspace policy gate. - function test_getActor_success_inlineSelfGatedActor( + /// @notice A gated inline-k1 self: getActorWithPolicy resolves the inline config and the shared-keyspace policy gate. + function test_getActorWithPolicy_success_inlineSelfGatedActor( uint256 eoaSeed, uint8 scopeSeed, uint256 managerSeed, @@ -639,7 +639,7 @@ contract PolicyAccessorsTest is KeystoreTest { _authorizeInlineSelfWithPolicy(eoa, eoaPk, scope, manager, commitment); (Keystore.ActorConfig memory config, address outManager, bytes32 outCommitment) = - keystore.getActor(eoa, selfActorId); + keystore.getActorWithPolicy(eoa, selfActorId); assertEq(config.authenticator, keystore.K1_AUTHENTICATOR()); assertEq(config.scope, scope); assertEq(outManager, manager);