1

I want to create a raw ERC-4337 UserOperation. I am using ethers library to write the test cases .

According to ERC-4337 official documentation , the UserOperation is a struct with many fields .

Reference : https://eips.ethereum.org/EIPS/eip-4337#definitions

How to calculate each fields value ( like initCode , calldata , signature etc ) and combine them to make a UserOperation struct which will then be sent to the alternative mempool ? [ Edit : I searched through repos and got something , dont know if it's correct ] Below is the test case I wrote to create a UserOperation and send it to bundler ( I have StackUp's bundler url )

it("creating a user operation from scratch and then send it to bundler", async function () {
const EntryPoint_Addr = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789";
const RECEIVER_ADDR = "0x53C242Cc21d129155aCC5FAc321abDfe83C35Af7"; // sending ether to this address
const amount = '1000000000000000'; // amount to send to RECEIVER_ADDR

const sender = "0x1e87a1Eca600313aE1388D04e71ea473AD468CAE"; // smart account address , created by calling createAccount method of AccountFactory ( deployed at 0x9406Cc6185a346906296840746125a0E44976454 ) passing RECEIVER_ADDRESS as address and 1 as salt value
const nonce = '1'; // used salt's value as 1 at the time of smart account creation . that's why I kept it same .
const callGasLimit = '500000';
const verificationGasLimit = '200000';
const preVerificationGas = '50000';
const maxFeePerGas = '1000000000'; // adjust the value according to your needs
const maxPriorityFeePerGas = '100000000'; // adjust the value according to your needs
const account = new ethers.utils.Interface(accountABI); // ethers code
const calldata = account.encodeFunctionData('execute',[RECEIVER_ADDR, amount, "0x"]);
// calldata : 0xb61d27f600000000000000000000000053c242cc21d129155acc5fac321abdfe83c35af700000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000
    
// getUserOpHash function is taken from StackUp's UserOp library . Reference : https://github.com/stackup-wallet/userop.js/blob/main/src/context.ts
const getUserOpHash = () => {
            const packed = ethers.utils.defaultAbiCoder.encode(
              [
                "address",
                "uint256",
                "bytes32",
                "bytes32",
                "uint256",
                "uint256",
                "uint256",
                "uint256",
                "uint256",
                "bytes32",
              ],
              [
                sender,
                nonce,
                ethers.utils.keccak256('0x'),             ethers.utils.keccak256('0xb61d27f600000000000000000000000053c242cc21d129155acc5fac321abdfe83c35af700000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000'),
                callGasLimit,
                verificationGasLimit,
                preVerificationGas,
                maxFeePerGas,
                maxPriorityFeePerGas,
                ethers.utils.keccak256('0x'),
              ]
            );
        
            const enc = ethers.utils.defaultAbiCoder.encode(
              ["bytes32", "address", "uint256"],
              [ethers.utils.keccak256(packed), EntryPoint_Addr, 80001]
            );
        
            return ethers.utils.keccak256(enc);
         }

         const userOpHash = getUserOpHash();
        //  userOpHash value : 0x1a23a91638f4a6c594c64b1b17bb930f9505c244b7a9cbc7065213bfccc71ba9

        // Arraified the userOpHash . Reference : https://github.com/stackup-wallet/userop.js/blob/main/src/preset/middleware/signature.ts
        
        const arraifiedHash =  ethers.utils.arrayify(userOpHash);
        console.log("arraified Hash :",arraifiedHash);
        const provider = new ethers.providers.JsonRpcProvider("https://polygon-mumbai.infura.io/v3/infurakey"); 
        const wallet = new ethers.Wallet('myprivatekey', provider);
        const signer = wallet.provider.getSigner(wallet.address);
        const serializeObj = JSON.stringify(arraifiedHash);   // **error suspect 1**
        const signature = signer.signMessage(serializeObj);

        const options = {
          method: "POST",
          url: "https://api.stackup.sh/v1/node/stackupkey",
          headers: {
            accept: "application/json",
            "content-type": "application/json",
          },
          data: {
            jsonrpc: "2.0",
            id: 1,
            method: "eth_sendUserOperation",
            params: [
              {
                sender: "0x1e87a1Eca600313aE1388D04e71ea473AD468CAE",
                nonce: "0x0",
                initCode: '0x',
                callData: "0xb61d27f600000000000000000000000053c242cc21d129155acc5fac321abdfe83c35af700000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000",
                callGasLimit: "500000",
                verificationGasLimit: "200000",
                preVerificationGas: "50000",
                maxFeePerGas: "1000000000",
                maxPriorityFeePerGas: "100000000",
                paymasterAndData: "0x",
                signature:signature
              },
              "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789",
            ],
          },
        }; 
        await axios
          .request(options)
          .then(function (response) {
            console.log(response.data);
          })
          .catch(function (error) {
            console.error(error);
          });
});

