Account Abstraction Part 2: Sponsoring Transactions Using Paymasters
In the first part of this series we fully replicated the functionality of an EOA and improved by allowing users to choose their own custom validation logic. But for now, the wallet still needs to pay for gas, which means that the wallet owner needs to find a way to get some ETH before they can perform any actions on-chain.
What if we wanted it to be possible for someone other than the wallet owner to pay for the gas instead?
There are some great reasons to want this:
If the wallet owner is a blockchain newbie, then the need to acquire ETH before performing on-chain actions is a huge stumbling block
A dapp might be willing to pay for gas for its methods so that gas fees don’t scare off potential users
A sponsor might allow the wallet to pay for gas in some token other than ETH, for example with USDC
For privacy, a user might want to withdraw assets from a mixer to a fresh address and charge the gas fees to an account not associated with them
Introducing paymasters
Let’s say I’m a dapp that wants to pay for other people’s gas. Presumably I don’t want to pay for everyone’s gas everywhere, so I need to put custom logic onto the chain which can look at a user op and decide if it wants to pay for that op or not.
The way to put custom logic onto the chain is to deploy a contract, which we’ll call a paymaster.
It will have one method which looks at a user op and decides if it’s willing to pay for that op or not:
contract Paymaster {
function validatePaymasterOp(UserOperation op);
}
Then, when a wallet submits an op, they’ll need to indicate which paymaster (if any) they expect to cover their gas.
We'll add a new field to UserOperation
to specify this.
We’ll also add a field to the user op that the wallet can use to pass arbitrary data to the paymaster to help it convince the paymaster to pay for its costs.
For example, this might be something that was signed by the paymaster’s owner off-chain.
struct UserOperation {
// ...
address paymaster;
bytes paymasterData;
}
Next we’ll change up the entry point’s handleOps
to make use of the new paymasters.
Its behavior will now be:
For each op:
Call
validateOp
on the wallet specified by the op’s senderIf the op has a paymaster address, then call
validatePaymasterOp
on that paymasterAny ops which fail either validation are discarded
For each op, call
executeOp
on the op’s sender wallet, tracking how much gas we use, then transfer ETH to the executor to pay for that gas. If the op has apaymaster
field, then this ETH comes from the paymaster. Otherwise, it comes from the wallet as before.
Just like wallets, paymasters deposit their ETH via the entry point’s deposit
method before it can be used to pay for operations.
That’s actually pretty simple, right?
We’ll just have the bundler update its simulations and…
Paymaster staking
In the previous article where the wallet refunded the bundler, the bundler used simulations to try to avoid executing operations that failed validation, because it meant the wallet wouldn’t pay and so the bundler would be on the hook for gas costs.
The same problem comes up here:
The bundler wants to avoid submitting ops that fail paymaster validation because the paymaster won’t pay and the bundler would be on the hook again.
At first, it seems like we can put the same restrictions on validatePaymasterOp
and that we did on validateOp
(i.e. it can only access the wallet’s and its own associated storage and can’t use banned opcodes), and then the bundler could simply simulate validatePaymasterOp
for a user op at the same time it simulates the wallet’s validateOp
.
But there’s a catch here.
Because of the storage restriction that said a wallet’s validateOp
can only access that wallet’s associated storage, we knew that the validations of multiple ops in a bundle can’t interfere with each other as long as they came from different wallets, because they access very little storage in common.
But a paymaster’s storage is shared across all the operations in the bundle that use that paymaster.
This means the actions of one validatePaymasterOp
could potentially cause validation to fail for numerous other ops in the bundle that use that same paymaster.
A malicious paymaster could use this to DoS the system.
To prevent this, we introduce a reputation system.
We’ll have the bundler keep track of how often each paymaster has failed validation recently, and penalize paymasters that fail a lot by throttling or banning ops that use that paymaster.
This reputation system won’t work if a malicious paymaster can just create many instances of itself (a Sybil attack), so we require paymasters to stake ETH. This way it doesn’t benefit from having multiple accounts.
Let's add new methods to the entry point to handle stakes:
contract EntryPoint {
// ...
function addStake() payable;
function unlockStake();
function withdrawStake(address payable destination);
}
Once a stake is put in, it can’t be removed until some delay has passed after calling unlockStake
.
These new methods are distinct from the previously discussed deposit
and withdrawTo
, which are used by wallets and paymasters to add ETH that will be used to pay for gas and can be withdrawn immediately at any time.
There is an exception to the staking rules:
If the paymaster only ever accesses the wallet’s associated storage and not the paymaster’s own, then it does not need to put up a stake, because in this case, the storage accessed by multiple ops in the bundle will not overlap with each other for the same reason as in the wallets’ validateOp
calls.
I actually don’t think the detailed rules of the reputation system are that important to understand. You can read about them here, but as long as you know that bundlers will have a mechanism to avoid choosing ops from a paymaster that just burned them, that’s good enough.
Also, each bundler tracks reputation locally, so a bundler implementation is free to implement its own reputation logic if it thinks it can do a better job and won’t cause trouble for other bundlers.
💡 Unlike many staking schemes, the stakes here are never slashed. They exist simply as a way to require a potential attacker to lock up a very large amount of capital to attack at scale.
Improvement: Paymaster postOp
We can make a small improvement to allow paymasters to do more. Right now, paymasters are only called during the validation step, before the operation has actually run.
But a paymaster might also need to do something differently based on the result of the operation.
For example, a paymaster that is allowing users to pay for gas in USDC needs to know how much gas was actually used by the operation so it knows how much USDC to charge.
Thus, we’ll add a new method, postOp
, to the paymaster which the entry point will call after the operation is done and pass it how much gas was used.
We also want the paymaster to be able to “pass information to itself” and use data that it has computed during validation in the post-op step, so we’ll allow the validation to return arbitrary “context” data which will later be passed to postOp
.
Our first attempt at postOp
will look like this:
contract Paymaster {
function validatePaymasterOp(UserOperation op) returns (bytes context);
function postOp(bytes context, uint256 actualGasCost);
}
But there’s something tricky in store for a paymaster that wants to charge in USDC at the end.
Presumably, the paymaster checked that the user had enough USDC to pay for the operation before it approved execution (in validatePaymasterOp
). But it’s entirely possible that the operation gives away all the wallet’s USDC during execution, which would mean the paymaster wouldn’t be able to extract payment at the end.
💡 Could the paymaster avoid this by charging the max amount of USDC at the start, then refunding the unused portion at the end? That would sort of work, but it’s messy: it takes two transfer
calls instead of one, which increases gas costs and emits two distinct transfer
events. We’ll see if we can do better.
We need a way for the paymaster to cause the operation to fail after it’s done executing, and if it does it should be able to extract the payment anyways since regardless of what happens it already agreed to pay for gas during validatePaymasterOp
.
The way to set this up is to have the entry point potentially call postOp
twice.
The entry point first calls postOp
as part of the same execution as where it just ran the wallet’s executeOp
, and thus if postOp
reverts, it causes all the effects of executeOp
to revert as well.
If this happens, then the entry point calls postOp
once more, but now we’re in the situation we were in before executeOp
took place, and since in this situation we just checked validatePaymasterOp
, the paymaster should be able to extract its due.
To give postOp
a bit more context, we’ll give it one more parameter: a flag to indicate whether we are in its “second run” after it already reverted once:
contract Paymaster {
function validatePaymasterOp(UserOperation op) returns (bytes context);
function postOp(bool hasAlreadyReverted, bytes context, uint256 actualGasCost);
}
Recap: How paymasters enable sponsored transactions
To allow someone other than the wallet owner to pay for gas, we introduce a new entity type, paymaster, who deploys a smart contract with the following interface:
contract Paymaster {
function validatePaymasterOp(UserOperation op) returns (bytes context);
function postOp(bool hasAlreadyReverted, bytes context, uint256 actualGasCost);
}
We added new fields to user operations to allow wallets to specify which paymaster they want:
struct UserOperation {
// ...
address paymaster;
bytes paymasterData;
}
The paymasters deposit ETH into the entry point in the same way a wallet paying for its own gas would.
The entry point updates its handleOps
method so that for each op, in addition to doing wallet validation via a wallet’s validateOp
, it also validates with the op’s paymaster (if any) via the paymaster’s validatePaymasterOp
, then executes the operation, and finally calls the paymaster’s postOp
.
To deal with some unfortunate problems with simulating paymaster validation, we need to introduce a staking system where paymasters lock up ETH.
They do so with some new entry point methods:
contract EntryPoint {
// ...
function addStake() payable;
function unlockStake();
function withdrawStake(address payable destination);
}
With the addition of paymasters, we have achieved all the features that most people think of when they ask for Account Abstraction!
We’ve also gotten pretty close to ERC-4337, but there are still a few more features we need for parity.
Feels good to be here!
You Could Have Invented Account Abstraction Series
Read the other articles in this 4-part series!
Related articles
Preparing for the Agave 2.0 Upgrade
ERC-1271 Signature Replay Vulnerability
On October 27th 2023, Alchemy discovered a ERC1271 contract signature replay vulnerability that affected a large number of smart contract accounts (SCA), and led to risks when interacting with several applications.
What is RIP-7212? Precompile for secp256r1 Curve Support
RIP-7212 is a core change in the Ethereum protocol that opens up a way to have cheap, secure, and fast P256 curve verification with a precompiled contract.