Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion script/SmokeTest.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ contract SmokeTest is Script {
returns (address)
{
AccountConfiguration.InitialActor[] memory actors = new AccountConfiguration.InitialActor[](1);
actors[0] = AccountConfiguration.InitialActor({actorId: actorId, authenticator: k1Authenticator});
actors[0] = AccountConfiguration.InitialActor({
actorId: actorId, authenticator: k1Authenticator, scope: 0, policyData: ""
});

bytes memory bytecode =
abi.encodePacked(hex"363d3d373d3d3d363d73", defaultImpl, hex"5af43d82803e903d91602b57fd5bf3");
Expand Down
552 changes: 334 additions & 218 deletions src/AccountConfiguration.sol

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/accounts/DefaultAccount.sol
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ contract DefaultAccount is Receiver {
// ERC-1271
// ══════════════════════════════════════════════

/// @notice Validates an ERC-1271 signature via AccountConfiguration; requires the verified actor to hold
/// SIGNER scope or be an unrestricted owner. Never reverts.
/// @notice Validates an ERC-1271 signature via AccountConfiguration; requires the verified actor to be
/// operational (the unrestricted admin, scope == 0x00, or a SENDER actor without POLICY). Never reverts.
///
/// @param hash The digest to authenticate.
/// @param signature Auth data in `authenticator || data` format.
Expand Down
27 changes: 11 additions & 16 deletions src/accounts/UpgradeableAccount.sol
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,14 @@ import {DefaultAccount} from "./DefaultAccount.sol";
/// Deploy behind an UpgradeableProxy instead of ERC-1167.
/// 7702 accounts don't need this — they can re-delegate anytime.
///
/// Upgrades are authorized via upgradeBySignature — a CONFIG-key-signed, relayable upgrade with
/// Upgrades are authorized via upgradeBySignature — an unrestricted-owner-signed, relayable upgrade with
/// compare-and-swap replay protection that is safe to broadcast across every chain the account lives on.
/// See {_authorizeUpgrade} for how the signature requirement is enforced on the actual implementation
/// change.
contract UpgradeableAccount is DefaultAccount, UUPSUpgradeable {
/// @dev AccountConfiguration scope granting authority to change account configuration ("CONFIG"). An account
/// gates upgrades on this scope so the same key that manages the account's actors can also manage its
/// implementation.
uint8 internal constant SCOPE_CONFIG = 0x08;

/// @dev One-shot flag set by {upgradeBySignature} immediately before its internal self-call to
/// `upgradeToAndCall`, and consumed by {_authorizeUpgrade} to confirm a CONFIG-scoped signature has
/// already authorized this specific upgrade.
/// `upgradeToAndCall`, and consumed by {_authorizeUpgrade} to confirm an unrestricted-owner (scope 0)
/// signature has already authorized this specific upgrade.
bool private _upgradeAuthorized;

/// @dev Typehash binding a signed upgrade to (account, from, to, dataHash). chainId is intentionally omitted:
Expand All @@ -37,17 +32,17 @@ contract UpgradeableAccount is DefaultAccount, UUPSUpgradeable {

/// @dev The current implementation does not match the signed `fromImplementation` (compare-and-swap failed).
error UpgradeFromMismatch();
/// @dev The authenticated actor may not authorize upgrades (not an unrestricted owner and lacks CONFIG scope).
/// @dev The authenticated actor may not authorize upgrades (not an unrestricted owner).
error UpgradeUnauthorized();

/// @dev upgradeToAndCall was called directly instead of through {upgradeBySignature}, so no CONFIG-signed
/// authorization set the one-shot flag.
/// @dev upgradeToAndCall was called directly instead of through {upgradeBySignature}, so no unrestricted-owner
/// signed authorization set the one-shot flag.
error UpgradeNotInitiated();

constructor(address accountConfiguration) DefaultAccount(accountConfiguration) {}

/// @dev {upgradeBySignature} is the only place that sets {_upgradeAuthorized}, so satisfying this confirms a
/// CONFIG-scoped signature has already authorized the implementation change being applied.
/// @dev {upgradeBySignature} is the only place that sets {_upgradeAuthorized}, so satisfying this confirms an
/// unrestricted-owner (scope 0) signature has already authorized the implementation change being applied.
/// @dev Reverts with UpgradeNotInitiated when the flag is unset (a direct upgradeToAndCall call).
function _authorizeUpgrade(address) internal override {
if (!_upgradeAuthorized) revert UpgradeNotInitiated();
Expand Down Expand Up @@ -85,9 +80,9 @@ contract UpgradeableAccount is DefaultAccount, UUPSUpgradeable {
abi.encode(SIGNED_UPGRADE_TYPEHASH, address(this), fromImplementation, toImplementation, keccak256(data))
);

// A CONFIG key authorizes the upgrade: an unrestricted owner (scope 0) or an actor with SCOPE_CONFIG.
(uint8 scope,,) = ACCOUNT_CONFIGURATION.authenticateActor(address(this), digest, auth);
if (scope != 0 && scope & SCOPE_CONFIG == 0) revert UpgradeUnauthorized();
// Only an unrestricted owner (scope 0) may authorize an upgrade; there is no elevated "admin" scope bit.
(uint8 scope,) = ACCOUNT_CONFIGURATION.authenticateActor(address(this), digest, auth);
if (scope != 0) revert UpgradeUnauthorized();

// Reuse Solady's tested upgrade path (proxiableUUID check, Upgraded event, optional init delegatecall).
// The flag set above is what satisfies _authorizeUpgrade for this call.
Expand Down
86 changes: 31 additions & 55 deletions src/accounts/example/BackwardsCompatible4337Account.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
pragma solidity ^0.8.30;

import {AccountConfiguration} from "../../AccountConfiguration.sol";
import {Call, DefaultAccount} from "../DefaultAccount.sol";
import {DefaultAccount} from "../DefaultAccount.sol";

struct PackedUserOperation {
address sender;
Expand Down Expand Up @@ -53,8 +53,8 @@ contract BackwardsCompatible4337Account is DefaultAccount {
bytes32 internal constant SIGNED_ACTOR_CHANGES_MAGIC = keccak256("ERC4337Account.signedActorChanges.v1");

/// @dev Elevated-scope bitflags, mirroring AccountConfiguration. A scope of 0x00 is an unrestricted owner.
uint8 internal constant SCOPE_SENDER = 0x02; // may initiate transactions (authorize the op's calls)
uint8 internal constant SCOPE_PAYER = 0x04; // may spend account funds paying for the op
uint8 internal constant SCOPE_SENDER = 0x01; // may initiate transactions (authorize the op's calls)
uint8 internal constant SCOPE_SELF_PAYER = 0x08; // may self-pay gas for the account's own op (payer == sender)

constructor(address accountConfiguration) DefaultAccount(accountConfiguration) {}

Expand Down Expand Up @@ -85,18 +85,19 @@ contract BackwardsCompatible4337Account is DefaultAccount {
}

/// @notice Validates `userOp` by authenticating it as a plain authenticator blob over `userOpHash` and
/// enforcing the verified actor's elevated scope and policy. A signature carrying signed actor/owner
/// changes additionally applies them during validation before the op itself is authenticated.
/// enforcing the verified actor's elevated scope. A signature carrying signed actor/owner changes
/// additionally applies them during validation before the op itself is authenticated.
/// @dev Signed-actor-changes path: each set is applied via `applySignedActorChanges` (empty batch rejected).
/// Every slot it writes is keyed by `account`, so under ERC-7562 it's the account's own associated
/// storage — allowed by STO-021 for an existing account; only a combined create+change op falls under
/// STO-022, requiring the AccountConfiguration factory to be staked.
///
/// Op authentication (both paths) enforces the verified actor's elevated scope and policy:
/// Op authentication (both paths) enforces the verified actor's elevated scope:
/// - the actor must be unrestricted (scope 0x00) or hold {SCOPE_SENDER} to authorize the calls;
/// - a self-funded op (`missingAccountFunds != 0`) additionally requires {SCOPE_PAYER};
/// - a policy-gated actor (non-zero `policyType`) may only drive calls to its policy target, so
/// `userOp.callData` must be an `executeBatch` whose every call targets that address.
/// - a self-funded op (`missingAccountFunds != 0`) additionally requires {SCOPE_SELF_PAYER}.
/// This reduced 4337 bridge does not replicate the native-dispatch policy-target gate: a SCOPE_POLICY
/// actor without SCOPE_SENDER is rejected here by construction (see {_authorize}), and this repo does not
/// implement protocol-side lane/exclusivity checks for actors that combine SCOPE_POLICY with SCOPE_SENDER.
function _validateSignature(PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds)
internal
returns (bool)
Expand All @@ -123,70 +124,45 @@ contract BackwardsCompatible4337Account is DefaultAccount {
}

// Applying changes never authorizes the op; `opAuth` must still sign for this `userOpHash`.
(bool valid, uint8 scope, address policyTarget) = _authenticate(userOpHash, opAuth);
(bool valid, uint8 scope) = _authenticate(userOpHash, opAuth);
if (!valid) return false;

// Authentication only proves WHO signed; authorization decides whether that actor may drive THIS op.
return _authorize(scope, policyTarget, userOp.callData, missingAccountFunds);
return _authorize(scope, missingAccountFunds);
}

/// @notice Authenticates `auth` over `hash` via AccountConfiguration, resolving the signing actor's authorization
/// surface. This answers only "who signed", never "may they do this" — see {_authorize}.
/// @notice Authenticates `auth` over `hash` via AccountConfiguration, resolving the signing actor's scope.
/// This answers only "who signed", never "may they do this" — see {_authorize}.
/// @return valid True if `auth` is a valid signature from a live actor of this account.
/// @return scope The verified actor's scope (0x00 = unrestricted owner).
/// @return policyTarget The verified actor's policy gate target, or address(0) if ungated.
function _authenticate(bytes32 hash, bytes memory auth)
internal
view
returns (bool valid, uint8 scope, address policyTarget)
{
// policyType is reserved for future manager-defined semantics; the account gates on scope + target today.
try ACCOUNT_CONFIGURATION.authenticateActor(address(this), hash, auth) returns (uint8 s, uint8, address p) {
return (true, s, p);
function _authenticate(bytes32 hash, bytes memory auth) internal view returns (bool valid, uint8 scope) {
// policyTarget is a protocol-side / policy-manager concern this reduced 4337 bridge does not replicate:
// a SCOPE_POLICY actor is rejected below by the SCOPE_SENDER check (see {_authorize}), since this repo
// does not implement protocol-side lane/exclusivity checks.
try ACCOUNT_CONFIGURATION.authenticateActor(address(this), hash, auth) returns (uint8 s, address) {
return (true, s);
} catch {
return (false, 0, address(0));
return (false, 0);
}
}

/// @notice Decides whether an already-authenticated actor may drive this UserOperation, from its scope and
/// policy gate. Split out from {_authenticate} so the two concerns — who signed vs. what they may do —
/// are independently reviewable and overridable.
/// @notice Decides whether an already-authenticated actor may drive this UserOperation, from its scope.
/// Split out from {_authenticate} so the two concerns — who signed vs. what they may do — are
/// independently reviewable and overridable.
/// @dev Enforces:
/// - scope 0x00 is an unrestricted owner; any other actor must hold {SCOPE_SENDER} to authorize the calls;
/// - a self-funded op (`missingAccountFunds != 0`) additionally requires {SCOPE_PAYER};
/// - a policy-gated actor (`policyTarget != address(0)`) may only drive calls to its policy target, so
/// `callData` must be a non-empty `executeBatch` whose every call targets that address.
/// - a self-funded op (`missingAccountFunds != 0`) additionally requires {SCOPE_SELF_PAYER}.
/// A SCOPE_POLICY actor without SCOPE_SENDER fails the check below by construction — this reduced 4337
/// bridge does not give policy-gated actors special call-target enforcement (that is native-dispatch,
/// protocol-side behavior out of scope for this repo). An actor combining SCOPE_POLICY | SCOPE_SENDER is
/// authorized here exactly like any other SENDER-scoped actor.
/// @param scope The verified actor's scope (0x00 = unrestricted owner).
/// @param policyTarget The verified actor's policy gate target, or address(0) if ungated.
/// @param callData The op's callData (committed to by `userOpHash`), gated against `policyTarget`.
/// @param missingAccountFunds The prefund the account owes the EntryPoint; non-zero means a self-funded op.
function _authorize(uint8 scope, address policyTarget, bytes calldata callData, uint256 missingAccountFunds)
internal
view
virtual
returns (bool)
{
function _authorize(uint8 scope, uint256 missingAccountFunds) internal view virtual returns (bool) {
// scope 0x00 = unrestricted owner; otherwise the actor must explicitly hold the required scopes.
if (scope != 0) {
if (scope & SCOPE_SENDER == 0) return false;
if (missingAccountFunds != 0 && scope & SCOPE_PAYER == 0) return false;
}

// A policy-gated actor may only direct the account's calls to its policy target.
if (policyTarget != address(0) && !_callsTargetOnly(callData, policyTarget)) return false;

return true;
}

/// @dev True iff `callData` is a non-empty `executeBatch` whose every call targets `policyTarget`.
/// Because `userOpHash` commits to `callData`, enforcing this during validation binds the gated
/// actor's authorization to calls that stay within its policy target.
function _callsTargetOnly(bytes calldata callData, address policyTarget) internal pure returns (bool) {
if (callData.length < 4 || bytes4(callData[:4]) != DefaultAccount.executeBatch.selector) return false;
Call[] memory calls = abi.decode(callData[4:], (Call[]));
if (calls.length == 0) return false;
for (uint256 i; i < calls.length; i++) {
if (calls[i].target != policyTarget) return false;
if (missingAccountFunds != 0 && scope & SCOPE_SELF_PAYER == 0) return false;
}
return true;
}
Expand Down
12 changes: 10 additions & 2 deletions src/authenticators/DelegateAuthenticator.sol
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,15 @@ contract DelegateAuthenticator is IAuthenticator {
address nestedAuthenticator = address(bytes20(nestedAuth[:20]));
if (nestedAuthenticator == address(this)) revert RecursiveDelegation();

// Nested signer must have SIGNATURE scope on the delegate account.
if (!ACCOUNT_CONFIGURATION.verifySignature(delegate, hash, nestedAuth)) revert InvalidNestedSignature();
// The nested actor MUST be the admin (scope == 0x00) of the delegate account. This is enforced
// independently of verifySignature, which is now operational (signing is not admin-only, but a delegate
// vouch requires admin to preserve non-escalation): an operational SENDER key can sign for its own account,
// yet it must NOT be able to vouch as a delegate here. authenticateActor reverts on any auth failure and
// otherwise returns the resolved scope, so we require scope == 0x00 explicitly.
try ACCOUNT_CONFIGURATION.authenticateActor(delegate, hash, nestedAuth) returns (uint8 nestedScope, address) {
if (nestedScope != 0) revert InvalidNestedSignature();
} catch {
revert InvalidNestedSignature();
}
}
}
2 changes: 1 addition & 1 deletion src/examples/policies/Policy.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {PolicyManager} from "./PolicyManager.sol";
/// per-use *action* (`executionData`, supplied when the session key transacts) into an account call plan.
/// The plan is ABI-encoded calldata that the manager forwards to the account (e.g. `executeBatch`).
///
/// This is the example/reference shape for an EIP-8130 actor policy (non-zero `policyType`): the
/// This is the example/reference shape for an EIP-8130 actor policy (`scope & SCOPE_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}.
abstract contract Policy {
Expand Down
Loading
Loading