Cover photo

干货-用Trampoline开发账户抽象钱包

trampoline是什么?

trampoline是eth-infinitism提供的AA钱包框架,地址为

https://github.com/eth-infinitism/trampoline

。当你开发完Wallet合约和对应Account-API后,再做几个定制化页面,一个chrome插件就做好了。 下图为trampoline提供的主页界面,大部分页面都帮你做好了:

post image

AA基本知识回顾(1)——概念

从概念上来说,AA钱包是一个合约,它和EOA钱包的差异在于验签模式的不同。EOA在矿工层用ECDSA验签;而AA由合约定义验签逻辑,你可以用任何方式方式去定义它,可以是多签,可以是zk,可以更换曲线算法,等等。

其他方面,它和EOA没有差别,都可以储存资产,发起交易等。例如,对于一个ERC20合约,它保存的账本中,并不关心保存的地址是AA地址,还是EOA地址。

post image

AA基本知识回顾(2)——流程

下图是发起AA调用的流程,黄色部分为需要自行开发的地方。

post image

整体过程包括:

  • 业务逻辑调用一个合约,例如xxxContract.xxxMethod(args),其中xxxContract可以是AA钱包,也可以是其他合约

  • AA的signer借助AccountAPI,将这次调用转化成UserOperation

  • UserOperation通过Provider,调用eth_sendUserOp发往Bundler

  • Bundler先借助EntryPoint.simulateValidation验证UserOp,根据UserOp的initCode字段按需创建Factory,然后执行validateUserOp,随后回滚整个操作,将验证结果放到revert信息中

  • Bundler验证通过后,调用EntyrPoint.handleOps

  • EntryPoint先按需部署Wallet,然后根据UserOp的calldata字段,执行Wallet的函数

  • UserOp的验证和执行都需要扣除gas费用以支付bundler,这笔费用从EntryPoint的Stake资金池 或者从Wallet的eth余额中扣除,因此建议发起第一笔UserOp前,先给这个地址打款

相关技能储备

Trampoline采用React和MUI开发,所以建议熟悉这两样技能,但它们都是可以快速入门的技能。

完整的技能如下:

  • 熟悉react,mui——trampoline基于这两样框架

  • 熟悉ethers,hardhat,还有provider等概念

  • 熟悉aa框架的组件,wallet、sdk、aaprovider、aasigner、bundler、entrypoint、factory、paymaster

  • 了解chrome插件的结构,至少知道前台,后台

trampoline开发综述

下图是trampoline开发钱包的完整流程。

AA组件开发——Wallet、Factory、API

这里面我们要演示一个简单的多签钱包,需要2把私钥才能创建交易。三个组件:

本地环境搭建(1)——链节点和EntryPoint

account-abstraction项目提供了hardhat node,启动时会自动编译、部署EntryPoint

git clone https://github.com/eth-infinitism/account-abstraction
cd account-abstraction
git checkout v0.6.0
yarn 
yarn hardhat node

可以在控制台中得到EntryPoint地址:

post image

通常,如果你不修改EntryPoint,那么地址默认是:0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789

ps:

  • 一定要切到v0.6.0的tag,不要用main分支,因为给UserOp计算哈希的逻辑存在差异,而Trampoline以0.6.0为准,后文有个踩坑案例

    • 具体可以见UserOperatiionLib.sol的packOp函数

  • 如果出于debug目的,可以去contracts目录中修改源码

本地环境搭建(2)——Factory合约

可以用yarn hardhat写脚本部署,我写了一个示例,中可以用

git clone https://github.com/arc0035/aa_demo.git
cd aa_demo
yarn
yarn hardhat run scripts/deploy.js --network development

脚本中会打印出部署后的地址,示例中的地址是:

0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9

其他部署方法:

  • 把工厂合约的代码放到account-abstraction,通过hardhat-deploy插件,随节点启动一起部署, 或者

  • 使用remix链接本地节点后手动部署

本地环境搭建(3)——启动bundler

1)首先获取项目,并初始化:

git clone git@github.com:eth-infinitism/bundler.git
cd bundler
git checkout v0.6.0
yarn
yarn preprocess

2)然后配置:

进入packages/bundler/localconfig/bundler.config.json,主要是确认network、entryPoint:

