-
Notifications
You must be signed in to change notification settings - Fork 670
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
AA-245 Proposal: add executeUserOp() #380
Conversation
Curious for the reasoning behind adding this method. By using storage or transient storage, it is already possible to pass information from validation to execution, and the amount passed may be limited to just what is needed. It may be more expensive, however. Between |
Thanks for the feedback @adam-alchemy Context: the purpose of this PR is to collect feedback from the community before deciding whether to implement this. We'll publish it and seek feedback before merging. Keep in mind that this method is optional and does not affect accounts that don't use it.
Several projects asked to access parts of the UserOp or to know the UserOpHash during execution. And other forms of native AA (zksync and starknet) make it available during execution. In 4337 and 7560 we couldn't do it because we didn't want to mandate a specific execution function and break some projects' compatibility. We figured we could accommodate this request by adding this optional method. Accounts that don't need it will keep working the usual way, and ones who do - can use this method. In fact it was also requested by one of the other ERC-6900 authors at some point, so we thought it would simplify some ERC-6900 flows as well. Another benefit is that it'll help wallets that run on zksync support 4337 and 7560.
The former two are mandatory (one in 4337 and one in 7560). The latter one is not. It's just an execution function. Any account may have any number of execution functions, we're only suggesting assigning a special meaning to one and reserving it to support this use case. If you're not using it, you don't need to implement it or even know about it. It might still make sense for SDKs to implement an empty/reverting one, just to make sure no one implements a methodsig that collides with it and behaves unexpectedly.
Most accounts won't make use of it and can just ignore it. You should only use it if you need to access parts of UserOp (other than UserOp.calldata). Until now this use case was not supported, so accounts don't need to reimplement anything (since they didn't have that functionality before). Now they can use a dispatcher (like the one in the example) to call their existing execution functions after extracting what they need from the UserOp. There are two separate questions we seek feedback on:
|
Thank you for the very detailed description and reasoning. Having access to the UO hash does seem useful and brings feature parity with other AA implementations, and like you said makes the process of sharing context between phases more straightforward to implement. I think from the perspective of an account developer, this is relatively straightforward: you use it if it is mandatory for some account feature, and ignore it otherwise. That is the benefit of making this optional. It gets a little bit trickier if you approach this from the perspective of a plugin/module developer: you have to consider whether it is necessary for your specific feature, find a way to enforce that it is called, then perform your feature-specific processing of the user operation's data before the actual action specified in From the perspective of 6900 & modular accounts, supporting multiple validation conditions & execution configurations gets a bit more complicated, as does enforcing protections preventing incorrect call flows. But it could also be an unlock for easier context-aware features, like using gas values, the signature, or the paymaster and data. It seems like this is a net positive for accounts. I just need to think a bit more on how this can fit into a flexible account framework, and if there are any changes that could be made to the proposal to make it easier. |
Thanks @adam-alchemy I think for plugins/modules it can be abstracted away, and they'll just get the information they need. Let's consider how ERC-6900 would use it:
In the core account:
The core account contract makes things transparent to plugins. They just choose whether they need this extra info by specifying the right kind of hooks in their manifest. |
Thanks @drortirosh and @yoavw ! We are very much looking forward to be able to get What do you think about the approach of using transient storage to store bytes32 private transient _userOpHash;
function getUserOpHash() view returns (bytes32) {
return _userOpHash;
}
function _executeUserOp(...) {
...
_userOpHash = userOpHash;
try this.innerHandleOp(userOp.callData, opInfo, context) returns (...) {
...
}
_userOpHash = bytes32(0);
...
} |
We thought of it, but feel uneasy adding userop-specific state to EntryPoint. |
assembly { | ||
success := call(gas(), address(), 0, add(innerCall, 0x20), mload(innerCall), 0, 32) | ||
collected := mload(0) | ||
mstore(0x40, saveFreePtr) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there no reasonable way to implement this without manually resetting the free pointer? This code has two assembly chunks and looks a bit complicated.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it is very expensive to expand memory and never use it.
bool success; | ||
assembly { | ||
success := call(gas(), address(), 0, add(innerCall, 0x20), mload(innerCall), 0, 32) | ||
collected := mload(0) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the meaning of mload(0)
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the return value of innerCall is saved into address 0 (the last 2 params of call are "offset", "length")
mload load that return value
} | ||
} | ||
if (methodSig == IAccountExecute.executeUserOp.selector) { | ||
bytes memory executeUserOp = abi.encodeCall(IAccountExecute.executeUserOp, (userOp, opInfo.userOpHash)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is actually a bit too awkward. When the wallet app wants to pass some params to the Smart Account (say it wants to know what is 2+2) so it will need to create a calldata that is abi.encodeWithSelector("executeUserOp", "add", 2, 2)
.
However this (string, uint,uint)
is not the correct method signature for the executeUserOp
method.
I think that developers will (rightfully) feel like they need to abi.encode
the correct method call, which is executeUserOp(UserOperation,bytes32)
, and will be confused.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the executeUserOp is not a real methodsig, but instead a MAGIC value at the beginning of the callData which is expected not to collide with any valid methodSig.
The executeUserOp method is expected to read more than just the calldata to execute (otherwise, it wouldn't use this mechanism, but normal execute()
...
if all you want is an executeUserOp that performs just what "execute" does, then the encoding should probably be:
abi.encodePacked( executeUserOp.selector, abi.encodeCall( execute, (target,0,data)));
and executeUserOp would call the inner method as:
address(this).call(userOp.callData[4:])
but again, this is a very heavy way to achieve just what "execute" does.
just using encodeCall saves gas
52c4e6d
to
ff5ffd7
Compare
Thanks so much for enabling access to However, I would like to point out that even without considering 'just to make sure no one implements a methodsig that collides with it and behaves unexpectedly,' it may still be necessary to have an empty function. Reason: In fact, contract wallets often have the ability to dynamically change the execution process of Therefore, I suggest that even if the |
Proposal: process the entire UserOp during account execution.
The account MAY implement a method:
if it needs parts of the UserOp during its execution.
(note that account is not required to use it: it may continue to use standard "execute" methods, and even support both calling conventions, for different userops)
To signal the EntryPoint to call this method, the wallet should start the
userOp.callData
with the "magic" value -executeUserOp.selector
Note that the calldata is no longer directly callable - since obviously the rest of the callData is NOT the parameters of executeUserOp.
The account should parse the callData to remove this 4-byte prefix and process the rest of the callData as it sees fit.
See the
TestExecAccount
for a sample that uses this callData.The calling wallet should properly encode this call data.
Passing information between validation and execution.
If the account needs to transfer information from the validateUserOp to the executeUserOp, it should use storage (or, when available,
transient storage
) based on the userOpHash, which is the same value for both validate and execute.