Photo by Raj Dhiman on Unsplash

Connecting Web2 & Web3 — Account Abstraction (Part I)

Kushagra Jindal
cumberlandlabs
Published in
4 min readJul 23, 2023

--

In the article by Vitalik Why we need wide adoption of social recovery wallets, he highlighted the major issues with building and using cryptocurrency and blockchain applications. What happens if someone forgets their Private Key? Is there a mechanism to recover the wallets? Why do users always have to pay the Gas fees in ETH? This is difficult for users due to the high volatility in ETH prices.

Consider buying a coffee. First, we need ETH in the wallet, and not sure if my coffee can cost up to $5 while waiting in the queue or $10. Can users set up a transaction limit, as they use to configure in the banking industry? Can users set up whitelisted receivers?

Many different EIPs proposed based on (a) letting EOAs execute EVM code and (b) allowing smart contracts to initiate transactions. Considering the EIPs 3074: AUTH and AUTHCALL opcodes, EIP 5003: Insert Code into EOAs with AUTHUSURP, and EIP 2938: Account Abstraction requires protocol level changes, ERC 4337: Account Abstraction Using Alt Mempool got the most considerations.

EIP 4337 High-Level Understanding

Account Abstraction Understanding

In this series of articles, we will build an account abstraction model which can perform all the basic operations like

  • Creating the AA wallet(CodeInit)
  • Calling another contract from the wallet(CallData)
  • Let someone else pay the Gas Fees(Paymaster)

In case you don't understand the terminologies, don’t worry. We will discuss it below.

For a detailed sequence of flow, you can refer to this — https://medium.com/cumberlandlabs/building-account-abstraction-erc-4337-part-1-create-wallet-contract-initcode-82024f9c0ec1

Before the start, please make sure you have basic information knowledge of the following topics:

  1. Node.js
  2. web3.js
  3. Ganache and Truffle Framework

Initial Setup

Start the Ganache Server

ganache --chain.chainId 1 --fork https://mainnet.infura.io/v3/<infura_id>

The dev chain will run on port 8545 with 10 wallets with 100 ETH each by default. The next step is to deploy the contracts provided by eth-infinity, but before that, let’s make sure to set the optimizer for the truffle to compile the contract; this is required because the size of the EntryPoint contract that we are deploying is above the limit allowed in GETH.

compilers: {
solc: {
version: "0.8.19", // Fetch exact version from solc-bin (default: truffle's version)
// docker: true, // Use "0.5.1" you've installed locally with docker (default: false)
settings: { // See the solidity docs for advice about optimization and evmVersion
optimizer: {
enabled: true,
runs: 200
},
// evmVersion: "byzantium"
}
}
}

Set up the truffle migrations. Here, I am deploying EntryPoint, SimpleAccountFactory, and PaymasterVerifier. But as we are working on a mainnet fork, we would not need to deploy the EntryPoint and SimpleAccountFactory.

/* eslint-disable no-undef */
const EntryPoint = artifacts.require("EntryPoint");
const SimpleAccountFactory = artifacts.require("SimpleAccountFactory");
const VerifyingPaymaster = artifacts.require("VerifyingPaymaster");

module.exports = async (deployer) => {
// Deploying the EntryPoint Contract
await deployer.deploy(EntryPoint);
await EntryPoint.deployed();

// Deploying the SimpleAccountFactory
await deployer.deploy(SimpleAccountFactory, EntryPoint.address);
await SimpleAccountFactory.deployed();

// Deploying the VerifyingPaymaster
await deployer.deploy(
VerifyingPaymaster,
// EntryPoint.address,
config.entryPoint.contractAddress,
config.paymaster.signerPublicAddress
);
await VerifyingPaymaster.deployed();
};

Here, we are ready for the migrations using the truffle migrate command. If you face any issues, you can explicitly set the deployed in truffle-config.js

development: {
provider: () => new HDWalletProvider({
privateKeys: ['<your pvt key>'],
providerOrUrl: 'http://localhost:8545'
}),
network_id: "*",
gasPrice: 20000000000
},

With this, we are ready with the initial setup. Let’s start with building the Wallet using codeInit.

