Integrating Account Abstraction and Inclusion Preconfirmations

Integrating Account Abstraction and Inclusion Preconfirmations

Tags
preconfirmationsaccount abstraction
Author
Lorenzo Feroleto
Publish on
Read Time

10 minutes

Theme
Bolt
icon

Ethereum’s continuous evolution relies on innovations that enhance usability, scalability, and efficiency. EIP-7702 is one such innovation, offering a significant leap in account abstraction by enabling Externally Owned Accounts (EOAs) to execute custom code natively. This proposal integrates directly into Ethereum’s protocol, simplifying its adoption and unlocking features like batching, gas sponsorship, and secure delegations. These improvements aim to streamline user interactions and reduce the friction inherent in managing accounts and sending transactions.

Although powerful, this EIP does not address reducing the average six-second wait time for feedback on a user’s pending transaction on Ethereum Mainnet. Some out-of-protocol approaches have been explored, emerging as a complementary solution to this UX challenge. One of them is bolt: a proposer commitment protocol designed to provide sub-second transaction confirmations through inclusion preconfirmations.

This piece explores how both EIP-7702 and bolt work, and whether these two innovations can work together seamlessly. We analyze key questions such as compatibility, the role of batching, fault attribution mechanisms, and inclusion tipping strategies.

Thanks to Francesco, Jonas and Nicolas from the Chainbound team for feedback and review!

Table of contents

EIP-7702

Ethereum Improvement Proposals EIP-7702: Set EOA account code is the latest proposal to enable Account Abstraction on Ethereum, meant to be an upgrade over ERC-4337 while being compatible with it. The core of the EIP consists in introducing a new transaction type that allows users to set the code of Externally Owned Accounts (EOAs) by signing authorizations. This code can be called in the call context of the EOA which has authorized it, unlocking many desirable features natively into the protocol.

Developing intuition

A common pattern in many DEX applications when swapping ERC-20s consists of an approval transaction followed by the swap itself, which is a UX friction because the user has to sign two different transactions sequentially to perform a single action, impeding UX significantly. Suppose we want to get rid of having to send two different transactions and just send one. A naive way we could approach this is by writing a general-purpose smart contract that executes many calls in a row, like the following:

contract BatchCaller {
  struct Call {
    bytes data;
    address to;
    uint256 value;
  }
 
  function execute(Call[] calldata calls) public payable {
    for (uint256 i = 0; i < calls.length; i++) {
      Call calldata call = calls[i];
      (bool success, ) = call.to.call{value: call.value}(call.data);
      require(success, "call reverted");
    }
  }
}

Assume the smart contract is deployed at address 0xbatcher. In order to test it, we send a transaction to it with the data of both the approval and the swap, signed by the account 0xalice that we assume to own.

What happens is the following:

  • inside the execute function, tx.origin == msg.sender == 0xalice
  • when we call the ERC20 to make the approval, msg.sender == 0xbatcher and not 0xalice
    • that is because the message call is indeed done by the 0xbatcher contract, not by us.

How can we fix this? We’d like our account to have this code to provide extra functionality, without needing to create a new wallet like with ERC-4337.

We can create a new transaction that expresses the following:

I’m 0xalice. I want to have the code of 0xbatcher permanently.

In order to do that, our transaction must specify a sort “authorization”, that is a tuple [address, y_parity, r, s]. address would be the address of the contract code we’d like to have, that is 0xbatcher. The other fields are the signature fields. By recovering the signature, one can see that 0xalice has signed up for adopting and authorizing the code of 0xbatcher.

This alone would satisfy our goal. That is because as 0xalice, we can sign the appropriate authorization and then proceed by doing a batch call to ourself with the data for the multiple Calls.

What if we’d also like to support gas sponsoring? Gas sponsoring allows an external entity, such as `0xbob`, to execute the transaction on our behalf and pay the associated gas fee. The previous authorization pattern would allow this if we slightly change its semantics:

I’m 0xalice. I want to have the code of 0xbatcher permanently. When someone calls 0xalice, it will run 0xbatcher code with msg.sender == 0xalice.

Letting anyone make messages using our account 0xalice as msg.sender may sound risky, but rules can be coded to allow only intended behaviour. Security notes aside, this approach has the benefit that since gas is always paid by tx.origin, if 0xbob signs a transactions for calling 0xalice with the Calls we want to make, such calls will be performed with msg.sender == 0xalice, and 0xbob will pay for the gas.

We’re now ready for a more formal treatment of the EIP.

Set Code Transaction

