0%
HomeBlogTechnical
Account Abstraction Part 2: Sponsoring Transactions Using Paymasters

Account Abstraction Part 2: Sponsoring Transactions Using Paymasters


Published on February 14, 20236 min read

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:

Copied
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.

Copied
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 sender

  • If the op has a paymaster address, then call validatePaymasterOp on that paymaster

  • Any 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 a paymaster 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.

Diagram of how the entry point contract validates user ops via the paymaster contract, and refunds the bundler with funds from the paymaster.
Executor calls both a paymaster contract and a user's smart contract wallet to determine if the user's transaction can be sponsored.

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:

Copied
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:

Copied
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:

Copied
contract Paymaster { function validatePaymasterOp(UserOperation op) returns (bytes context); function postOp(bool hasAlreadyReverted, bytes context, uint256 actualGasCost); }

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:

Copied
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:

Copied
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:

Copied
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!

Section background image

Build blockchain magic with Alchemy

Alchemy combines the most powerful web3 developer products and tools with resources, community and legendary support.