Note: Since publishing this playground, we have contined to iterate on the account abstraction specifications and have recently published a simplified EIP. Our implementation as presented in this repository is not yet fully in line with this proposal. If there is interest in an updated version of the playground, we will work on bringing it back in sync with the new specs. So let us know if that is something you would want to see!
The goal of this playground repo is to get you to create your first real-world account abstraction (AA) contract in 3 simple steps. Account abstraction is the proposed idea of letting contracts validate and pay for their own transactions, without the involvement of EOAs (i.e. private key accounts).
This repo is part of the Quilt team's R&D effort on the feasibility of bringing AA to eth1. At the core of this effort is our fork of go-ethereum that implements a basic version of AA as outlined by Vitalik earlier this year. We are currently in the process of collecting metrics and will be writing a comprehensive overview of our work so far and our future goals once that is done. In the meantime, this repo aims to enable anyone interested to already explore our current AA MVP implementation. It tracks the latest stable version of our go-ethereum fork and as such is subject to change as we continue development. Note that at the current time we do not yet have a position on bringing AA to mainnet, but will communicate our assessment of AA feasibility as part of our upcoming writeup.
The following instructions are written for macOS, but should be similar for most Linux systems. Windows instructions might differ.
The repo uses git submodules to bundle our forks of go-ethereum and solidity with some additional resources to help with quickly spinning up a local AA testnet.
To clone this repo and both submodules in one step, do:
git clone --recurse-submodules https://github.com/quilt/account-abstraction-playground.git
All further commands will be relative to this account-abstraction-playground
base directory.
For building go-ethereum, you need the most recent version of Go. See here for Go install instructions.
On macOS, you also need the Xcode Command Line Tools, which you can install via xcode-select --install
.
To compile geth
, do:
cd go-ethereum
make geth
You should now have a geth
executable at build/bin/geth
.
Throughout this tutorial, every reference to geth
is to this modified executable that includes AA support.
See the solidity documentation for building prerequisites.
To compile solc
, do:
cd solidity
mkdir build
cd build
cmake .. && make solc
You should now have a solc
executable at solc/solc
.
If you are running into a Could NOT find Boost
issue on macOS, try brew install boost-python
.
The next step is to set up a local geth test chain. If you are already familiar with setting up geth testnets, you can skip this section and do the setup on your own. Otherwise you can follow this simple 3-step process to set up a local Proof-of-Authority (PoA) testnet:
To create an account that will serve as the signer (PoA equivalent of a miner) for the testnet, do:
go-ethereum/build/bin/geth account new --datadir data
This should output the public address of the newly created account.
We will refer to this address as __SIGNER__
.
Next you need to create a genesis file at data/genesis.json
for the new test chain.
You can use the existing data/genesis_template.json
, replacing the two occurrences of __SIGNER__
with the address of your signer, in both cases without the leading 0x
.
For the last step of the test chain setup, do (once again replacing __SIGNER__
):
go-ethereum/build/bin/geth init --datadir data data/genesis.json
go-ethereum/build/bin/geth --unlock 0x__SIGNER__ --datadir data --mine --http --http.api personal,eth --allow-insecure-unlock --networkid 12345 --nodiscover
After entering the signer account password, you should now see the local geth testnet running and producing new blocks every 3 seconds.
This repo currently contains two example AA contracts.
The first one, Whiteboard
, is a simple hello world AA contract that lets you write to and read from a virtual whiteboard.
The second one, Wallet
, is a more interesting smart contract wallet example that uses ecrecover
to only accept transactions signed by its owner.
For interacting with the AA test chain we are using nodejs
with web3.js
version 1.2.9
.
The contract file for the Whiteboard
contract can be found at contracts/Whiteboard.sol
.
When you first look at the contract code, you can see a few small differences to normal solidity code:
pragma experimental AccountAbstraction;
tells the compiler to enable the experimental AA support.account contract Whiteboard {...}
signals thatWhiteboard
is an AA contract. The compiler therefore includes the AA bytecode prefix at the beginning of the contract bytecode.assembly { paygas(gasPrice) }
uses inline assembly and a newpaygas
Yul function to call the new AAPAYGAS
opcode with the provided gas price.PAYGAS
is required in any AA contract execution (even for read-only access) and signals that the contract has decided to pay for the transaction. If the transaction fails beforePAYGAS
, it is thus considered invalid and cannot be included in the blockchain at all. If the transaction fails afterPAYGAS
, it is still considered valid and only state changes afterPAYGAS
are reverted. The gas price provided toPAYGAS
is used to deductgas price * gas limit
as total transaction cost from the contract balance. As with normal transactions, any unused gas is then refunded at the end of the transaction.
The Whiteboard
contract itself has two variables:
uint256 nonce
is the contract's internal replay protection mechanism and mirrors the protocol-enshrined nonce functionality. While it is still an open question whether AA would use the protocol-enshrined nonce, the MVP does not do so. Thus, all AA transactions have a protocol-level nonce of0
and it is up to the contract to provide a replay protection mechanism.string message
is the "whiteboard content". It can be changed by the contract via thesetMessage(...)
function.
Note that the MVP implementation does not currently support public variables, as solidity-defined getter functions do not call PAYGAS
and are thus not AA compliant.
Instead, the contract provides getNonce()
and getMessage()
as getters for nonce
and message
respectively.
To "write" a new message to the whiteboard, the contract provides function setMessage(uint256 txNonce, uint256 gasPrice, string calldata newMessage, bool failAfterPaygas)
.
This function takes four parameters:
txNonce
is the nonce of the transaction. If it is not equal to the current value ofnonce
, execution aborts without ever reachingPAYGAS
. In that case the transaction not only fails, but is considered invalid and cannot be included in a block. If instead the nonce is valid, the contract then increments its internal nonce by one, making this transaction un-replayable in the future.gasPrice
is the gas price the contract is supposed to pay for the transaction. While under AA the contract could decide this value on its own, it here lets the caller set it.newMessage
is the new message string that replaces the oldmessage
.failAfterPaygas
is an extra parameter for AA experimentation. If set totrue
, the contract will throw. AsPAYGAS
has already been called at that point, the contract has already committed to paying for the transaction. Thus, the result is a valid, but failed transaction. If the transaction is included in a block, the contract balance will be reduced by the total transaction fee andnonce
will be incremented by one. In contrast, any state changes afterPAYGAS
, in this case the message update, are reverted, resulting in an unchangedmessage
.
Finally, the contract also contains an empty constructor() public payable
, which allows ETH transfer to the contract as part of its deployment.
Given that AA contracts have to pay for their own transaction, transfering some ETH to it is required.
While the AA prefix of the MVP implementation also allows for incoming ETH transfers to a deployed AA contract, sending ETH as part of the initial deployment makes this extra step unnecessary.
To compile the Whiteboard
contract with the forked version of solidity, do:
solidity/build/solc/solc --bin --abi contracts/Whiteboard.sol
This should output both the contract bytecode, which we will reference as __BYTECODE__
, as well as its ABI, which we will reference as __ABI__
.
To deploy the compiled contract to the local AA test chain, you first have to ensure geth is still running. If that is not the case, you can start geth back up via
go-ethereum/build/bin/geth --unlock 0x__SIGNER__ --datadir data --mine --http --http.api personal,eth --allow-insecure-unlock --networkid 12345 --nodiscover
The interaction with the chain will now happen from inside nodejs
, where we first import web3.js
and connect it to geth:
const Web3 = require('web3');
let web3 = new Web3('http://localhost:8545');
let signer = '0x__SIGNER__';
web3.eth.getBalance(signer).then(console.log);
If you replaced __SIGNER__
with your signer address, you should now see the current balance of your signer account.
You can now deploy the Whiteboard
contract:
let bytecode = '0x__BYTECODE__';
let abi = __ABI__; // note: no quotes!
let Contract = new web3.eth.Contract(abi);
let contract;
Contract.deploy({data: bytecode}).send({from: signer, value: 10000000}).then(function(contractInstance){contract = contractInstance; console.log(contractInstance);});
After a few seconds, the AA contract should now be deployed and referenced by the contract
variable.
The deployment itself was a normal Ethereum transaction, paid for by the signer account.
As the signer is also the block producer and thus collects all transaction fees, the only balance change is the 10000000 wei
sent to the contract as part of its deployment.
To inspect the current balances, you can do:
web3.eth.getBalance(contract._address).then(console.log);
web3.eth.getBalance(signer).then(console.log);
Before sending the first AA transaction, you can first use the static getter functions to read the current contract state:
contract.options.from = '0xffffffffffffffffffffffffffffffffffffffff';
contract.methods.getNonce().call().then(console.log);
contract.methods.getMessage().call().then(console.log);
This should output a currently empty message
and a nonce
of 0
.
Note the first line, where the default address for contract interactions is set to the entry point address.
This is required already for static ethcall
interactions in order to pass the AA bytecode prefix.
You can now send your first AA transaction:
contract.methods.setMessage(0, 1, "hello world!", false).send({gasPrice: "0", gasLimit: 100000}).then(console.log);
After a few seconds, the transaction - your first ever AA transaction - should make its way into a block. To analyze the effect of this transaction, we can again inspect the relevant parts of the chain state:
contract.methods.getNonce().call().then(console.log);
contract.methods.getMessage().call().then(console.log);
web3.eth.getBalance(contract._address).then(console.log);
web3.eth.getBalance(signer).then(console.log);
As you should see, the transaction was successfully executed, incrementing the contract nonce
by one and setting its message
.
Furthermore, the contract balance is decreased, indicating that the contract did in fact pay for the transaction on its own.
The signer account had no part in this interaction directly (the caller address was the 0xffffffffffffffffffffffffffffffffffffffff
entry point address) and only collected the transaction fee in its role as block producer.
This concludes the demonstration of the Whiteboard
contract.
Feel free to play around with the contract some more - this is the Account Abstraction Playground after all - e.g. by sending a transaction with failAfterPaygas = true
.
For a somewhat more advanced use case, we will next look at the Wallet
contract.
The contract file for the Wallet
contract can be found at contracts/Wallet.sol
.
The Wallet
contract contains 2 variables:
uint256 nonce
is the contract's internal replay protection analogous to the one used in theWhiteboard
example.address owner
is the contract owner and gets set to the account that deploys the contract. The owner has to sign all outgoing transfers from the wallet.
The contract inherits ECDSA signature verification logic from contracts/ECDSA.sol
, which is based on the OpenZeppelin ECDSA library.
The only change from the OppenZeppelin original is the change from a library to a contract that the Wallet
contract then inherits from.
As signature verification happens before PAYGAS
, the AA opcode restrictions apply.
For the MVP, these include a ban of the DELEGATECALL
used for library interactions.
As AA transaction verification must not rely on external state, there are concerns around library contracts changing their code via SELFDESTRUCT
, thus potentially rendering previously valid AA transactions invalid.
While we do expect future iterations of AA to support DELEGATECALL
in some form, for the MVP one has to directly inherit from the ECDSA
contract such that all of its logic gets deployed together with the rest of the Wallet
contract.
In contrast to library interactions, calling into precompiles via STATICCALL
is allowed even before PAYGAS
.
The ECDSA
makes use of this by internally calling the ecrecover
precompile for reconstruction of the signer address.
The relevant function provided by the contract is recover(bytes32 hash, bytes memory signature)
, which takes a message hash and a 65-byte signature and returns the address of the account that signed the message.
To transfer ETH from the wallet to an external address, the contract provides function transfer(uint256 txNonce, uint256 gasPrice, address payable to, uint256 amount, bytes calldata signature)
.
This function takes five parameters:
txNonce
is the nonce of the transaction, analogous toWhiteboard.setMessage(...)
.gasPrice
is the gas price the contract is supposed to pay for the transaction, analogous toWhiteboard.setMessage(...)
.to
is the recipient address.amount
is the amount in wei to be transferred to the address.signature
is the 65-byte signature of the transaction. It consists of the 32-byter
, 32-bytes
, and 1-bytev
values of the ECDSA signature over the hash ofcontract._address, txNonce, gasPrice, to, amount
.
To compile the Wallet
contract, do:
solidity/build/solc/solc --bin --abi contracts/Wallet.sol
This should output both the contract bytecode, which we will reference as __BYTECODE__
, as well as its ABI, which we will reference as __ABI__
.
To deploy the contract in nodejs
, do:
const Web3 = require('web3');
let web3 = new Web3('http://localhost:8545');
let signer = '0x__SIGNER__';
let bytecode = '0x__BYTECODE__';
let abi = __ABI__; // note: no quotes!
let Contract = new web3.eth.Contract(abi);
let contract;
Contract.deploy({data: bytecode}).send({from: signer, value: 10000000}).then(function(contractInstance){contract = contractInstance; console.log(contractInstance);});
If you are using the same nodejs
session as before, be sure to omit the let
at the beginning of each statement for already declared variables, e.g. bytecode = ...
instead of let bytecode = ...
As an example transaction, you are going to send 42 wei
to the 0x0000000000000000000000000000000000000000
address.
Before doing so, you can inspect the current chain state via:
let zeroAddress = '0x0000000000000000000000000000000000000000';
contract.options.from = '0xffffffffffffffffffffffffffffffffffffffff';
contract.methods.getNonce().call().then(console.log);
contract.methods.getOwner().call().then(console.log);
web3.eth.getBalance(contract._address).then(console.log);
web3.eth.getBalance(zeroAddress).then(console.log);
web3.eth.getBalance(signer).then(console.log);
As you can see, the signer account that deployed the AA contract is registered as its owner. To send a transfer, you thus first have to create and sign the transaction hash from the signer account:
let hash = web3.utils.soliditySha3(contract._address, 0, 1, zeroAddress, 42);
let txSignature;
web3.eth.personal.sign(hash, signer).then(function(signature){txSignature = signature;});
You can then send the transfer as an AA transaction:
contract.methods.transfer(0, 1, zeroAddress, 42, txSignature).send({gasPrice: "0", gasLimit: 100000}).then(console.log);
After a few seconds, the transaction should be included in a block.
The contract balance should be reduced by the sum of the transaction fee and the 42 wei
sent out.
The balance of the zero address should now be 42 wei
.