1. Execute a User Operation
Step 1 in the "Smart Accounts From Scratch" Series: Let's execute a user operation!
Want a Video Walkthrough?
Check out this YouTube video of an implementation of this step!
Ok, let's execute a user operation! Sounds simple, right? 😄
Well, there are a few things we'll need to do to make this work:
- Deploy the EntryPoint - we'll use this one!
- Build the Account Contract - we'll build an AccountFactory which deploys an account that implements this interface
- Build & Send the UserOp
- Top Fields - we'll focus on four fields:
sender
,nonce
,initCode
,callData
- Pre-Fund the Account - the account will need to pay for gas costs for now!
- ...other fields - there's several other fields in the userop we'll need to fill in, but we don't need to be precise with these for now!
- Top Fields - we'll focus on four fields:
For this first step, we can deploy these to a local blockchain network to keep things simple! Later on, we'll deploy these to a testnet, so keep that in mind.
Deploy the EntryPoint
In your codebase we recommend you start by installing these contracts: https://github.com/eth-infinitism/account-abstraction (if you're using npm
you can do npm i @account-abstraction/[email protected]
).
Deploying the EntryPoint is your first step, you'll find that in the repository under @account-abstraction/contracts/core/EntryPoint.sol
.
OpenZeppelin Contracts Dependency
Until OpenZeppelin is included as a dependency in this package, you'll need to install it as well. Again, with npm you can do like this:
npm i @openzeppelin/[email protected]
Large Contract Size
When deploying this contract, you may get an error saying the contract code size is too large. You'll need to set the compiler settings to use the optimizer at 1000 runs. See here for Hardhat and here for Foundry
Once you have an address for this smart contract on your local blockchain, we can move onto the next task!
✅ Task 1/4: Did you deploy the EntryPoint?
Build the Account Contract
Ok, next you'll need to put on your Solidity hat! 🎩
We're going to be making two smart contracts: the Account
and the AccountFactory
. First, let's focus on the factory! We mentioned you'll be working closely with the EntryPoint. Here's our first look! We need to find the spot where the EntryPoint is creating the smart account. Here it is: https://github.com/eth-infinitism/account-abstraction/blob/ver0.6.0/contracts/core/EntryPoint.sol#L337-L348
Specifically, this line:
senderCreator.createSender{gas : opInfo.mUserOp.verificationGasLimit}(initCode);
Is calling out to senderCreator
to create the smart contract. Well, let's take a look at senderCreator
then! Here it is: https://github.com/eth-infinitism/account-abstraction/blob/ver0.6.0/contracts/core/SenderCreator.sol
This code looks pretty low-level, but we should take a close look at it:
function createSender(bytes calldata initCode) external returns (address sender) {
// the first 20 bytes of the initCode is the factory address
address factory = address(bytes20(initCode[0 : 20]));
// the rest of the bytes are the calldata we're sending to the factory
bytes memory initCallData = initCode[20 :];
bool success;
/* solhint-disable no-inline-assembly */
assembly {
// make the call to the factory, sending the calldata from the initCode
success := call(gas(), factory, 0, add(initCallData, 0x20), mload(initCallData), 0, 32)
// read the sender address as the return value
sender := mload(0)
}
if (!success) {
sender = address(0);
}
}
First, notice that the factory
address is the first 20 bytes of the initCode
. We'll be creating this initCode
ourselves later, so it's important to keep that in mind. The rest of the initCode
is the initCallData
which gets sent to the factory inside of the assembly block. The important thing to notice there is that there is no standard method to call on the factory. Instead, the contract forwards all of the calldata along to the factory, meaning we'll need to decide which method we want to call ourselves.
The last thing you should notice is that the result of the call should be the address
of the newly created smart account. This is expected by the EntryPoint, so we'll need to make sure to return it. So, then, maybe our factory looks something like this:
contract AccountFactory {
function createAccount(address _owner) public returns (address) {
Account newAccount = new Account(_owner);
return address(newAccount);
}
}
How do you want your account to work?
You have a ton of flexibility here! Are there other constructor arguments you would like to pass to your account? The
_owner
seems helpful here, but it's entirely up to you how you would like to implement your account. Your welcome to also name the functioncreateAccount
like we did, but that's completely up to you. Additionally, here we chose theCREATE
opcode, which will deploy the address using thehash(factory, nonce)
but maybe you would prefer to useCREATE2
? Perhaps something to come back to, later on!
Once you have decided what your factory looks like, it's time to make your Account
. The Account smart contract will need to take in the constructor arguments you specified in your factory.
Additionally, it will need to implement the account interface here: https://github.com/eth-infinitism/account-abstraction/blob/ver0.6.0/contracts/interfaces/IAccount.sol - specifically the validateUserOp
method. For now, let's keep it simple! Return 0
so that the EntryPoint knows the user op it sent is valid, and can be executed.
Lastly, let's create some kind of a public state change so we can easily check that the user operation executed as we expected! Here is the Account
as I'm picturing it:
contract Account is IAccount {
address public owner;
uint256 public count;
constructor(address _owner) {
owner = _owner;
}
function validateUserOp(UserOperation calldata, bytes32, uint256) external pure returns (uint256 validationData) {
// typically here we'd check this signature
return 0;
}
// this is our state changing function, which could be called anything
function execute() external {
count++;
}
}
Again, feel free to challenge yourself and make your own changes to this account! Just hold off on validating the user operation for now, we'll do that in a future step.
✅ Task 2/4: Did you build both your Account and AccountFactory, and do they compile?
Build & Send the UserOp
Phew, we made it to the last step of this task! Great work. This one is going to be real satisfying once we get it working. The goal here is to make that state change on your smart account using a user operation.
Before we dive in, let's just take a look at all the fields of a user operation:
{
// these fields matter for this step
initCode,
sender,
nonce,
callData,
// from here down we'll fill in estimates or 0x
callGasLimit,
verificationGasLimit,
preVerificationGas,
maxFeePerGas,
maxPriorityFeePerGas,
paymasterAndData,
signature,
}
It sure seems like a lot of fields! Fortunately, we'll only worry about the first four fields, everything else we'll fill in with gas estimates or "0x"
for now.
InitCode
It's worth having a section specifically to talk about the initCode
field. When we took a look at this field in the SenderCreator
contract we saw that the first 20 bytes are the factory address, followed by the calldata to be sent to the factory. So let's go ahead and write the code to figure out what our initCode
will be!
First, you'll need to deploy the AccountFactory
, since we'll need its address as the first 20 bytes. After that, you'll need to encode the calldata
for the factory's method for creating the account. In the example we posted above we used createAccount
. So for me the calldata will be the keccak hash of createAccount(address)
plus the address
we want to use for the owner, padded to 32 bytes. Most libraries (i.e. viem
, ethers
) have methods to help you encode function data so you don't need to do this manually.
Once you have these two pieces, combine them! The calldata should be the factory address plus the encoded calldata to send along to the factory (be careful here if you're using strings to slice off the 0x
from the encoded calldata, so you don't end up with 0x{factory}0x{calldata}
, but rather 0x{factory}{calldata}
)
Who is the owner?
You may be asking yourself "who is the owner" of the smart account? And really, you get to decide! With AA we gain flexibility to programmatically decide what privileges each key has, and what kind of cryptography we use to verify that key.
In my example above we just provided an
address
as the owner, and in a future step we'll do a ECDSA recover to validate a signature on-chain. It's useful to keep in mind that this could instead be many keys and any type of cryptography to validate the signature.
Sender
An interesting artifact of the way the EntryPoint is constructed is that we'll need to pre-calculate the sender
address to be provided in the user operation. There's two ways we can do this:
- Calculate it locally - We can calculate it locally, ideally using a library (
ethers
andviem
both have methods for getting a contract address, with bothCREATE
andCREATE2
). If you usedCREATE
like we did, then you'll need to include the factory's nonce in the calculation to get the sender address (it ishash(deployer + nonce)
. The first time you deploy a smart account, the nonce to use in the address calculation will be1
and will increase by1
each time you deploy another contract. - Ask the EntryPoint - The
EntryPoint
has a public method calledgetSenderAddress
where you can pass theinitCode
and get back thesender
address.
Nonce
Another really interesting aspect of the EntryPoint is its internal nonce management functions, which can be found here: https://github.com/eth-infinitism/account-abstraction/blob/ver0.6.0/contracts/core/NonceManager.sol
Each sender can have many nonces, which allows for transactions with different keys not to require sequential ordering, while still providing the replay protection. For our nonce, let's just a key of 0
. So in order to retrieve the nonce
for our sender we can call out to the entryPoint entryPoint.getNonce(sender, 0);
CallData
Finally, it's time to encode what we actually want to do with the smart account! Similar to the second part of the initCode
, you'll likely want to use a library to encode this calldata for you. In the Account
we showed above, this would be the calldata to call the state changing function execute()
.
Pre-Fund the Account
After you calculate the sender
address, you'll need to pre-fund this address with some ether on the EntryPoint. The EntryPoint uses this deposited ether for gas fees, so we'll need to make sure there is ether there before we try to execute a user operation. To do this you can call entryPoint.depositTo(sender, { value: 1 ether })
Other User Op Fields
As mentioned above the rest of the user operation fields can be filled in with either 0x
or estimates, here they are:
{
sender,
nonce,
initCode,
callData,
callGasLimit: 200_000,
verificationGasLimit: 200_000,
preVerificationGas: 50_000,
maxFeePerGas: ethers.parseUnits("10", "gwei"),
maxPriorityFeePerGas: ethers.parseUnits("5", "gwei"),
paymasterAndData: "0x", // we're not using a paymaster, for now
signature: "0x", // we're not validating a signature, for now
}
At this point, you should have a user operation ready to be sent to the entry point!
✅ Task 3/4: Did you fill out all the fields in your user operation?
Ship it! 🚢
The entryPoint.handleOps
method takes two arguments: an array of user ops and an address as the beneficiary to receive fees. Simply pass in the user operation we created as the only element in the ops array and set a beneficiary, and you should be good to go! Wait for the transaction to be included and then go check to see if sender
address has code deployed to it. If you kept your state variables public, check to see if they have been affected as you would expect.
✅ Task 4/4: Was your state change successfully initiated from the user operation?
If they have been, you are ready to move onto step 2!
Updated 10 months ago