A new EIP-2718 transaction, "set code transaction", is introduced. The TransactionType is SET_CODE_TX_TYPE and the TransactionPayload is the RLP serialization of the following:

rlp([chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, value, data, access_list, authorization_list, signature_y_parity, signature_r, signature_s])

authorization_list = [[chain_id, address, nonce, y_parity, r, s], ...]

Some considerations:

  • the authorization_list is a list of tuples that store the address to code which the signer desires to execute in the context (msg.sender) of their EOA. The transaction is considered invalid if the length of authorization_list is zero.
  • chain_id specifies the scope of the authorization. If set to zero, it means validity on all chains supporting EIP-7702. It is suggested to use it when the code was deployed using a deterministic process.

For a given authorization_list, we define:

  • authority as the entity which has signed an authorization_list tuple, that is:
  • authority = ecrecover(keccak(MAGIC || rlp([chain_id, address, nonce])), y_parity, r, s)

Note that is not required that authority == tx.origin i.e., the signer of the transaction. Also nonce must be a valid for authority.

Behaviour

At the start of execution of an EIP-7702 transaction, after incrementing the sender’s nonce, the following actions are performed for each authorization_list tuple (omitting some validity checks):

  1. Perform a “delegation designation”, which means setting the code of authority to be 0xef0100 || address .
    • The first three bytes 0xef0100 are used to identify that this is special code and it should be interpreted differently by the EVM. In particular, the opcode ef is an undefined instruction and the bytes0100 are used for versioning and identification purposes.
    • As a special case, if address is 0x0000000000000000000000000000000000000000 do not write the designation. On the authority account, reset its code and reset the its code hash to the empty hash 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470.
  2. Increase the nonce of authority by one.

Then the transaction is processed as usual, and if it reverts the delegation designations are not rolled back.

Semantics of the delegation designation

💡

The designator requires all code executing operations (CALLCALLCODESTATICCALLDELEGATECALL, as well as transactions with destination targeting the code with delegation designation) to follow the address pointer to get the account’s executable code.

The calls will be executed in the context of authority.

That is, the “functionality” of the authority account has been delegated to the code of address.

Note: the invariant that accounts with non-empty code can originate transactions is broken for accounts whose code is a valid delegation designation.

In-protocol revocation

The EIP allows to revoke or change an existing delegation designation. This is possible by sending another EIP-7702 transaction which contains an authorization whose contains another address and a valid nonce.

Self-sponsoring and batching

Let’s consider an example of EIP-7702 transaction where the tx.origin == authority . This would set the tx.origin code to the delegation designation. The ERC-20 approve-then-transfer pattern, which currently requires two separate transactions, could be completed in a single transaction with this proposal.

Example

Consider the same BatchCaller smart contract we’ve introduced in the previous section:

contract BatchCaller {
  struct Call {
    bytes data;
    address to;
    uint256 value;
  }
 
  function execute(Call[] calldata calls) public payable {
	  require(tx.origin == msg.sender, "yo don't rug me");
	  
    for (uint256 i = 0; i < calls.length; i++) {
      Call calldata call = calls[i];
      (bool success, ) = call.to.call{value: call.value}(call.data);
      require(success, "call reverted");
    }
  }
}

Suppose Alice, with address 0xalice , deploys this smart contract at address 0xbatch with nonce 0 and then she signs an EIP-7702 transaction with nonce 1 which contains the following authorization: [chain_id = 0, address = 0xbatch, nonce = 2] . Note that the nonce must be 2 since it is updated before processing the authorization.

As a result, now Alice will have code 0xef0100 || 0xbatch. Now, if she wants to approve + swap USDC for ETH she can create the following transaction:

  • to herself, that is the address 0xalice
  • the behavior of the EVM now is loading the code pointed at 0xbatch.
  • the calldata would be the encoding of an array of Calls, among with the function selector
    • the first call would contain the data for approving USDC, with to the address of USDC
    • the second call would contain the data for swapping USDC to ETH, with to the address of a certain pool.

What happens now if Bob, with address 0xbob, sends a transaction where to to field is 0xalice? Since the account at 0xalice is now a smart contract, its code is loaded and execute. If in Bob’s transaction the data field specifies the execute function then it will be executed with:

  • tx.origin == 0xbob
  • msg.sender == 0xalice

In this case the transaction will fail as the require check is not met. If missing, then anyone could send message calls using msg.sender = 0xalice, and Alice would have her funds stolen. Note that the balance for paying gas would be still by paid from the origin of the transaction, which is Bob.

Batching

