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

Conversation

adamegyed
Copy link
Contributor

Motivation

As described in erc6900/resources#4, supporting multiple validation functions has been a long-standing goal of ERC-6900.

Solution

This PR implements switching between different validation functions using a field in the user op signature for user op validation, and a calldata field for runtime validation.

It also lays the groundwork for, but does not implement, associating pre-validation hooks with validators, and per-hook/per-validation auth data (erc6900/resources#32).

To avoid needing a "default" or "primary" validation per selector, like in #52, this PR also changes the default auth flow through accounts. Previously, there were three validation flows through the account:

  • User op validation (+ any pre-validation hooks): runs through validateUserOp called by EntryPoint
  • Runtime validation (+ any pre-validation hooks): runs through fallback & native functions
  • Authorized caller check: runs through executeFromPlugin

This PR keeps the user op validation flow, and reorganizes the auth flows of the latter two:

  • Runtime validation (+ any pre-validation hooks): runs through executeWithAuthrization
  • Authorized caller check: runs through fallback & native functions

Note that the isPublic property of an execution function, introduced earlier in the stack at #61, is still applied when calling functions through the fallback and native function paths.

By making this organizational change, this solution does not require a "default" or "primary" validation per selector. Whenever a validation function needs to run, it is specified by the caller and checked by the account to be an installed validator for the given function.

Future work

The validation encoded in the user op signature (currently 21 bytes) would easily fit within the key portion of the user op nonce (24 bytes). However, due to potential future changes to how we identify validation functions likely coming with composable validation (erc6900/resources#36) and user-supplied install configs (erc6900/resources#9), I've avoided making that optimization for the time being. We should revisit the possibility of optimizing this once those are addressed.

Additionally, this PR does not make changes to pre-validation hook associations, which I believe multi-validation makes necessary. See #52 for more info on this.

@adamegyed adamegyed changed the title feat: [v0.8-develop, experimental] multi validation in user op signature feat: [v0.8-develop, experimental] multi validation in user op signature [5/N] May 21, 2024
@adamegyed adamegyed marked this pull request as ready for review May 21, 2024 18:46
@adamegyed adamegyed requested a review from a team May 21, 2024 23:30
src/account/AccountLoupe.sol Outdated Show resolved Hide resolved
src/interfaces/IAccountLoupe.sol Show resolved Hide resolved
@adamegyed adamegyed force-pushed the adam/simplify-rt-allowed-calls branch from c83ac72 to e871784 Compare May 29, 2024 21:01
@adamegyed adamegyed force-pushed the adam/multi-validation-uo-sig branch 2 times, most recently from 41b30e7 to f347833 Compare May 29, 2024 21:28
@adamegyed adamegyed force-pushed the adam/simplify-rt-allowed-calls branch 2 times, most recently from 77ce9ff to f48ccd7 Compare May 31, 2024 18:48
Base automatically changed from adam/simplify-rt-allowed-calls to v0.8-develop May 31, 2024 18:49
@adamegyed adamegyed force-pushed the adam/multi-validation-uo-sig branch from aefbc5f to 6d48a68 Compare May 31, 2024 18:53
@howydev
Copy link
Collaborator

howydev commented Jun 5, 2024

taking a look!

thoughts on adding something to the loupe to expose isPublic for a selector?

@howydev
Copy link
Collaborator

howydev commented Jun 6, 2024

Overall this looks good!

Some thoughts/q's:

  1. What/how is execute executeBatch used now?
  2. I think this shifts some complexity into the SDK for user ops, but don’t see this being a huge lift from existing flows since validation plugin devs already should be specifying a format for {dummy signature format, signing format} somewhere
  3. It adds some complexity into runtime calls, but that’s a path thats not very widely used today, so not a big downside too
  4. IMO we should spend some time on what a validation grouping mechanism for efficiency might look like, since validations might be replicated across execute, executeBatch and executeWithAuthorization? (callout: I briefly looked at the default validation PR, saw it might be handled there)
  5. What’s the difference between a plugin installing a new exec function vs using executeWithAuthorization beyond isPublic functions? Only 2 reasons I can think of rn:

a. security related - calls from plugins are less dangerous, e.g. account doing token.transfer vs plugin doing token.transfer, and installing a validation/prevalidation function stack on a new execution function is also less dangerous
b. standardization related - dapp wants a standardized format to do calls across 6900 and non6900 accounts and the common denominator would be to utilize the fallback routing mechanism (i.e. IDapp(account).dappAction())

Curious if there are more reasons here

Copy link
Collaborator

@jaypaik jaypaik left a comment

Choose a reason for hiding this comment

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

Lgtm.

This got me thinking: are we comfortable with the idea of providing a single validation function for an executeBatch? I know we had toyed around with the idea of being able to specify a different one for each in the past.

src/account/AccountLoupe.sol Outdated Show resolved Hide resolved
if (_selectorData.validation.notEmpty()) {
// Fail on duplicate definitions - otherwise dependencies could shadow non-depdency
// validation functions, leading to partial uninstalls.
if (!_selectorData.validations.add(toSetValue(validationFunction))) {
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.

if (_selectorData.validation.notEmpty()) {
// Fail on duplicate definitions - otherwise dependencies could shadow non-depdency
// validation functions, leading to partial uninstalls.
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.

src/account/PluginManagerInternals.sol Outdated Show resolved Hide resolved
src/account/UpgradeableModularAccount.sol Show resolved Hide resolved
Comment on lines +577 to +578
if (_storage.selectorData[msg.sig].denyExecutionCount > 0) {
revert AlwaysDenyRule();
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

@@ -262,6 +262,41 @@ 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.

Copy link
Contributor

@huaweigu huaweigu left a comment

Choose a reason for hiding this comment

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

This is great! Would this also unlock the support for runtime multisig?

{
bytes4 execSelector = bytes4(data[0:4]);

FunctionReference runtimeValidationFunction = FunctionReference.wrap(bytes21(authorization[0:21]));
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: do we allow invalid runtimeValidationFunction? If not, should we consider validating here before reading into storage?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Could you please explain your question a bit more? The workflow here is currently:

  • The requested validation function is read from the data in authorization.
  • The account checks to make sure that this validation function is actually installed.
  • The account checks that the validation function applies to the selector this call is trying to run.

Copy link
Contributor

Choose a reason for hiding this comment

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

ah I just meant function references like bytes21(0) might not be valid, so we can probably fail fast here

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah I see, since that step will fail anyways on the storage check later I don't know if it's worth failing early - it reduces gas in the failure case but increases gas in the success case, and generally I think optimizing for the success case is better.

payable
returns (bytes memory)
{
bytes4 execSelector = bytes4(data[0:4]);
Copy link
Contributor

Choose a reason for hiding this comment

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

ditto on validity check

/// @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)

@adamegyed
Copy link
Contributor Author

@huaweigu sorry I missed the earlier comment - yes this does make it possible to have a runtime path multisig. The signatures (or any other data related to authorization) can be put in the authorization field of the runtime validation function.

@adamegyed
Copy link
Contributor Author

@howydev just saw this

thoughts on adding something to the loupe to expose isPublic for a selector?

Yeah that definitely makes sense! we should revisit loupe functions overall at the end of the major spec changes, this and probably more might need to change.

@adamegyed adamegyed merged commit 3d54094 into v0.8-develop Jun 10, 2024
3 checks passed
@adamegyed adamegyed deleted the adam/multi-validation-uo-sig branch June 10, 2024 18:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants