关于 web3 account abstraction 的探索

359 阅读9分钟

AA 账户的诞生

在 Ethereum 中有两种 accounts,分别为 Externally Owned Accounts(EOA) 和 Contract Account(CA),其中只有 EOA 能够发起一笔交易(Tx);

而 CA 仅能被 EOA 调用,然后和其他帐户进行互动,可见 EOA 是驱动以太坊所有交易的主体。

但是,EOA 拥有诸多的限制,并且 CA 又受限于 EOA:

  1. 需要自行维护私钥或助记词,遗失或是被黑掉就等于失去所有资产
  2. EOA 无法定义函数与逻辑(例如想要设定黑名单、每日交易限额),欠缺灵活性
  3. 只能够使用 Ether 付手续费
  4. 不支持原生的多签钱包,要通过智能合约做事

EOA 拥有诸多的限制,并且 CA 又受限于 EOA。

有鉴于上述的限制,AA 账户的构想就已经开始。

AA 账户最早是由 EIP-2938 提出的一个完整概念;

随后在 EIP-3074 中,提出了一个 授权智能合约代表 EOA 进行操作 的新的 OpCode AUTH (0xf6) 和 AUTHCALL(0xf7)

最后由 EIP-4337 在最大兼容性下完成帐户抽象的任务,目前我们会将 AA 钱包称为 Smart Wallet 或者 Smart Contract Account (SCA)。

AA 账户的工作流程

  1. 使用者创建一个User Operation
  2. 使用者使用任何签名算法对User Operation 签名( 过去以太坊的交易只能使用ECDSA )
  3. 使用者将UserOperation 送至链下的User Operation mempool
  4. Bundler 会从mempool 中挑选出一些User Operation 进行打包
  5. 打包完后会送给矿工并上链

aa1.png

aa2.png

AA 账户的优点

Account Abstraction 很像是拥有 EOA 特性的 Contract Account,让交易和帐户从底层脱离成为 High-Level 智能合约的角色

Private Key Management

在ERC 4337 ,由于 Signature 不再仅限于以太坊传统的 ECDSA,可以自行使用想要的签名算法。

所以本质上 AA Wallet 是可以支援原生的多签钱包(MultiSig Wallet),能更弹性的由多方共管帐号;

此外,也能够通过社交恢复来重置合约帐户的所有权,使用如 gmail 信箱验证来取回合约帐号的存取权限( 参考UniPass Wallet )。

Enhanced security

智能合约可以实现多重签名授权等功能,交易完成前需要多方批准。与仅依赖私钥相比,这降低了未经授权访问的风险。

Pay Tx Fee

由于引入了 Paymaster 当手续费支付者的角色,AA Wallet 可以不用自己付手续费。 Dapp 可以协助使用者支付他们操作的手续费,改善使用者体验。

使用者也可以使用ERC20 支付手续费,这是过去 EOA 无法做到的事情。

Multi-Call

过去一个 EOA 一次只能进行一笔交易,然而通过 ERC 4337 ,可以将不同的交易都放在 User Operation 中 的callData栏位里,可以原子性地(atomic) 一次执行多种不同的交易。

Social media recovery

社交账号恢复也是AA wallet的一个优势,可以通过设置社交账号为钱包的监护人,可以通过社交账号验证来取回合约帐号的存取权限。

目前,丢失私钥意味着永远无法访问以太坊资金。账户抽象将账户访问与私钥分开。

通过社交恢复,您可以指定可信赖的联系人(如家人/朋友),如果您丢失了签名密钥,他们可以帮助您重新获得访问权限。

这可能涉及多步验证过程或延时访问程序,以防止未经授权的恢复。

以下是关于社交登录/恢复的流程 (aelf为上链操作)

aa3.png

AA 账户的缺点

Higher Gas Fee

过去EOA 和EOA 之间的转帐只需要消耗21000 Gwei ,然而使用了ERC 4337 之后,因为一定会发生合约调用,所以也额外产生了一些费用,导致 User Operation 的成本相对比较高。详情可以参考这篇文章

目前最好的解决的方法是利用Layer 2 来进行交易,能显著降低手续费成本。

Security

前面有提到,Paymaster 之所以要向EntryPoint 质押原生代币,是为了避免恶意的Paymaster 进行DoS 攻击。

由于Paymaster 为第三方实作的合约,按照下面的流程,它可以让Bundler 送出无效的交易,让Bundler 白白浪费交易手续费 :

  1. 建立恶意的 Paymaster contract,检查函数都回传 true
  2. 当UserOperation 进mempool 的时候,所有的模拟和检查都会通过
  3. 当Bundler 打包交给矿工时,Paymaster 可以从合约提出所有的原生代币并且frontrun Bundler 的上链请求
  4. 最终当Bundler 的交易被执行时,就会发生revert ,导致Bundler 无法从postOP取得补偿的原生代币