2 Answers 2

1
+50

Some information to understand UserOptions and to create them.

  1. You should know your ERC4337 infrastructure

    • EntryPoint - a contract to get your UserOperation and handle it
    • Account - your erc4337 contract account (like multisig)
    • Account Factory - a contract that creates that Account for you

There will be many account abstraction implementations. Below, we'll look into https://github.com/eth-infinitism/account-abstraction, which StackUp also uses, to prepare raw UserOperation.

  1. Prepare the ERC4337 account initialization data via Account Factory

Here we generate the initCode and calculate initCodeGas.

SimpleAccountFactory.sol

Create the transaction data for the createAccount method of the AccountFactory and prepend the AccountFactory address so that the EntryPoint contract can create the account during the handling of the UserOperation.

It will look like this:

let createAccountTx = await accountFactoryContract.$data().createAccount({ address: owner }, owner, salt);
let initCode = $abiUtils.encodePacked(['address', 'bytes'], [ accountFactoryContract.address, createAccountTx.data ]);
let initCodeGas = await this.client.getGasEstimation(entryPointContract.address, createAccountTx);
  1. Calculate ERC4337 account address

To make it simple, we'll use on-chain method EntryPoint.sol::getSenderAddress to get the erc4337 account address based on initCode

As this is the WRITE method which reverts with the address, we simply call it and get the sender.

let { error, result } = await this.entryPointContract.$call().getSenderAddress({ address: owner }, initCode);
let erc4337AccountAddress = error.data.params.sender;
  1. Prepare Target contract transaction data

Here we generate the callData and calculate callDataGas for our arbitrary contract transactions as we would do for normal transactions.

  1. Get the nonce

The EntryPoint contract has the method getNonce(address sender, uint192 key) - so, we can use it:

let nonce = await entryPointContract.getNonce(erc4337AccountAddress, 0);
  1. Prepare maxFeePerGas and maxPriorityFeePerGas. You can leave these values empty, then the EOA who sends the transaction to the entry point is paying for this UserOperation. Otherwise, you can retrieve the current gas price and set the values as you do it for a normal transaction. But your Erc4337 account must be pre-funded with ETH, or you use Paymasters.

What we have so far:

let op: Partial<UserOperation> = {
    sender: erc4337AccountAddress,
    initCode: initCode,
    callData: callData,
    callGasLimit: BigInt(callDataGas),
    verificationGasLimit: 150000n + BigInt(initCodeGas),
    nonce: nonce,
    maxFeePerGas: 0n,
    maxPriorityFeePerGas: 0n,
}
  • We use the min value for verificationGasLimit required by the entrypoint
  • If the erc4337 Account was already previously deployed, you won't need any initCode and initCodeGas

  1. Get the opHash (Operation Hash)

The EntryPoint contract has the method getUserOpHash(UserOperation calldata userOp) - so, we can use it:

let opHash = await entryPointContract.getUserOpHash(op)
  1. Get the signature by signing the opHash with the owner's private key (eip-712):
let sig = await signEIPHashed(opHash, owner);
let opSigned = {
    ...op,
    signature: sig.signature
};

The steps above may be different depending on ERC4337 implementation and web3 library, but the key points remain.

Finally, your UserOperation (opSigned) is ready to be submitted to the blockchain by you, your friend, or Bundler.

