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

By [Arc_Yuzhi](https://paragraph.com/@arc-yuzhi) · 2023-09-03

---

**trampoline是什么？**
==================

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

[https://github.com/eth-infinitism/trampoline](https://github.com/eth-infinitism/trampoline)

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

![](https://storage.googleapis.com/papyrus_images/e6e37ec76294cabbe63094dca9638a1450d2a66244ffc01de7128ac4c294b11d.png)

**AA基本知识回顾（1）——概念**
===================

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

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

![](https://storage.googleapis.com/papyrus_images/f551c4b822a965aa0b13c8a9521a9784a67f3b954cb6aea3d313eb53eca635b1.png)

**AA基本知识回顾（2）——流程**
===================

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

![](https://storage.googleapis.com/papyrus_images/30628913e4120699e26a5df44c2658e37042b83e9e679378218cccde9425d82f.png)

整体过程包括：

*   业务逻辑调用一个合约，例如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
    
    *   可以见上一次课程 ：[https://www.youtube.com/watch?v=1m9QmyDCpFc](https://www.youtube.com/watch?v=1m9QmyDCpFc)
        
*   了解chrome插件的结构，至少知道前台，后台
    

**trampoline开发综述**
==================

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

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

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

*   Wallet开发：[参考实现](https://github.com/arc0035/aa_demo/blob/main/contracts/MultiSigWallet.sol)。要点：继承
    
*   WalletFactory开发：[参考实现](https://github.com/arc0035/aa_demo/blob/main/contracts/WalletFactory.sol)
    
*   AccountAPI开发：[参考实现](https://github.com/arc0035/aa_demo/blob/main/api/MultiSigAPI.js)
    

**本地环境搭建(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地址：

![](https://storage.googleapis.com/papyrus_images/fba1a9c5f13d0149b6ebbf4758c4c60f80419d0f30a6fd1f1da7709611b9d1c7.png)

通常，如果你不修改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端口

![](https://storage.googleapis.com/papyrus_images/e02930186b9cf976b55fb6a94240fe40f48a872aba17672c046b1214c313d3df.png)

启动的时候，它还会执行配置检查，例如检查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目录加载插件。

![](https://storage.googleapis.com/papyrus_images/7dd8dd0a4e080f21c81a381da6e848ce4a7bbebea618d0e4df01feae55d47c56.png)

![](https://storage.googleapis.com/papyrus_images/91c45d740b5fc7f6c46f665c3c78f5101a70672a6cad596eeba81e101d75761a.png)

![](https://storage.googleapis.com/papyrus_images/5baff5b7d7082af67582465d76903cad3288f89f956337ebc9347404285d1e46.png)

![](https://storage.googleapis.com/papyrus_images/5baff5b7d7082af67582465d76903cad3288f89f956337ebc9347404285d1e46.png)

**trampoline整体结构**
==================

全局视角

![](https://storage.googleapis.com/papyrus_images/32cfbde3366f5fc8fa7499fd737c3e28753d9062868a6f1913e72560d38b2f00.png)

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

如果原图不清晰，可以先尝试获取原图（

[https://www.processon.com/diagraming/64f36c6e0ab2603a3f3e98c9](https://www.processon.com/diagraming/64f36c6e0ab2603a3f3e98c9)

），再不行可以加本人推特@masonlog获取。

**trampoline开发——（1）认识目录**
=========================

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

![](https://storage.googleapis.com/papyrus_images/fc9ea7a7e000d022be304675d4cabfed555384148e5efcb046d00d52195458d4.png)

**trampoline开发——（2）编写account-api**
==================================

*   编写constructor，以初始化两个addr字段。
    
    *   OnBoarding页面结束后，会创建account-api，并传递状态，本例中为两个addr
        
    *   Background会在拉起插件时恢复状态，反序列化addr字段并创建account-api
        
*   编写serialize函数，以持续持久化addr字段
    
    *   serialize函数规定了哪些字段要持久化，本例中为两个addr字段
        
*   编写createUnsignedUserOpWithContext，以初始化两把私钥
    
    *   PreTransaciton页面结束后，会调用该函数，并传递收集的信息，本例中为两把私钥
        
*   其他和先前AccountAPI一样
    

图：constructor

![](https://storage.googleapis.com/papyrus_images/84d5e72fa653159418405ba86b47f8e58f21eb4e08afb4c85b64bf470c9a0b77.png)

        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

![](https://storage.googleapis.com/papyrus_images/3449fc104d016e8551b59c3e0e152e8f396e3b3579831a2836578162f828ec99.png)

图：编写createUnsignedUserOpWithContext

![](https://storage.googleapis.com/papyrus_images/9e1dea5c8e04811a5d9f46c45125bd55195dd9e5e28f12b3262aea94ac64a233.png)

图：签名

![](https://storage.googleapis.com/papyrus_images/1bc463916ffe546efd00ec208dcd88159b931beb6788c65d7799fce7b0b2dd98.png)

**Trampoline开发——onboarding页面**
==============================

*   开发OnBoarding页面，以收集用户需要的信息，我们的例子中收集两个地址用于多签
    
*   OnBoarding页面结束时，要调用onOnboardingComplete回调，它会将两个地址作为参数，创建AccountAPI
    
*   所以，不管该页面多复杂，结束的时候记得调用onOnboardingComplete来创建Account-Api就行了
    

![](https://storage.googleapis.com/papyrus_images/eacf41c32b072737bace6a888c71514ba2120c2564b859ebe92f15a082837010.png)

完整代码：

    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即可
    

![](https://storage.googleapis.com/papyrus_images/fa3be7ea47f000f8ce44d463bfb143864f4cf86764c4bc428174f867ce0f3fa6.png)

完整代码：

    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字节即可。

![](https://storage.googleapis.com/papyrus_images/56233283d2d4e2912905902b22623747eff00df40911bd0921e324402e51eab3.png)

**踩坑案例（3）——未初始化AccountAPI的provider，导致构建UserOp失败**
=================================================

问题：

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

分析：

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

解决：

![](https://storage.googleapis.com/papyrus_images/5478216371e7eb41e3b5f6312cc2db220d79baa5a02f1a7e599b48db57fc2e2c.png)

**踩坑案例（4）——钱包没有gas，导致余额不足**
===========================

分析：

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

为了解决它，需要了解两个知识点：

*   工厂建议用Create2，这样可以很轻松的在不部署Wallet的情况下，预测出Wallet地址。
    
*   即使一个地址未部署，也可以给它转账，矿工内部会在MPT树为该地址创建叶子。之后，我们仍然可以部署合约到该地址，且先前的余额仍然有效。可以去看go-ethereum源码。
    

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

结尾
==

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

---

*Originally published on [Arc_Yuzhi](https://paragraph.com/@arc-yuzhi/trampoline)*