所以当Paymaster 发生恶意行为时,可以藉由reputation, throttling and banning section **这段描述的机制来惩罚 Paymaster。

代码例子

当前支持AA的智能钱包

目前有很多的 Web3 钱包支持 AA,我们将用以下两个产品帮助我们创建 AA 钱包,并建立 EAO 钱包和 AA 钱包的联系。

  1. dynamic
  2. achemy

创建 dynamic EAO 钱包

WalletProvider.tsx

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { WagmiProvider } from "wagmi";
import { PropsWithChildren } from "react"; import {
    DynamicContextProvider,
} from '@dynamic-labs/sdk-react-core';
import { EthereumWalletConnectors } from '@dynamic-labs/ethereum';
import { DynamicWagmiConnector } from '@dynamic-labs/wagmi-connector';
import { wagmiConfig } from "../config";
const queryClient = new QueryClient()
 
export default function WalletProvider({ children }: PropsWithChildren) {
    return <DynamicContextProvider
        settings={{
            environmentId: process.env.NEXT_PUBLIC_dynamic_env as string,
            walletConnectors: [EthereumWalletConnectors],
        }}
    >
        <WagmiProvider config={wagmiConfig}>
            <QueryClientProvider client={queryClient}>
                <DynamicWagmiConnector>
                    {children}
                </DynamicWagmiConnector>
            </QueryClientProvider>
        </WagmiProvider>
    </DynamicContextProvider>
}

wagmiConfig.ts

import { http, createConfig } from 'wagmi';
import { sepolia } from 'wagmi/chains';
export const wagmiConfig = createConfig({
    chains: [sepolia],
    multiInjectedProviderDiscovery: false,
    transports: {
        [sepolia.id]: http(),
    },
});

使用 DynamicWidget 组件展示钱包

app.tsx

import { DynamicWidget } from "@dynamic-labs/sdk-react-core";
import AbstractAcount from "./components/AbstractAcount";
import { useAccount } from "wagmi";
import { Spinner } from "@material-tailwind/react";
 
export default function Home() {
    const { isConnected, isConnecting, isReconnecting } = useAccount();
    if (isConnecting || isReconnecting) return <main className="w-screen h-screen flex items-center justify-center">
        <Spinner />
    </main>
    return (
        <main className="w-screen h-screen flex items-center justify-center">
            <div>
                <DynamicWidget variant="modal" />
                {
                    isConnected && <AbstractAcount />
                }
            </div>
        </main>
    );
}

界面上会出现一个按钮,点击按钮就能调出 Dynamic 的钱包登录组件,使用你喜欢的钱包登录,本文使用的是 Metamask

aa4.png

建立EOA钱包与AA钱包联系

参考 Dynamic 的例子,将 Dynamic 会将 EAO 钱包与 Achemy 的 AA 钱包进行链接,其中主要的是将 Signer 传递给 Achemy 使用,Achemy 会创建一个智能合约钱包 Client,通过 Client 可以获取钱包地址和余额,当然也可以使用 Client 进行交易。

SmartAccountClientProvider.tsx

import useDynamicSigner from "@/hooks/useDynamicSigner";
import { createModularAccountAlchemyClient } from "@alchemy/aa-alchemy";
import { SmartAccountClient, SmartAccountSigner, sepolia } from "@alchemy/aa-core";
import { createContext, useEffect, useState } from "react";
import { Address } from "viem";
export default function SmartAccountClientProvider({ children }: any) {
    const [AAadress, setAAadress] = useState<Address>('' as Address);
    const [AAbalance, setAAbalance] = useState('');
    const [loadingBalance, setLoadingBalance] = useState(false);
    const [loadingAddress, setLoadingAddress] = useState(false);
    const [smartAccountClient, setSmartAccountClient] = useState<SmartAccountClient | null>(null)
    const dynamicSigner = useDynamicSigner(); // dynamic can provide the signer
    const getBalance = async (client: SmartAccountClient, address: Address) => {
        try {
            setLoadingBalance(true)
            const balance = await client?.getBalance({ address })
            setAAbalance(balance.toString())
        } catch (err) {
            throw err;
        } finally {
            setLoadingBalance(false)
        }
    }
    const createSmartAccountClient = async (signer: SmartAccountSigner<any>) => {
        try {
            setLoadingAddress(true)
            const smartAcClient = await createModularAccountAlchemyClient({
                apiKey: process.env.NEXT_PUBLIC_alchemy_apikey,
                chain: sepolia,
                signer,
            });
            setSmartAccountClient(smartAcClient)
            setAAadress(smartAcClient.getAddress())
            await getBalance(smartAcClient, smartAcClient.getAddress())
        } catch (err) {
            throw err;
        } finally {
            setLoadingAddress(false)
        }
    }
    const loadAABalance = async () => {
        try {
            if (smartAccountClient) {
                await getBalance(smartAccountClient, AAadress as Address)
            }
        } catch (err) {
            throw err;
        }
    }
    useEffect(() => {
        if (dynamicSigner) {
            createSmartAccountClient(dynamicSigner)
        }
    }, [dynamicSigner]);
    return <SmartAccountClientContext.Provider
        value={{
            smartAccountClient,
            AAadress,
            AAbalance,
            loadingBalance,
            loadingAddress,
            loadAABalance
        }}
    >
        {children}
    </SmartAccountClientContext.Provider>
}