{
  "gasFactor": "1",
  //可以考虑更改
  "port": "3000",
  //重点检查
  "network": "http://127.0.0.1:8545",
  //重点检查
  "entryPoint": "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789",
  "beneficiary": "0xd21934eD8eAf27a67f0A70042Af50A1D6d195E81",
  "minBalance": "1",
  "mnemonic": "./localconfig/mnemonic.txt",
  "maxBundleGas": 5e6,
  "minStake": "1" ,
  "minUnstakeDelay": 0 ,
  "autoBundleInterval": 3,
  "autoBundleMempoolSize": 10
}

通常,这些信息都已经给你配置好了,不需要额外的操作。

3)启动bundler

yarn bundler --unsafe --auto

出现下图即为成功,默认监听3000端口

post image

启动的时候,它还会执行配置检查,例如检查entryPoint是否是合约等,以免配置错误。

本地环境搭建(4)——配置trampoline

开发trampoline之前,需要先对trampoline做基本配置

1)  先下载项目

git clone https://github.com/eth-infinitism

2)  进行配置

打开src/esconfig.ts,检查工厂合约地址、EntryPoint、provider、bundler这几个选项:

// eslint-disable-next-line import/no-anonymous-default-export
export default {
  //如业务不需要密码加密,可以关掉
  enablePasswordEncryption: false,
  showTransactionConfirmationScreen: true,
  //重点检查
  factory_address: '0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9',
  stateVersion: '0.1',
  network: {
    chainID: '11155111',
    family: 'EVM',
    name: 'local',
    //重点检查
    provider: 'http://localhost:8545',
    //重点检查
    entryPointAddress: '0x6fcbA09f80EB7B12E61d846aa803D541969Bc033',
    //重点检查
    bundler: 'http:/localhost:3001/rpc',
    baseAsset: {
      symbol: 'ETH',
      name: 'ETH',
      decimals: 18,
      image:
'https://upload.wikimedia.org/wikipedia/commons/7/70/Ethereum_logo.svg'
    },
  },
};

3)启动项目

yarn start

执行后,会在trampoline目录下生成build目录。

4)加载插件

按下述步骤,从build目录加载插件。

post image
post image
post image
post image

trampoline整体结构

全局视角

post image

其中,黄色部分OnBoarding页面、PreTransaction页面、AccountAPI页面是我们需要去开发的。

如果原图不清晰,可以先尝试获取原图(

https://www.processon.com/diagraming/64f36c6e0ab2603a3f3e98c9

),再不行可以加本人推特@masonlog获取。

trampoline开发——(1)认识目录

下图是Trampoline的目录,其中红色部分我们需在这里开发

post image

trampoline开发——(2)编写account-api

  • 编写constructor,以初始化两个addr字段。

    • OnBoarding页面结束后,会创建account-api,并传递状态,本例中为两个addr

    • Background会在拉起插件时恢复状态,反序列化addr字段并创建account-api

  • 编写serialize函数,以持续持久化addr字段

    • serialize函数规定了哪些字段要持久化,本例中为两个addr字段

  • 编写createUnsignedUserOpWithContext,以初始化两把私钥

    • PreTransaciton页面结束后,会调用该函数,并传递收集的信息,本例中为两把私钥

  • 其他和先前AccountAPI一样

图:constructor

post image
    privateKey1: string;
    privateKey2: string;

    addr1: string;
    addr2: string;

    constructor(params: AccountApiParamsType<{
        addr1: string,
        addr2: string
    }, {
        addr1: string,
        addr2: string
    }>) {
        super({
            ...params,
            overheads: {
                sigSize: 320 //用于提高gas限制,因为签名数据变长了
            },
            provider: new ethers.providers.JsonRpcProvider("http://localhost:8545")
        });

        this.addr1 = params.deserializeState?.addr1 ? params.deserializeState?.addr1 : params.context?.addr1 || '';
        this.addr2 = params.deserializeState?.addr2 ? params.deserializeState?.addr2 : params.context?.addr2 || '';

        this.privateKey1 = "";
        this.privateKey2 = "";
    }

图:serialize

post image

图:编写createUnsignedUserOpWithContext

post image

图:签名

post image

Trampoline开发——onboarding页面

  • 开发OnBoarding页面,以收集用户需要的信息,我们的例子中收集两个地址用于多签

  • OnBoarding页面结束时,要调用onOnboardingComplete回调,它会将两个地址作为参数,创建AccountAPI

  • 所以,不管该页面多复杂,结束的时候记得调用onOnboardingComplete来创建Account-Api就行了

post image

完整代码:

