# 干货-用Trampoline开发账户抽象钱包 **Published by:** [Arc_Yuzhi](https://paragraph.com/@arc-yuzhi/) **Published on:** 2023-09-03 **URL:** https://paragraph.com/@arc-yuzhi/trampoline ## Content trampoline是什么? trampoline是eth-infinitism提供的AA钱包框架,地址为 https://github.com/eth-infinitism/trampoline 。当你开发完Wallet合约和对应Account-API后,再做几个定制化页面,一个chrome插件就做好了。 下图为trampoline提供的主页界面,大部分页面都帮你做好了: AA基本知识回顾(1)——概念 从概念上来说,AA钱包是一个合约,它和EOA钱包的差异在于验签模式的不同。EOA在矿工层用ECDSA验签;而AA由合约定义验签逻辑,你可以用任何方式方式去定义它,可以是多签,可以是zk,可以更换曲线算法,等等。 其他方面,它和EOA没有差别,都可以储存资产,发起交易等。例如,对于一个ERC20合约,它保存的账本中,并不关心保存的地址是AA地址,还是EOA地址。 AA基本知识回顾(2)——流程 下图是发起AA调用的流程,黄色部分为需要自行开发的地方。 整体过程包括: 业务逻辑调用一个合约,例如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 了解chrome插件的结构,至少知道前台,后台 trampoline开发综述 下图是trampoline开发钱包的完整流程。 AA组件开发——Wallet、Factory、API 这里面我们要演示一个简单的多签钱包,需要2把私钥才能创建交易。三个组件: Wallet开发:参考实现。要点:继承 WalletFactory开发:参考实现 AccountAPI开发:参考实现 本地环境搭建(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地址: 通常,如果你不修改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端口 启动的时候,它还会执行配置检查,例如检查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目录加载插件。 trampoline整体结构 全局视角 其中,黄色部分OnBoarding页面、PreTransaction页面、AccountAPI页面是我们需要去开发的。 如果原图不清晰,可以先尝试获取原图( https://www.processon.com/diagraming/64f36c6e0ab2603a3f3e98c9 ),再不行可以加本人推特@masonlog获取。 trampoline开发——(1)认识目录 下图是Trampoline的目录,其中红色部分我们需在这里开发 trampoline开发——(2)编写account-api 编写constructor,以初始化两个addr字段。 OnBoarding页面结束后,会创建account-api,并传递状态,本例中为两个addr Background会在拉起插件时恢复状态,反序列化addr字段并创建account-api 编写serialize函数,以持续持久化addr字段 serialize函数规定了哪些字段要持久化,本例中为两个addr字段 编写createUnsignedUserOpWithContext,以初始化两把私钥 PreTransaciton页面结束后,会调用该函数,并传递收集的信息,本例中为两把私钥 其他和先前AccountAPI一样 图:constructor 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 图:编写createUnsignedUserOpWithContext 图:签名 Trampoline开发——onboarding页面 开发OnBoarding页面,以收集用户需要的信息,我们的例子中收集两个地址用于多签 OnBoarding页面结束时,要调用onOnboardingComplete回调,它会将两个地址作为参数,创建AccountAPI 所以,不管该页面多复杂,结束的时候记得调用onOnboardingComplete来创建Account-Api就行了 完整代码: 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即可 完整代码: 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字节即可。 踩坑案例(3)——未初始化AccountAPI的provider,导致构建UserOp失败 问题: 发送交易前,无法构建AccountAPI。 分析: 排查下来是因为UserOperation的callGasLimit需要访问区块链的rpc(estimateGas)获取。因此,我们必须指定一个Provider给AccountAPI。 解决: 踩坑案例(4)——钱包没有gas,导致余额不足 分析: UserOp的执行,会直接从Wallet中扣除gas费用,包括它部署的费用。如果费用不够,就会执行失败。 为了解决它,需要了解两个知识点: 工厂建议用Create2,这样可以很轻松的在不部署Wallet的情况下,预测出Wallet地址。 即使一个地址未部署,也可以给它转账,矿工内部会在MPT树为该地址创建叶子。之后,我们仍然可以部署合约到该地址,且先前的余额仍然有效。可以去看go-ethereum源码。 综上,我们只需取到Wallet的地址,不用担心它是否已经部署过,直接给它转账即可。随后就可以部署、发交易啦! 结尾 欢迎和我交流,我的推特是masonlog。 ## Publication Information - [Arc_Yuzhi](https://paragraph.com/@arc-yuzhi/): Publication homepage - [All Posts](https://paragraph.com/@arc-yuzhi/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@arc-yuzhi): Subscribe to updates - [Twitter](https://twitter.com/masonlog): Follow on Twitter