使用AA钱包发起交易

由于现在由 Client 新创建的钱包的余额还是 0,所以在发起交易之前需要向智能钱包打入一些钱以供使用。

aa5.png

aa6.png

创建 AbstractAcount.tsx 组件来显示当前 AA wallet 的信息,以及充值的功能。

import { SmartAccountClientContext } from "@/alchemy/SmartAccountClientProvider";
import { Card, CardBody, CardFooter, Typography, Button, Input, Spinner } from "@material-tailwind/react";
import Link from "next/link";
import { ChangeEventHandler, useContext, useMemo, useState } from "react";
import { parseEther } from "viem";
import { useSendTransaction } from "wagmi";
 
export default function AbstractAcount() {
    const {
        AAadress,
        AAbalance,
        loadingBalance,
        loadingAddress,
        loadAABalance
    } = useContext(SmartAccountClientContext);
    const [value, setValue] = useState('');
    const { sendTransaction, isPending, isError } = useSendTransaction()
    const handleChange: ChangeEventHandler<HTMLInputElement> = (e) => {
        const val = e.target.value;
        setValue(val)
    }
    const isValid = useMemo(() => {
        if (!value) return false;
        if (isNaN(Number(value))) return false;
        return true;
    }, [value])
 
    const handleCharge = () => {
        chargeAA()
    }
 
    const chargeAA = async () => {
        sendTransaction({
            to: AAadress,
            value: parseEther(value)
        }, {
            onSuccess: () => {
                loadAABalance();
            },
        });
    }
    return <Card className="flex flex-col gap-2 mt-4">
        <CardBody>
            <Typography variant="h5" color="blue-gray">
                Smart Account Address
            </Typography>
            <Typography className="my-2">
                {loadingAddress ? <Spinner /> : <Link
                    className="hover:text-capstackBlue"
                    target="_blank"
                    href={`https://sepolia.etherscan.io/address/${AAadress}`}
                >{AAadress}</Link>}
            </Typography>
            <Typography variant="h5" color="blue-gray">
                Smart Account Balance
            </Typography>
            <Typography className="my-2">
                {loadingBalance ? <Spinner /> : `${Number(AAbalance) / 1e18} ETH`}
            </Typography>
        </CardBody>
        <CardFooter>
            <div className="mb-4">
                <Input
                    value={value}
                    type="text"
                    label="Charge ETH to AA"
                    crossOrigin={undefined}
                    onChange={handleChange}
                    icon={<div className="absolute">ETH</div>}
                />
                <Typography
                    variant="small"
                    color="gray"
                    className="mt-2 flex items-center gap-1 font-normal"
                >
                    Charge ETH to Abstract Acount to do transaction.
                </Typography>
            </div>
            <Button
                disabled={!isValid}
                onClick={handleCharge}
            >Charge</Button>
        </CardFooter>
    </Card>
}

aa7.png

使用 wagmi 提供的 transaction 功能,为 AA wallet 充值,充值完成之后,就会看到界面刷新显示刚才充值的数额,并且在对应的地址可以看到一笔交易。

aa8.png

aa9.png

使用AA钱包发起 multiple transactions

由于AA钱包发送交易时使用的是 calldata 发送所以我们只需要构造出对应操作的 calldata 就能够发起多次操作,在一个 tx 中。

import { Address, BatchUserOperationCallData, UserOperationCallData } from "@alchemy/aa-core";
import { encodeFunctionData } from "viem";
 
export type UserOperationRequests = UserOperationCallData[] | BatchUserOperationCallData[]
 
const ApproveAbi = [
    {
        "inputs": [
            {
                "internalType": "address",
                "name": "spender",
                "type": "address"
            },
            {
                "internalType": "uint256",
                "name": "amount",
                "type": "uint256"
            }
        ],
        "name": "approve",
        "outputs": [
            {
                "internalType": "bool",
                "name": "",
                "type": "bool"
            }
        ],
        "stateMutability": "nonpayable",
        "type": "function"
    },
]
 