module.exports = {
getSender: async (owner, salt) =>
SimpleAccountFactoryContract.methods.getAddress(owner, salt).call(),

getNonce: async (sender) =>
EntryPointContract.methods.getNonce(sender, 0).call(),

getInitCode: async (owner, salt) => {
const iface = new ethers.utils.Interface(
simpleFactoryContractDetails.abi
).encodeFunctionData("createAccount", [owner, salt]);
return hexConcat([config.simpleAccountFactory.contractAddress, iface]);
},
}

The next step is to build the userOps and send it to the bundler.

const sender = await getSender(
config.testData.account1PublicKey,
config.testData.salt
);

const initCode = await getInitCode(
config.testData.account1PublicKey,
config.testData.salt
);

// Fund the sender
await signSendTransaction(
await fund(
config.testData.account1PublicKey,
sender,
"1000000000000000000"
),
process.env.ACCOUNT_1_PRIVATE_KEY
);

const entryPointNonce = await getNonce(sender);

const userOps = {
sender,
nonce: entryPointNonce,
initCode,
callData: "0x",
callGasLimit: 260611,
gasLimit: 362451,
verificationGasLimit: 362451,
preVerificationGas: 53576,
maxFeePerGas: 29964445250,
maxPriorityFeePerGas: 100000000,
paymasterAndData: "0x",
signature: "0x",
};

Let’s sign the userOps

module.exports = {
getUserSignature: async (userOps) => {
const packUserOp = defaultAbiCoder.encode(
[
"address",
"uint256",
"bytes32",
"bytes32",
"uint256",
"uint256",
"uint256",
"uint256",
"uint256",
"bytes32",
],
[
userOps.sender,
userOps.nonce,
keccak256(userOps.initCode),
keccak256(userOps.callData),
userOps.callGasLimit,
userOps.verificationGasLimit,
userOps.preVerificationGas,
userOps.maxFeePerGas,
userOps.maxPriorityFeePerGas,
keccak256(userOps.paymasterAndData),
]
);
const userOpHash = keccak256(packUserOp);
const enc = defaultAbiCoder.encode(
["bytes32", "address", "uint256"],
[userOpHash, config.entryPoint.contractAddress, 1]
);
const encKecak = keccak256(enc);
const message = arrayify(encKecak);
const msg1 = Buffer.concat([
Buffer.from("\x19Ethereum Signed Message:\n32", "ascii"),
Buffer.from(message),
]);
const sig = ethereumjs.ecsign(
ethereumjs.keccak256(msg1),
Buffer.from(arrayify(process.env.ACCOUNT_1_PRIVATE_KEY))
);
return ethereumjs.toRpcSig(sig.v, sig.r, sig.s);
},
};

With this, our userOps object is ready to be sent by the Bundler to the EntryPoint Contract.

const getHandleOpsTransaction = async (userOps) => {
const handleOpsRawData = EntryPointContract.methods
.handleOps([userOps], config.testData.coordinatorPublicKey)
.encodeABI();
const nonce = await web3.eth.getTransactionCount(config.testData.coordinatorPublicKey, 'latest');
return {
nonce,
from: config.testData.coordinatorPublicKey,
to: config.entryPoint.contractAddress,
gasLimit: 1e7,
data: handleOpsRawData,
maxFeePerGas: config.chain.maxFeePerGas
};
}

const preparedHandleOps = await getHandleOpsTransaction(userOps);
const transaction = await signSendTransaction(
preparedHandleOps,
process.env.COORDINATOR_PRIVATE_KEY
);
console.log(`Transaction Completed!!! ${transaction}`);

Hurray, with this, we finally deployed the Wallet Contract, which is ready to be used.

Next Steps

In the next part of this series, we will go through the following:-

  1. Transfer USDC from the new Wallet to a Recipient.
  2. Let the paymaster pay for our Gas!!

References

  1. Why we need wide adoption of social recovery wallets
  2. Account Abstraction by Metamask
  3. ERC 4337: Account Abstraction Using Alt Mempool

I’d love to hear any feedback or questions. You can ask the question by commenting, and I will try my best to answer it.

--

--