In this section we’re going to examine whether an RPC server, or more generally a different entity than the user, can batch multiple transaction into a single one and provide lower gas costs for users of this service.

The RPC can be represented on-chain by an Ethereum address, which can be itself an AA account. Its code would be optimized for executing transactions on behalf of other users. An example could be the following:

contract RpcBatcher {
    struct Call {
        bytes data;
        address to;
        uint256 value;
    }
    struct DelegatedCall {
        address from;
        Call call;
        /// The signature over a Call hash digest made by "from"
        Signature signature;
    }
    struct Signature {
        bytes32 r;
        bytes32 s;
        uint8 v;
    }

    function execute(DelegatedCall[] calldata dCalls) public payable {
        require(tx.origin == msg.sender, "only owner of this account");

        // For each delegated call, call the account code and execute the transaction.
        for (uint256 i = 0; i < dCalls.length; i++) {
		        // NOTE: we can make it more gas efficient by calling assembly code directly.
            DelegatedCall calldata dCall = dCalls[i];
            bytes memory data = abi.encode(dCall);
            dCall.from.call(data);
        }
    }
}

An account like 0xalice could implement a function remoteExecute that allows external entities to make calls on its behalf, provided they got an off-chain authorization to do it.

contract Alice {
    struct Call {
        bytes data;
        address to;
        uint256 value;
    }
    struct DelegatedCall {
        address from;
        Call call;
        /// The signature over a Call hash digest made by "from"
        Signature signature;
    }
    struct Signature {
        bytes32 r;
        bytes32 s;
        uint8 v;
    }

    function remoteExecute(DelegatedCall calldata dCall) public payable {
        // Verify authorization of the call. 
        Call calldata call = dCall.call;
        bytes32 digest = keccak256(abi.encode(call));
        address from = ecrecover(
            digest,
            dCall.signature.v,
            dCall.signature.r,
            dCall.signature.s
        );
        // Since `0xalice` is an EIP-7702 account, `msg.sender == 0xalice`.
        // As such we need a check that `0xalice` really wanted to authorize
        // this call.
        require(from == msg.sender, "not authorized");

        // Execute the call itself.
        (bool success, ) = call.to.call{value: call.value}(call.data);
        require(success, "call reverted");
    }
}

Note: depending on the contract you need to call this step might not be necessary. For instance, solvers rely on the fact that swaps can be executed on behalf of users (provided they sign some off-chain data). Lastly, the code has a simple authentication mechanism but it requires more checks against replay attacks. These are simple examples to reason about the interactions between the different parties.

Paying the Batcher

We now examine how a batcher can be paid for its service. A simple strategy might be add a X%X\% fee, e.g. 10%10\%, on top of the gas used by a message call. An example:

function remoteExecuteWithTip(DelegatedCall calldata dCall) public payable {
    // Verify authorization of the call.
    Call memory call = dCall.call;
    bytes32 digest = keccak256(abi.encode(call));
    address from = ecrecover(
        digest,
        dCall.signature.v,
        dCall.signature.r,
        dCall.signature.s
    );
    require(from == msg.sender, "not authorized");

    // Execute the call itself.
    uint256 gasBeforeCall = gasleft();
    call.to.call{value: call.value}(call.data);
    uint256 gasUsed = gasBeforeCall - gasleft();
    // Tip the RPC -- the tx.origin. msg.sender here is still Alice
    (bool success, ) = (tx.origin).call{
        value: (gasUsed * (tx.gasprice - block.basefee)) / 10
    }("");
}

A simple observation on the batching logic is that as the amount of DelegatedCalls made in a single transaction increases, the gas savings for each user also increase. This is because the 21000 gas fixed fee for a transaction, which is paid by the batcher in advance, is amortised between all the calls.

If users save more on gas it means they have more funds to pay tips to the batcher in the future, so there is a natural incentive also by the batcher to have the highest number of transactions possible in a batch without sacrificing too much user experience.

Sponsoring

By sponsoring, we mean an entity sponsoring the gas for a transaction - either for free or by getting paid in some other ERC-20 for its service.

The way an ERC-20 tip can be achieved is by having the user also sign a message that allows some entity to transfer a certain amount of tokens to their account. An implementation could look similar to the remoteExecuteWithTip functionality, where instead of sending ETH, the user can specify whatever ERC-20 they wish to pay gas with.

Relationship with bolt

In this section we will explore how account abstraction relates to bolt protocol, which allows users to ask commitments of inclusion for their transactions.

bolt: a quick refresher