const newOrInvestToVaultPositionAbi = [
    {
        "inputs": [
            {
                "components": [
                    {
                        "internalType": "uint256",
                        "name": "vaultId",
                        "type": "uint256"
                    },
                    {
                        "internalType": "uint256",
                        "name": "vaultPositionId",
                        "type": "uint256"
                    },
                    {
                        "internalType": "uint256",
                        "name": "amount0Invest",
                        "type": "uint256"
                    },
                    {
                        "internalType": "uint256",
                        "name": "amount0Borrow",
                        "type": "uint256"
                    },
                    {
                        "internalType": "uint256",
                        "name": "amount1Invest",
                        "type": "uint256"
                    },
                    {
                        "internalType": "uint256",
                        "name": "amount1Borrow",
                        "type": "uint256"
                    },
                    {
                        "internalType": "uint256",
                        "name": "amount0Min",
                        "type": "uint256"
                    },
                    {
                        "internalType": "uint256",
                        "name": "amount1Min",
                        "type": "uint256"
                    },
                    {
                        "internalType": "uint256",
                        "name": "deadline",
                        "type": "uint256"
                    },
                    {
                        "internalType": "uint256",
                        "name": "swapExecutorId",
                        "type": "uint256"
                    },
                    {
                        "internalType": "bytes",
                        "name": "swapPath",
                        "type": "bytes"
                    }
                ],
                "internalType": "struct IVeloVaultPositionManager.NewOrInvestToVaultPositionParams",
                "name": "params",
                "type": "tuple"
            }
        ],
        "name": "newOrInvestToVaultPosition",
        "outputs": [],
        "stateMutability": "payable",
        "type": "function"
    },
]
 
function enterTransaction(
    formData: FormData,
    labels: string[],
) {
    const data: any = {}
    labels.forEach(l => {
        data[l] = formData.get(l)
    })
    const amount = data.amount ? Number(data.amount) : 0;
    if (amount) {
        const userOperations: UserOperationRequests = [
            {
                target: '0xEDfa23602D0EC14714057867A78d01e94176BEA0' as Address,
                data: encodeFunctionData({
                    abi: ApproveAbi,
                    functionName: ApproveAbi[0].name,
                    args: ['0xf9cFB8a62f50e10AdDE5Aa888B44cF01C5957055', BigInt(1 * 1e18)],
                }),
            },
            {
                target: '0xf9cFB8a62f50e10AdDE5Aa888B44cF01C5957055' as Address,
                data: encodeFunctionData({
                    abi: newOrInvestToVaultPositionAbi,
                    functionName: newOrInvestToVaultPositionAbi[0].name,
                    args: [{
                        vaultId: BigInt(63),
                        vaultPositionId: 0,
                        amount0Invest: 0,
                        amount0Borrow: 0,
                        amount1Invest: BigInt(amount * 1e18),
                        amount1Borrow: 0,
                        amount0Min: 0,
                        amount1Min: 0,
                        deadline: BigInt(Date.now() + 15 * 60 * 1000),
                        swapExecutorId: 0,
                        swapPath: '0x',
                    }],
                }),
            }
        ]
        return userOperations;
    }
    return [];
}
export default enterTransaction;
 
const { hash } = await smartAccountClient.sendUserOperation({ uo: userOperations });
const txhash = await smartAccountClient.waitForUserOperationTransaction({ hash });

首先使用 viem 提供的 encodeFunctionData 方法将调用合约函数的参数变为 calldata,然后将所有的 user operations 使用 smart client 提供的 sendUserOperation 发送到 smart wallet 进行交易。

在发出交易之后,Achemy 会为 smart wallet 挂载合约,让其成为真正的合约钱包。

aa10.png

但是在这其中 gas fee 的消耗并没有想象中的那么便宜,这跟之前提到的能降低 gas fee 的优点相悖,这是因为用户使用 AA wallet 之后,必定会进行合约之间的调用,这也会产生费用。

目前最好的解決的方法是利用 Layer 2 进行交易,能可以明显降低 gas fee。

Summary

利用 AA 这一特性,可以极大的降低 web3 的入门门槛,并且创建出具有多种功能的新时代的钱包应用

  • 通过熟悉的登录方式或无即时加密要求来简化加入流程。
  • 提供恢复选项,消除因丢失钥匙而永久丢失 web3 资产的担忧。
  • 与法定货币入口整合,以便 user 可以直接购买 web3 内资产。

Reference

  1. medium.com/@alan890104…
  2. www.stackup.sh/blog/how-mu…
  3. medium.com/@chainexeth…
  4. transak.com/blog/what-i…
  5. medium.com/portkey-aa-…
  6. Github Code Demo