Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [v0.8-develop, experimental] multi validation in user op signature [5/N] #62

Merged
merged 6 commits into from
Jun 10, 2024
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
37 changes: 17 additions & 20 deletions src/account/AccountLoupe.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,42 +7,38 @@ import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet
import {IAccountLoupe, ExecutionHook} from "../interfaces/IAccountLoupe.sol";
import {FunctionReference, IPluginManager} from "../interfaces/IPluginManager.sol";
import {IStandardExecutor} from "../interfaces/IStandardExecutor.sol";
import {
AccountStorage,
getAccountStorage,
SelectorData,
toFunctionReferenceArray,
toExecutionHook
} from "./AccountStorage.sol";
import {getAccountStorage, SelectorData, toFunctionReferenceArray, toExecutionHook} from "./AccountStorage.sol";

abstract contract AccountLoupe is IAccountLoupe {
using EnumerableSet for EnumerableSet.Bytes32Set;
using EnumerableSet for EnumerableSet.AddressSet;

/// @inheritdoc IAccountLoupe
function getExecutionFunctionConfig(bytes4 selector)
external
view
returns (ExecutionFunctionConfig memory config)
{
AccountStorage storage _storage = getAccountStorage();

function getExecutionFunctionHandler(bytes4 selector) external view override returns (address plugin) {
if (
selector == IStandardExecutor.execute.selector || selector == IStandardExecutor.executeBatch.selector
|| selector == UUPSUpgradeable.upgradeToAndCall.selector
|| selector == IPluginManager.installPlugin.selector
|| selector == IPluginManager.uninstallPlugin.selector
) {
config.plugin = address(this);
} else {
config.plugin = _storage.selectorData[selector].plugin;
return address(this);
}

config.validationFunction = _storage.selectorData[selector].validation;
return getAccountStorage().selectorData[selector].plugin;
}

/// @inheritdoc IAccountLoupe
function getValidations(bytes4 selector) external view override returns (FunctionReference[] memory) {
return toFunctionReferenceArray(getAccountStorage().selectorData[selector].validations);
}

/// @inheritdoc IAccountLoupe
function getExecutionHooks(bytes4 selector) external view returns (ExecutionHook[] memory execHooks) {
function getExecutionHooks(bytes4 selector)
external
view
override
returns (ExecutionHook[] memory execHooks)
{
SelectorData storage selectorData = getAccountStorage().selectorData[selector];
uint256 executionHooksLength = selectorData.executionHooks.length();

Expand All @@ -59,14 +55,15 @@ abstract contract AccountLoupe is IAccountLoupe {
function getPreValidationHooks(bytes4 selector)
external
view
override
returns (FunctionReference[] memory preValidationHooks)
{
preValidationHooks =
toFunctionReferenceArray(getAccountStorage().selectorData[selector].preValidationHooks);
}

/// @inheritdoc IAccountLoupe
function getInstalledPlugins() external view returns (address[] memory pluginAddresses) {
function getInstalledPlugins() external view override returns (address[] memory pluginAddresses) {
pluginAddresses = getAccountStorage().plugins.values();
}
}
2 changes: 1 addition & 1 deletion src/account/AccountStorage.sol
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ struct SelectorData {
// but it packs alongside `plugin` while still leaving some other space in the slot for future packing.
uint48 denyExecutionCount;
// User operation validation and runtime validation share a function reference.
FunctionReference validation;
EnumerableSet.Bytes32Set validations;
// The pre validation hooks for this function selector.
EnumerableSet.Bytes32Set preValidationHooks;
// The execution hooks for this function selector.
Expand Down
11 changes: 7 additions & 4 deletions src/account/PluginManagerInternals.sol
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,12 @@ abstract contract PluginManagerInternals is IPluginManager {
{
SelectorData storage _selectorData = getAccountStorage().selectorData[selector];

if (_selectorData.validation.notEmpty()) {
// Fail on duplicate validation functions. Otherwise, dependency validation functions could shadow
// non-depdency validation functions. Then, if a either plugin is uninstall, it would cause a partial
// uninstall of the other.
if (!_selectorData.validations.add(toSetValue(validationFunction))) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagine we'd want to revisit this if we end up allowing multiple installations of the same plugin with different permissions? Still maybe makes sense to check for duplicates but I guess the key might include the permissions.

cc @howydev

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validation routing is done off-chain so definitely worth exploring moving execution selector routing off-chain too!

Re: session keys, i think the decision is between plugin + 1-key-per-plugin (or multiple keys that are managed by a dapp) v.s. 1 plugin + n keys per plugin?

  1. I do prefer the security model of 1 key per plugin since we can use the account's permissions framework directly. That's preferable for 2 reasons - we don't need to trust the plugin to manage individual key permissions, and we dedupe permissions checking (in the other model, account allocates a global limit to the plugin, then the plugin allocates per-key limits). This plugin could be as lightweight as a signature validator if the native permissions framework is sufficiently granular/flexible
  2. But in the 1-key-per-plugin model, we'll potentially have to deploy a new contract to add a new key (probably a proxy from a ProxyFactory, which is the safe/zodiac modules model). Unless the deployed plugin is ultra lightweight it should be more costly than the other approach

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah we definitely need some changes to support that workflow, but I wanted to keep each change scoped down to just what is needed per PR.

Also, I think having 1 key per plugin is too inefficient - we can treat the function id as a per-account, per-key item to perform that switching, like in the validator experiments.

"Key" is also the wrong term here, it's leaking the underlying validation logic into a higher layer of abstraction. They're really independent "validation functions" or "validators".

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I think having 1 key per plugin is too inefficient - we can treat the function id as a per-account, per-key item to perform that switching, like in the validator experiments.

Hmm I might be missing something, but here's an example I was thinking about - in the case of adding a new session key to the existing MA session key plugin with n permissions, the minimal state change required will be:

  1. n x nonzero to nonzero SSTOREs via increment the ERC20 + native token spend limits from the account to the plugin
  2. n x zero to nonzero SSTOREs via incrementing the ERC20 + native token spend limits of the plugin to the session key

Whereas deploying a new validator would be just n x zero to nonzero SSTOREs via incrementing the ERC20 + native token spend limits of the account to the new validator

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know in the old v0.7 model, we basically needed two layers of permissions - account <> plugin, and in the case of a key-based validation plugin, then permissions for each key.

If we switch to composable validation (aka, allow validation plugins to be installed multiple times), then we go back to just needing 1 set of permissions per "validation", which is a combination of both the address of the validator plugin and the ID of the key within it.

I think the model of doing proxies on plugins per account is wrong - the cost of any contract creation is already too. high (32k), not to mention the added forwarding cost of being a proxy.

revert ValidationFunctionAlreadySet(selector, validationFunction);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should come back this naming problem (validation vs. validation function vs. ?) later. Same with isPublic.

}

_selectorData.validation = validationFunction;
}

function _removeValidationFunction(bytes4 selector, FunctionReference validationFunction)
Expand All @@ -99,7 +100,9 @@ abstract contract PluginManagerInternals is IPluginManager {
{
SelectorData storage _selectorData = getAccountStorage().selectorData[selector];

_selectorData.validation = FunctionReferenceLib._EMPTY_FUNCTION_REFERENCE;
// May ignore return value, as the manifest hash is validated to ensure that the validation function
// exists.
_selectorData.validations.remove(toSetValue(validationFunction));
}

function _addExecHooks(
Expand Down
124 changes: 88 additions & 36 deletions src/account/UpgradeableModularAccount.sol
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ contract UpgradeableModularAccount is
// Wraps execution of a native function with runtime validation and hooks
// Used for upgradeTo, upgradeToAndCall, execute, executeBatch, installPlugin, uninstallPlugin
modifier wrapNativeFunction() {
_doRuntimeValidationIfNotFromEP();
_checkPermittedCallerIfNotFromEP();

PostExecToRun[] memory postExecHooks = _doPreExecHooks(msg.sig, msg.data);

Expand Down Expand Up @@ -133,7 +133,7 @@ contract UpgradeableModularAccount is
revert UnrecognizedFunction(msg.sig);
}

_doRuntimeValidationIfNotFromEP();
_checkPermittedCallerIfNotFromEP();

PostExecToRun[] memory postExecHooks;
// Cache post-exec hooks in memory
Expand Down Expand Up @@ -262,6 +262,42 @@ contract UpgradeableModularAccount is
return returnData;
}

/// @inheritdoc IPluginExecutor
function executeWithAuthorization(bytes calldata data, bytes calldata authorization)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again re: naming (we can punt) - this is still (runtime) validation, so a bit surprising to introduce new vocabulary authorization.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah happy to revisit. For some background on why I started with this, I saw this field as analogous to the "signature" data in user ops, but we've overloaded that term in too many places already (including 1271), and it's just the authorization for one particular action.

external
payable
returns (bytes memory)
{
bytes4 execSelector = bytes4(data[:4]);

// Revert if the provided `authorization` less than 21 bytes long, rather than right-padding.
FunctionReference runtimeValidationFunction = FunctionReference.wrap(bytes21(authorization[:21]));

AccountStorage storage _storage = getAccountStorage();

// check if that runtime validation function is allowed to be called
if (_storage.selectorData[execSelector].denyExecutionCount > 0) {
revert AlwaysDenyRule();
}
if (!_storage.selectorData[execSelector].validations.contains(toSetValue(runtimeValidationFunction))) {
revert RuntimeValidationFunctionMissing(execSelector);
}

_doRuntimeValidation(runtimeValidationFunction, data, authorization[21:]);

// If runtime validation passes, execute the call

(bool success, bytes memory returnData) = address(this).call(data);

if (!success) {
assembly ("memory-safe") {
revert(add(returnData, 32), mload(returnData))
}
}

return returnData;
}

/// @inheritdoc IPluginManager
function installPlugin(
address plugin,
Expand Down Expand Up @@ -360,18 +396,28 @@ contract UpgradeableModularAccount is
revert AlwaysDenyRule();
}

FunctionReference userOpValidationFunction = getAccountStorage().selectorData[selector].validation;
// Revert if the provided `authorization` less than 21 bytes long, rather than right-padding.
FunctionReference userOpValidationFunction = FunctionReference.wrap(bytes21(userOp.signature[:21]));
adamegyed marked this conversation as resolved.
Show resolved Hide resolved

validationData = _doUserOpValidation(selector, userOpValidationFunction, userOp, userOpHash);
if (!getAccountStorage().selectorData[selector].validations.contains(toSetValue(userOpValidationFunction)))
{
revert UserOpValidationFunctionMissing(selector);
}

validationData =
_doUserOpValidation(selector, userOpValidationFunction, userOp, userOp.signature[21:], userOpHash);
}

// To support gas estimation, we don't fail early when the failure is caused by a signature failure
function _doUserOpValidation(
bytes4 selector,
FunctionReference userOpValidationFunction,
PackedUserOperation calldata userOp,
PackedUserOperation memory userOp,
bytes calldata signature,
bytes32 userOpHash
) internal returns (uint256 validationData) {
userOp.signature = signature;

if (userOpValidationFunction.isEmpty()) {
// If the validation function is empty, then the call cannot proceed.
revert UserOpValidationFunctionMissing(selector);
Expand Down Expand Up @@ -412,50 +458,40 @@ contract UpgradeableModularAccount is
}
}

function _doRuntimeValidationIfNotFromEP() internal {
AccountStorage storage _storage = getAccountStorage();

if (_storage.selectorData[msg.sig].denyExecutionCount > 0) {
revert AlwaysDenyRule();
}

if (msg.sender == address(_ENTRY_POINT)) return;

FunctionReference runtimeValidationFunction = _storage.selectorData[msg.sig].validation;
function _doRuntimeValidation(
FunctionReference runtimeValidationFunction,
bytes calldata callData,
bytes calldata authorizationData
) internal {
// run all preRuntimeValidation hooks
EnumerableSet.Bytes32Set storage preRuntimeValidationHooks =
getAccountStorage().selectorData[msg.sig].preValidationHooks;
getAccountStorage().selectorData[bytes4(callData[:4])].preValidationHooks;

uint256 preRuntimeValidationHooksLength = preRuntimeValidationHooks.length();
for (uint256 i = 0; i < preRuntimeValidationHooksLength; ++i) {
bytes32 key = preRuntimeValidationHooks.at(i);
FunctionReference preRuntimeValidationHook = toFunctionReference(key);

(address plugin, uint8 functionId) = preRuntimeValidationHook.unpack();
(address hookPlugin, uint8 hookFunctionId) = preRuntimeValidationHook.unpack();
try IValidationHook(hookPlugin).preRuntimeValidationHook(
hookFunctionId, msg.sender, msg.value, callData
)
// forgefmt: disable-start
// solhint-disable-next-line no-empty-blocks
try IValidationHook(plugin).preRuntimeValidationHook(functionId, msg.sender, msg.value, msg.data) {}
catch (bytes memory revertReason) {
revert PreRuntimeValidationHookFailed(plugin, functionId, revertReason);
{} catch (bytes memory revertReason) {
// forgefmt: disable-end
revert PreRuntimeValidationHookFailed(hookPlugin, hookFunctionId, revertReason);
}
}

// Identifier scope limiting
{
if (_storage.selectorData[msg.sig].isPublic) {
// If the function is public, we don't need to check the runtime validation function.
return;
}

if (runtimeValidationFunction.isEmpty()) {
revert RuntimeValidationFunctionMissing(msg.sig);
}
(address plugin, uint8 functionId) = runtimeValidationFunction.unpack();

(address plugin, uint8 functionId) = runtimeValidationFunction.unpack();
// solhint-disable-next-line no-empty-blocks
try IValidation(plugin).validateRuntime(functionId, msg.sender, msg.value, msg.data) {}
catch (bytes memory revertReason) {
revert RuntimeValidationFunctionReverted(plugin, functionId, revertReason);
}
try IValidation(plugin).validateRuntime(functionId, msg.sender, msg.value, callData, authorizationData)
// forgefmt: disable-start
// solhint-disable-next-line no-empty-blocks
{} catch (bytes memory revertReason) {
// forgefmt: disable-end
revert RuntimeValidationFunctionReverted(plugin, functionId, revertReason);
}
}

Expand Down Expand Up @@ -536,4 +572,20 @@ contract UpgradeableModularAccount is

// solhint-disable-next-line no-empty-blocks
function _authorizeUpgrade(address newImplementation) internal override {}

function _checkPermittedCallerIfNotFromEP() internal view {
AccountStorage storage _storage = getAccountStorage();

if (_storage.selectorData[msg.sig].denyExecutionCount > 0) {
revert AlwaysDenyRule();
Comment on lines +579 to +580
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kinda want to revisit naming of AlwaysDenyRule and denyExecutionCount too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or we can get rid of the magic values in #63, if that is finalized

}
if (
msg.sender == address(_ENTRY_POINT) || msg.sender == address(this)
|| _storage.selectorData[msg.sig].isPublic
) return;

if (!_storage.callPermitted[msg.sender][msg.sig]) {
revert ExecFromPluginNotPermitted(msg.sender, msg.sig);
}
}
}
17 changes: 8 additions & 9 deletions src/interfaces/IAccountLoupe.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,16 @@ struct ExecutionHook {
}

interface IAccountLoupe {
/// @notice Config for an execution function, given a selector.
struct ExecutionFunctionConfig {
address plugin;
FunctionReference validationFunction;
}

/// @notice Get the validation functions and plugin address for a selector.
/// @notice Get the plugin address for a selector.
/// @dev If the selector is a native function, the plugin address will be the address of the account.
/// @param selector The selector to get the configuration for.
/// @return The configuration for this selector.
function getExecutionFunctionConfig(bytes4 selector) external view returns (ExecutionFunctionConfig memory);
/// @return plugin The plugin address for this selector.
function getExecutionFunctionHandler(bytes4 selector) external view returns (address plugin);
adamegyed marked this conversation as resolved.
Show resolved Hide resolved

/// @notice Get the validation functions for a selector.
/// @param selector The selector to get the validation functions for.
/// @return The validation functions for this selector.
function getValidations(bytes4 selector) external view returns (FunctionReference[] memory);

/// @notice Get the pre and post execution hooks for a selector.
/// @param selector The selector to get the hooks for.
Expand Down
10 changes: 10 additions & 0 deletions src/interfaces/IPluginExecutor.sol
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,14 @@ interface IPluginExecutor {
external
payable
returns (bytes memory);

/// @notice Execute a call using a specified runtime validation, as given in the first 21 bytes of
/// `authorization`.
/// @param data The calldata to send to the account.
/// @param authorization The authorization data to use for the call. The first 21 bytes specifies which runtime
/// validation to use, and the rest is sent as a parameter to runtime validation.
function executeWithAuthorization(bytes calldata data, bytes calldata authorization)
Copy link
Contributor

@huaweigu huaweigu Jun 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we also need to update https://github.com/erc6900/reference-implementation/blob/6d48a68021bff6f3a50a508873595af9fb1939a6/standard/ERCs/erc-6900.md in this PR or later on
(update: okay, I see it's moved to different interface in #65)

external
payable
returns (bytes memory);
}
8 changes: 7 additions & 1 deletion src/interfaces/IValidation.sol
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ interface IValidation is IPlugin {
/// @param sender The caller address.
/// @param value The call value.
/// @param data The calldata sent.
function validateRuntime(uint8 functionId, address sender, uint256 value, bytes calldata data) external;
function validateRuntime(
uint8 functionId,
address sender,
uint256 value,
bytes calldata data,
bytes calldata authorization
) external;

/// @notice Validates a signature using ERC-1271.
/// @dev To indicate the entire call should revert, the function MUST revert.
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/owner/ISingleOwnerPlugin.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {IValidation} from "../../interfaces/IValidation.sol";

interface ISingleOwnerPlugin is IValidation {
enum FunctionId {
VALIDATION_OWNER_OR_SELF,
VALIDATION_OWNER,
SIG_VALIDATION
}

Expand Down
12 changes: 8 additions & 4 deletions src/plugins/owner/SingleOwnerPlugin.sol
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,12 @@ contract SingleOwnerPlugin is ISingleOwnerPlugin, BasePlugin {
}

/// @inheritdoc IValidation
function validateRuntime(uint8 functionId, address sender, uint256, bytes calldata) external view override {
if (functionId == uint8(FunctionId.VALIDATION_OWNER_OR_SELF)) {
function validateRuntime(uint8 functionId, address sender, uint256, bytes calldata, bytes calldata)
external
view
override
{
if (functionId == uint8(FunctionId.VALIDATION_OWNER)) {
// Validate that the sender is the owner of the account or self.
if (sender != _owners[msg.sender] && sender != msg.sender) {
revert NotAuthorized();
Expand All @@ -97,7 +101,7 @@ contract SingleOwnerPlugin is ISingleOwnerPlugin, BasePlugin {
override
returns (uint256)
{
if (functionId == uint8(FunctionId.VALIDATION_OWNER_OR_SELF)) {
if (functionId == uint8(FunctionId.VALIDATION_OWNER)) {
// Validate the user op signature against the owner.
(address signer,,) = (userOpHash.toEthSignedMessageHash()).tryRecover(userOp.signature);
if (signer == address(0) || signer != _owners[msg.sender]) {
Expand Down Expand Up @@ -154,7 +158,7 @@ contract SingleOwnerPlugin is ISingleOwnerPlugin, BasePlugin {

ManifestFunction memory ownerValidationFunction = ManifestFunction({
functionType: ManifestAssociatedFunctionType.SELF,
functionId: uint8(FunctionId.VALIDATION_OWNER_OR_SELF),
functionId: uint8(FunctionId.VALIDATION_OWNER),
dependencyIndex: 0 // Unused.
});
manifest.validationFunctions = new ManifestAssociatedFunction[](5);
Expand Down
Loading
Loading