bolt is a proposer commitment protocol. It empowers Ethereum block proposer to make credible commitments about the contents of their block, in exchange for a tip currently expressed using an higher priority fee. At the time of writing bolt supports inclusion preconfirmations: a type of commitment where a user can ask a proposer guaranteed inclusion of its transactions. For a wide range of transactions, such as ETH transfers, this approach enables a significantly faster user experience, with confirmation times of less than 500ms. This rapid confirmation is achievable because, after performing basic checks — like verifying that the user has sufficient funds — these transactions are guaranteed to succeed and will not fail.

bolt is completely trustless: proposers commit to include a transaction and in case of any misbehaviour they can get challenged and be slashed if they fail to solve the challenge. The dispute consists having the proposers show that the transactions_root field of the block they proposed, which is a succinct summary of all the transactions included, proves the inclusion of a certain transaction.

For the scope of this piece we’re not concerned about the architectural details of bolt. We’ll assume that a wallet will give users near instant feedback about their transactions if they are sent directly to bolt-proposers in the upcoming slots.

In the context of self-batching, it is clear that this is supported by simply having users ask an inclusion preconfirmation over their batch. However, one might argue this isn’t still as gas efficient as the fixed transaction cost of 21000 is spread over at most an handful of transactions for most use-cases.

To achieve better gas saving for users we need an external batcher entity carrying this duty for them. Below we will explore how feasible this is in practice and the relationship between the batcher and the proposer.

Can the bolt proposer be a batcher?

If proposers are not offering inclusion preconfirmations, or more generally proposer commitments, then the batcher would need to send a batch every slot in order to match the usual UX of seeing a transaction included by the 12s mark. Note that this is the best service it can offer: by no means a batcher can credibly give quicker feedback to its users.

The scenario is different with proposer commitments. For instance, proposers or their delegated entities could sign commitments over including many DelegatedCalls and become batchers themselves. This approach can offer both gas savings and quick feedbacks to users.

Some natural questions arise:

  1. is it still possible to have a fault dispute mechanism in the context of batching? More specifically, what if the proposer misbehaves by including a batch with a missing DelegateCall?
  2. how do users now pay an inclusion tip?
  3. is it still possible to achieve the same benefits with delegation?
  4. are current smart account proposal able to express this functionality?

We will answer these questions in the following subsections.

Fault attribution and batching

When a user makes a commitment request, the proposer isn’t signing a commitment over a concrete transaction object for which inclusion can be checked with the transactions_root, but rather some data with which the proposer should execute message calls on behalf of the user. This implies the current proof system that relies on the aforementioned block header field doesn’t work, the batch, even if included, might be missing some of the calls that were committed to.

One way this problem could be tackled is by emitting a log after every DelegateCall has been performed, and by checking via the receipts_root that a certain log has been emitted during the execution of the block. Then, the log would be emitted if and only if the proposer has included the delegated call into its batch.

Here’s an example of the possible implementation:

  function remoteExecute(DelegatedCall calldata dCall) public payable {
      // Verify authorization of the call.
      Call calldata call = dCall.call;
      bytes32 digest = keccak256(abi.encode(call));
      address from = ecrecover(
          digest,
          dCall.signature.v,
          dCall.signature.r,
          dCall.signature.s
      );
      require(from == msg.sender, "not authorized");
      // Execute the call itself.
      call.to.call{value: call.value}(call.data);
      emit RemoteCallExecuted(from, digest);
  }

We will know examine two different approaches for tackling fault attribution, by leveraging two fields available in Ethereum block headers: receipts_root, and logs_bloom.

Proving delegated call inclusion via receipts_root.

A transaction receipt RR, is a tuple of five items comprising:

  • the type of the transaction, RxR_x,
  • the status code of the transaction, RzR_z,
  • the cumulative gas used in the block containing the transaction receipt as of immediately after the transaction has happened, RuR_u,
  • the set of logs created through execution of the transaction, RlR_l
  • the Bloom filter composed from information in those logs.

The receipt trie is the trie structure populated with the receipts of each transactions in a block. As such, we can use the receipts_root, defined as the hash of the root node of the receipts trie to check inclusion of a message call in batch.

For us it is particularly useful that a receipt contains the set of logs RlR_l, because we can use it in our fault attribution mechanism. In particular, a user can open a challenge in a smart contract asserting that the proposer hasn’t included a certain DelegatedCall . If the proposer isn’t able to provide of proof of inclusion of its associated RemoteCallExecuted log in a certain time window, the proposer is slashed. The flow mimics the current setup bolt uses for attributing faults, but we simply carry the checks over different objects.