You can get additional information from tests

0

So , this is how I was able to send a UserOperation.

it("creating a user operation from scratch and then send it to bundler", async function () {
         const sender = "0x0Dc570f9aD08D370dCa4D097A9f1b1E477Cb79A2"; // smart account address
         const nonce = '0'; 
         const callGasLimit = '500000';
         const verificationGasLimit = '200000';
         const preVerificationGas = '50000';
         const maxFeePerGas = '1000000000'; 
         const maxPriorityFeePerGas = '100000000';
         const account = new ethers.utils.Interface(accountABI); 
         const amount = '1000000000000000'; // amount to send to RECEIVER_ADDR

         const calldata = account.encodeFunctionData('execute',[RECEIVER_ADDR, amount, "0x"]);
         console.log("calldata :", calldata);
         // 0xb61d27f600000000000000000000000053c242cc21d129155acc5fac321abdfe83c35af700000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000

         const getUserOpHash = () => {
            const packed = ethers.utils.defaultAbiCoder.encode(
              [
                "address",
                "uint256",
                "bytes32",
                "bytes32",
                "uint256",
                "uint256",
                "uint256",
                "uint256",
                "uint256",
                "bytes32",
              ],
              [
                sender,
                nonce,
                ethers.utils.keccak256('0x'), // initCode is kept 0x because I called createAccount method of Smart Wallet Factory before sending the user operation , if the smart account is not onchain we have to calculate initCode 
                ethers.utils.keccak256(calldata),
                callGasLimit,
                verificationGasLimit,
                preVerificationGas,
                maxFeePerGas,
                maxPriorityFeePerGas,
                ethers.utils.keccak256('0x'),
              ]
            );
        
            const enc = ethers.utils.defaultAbiCoder.encode(
              ["bytes32", "address", "uint256"],
              [ethers.utils.keccak256(packed), EntryPoint_Addr, '80001']
            );
        
            return ethers.utils.keccak256(enc);
         }

         const userOpHash = getUserOpHash();
         console.log("userOpHash :",userOpHash);
        //  0xcd11aba8b720ebb64a7d7359fd6af0d9b7977095b921a094b8a745ce520f6888

        const arraifiedHash =  ethers.utils.arrayify(userOpHash);
        // console.log("arraified Hash :",arraifiedHash);
        const provider = new ethers.providers.JsonRpcProvider("https://polygon-mumbai.infura.io/ws/v3/key"); // Connect to the Matic mainnet (replace with your desired network)
        const signer = new ethers.Wallet('privatekey',provider);
        const signature = await signer.signMessage(arraifiedHash);
        // console.log("signature :",signature);
        // 0x39770a3658efac589f5b3fcbf0292061bddae46d88d6c4e3ff1412d1a037b0a633cfda59a27ad7a48d39a8af7d5a5ee8bf6904e294fa354b1c42f7f71727839f1c
        const options = {
          method: "POST",
          url: "https://api.stackup.sh/v1/node/093e60e352135a8b93ba198c8e62138868eb9bfca0147ff9e00da75b267e2b0c",
          headers: {
            accept: "application/json",
            "content-type": "application/json",
          },
          data: {
            jsonrpc: "2.0",
            id: 1,
            method: "eth_sendUserOperation",
            params: [
              {
                sender: "0x0Dc570f9aD08D370dCa4D097A9f1b1E477Cb79A2",
                nonce: "0x0",
                initCode: '0x',
                callData: "0xb61d27f600000000000000000000000053c242cc21d129155acc5fac321abdfe83c35af700000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000",
                callGasLimit: "500000",
                verificationGasLimit: "200000",
                preVerificationGas: "50000",
                maxFeePerGas: "1000000000",
                maxPriorityFeePerGas: "100000000",
                paymasterAndData: "0x",
                signature:signature
              },
              "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789",
            ],
          },
        }; 
        await axios
          .request(options)
          .then(function (response) {
            console.log(response.data);
          })
          .catch(function (error) {
            console.error(error);
          });
    });

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.