import {
  Box,
  Button,
  CardActions,
  CardContent,
  TextField,
  Typography,
} from '@mui/material';
import { Stack } from '@mui/system';
import React, { useState } from 'react';
import { OnboardingComponent, OnboardingComponentProps } from '../types';
import { useNavigate } from 'react-router-dom';

const Onboarding: OnboardingComponent = ({
  onOnboardingComplete,
}: OnboardingComponentProps) => {

  const [step, setStep] = useState(1);
  const [addr1, setAddr1] = useState('');
  console.log('onboarding!')
  return (
    <Box p="2">
      {step == 1 && <Step1Page onStepComplete={(ret: any) => {
        setAddr1(ret);
        setStep(2);
      }} />}

      {step == 2 && <Step2Page onStepComplete={(addr2: any) => {
        onOnboardingComplete({
          addr1: addr1,
          addr2: addr2
        });
      }} />}
    </Box>
  )
};


const Step1Page = ({
  onStepComplete
}: any) => {

  const [addr1, setAddr1] = useState('');
  return (
    <Box p="2">
      <CardContent>
        <Typography variant="h3" gutterBottom>
          请输入第一个地址:
        </Typography>
        <div>提示:
          0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

        </div>
      </CardContent>
      <CardActions sx={{ pl: 4, pr: 4, width: '100%' }}>
        <Stack spacing={2} sx={{ width: '100%' }}>
          <TextField variant='outlined' required
            value={addr1}
            fullWidth
            onInput={e => setAddr1(e.target.value)}
            size="small">

          </TextField>
          <Button
            size="large"
            variant="contained"
            onClick={() => { onStepComplete(addr1) }}
          >
            Continue
          </Button>
        </Stack>
      </CardActions>
    </Box>
  )
}

const Step2Page = ({
  onStepComplete
}: any) => {

  const [addr2, setAddr2] = useState('');
  return (
    <Box p="2">
      <CardContent>
        <Typography variant="h3" gutterBottom>
          请输入第二个地址:
        </Typography>
        <div>
          提示:
          0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

        </div>
      </CardContent>
      <CardActions sx={{ pl: 4, pr: 4, width: '100%' }}>
        <Stack spacing={2} sx={{ width: '100%' }}>
          <TextField variant='outlined' required
            value={addr2}
            fullWidth
            onInput={e => setAddr2(e.target.value)}
            size="small">

          </TextField>
          <Button
            size="large"
            variant="contained"
            onClick={() => { onStepComplete(addr2) }}
          >
            Continue
          </Button>
        </Stack>
      </CardActions>
    </Box>
  )
}
export default Onboarding;

Trampoline开发——preTransaction页面

  • 开发PreTransaction页面,以收集用户需要的信息,我们的例子中收集两个私钥用于多签

  • PreTransaction页面结束时,要调用onComplete回调,它会将两个私钥作为参数,去调用AccountAPI的createUnsignedUserOpWithContext

  • 总之,不管它页面多复杂,结束的时候调用onComplete回调去构造UserOperation即可

post image

完整代码:

import {
  Box,
  Button,
  CardActions,
  CardContent,
  CircularProgress,
  Paper,
  Stack,
  TextField,
  Typography,
} from '@mui/material';
import React, { useCallback, useState } from 'react';
import {
  PreTransactionConfirmation,
  PreTransactionConfirmationtProps,
} from '../types';


const PreTransactionConfirmationComponent: PreTransactionConfirmation = ({
  onComplete,
  transaction,
  onReject,
}: PreTransactionConfirmationtProps) => {

  const [step, setStep] = useState(1);
  const [privateKey1, setPrivateKey1] = useState('');
  console.log('pre-transaction-confirmation!')
  return (
    <Box p="2">
      {step == 1 && <Step1Page onStepComplete={(ret: any) => {
        setPrivateKey1(ret);
        setStep(2);
      }} />}

      {step == 2 && <Step2Page onStepComplete={(ret: any) => {
        alert('即将开始签名!');
        onComplete(
          transaction,
          {
            privateKey1: privateKey1,
            privateKey2: ret
          });
      }} />}
    </Box>
  )
};