The idea of using the receipts_root isn’t entirely new. For example, it has been already explored that in the context of execution preconfirmations (see OpenOpen). An execution preconfirmation is a more sophisticated type of proposer commitment where the successful execution of a transaction is guaranteed before its actual inclusion in a block. In case of a fault, the field RzR_z which represents the status code of the transaction can be used as evidence to prove that a proposer has included a transaction that reverted, even if its commitment promised the opposite. Moreover, by providing proofs about the contents of set of logs RlR_l we can make these checks even more expressive and granular.

logs_bloom isn’t suitable for the job

The logs bloom filter summarizes the logs generated by all transactions within a block. This bloom filter allows nodes and clients to quickly determine whether a block potentially contains logs of interest without scanning every transaction.

While very efficient, the logs_bloom may output false positives: it may indicate that a block contains relevant logs when it does not. Since it internally relies on hashing the logs the set the bits of the filter, the higher the amount of logs in a block the higher the probability of the filter outputting false positives.

This means a bloom filter cannot be used inside a challenge mechanism because it might incorrectly give fault attribution.

Inclusion tips

The mechanism of batching introduces an additional challenge: inclusion tips to a proposer cannot be expressed as priority fees anymore because users are not sending full transaction objects to proposers. That is because the ETH required to pay gas is subtracted from the tx.origin account of the batch transaction, which in our construction is the proposer itself.

In previous sections we’ve also been agnostic of how the batcher-proposer is paid for its service. The two concepts are intimately related: we can use the same tip mechanism described in remoteExecuteWithTip for both services. That is, the ETH transfer performed during call to a user’s code can cover both the inclusion tip and the batching tip. Furthermore, using an ETH transfer to express the tip enables the system to direct the inclusion tip specifically to the proposer from whom we are requesting an inclusion commitment. This was not feasible with the maxPriorityFeePerGas field of a transaction, as a block proposer might not receive any reward for issuing a commitment; instead, the proposer of the current slot could include the transaction and claim the entire payment.

On the implementation side, expressing a tip directly to the next opted-in proposer requires users specifying the proposer’s preferred address for receiving such rewards. This can be achieve by slightly modifying the DelegateCall struct inside remoteExecuteWithTip function to add that address.

function remoteExecuteWithTip(DelegatedCall calldata dCall) public payable {
    // Verify authorization of the call.
    Call memory call = dCall.call;
    bytes32 digest = keccak256(abi.encode(call));
    address from = ecrecover(
        digest,
        dCall.signature.v,
        dCall.signature.r,
        dCall.signature.s
    );
    require(from == msg.sender, "not authorized");

    // Execute the call itself.
    uint256 gasBeforeCall = gasleft();
    call.to.call{value: call.value}(call.data);
    uint256 gasUsed = gasBeforeCall - gasleft();
    // 10% tip to the proposer
    (bool success, ) = (dCall.proposer).call{
        value: (gasUsed * (tx.gasprice - block.basefee)) / 10
    }("");
    // OPTIONAL: 10% tip as well to whoever will include this transaction
    (bool success, ) = (tx.origin).call{
        value: (gasUsed * (tx.gasprice - block.basefee)) / 10
    }("");
}

Note that tipping the tx.origin is still useful, as it incentivises inclusion without waiting for the slot for which the commitment as been made.

Delegations and batching

By delegation we mean the act of a proposer to delegate the right to issue commitments to a third party. Delegation introduces additional challenges in the protocol because it can affect attributability: in case of faults it is hard to establish whether it’s the proposer fault or the delegated entity which didn’t send a commitment in time. While we’re not concerned about these difficulties in this piece, we wonder whether our batching construction leveraging account abstraction is still compatible with whatever delegation mechanism might be introduced. The answer is positive, because batching is achieved by only modifying the digest of an inclusion commitment request and adjusting the fault dispute mechanism.

One last consideration is geared towards paying the delegated entity for its service of issuing commitments if empowered to do so. In practice, this can still be done by mimicking the ETH transfer mechanism of remoteExecuteWithTip by splitting the tip between the proposer and the delegated entity.

Conclusion

Our analysis concludes that EIP-7702 and bolt can work together effectively, with complementary strengths: EIP-7702 enhances account abstraction and simplifies batching by integrating features natively into Ethereum’s protocol; bolt provides faster transaction confirmations through inclusion preconfirmations. While challenges like fault attribution and inclusion tipping in batching scenarios require specific mechanisms, the proposed example solutions demonstrate feasibility and compatibility between the two innovations.