const Step1Page = ({
  onStepComplete
}: any) => {

  const [privateKey1, setPrivateKey1] = useState('');
  return (
    <Box p="2">
      <CardContent>
        <Typography variant="h3" gutterBottom>
          请输入第一个地址:
        </Typography>
        <div>提示:
          Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
        </div>
      </CardContent>
      <CardActions sx={{ pl: 4, pr: 4, width: '100%' }}>
        <Stack spacing={2} sx={{ width: '100%' }}>
          <TextField variant='outlined' required
            value={privateKey1}
            fullWidth
            onInput={e => setPrivateKey1(e.target.value)}
            size="small">

          </TextField>
          <Button
            size="large"
            variant="contained"
            onClick={() => { onStepComplete(privateKey1) }}
          >
            Continue
          </Button>
        </Stack>
      </CardActions>
    </Box>
  )
}

const Step2Page = ({
  onStepComplete
}: any) => {

  const [privateKey2, setPrivateKey2] = useState('');
  return (
    <Box p="2">
      <CardContent>
        <Typography variant="h3" gutterBottom>
          请输入第二个地址:
        </Typography>
        <div>
          提示:
          Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
        </div>
      </CardContent>
      <CardActions sx={{ pl: 4, pr: 4, width: '100%' }}>
        <Stack spacing={2} sx={{ width: '100%' }}>
          <TextField variant='outlined' required
            value={privateKey2}
            fullWidth
            onInput={e => setPrivateKey2(e.target.value)}
            size="small">

          </TextField>
          <Button
            size="large"
            variant="contained"
            onClick={() => { onStepComplete(privateKey2) }}
          >
            Continue
          </Button>
        </Stack>
      </CardActions>
    </Box>
  )
}



export default PreTransactionConfirmationComponent;

调试方法

  • 合约逻辑相关的debug,EntryPoint、Wallet中都可以用hardhat/console.sol

  • bundler相关的debug,可以在bundler源码关键位置,例如handleMethod中去console.log

  • 页面相关的bug,其中背景部分,例如AccountAPI,需要在背景页查看日志

  • UI相关的bug,F12查看日志

踩坑案例(1)——因为分支未切换,导致签名不过

问题:AA框架报“wallet或paymaster签名验证失败”

排查:

首先排除paymaster原因,可以在EntryPoint的simulateValidation处打日志,查看validationData和pmValidationData字段,看到前者为1,即失败;

然后排查Wallet合约的原因,可以分别在sdk、Wallet处打印hash和地址,发现是OpHash不一致,跟踪代码到EntryPoint,发现我用的main分支,它和v0.6.0分支的packOp逻辑不一致,而trampoline依赖的是v0.6.0版本

解决:切换EntryPoint到v0.6.0分支解决

踩坑案例(2)——因为未设置好sigSize,导致preVerification验证失败

报错:

provider-bridge.ts:495 error processing request Error: processing response error (body="{\"jsonrpc\":\"2.0\",\"id\":44,\"error\":{\"message\":\"preVerificationGas too low: expected at least 45784\",\"code\":-32602}}", error={"code":-32602},
...

分析:

每个UserOperation的开销,除了验证的gasLimit和执行的gasLimit,还有一个是PreVerification,它是固定开销,取决于UserOperation的长度。bundler收到UserOperation后,会根据UserOperation对象的长度重新算出PreVerification,并和UserOperation的preVerification字段进行对比,如果少了,就会报上述错误。

由于我们采用了多签,因此,签名长度会比较长,本例子中是320字节,而不是65字节,因此需要调整。

解决:SDK提供了一个入口,让我们在Account-API中设置UserOperation各个部件的长度,其中sigSize设置为320字节即可。

post image

踩坑案例(3)——未初始化AccountAPI的provider,导致构建UserOp失败

问题:

发送交易前,无法构建AccountAPI。

分析:

排查下来是因为UserOperation的callGasLimit需要访问区块链的rpc(estimateGas)获取。因此,我们必须指定一个Provider给AccountAPI。

解决:

post image

踩坑案例(4)——钱包没有gas,导致余额不足

分析:

UserOp的执行,会直接从Wallet中扣除gas费用,包括它部署的费用。如果费用不够,就会执行失败。

为了解决它,需要了解两个知识点:

  • 工厂建议用Create2,这样可以很轻松的在不部署Wallet的情况下,预测出Wallet地址。

  • 即使一个地址未部署,也可以给它转账,矿工内部会在MPT树为该地址创建叶子。之后,我们仍然可以部署合约到该地址,且先前的余额仍然有效。可以去看go-ethereum源码。

综上,我们只需取到Wallet的地址,不用担心它是否已经部署过,直接给它转账即可。随后就可以部署、发交易啦!

结尾

欢迎和我交流,我的推特是masonlog。