从零开始动手构建账户抽象 DApp - 不使用第三方库

本文详细介绍了如何从零开始构建一个完全可用的 Account Abstraction dApp,避免使用便捷库,手动构建 User Operations,直接进行 JSON-RPC 调用,处理 gas 赞助,并将 User Operation 发送到 bundler。通过这种底层方法,可以更深入地理解 AA 的工作原理。

介绍

在本系列的前两篇文章中,我们奠定了概念基础,探索了 账户抽象的历史连接了 ERC-4337 核心组件之间的联系。现在,是时候从理论转向实践了。欢迎来到最后的实践章节,我们将从头开始构建一个功能齐全的账户抽象 dApp。

这个项目的特别之处在于,我们刻意避免使用诸如 permissionless.js、viem/account-abstraction 等便捷库。相反,我们将手动构建用户操作(User Operations),使用原生的 fetch API 进行直接的 JSON-RPC 调用,处理用于 Gas 赞助的支付方(paymasters),并将我们的用户操作发送到 Bundler。这种底层方法将使你对 AA 在底层是如何工作的有一个无与伦比的理解。

让我们从克隆项目仓库开始:

git clone https://github.com/AjayiMike/simple-aa-dapp.git
cd simple-aa-dapp
pnpm install

项目结构

在深入研究代码之前,让我们了解一下项目结构。这是一个 pnpm monorepo,包含两个主要的包:

  1. contracts: 包含我们的 Solidity 智能合约
  2. ui: 包含我们的 Next.js 前端应用程序

项目的结构是这样组织的,目的是为了分离关注点:链上逻辑在 contracts 中,链下逻辑在 ui 中。

智能合约

让我们从项目中最简单的部分开始:智能合约。导航到 contracts/contracts/SimpleAAToken.sol:

// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.27;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";

contract SimpleAAToken is ERC20, ERC20Burnable {
    constructor() ERC20("SimpleAAToken", "SAT") {}

    function mint(address to, uint256 amount) public {
        _mint(to, amount);
    }
}

这是一个标准的 ERC20 代币,具有铸造和销毁功能。合约本身没有任何特定于账户抽象的内容。这是因为 AA 主要是一种链下创新,它改变了我们与合约交互的方式,而不是合约本身。

要部署此合约,请按照项目中的 README.md 中的说明进行操作。部署完成后,请务必保存合约地址,因为 UI 设置需要用到它。

用户界面

现在让我们深入研究 UI 包,其中包含我们所有的账户抽象逻辑。我们将系统地浏览它,从基础部分开始,逐步向上。

类型和常量

首先,让我们看一下定义用户操作结构的类型:

ui/types/account-abstraction.ts

// ui/types/account-abstraction.ts
import { Address, Hex } from "viem";

// entrypoint v0.6 user operation type
export interface IUserOperation {
    sender: Address;
    nonce: bigint;
    initCode: Hex;
    callData: Hex;
    maxFeePerGas: bigint;
    maxPriorityFeePerGas: bigint;
    callGasLimit: bigint;
    verificationGasLimit: bigint;
    preVerificationGas: bigint;
    paymasterAndData: Hex;
    signature: Hex;
}

这个接口根据 ERC-4337 规范定义了 entrypoint v0.6 的用户操作结构。每个字段都是有效 UserOp 所必需的。

接下来,让我们看一下我们的常量:

ui/constants/config.ts

// ui/constants/config.ts
import { Address } from "viem";

export const entryPointAddress: Address =
    "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"; // Entrypoint V0.6
export const simpleAccountFactoryAddress: Address =
    "0x9406Cc6185a346906296840746125a0E44976454"; // Simple Smart Account Factory

这些地址至关重要。EntryPoint 是 ERC-4337 的中心合约,它执行所有用户操作。SimpleAccountFactory 用于为我们的用户部署新的智能账户。

ABIs

ui/abis/index.ts 中,我们定义了合约的 ABI:

  1. simpleAATokenAbi:用于与我们的 ERC20 代币交互
  2. entryPointAbi:用于与 EntryPoint 合约交互
  3. simpleAccountAbi:用于与用户的智能账户交互
  4. simpleAccountFactoryAbi:用于构造 initcode:部署新的智能账户。

这些 ABI 对于正确编码函数调用至关重要。

使用 Magic 进行身份验证

我们进入代码库的旅程从身份验证开始。我们使用 Magic Link 通过 Google OAuth 提供无缝登录体验。这为我们提供了一个外部拥有的账户 (EOA),它将拥有用户的智能账户。

ui/providers/MagicProvider.tsx

// ui/providers/MagicProvider.tsx
"use client";
import { OAuthExtension } from "@magic-ext/oauth2";
import {
    Magic as MagicBase,
    MagicUserMetadata,
    RPCError,
    RPCErrorCode,
} from "magic-sdk";
import {
    ReactNode,
    createContext,
    useCallback,
    useContext,
    useEffect,
    useMemo,
    useState,
} from "react";
import { toast } from "sonner";

export type Magic = MagicBase<OAuthExtension[]>;

type MagicContextType = {
    magic: Magic | null;
    isLoginInProgress: boolean;
    isLoggedIn: boolean;
    user: MagicUserMetadata | null;
    login: () => Promise<void>;
    logout: () => Promise<void>;
};

const MagicContext = createContext<MagicContextType>({
    magic: null,
    isLoginInProgress: false,
    isLoggedIn: false,
    user: null,
    login: () => Promise.resolve(),
    logout: () => Promise.resolve(),
});

export const useMagic = () => {
    const context = useContext(MagicContext);
    if (!context) {
        throw new Error("useMagic must be used within a MagicProvider");
    }
    return context;
};

const MagicProvider = ({ children }: { children: ReactNode }) => {
    const [magic, setMagic] = useState<Magic | null>(null);
    const [isLoginInProgress, setLoginInProgress] = useState(true);
    const [isLoggedIn, setIsLoggedIn] = useState(false);
    const [user, setUser] = useState<MagicUserMetadata | null>(null);

    useEffect(() => {
        if (magic) {
            magic.user
                .isLoggedIn()
                .then((isLoggedIn) => {
                    if (isLoggedIn) {
                        magic.user.getInfo().then((info) => {
                            setUser(info);
                            setIsLoggedIn(isLoggedIn);
                        });
                    }
                })
                .finally(() => {
                    setLoginInProgress(false);
                });
        }
    }, [magic]);

    const initiateLogin = useCallback(async () => {
        try {
            if (!magic || isLoginInProgress || isLoggedIn) return;
            setLoginInProgress(true);
            localStorage.setItem("login-in-progress", "true");
            await magic.oauth2.loginWithRedirect({
                provider: "google",
                redirectURI: new URL(window.location.origin).href,
            });
        } catch (e) {
            localStorage.removeItem("login-in-progress");
            if (e instanceof RPCError) {
                switch (e.code) {
                    case RPCErrorCode.MagicLinkFailedVerification:
                    case RPCErrorCode.MagicLinkExpired:
                    case RPCErrorCode.MagicLinkRateLimited:
                    case RPCErrorCode.UserAlreadyLoggedIn:
                        toast.error(e.message);
                        break;
                    default:
                        toast.error("Something went wrong. Please try again");
                }
            }
        } finally {
            setLoginInProgress(false);
        }
    }, [isLoggedIn, isLoginInProgress, magic]);

    const finalizeLogin = useCallback(async () => {
        try {
            const loginInProgress = localStorage.getItem("login-in-progress");
            if (loginInProgress !== "true") return;
            setLoginInProgress(true);
            const result = await magic?.oauth2.getRedirectResult({});
            if (!result) {
                toast.error("Error logging in. Try again");
                return;
            }
            setUser(result.magic.userMetadata);
            setIsLoggedIn(true);
        } catch (err) {
            console.log("error signing in", err);
            toast.error("Error logging in. " + (err as Error).message);
        } finally {
            localStorage.removeItem("login-in-progress");
            setLoginInProgress(false);
        }
    }, [magic?.oauth2]);

    useEffect(() => {
        if (!magic) return;
        finalizeLogin();
    }, [finalizeLogin, magic]);

    useEffect(() => {
        if (process.env.NEXT_PUBLIC_MAGIC_API_KEY) {
            const magic = new MagicBase(
                process.env.NEXT_PUBLIC_MAGIC_API_KEY as string,
                {
                    network: {
                        rpcUrl: process.env.NEXT_PUBLIC_RPC_URL as string,
                        chainId: parseInt(
                            process.env.NEXT_PUBLIC_CHAIN_ID as string
                        ),
                    },
                    extensions: [new OAuthExtension()],
                }
            );

            setMagic(magic);
        }
    }, []);

    const logout = useCallback(async () => {
        try {
            await magic?.user.logout();
            setUser(null);
            setIsLoggedIn(false);
        } catch (error) {
            console.log("error logging out", error);
        }
    }, [magic]);

    const value = useMemo(() => {
        return {
            magic,
            isLoginInProgress,
            isLoggedIn,
            user,
            login: initiateLogin,
            logout,
        };
    }, [magic, isLoggedIn, user, initiateLogin, isLoginInProgress, logout]);

    return (
        <MagicContext.Provider value={value}>{children}</MagicContext.Provider>
    );
};

export default MagicProvider;

这个 provider 创建了一个 React 上下文,该上下文向我们应用程序的其余部分公开 Magic 实例、登录状态、用户元数据以及登录/注销功能。

核心账户抽象实用程序

现在我们来到了实现的核心:account-abstraction.ts 实用程序文件。在这里,我们手动实现账户抽象所需的底层函数。

ui/utils/account-abstraction.ts

// ui/utils/account-abstraction.ts
import { entryPointAbi, simpleAccountFactoryAbi } from "@/abis";
import { entryPointAddress, simpleAccountFactoryAddress } from "@/constants/config";
import { IUserOperation } from "@/types/account-abstraction";
import { /* viem imports */ } from "viem";

// 获取智能账户的地址
export const getSmartAccountAddress = async (
    initCode: Hex,
    publicClient: PublicClient
): Promise<Address> => {
    try {
        await publicClient.readContract({
            address: entryPointAddress,
            abi: entryPointAbi as Abi,
            functionName: "getSenderAddress",
            args: [initCode],
        });
    } catch (error) {
        // EntryPoint 会使用包含地址的 "SenderAddressResult" 错误恢复
        const err = error as ContractFunctionExecutionErrorType;
        if (err.cause.name === "ContractFunctionRevertedError") {
            const revertError = err.cause as ContractFunctionRevertedErrorType;
            const errorName = revertError.data?.errorName ?? "";
            if (
                errorName === "SenderAddressResult" &&
                revertError.data?.args &&
                revertError.data?.args[0]
            ) {
                return revertError.data?.args[0] as Address;
            }
        }
        throw error;
    }
    throw new Error("Failed to get smart account address");
};

// 检查智能账户是否已经部署
export const getIsSmartAccountDeployed = async (
    address: Address,
    publicClient: PublicClient
): Promise<boolean> => {
    const code = await publicClient?.getCode({
        address,
    });
    return code !== undefined;
};

// 生成新智能账户的 initCode
export const getSmartAccountInitCode = async (owner: Address) => {
    const createAccountFunctionData = encodeFunctionData({
        abi: simpleAccountFactoryAbi,
        functionName: "createAccount",
        args: [owner, BigInt(0)],
    });
    return (simpleAccountFactoryAddress +
        createAccountFunctionData.substring(2)) as Hex;
};

// 获取智能账户的 nonce
export const getSmartAccountNonce = async (
    address: Address,
    publicClient: PublicClient
): Promise<bigint> => {
    return await publicClient.readContract({
        address: entryPointAddress,
        abi: entryPointAbi,
        functionName: "getNonce",
        args: [address, BigInt(0)],
    });
};

这前四个函数处理与用户智能账户相关的基本操作:获取其地址、检查是否已部署、生成用于部署它的代码以及获取其 nonce。

现在让我们看一下处理用户操作的函数:

// ui/utils/account-abstraction.ts (continued)

// 估计用户操作的 Gas
export const getUserOperationGasPrice = async (
    userOperation: Partial<IUserOperation>,
    entryPoint: Address
): Promise<{
    callGasLimit: Hex;
    preVerificationGas: Hex;
    verificationGasLimit: Hex;
    maxFeePerGas: Hex;
    maxPriorityFeePerGas: Hex;
}> => {
    const userOpPayload = {
        ...userOperation,
        nonce: numberToHex(userOperation.nonce!),
    };

    const response = await fetch(
        process.env.NEXT_PUBLIC_BUNDLER_URL as string,
        {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({
                jsonrpc: "2.0",
                method: "eth_estimateUserOperationGas",
                params: [userOpPayload, entryPoint],
                id: 1,
            }),
        }
    );
    if (!response.ok) {
        throw new Error("Failed to get user operation gas price");
    }
    const data = await response.json();
    return data.result;
};

// 从支付方获取赞助数据
export const getUserOperationSponsorshipData = async (
    userOperation: Partial<IUserOperation>,
    entryPoint: Address
): Promise<{
    callGasLimit: Hex;
    paymasterAndData: Hex;
    preVerificationGas: Hex;
    verificationGasLimit: Hex;
}> => {
    const userOpPayload = {
        ...userOperation,
        nonce: numberToHex(userOperation.nonce!),
        callGasLimit: numberToHex(userOperation.callGasLimit!),
        verificationGasLimit: numberToHex(userOperation.verificationGasLimit!),
        preVerificationGas: numberToHex(userOperation.preVerificationGas!),
    };

    const response = await fetch(
        process.env.NEXT_PUBLIC_PAYMASTER_URL as string,
        {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({
                jsonrpc: "2.0",
                method: "pm_sponsorUserOperation",
                params: [userOpPayload, entryPoint],
                id: 1,
            }),
        }
    );
    if (!response.ok) {
        throw new Error("Failed to get user operation gas sponsorship data");
    }
    const data = await response.json();
    return data.result;
};

// 将用户操作发送到 Bundler
export const sendUserOperation = async (
    userOperation: IUserOperation,
    entryPoint: Address
): Promise<Hex> => {
    const userOpPayload = {
        ...userOperation,
        nonce: numberToHex(userOperation.nonce),
        callGasLimit: numberToHex(userOperation.callGasLimit),
        preVerificationGas: numberToHex(userOperation.preVerificationGas),
        verificationGasLimit: numberToHex(userOperation.verificationGasLimit),
        maxFeePerGas: numberToHex(userOperation.maxFeePerGas),
        maxPriorityFeePerGas: numberToHex(userOperation.maxPriorityFeePerGas),
    };

    const response = await fetch(
        process.env.NEXT_PUBLIC_BUNDLER_URL as string,
        {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({
                jsonrpc: "2.0",
                method: "eth_sendUserOperation",
                params: [userOpPayload, entryPoint],
                id: 1,
            }),
        }
    );
    if (!response.ok) {
        throw new Error("Failed to send user operation");
    }
    const data = await response.json();
    if (data.error) {
        throw new Error(data.error.message);
    }
    return data.result;
};

这三个函数处理对 Bundler 和支付方的核心 RPC 调用。请注意,我们如何使用原生的 fetch API 直接进行这些调用,而无需任何便捷库。

我们实现中最复杂的部分是 getUserOpHash 函数,该函数根据 ERC-4337 规范手动计算用户操作的哈希:

// ui/utils/account-abstraction.ts (continued)

// 计算用户操作的哈希
export const getUserOpHash = async (
    userOperation: Omit<IUserOperation, "signature">,
    entryPointAddress: Address,
    walletClient: WalletClient
) => {
    if (!walletClient.chain || !walletClient.account)
        throw new Error("Wallet client chain or account not found");
    const chainId = BigInt(walletClient.chain.id);
    const packedUserOp = encodeAbiParameters(
        [\
            { type: "address" },\
            { type: "uint256" },\
            { type: "bytes32" },\
            { type: "bytes32" },\
            { type: "uint256" },\
            { type: "uint256" },\
            { type: "uint256" },\
            { type: "uint256" },\
            { type: "uint256" },\
            { type: "bytes32" },\
        ],
        [\
            userOperation.sender,\
            userOperation.nonce,\
            keccak256(userOperation.initCode),\
            keccak256(userOperation.callData),\
            userOperation.callGasLimit,\
            userOperation.verificationGasLimit,\
            userOperation.preVerificationGas,\
            userOperation.maxFeePerGas,\
            userOperation.maxPriorityFeePerGas,\
            keccak256(userOperation.paymasterAndData ?? "0x"),\
        ]
    );
    return keccak256(
        encodeAbiParameters(
            [{ type: "bytes32" }, { type: "address" }, { type: "uint256" }],
            [keccak256(packedUserOp), entryPointAddress, chainId]
        )
    );
};

// 签署 User Operation 哈希
export const signUserOpHash = async (
    userOpHash: Hex,
    signerWalletClient: WalletClient
) => {
    if (!signerWalletClient || !signerWalletClient.account)
        throw new Error("Signer wallet client or account not found");

    const signature = await signMessage(signerWalletClient, {
        account: signerWalletClient.account,
        message: { raw: userOpHash },
    });

    return signature;
};

getUserOpHash 函数尤为重要。它遵循 ERC-4337 中指定的精确哈希算法,以特定方式打包 UserOp 字段,对它们进行哈希处理,然后将该哈希与 EntryPoint 地址和链 ID 组合,然后再进行哈希处理。

智能账户 Hook

现在让我们看一下 useSmartAccount hook,它提供了一个干净的 API,用于与用户的智能账户交互:

ui/hooks/useSmartAccount.ts

// ui/hooks/useSmartAccount.ts
import usePublicClient from "./usePublicClient";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMagic } from "@/providers/MagicProvider";
import useWalletClient from "./useWalletClient";
import { Address, Call, encodeFunctionData } from "viem";
import {
    getIsSmartAccountDeployed,
    getSmartAccountAddress,
    getSmartAccountInitCode,
    getSmartAccountNonce,
} from "@/utils/account-abstraction";
import { simpleAccountAbi } from "@/abis";

const useSmartAccount = () => {
    const [address, setAddress] = useState<Address>();
    const publicClient = usePublicClient();
    const walletClient = useWalletClient();
    const magic = useMagic();

    // 获取智能账户地址
    useEffect(() => {
        (async () => {
            if (!magic.user?.publicAddress || !publicClient) return;

            const initCode = await getSmartAccountInitCode(
                magic.user?.publicAddress as Address
            );

            const smartAccountAddress = await getSmartAccountAddress(
                initCode,
                publicClient
            );

            setAddress(smartAccountAddress);
        })();
    }, [publicClient, walletClient, magic.user?.publicAddress]);

    // 检查智能账户是否已部署
    const isDeployed = useCallback(async () => {
        if (!address || !publicClient) {
            throw new Error("Smart account address or public client not found");
        }
        return await getIsSmartAccountDeployed(address, publicClient);
    }, [address, publicClient]);

    // 获取智能账户的 initCode
    const getInitCode = useCallback(async () => {
        if (!magic.user?.publicAddress || !publicClient) {
            throw new Error(
                "Magic user public address or public client not found"
            );
        }
        if (await isDeployed()) return "0x";
        return await getSmartAccountInitCode(
            magic.user?.publicAddress as Address
        );
    }, [magic.user?.publicAddress, publicClient, isDeployed]);

    // 获取智能账户的 nonce
    const getNonce = useCallback(async () => {
        if (!address || !publicClient) {
            throw new Error("Smart account address or public client not found");
        }
        return await getSmartAccountNonce(address, publicClient);
    }, [address, publicClient]);

    // 编码智能账户的调用
    const encodeCalls = useCallback(
        async (calls: Call[]) => {
            if (!address || !publicClient) {
                throw new Error(
                    "Smart account address or public client not found"
                );
            }
            if (calls.length === 1)
                return encodeFunctionData({
                    abi: simpleAccountAbi,
                    functionName: "execute",
                    args: [\
                        calls[0].to,\
                        calls[0].value ?? BigInt(0),\
                        calls[0].data ?? "0x",\
                    ],
                });
            return encodeFunctionData({
                abi: simpleAccountAbi,
                functionName: "executeBatch",
                args: [\
                    calls.map((call) => call.to),\
                    calls.map((call) => call.data),\
                ],
            });
        },
        [address, publicClient]
    );

    return useMemo(
        () => ({
            address,
            isDeployed,
            getInitCode,
            getNonce,
            encodeCalls,
        }),
        [address, isDeployed, getInitCode, getNonce, encodeCalls]
    );
};

export default useSmartAccount;

这个 hook 封装了与用户智能账户相关的所有功能。它提供:

  1. 账户地址
  2. 检查账户是否已部署的函数
  3. 获取用于部署账户的 initCode 的函数
  4. 获取账户 nonce 的函数
  5. 编码账户调用的函数(单个调用或批量调用)

主 Hook:useSendUserOperation

现在我们来讨论最重要的 Hook:useSendUserOperation。这个 Hook 协调了我们到目前为止构建的所有内容以用于发送用户操作:

ui/hooks/useSendUserOperation.ts

// ui/hooks/useSendUserOperation.ts
import { useCallback } from "react";
import { Abi, Address, encodeFunctionData, hexToBigInt } from "viem";
import useSmartAccount from "./useSmartAccount";
import { IUserOperation } from "@/types/account-abstraction";
import {
    getUserOperationGasPrice,
    getUserOperationSponsorshipData,
    getUserOpHash,
    sendUserOperation,
    signUserOpHash,
} from "@/utils/account-abstraction";
import { entryPointAddress } from "@/constants/config";
import useWalletClient from "./useWalletClient";
import useGasSponsorship from "./useGasSponsorship";

type SendUserOperationArgs = {
    contractAddress: Address;
    abi: Abi;
    functionName: string;
    args: unknown[];
};

const useSendUserOperation = () => {
    const smartAccount = useSmartAccount();
    const walletClient = useWalletClient();
    const { gasSponsorship: isGasSponsorshipEnabled } = useGasSponsorship();

    return useCallback(
        async (calls: SendUserOperationArgs[]) => {
            if (!walletClient || !smartAccount || !smartAccount.address) {
                throw new Error("Wallet client or smart account not found");
            }

            // 1. 编码函数调用
            const callData = calls.map((call) =>
                encodeFunctionData({
                    abi: call.abi,
                    functionName: call.functionName,
                    args: call.args,
                })
            );

            // 2. 对智能账户的调用进行编码
            const userOpCallData = await smartAccount.encodeCalls(
                calls.map((call, index) => ({
                    to: call.contractAddress,
                    data: callData[index],
                }))
            );

            // 3. 获取 nonce 和 initCode
            const nonce = await smartAccount.getNonce();
            const initCode = await smartAccount.getInitCode();

            // 4. 创建带有虚拟签名的用户操作
            const userOperation: Partial<IUserOperation> = {
                sender: smartAccount.address,
                nonce: nonce,
                initCode,
                callData: userOpCallData,
                signature: `0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c`, //dummy signature
            };

            // 5. 估计 Gas
            const userOpGasPrice = await getUserOperationGasPrice(
                userOperation,
                entryPointAddress
            );

            userOperation.callGasLimit = hexToBigInt(
                userOpGasPrice.callGasLimit
            );
            userOperation.preVerificationGas = hexToBigInt(
                userOpGasPrice.preVerificationGas
            );
            userOperation.verificationGasLimit = hexToBigInt(
                userOpGasPrice.verificationGasLimit
            );

            // 6. 如果启用,处理 Gas 赞助
            if (isGasSponsorshipEnabled) {
                const userOpSponsorshipData =
                    await getUserOperationSponsorshipData(
                        userOperation,
                        entryPointAddress
                    );

                userOperation.paymasterAndData =
                    userOpSponsorshipData.paymasterAndData;
                userOperation.verificationGasLimit = hexToBigInt(
                    userOpSponsorshipData.verificationGasLimit
                );
                userOperation.preVerificationGas = hexToBigInt(
                    userOpSponsorshipData.preVerificationGas
                );
                userOperation.callGasLimit = hexToBigInt(
                    userOpSponsorshipData.callGasLimit
                );

                userOperation.maxFeePerGas = hexToBigInt(
                    userOpGasPrice.maxFeePerGas
                );
                userOperation.maxPriorityFeePerGas = hexToBigInt(
                    userOpGasPrice.maxPriorityFeePerGas
                );
            } else {
                userOperation.maxFeePerGas = hexToBigInt(
                    userOpGasPrice?.maxFeePerGas
                );
                userOperation.maxPriorityFeePerGas = hexToBigInt(
                    userOpGasPrice?.maxPriorityFeePerGas
                );
                userOperation.paymasterAndData = "0x";
            }

            // 7. 计算用户操作哈希
            const userOpHash = await getUserOpHash(
                userOperation as Omit<IUserOperation, "signature">,
                entryPointAddress,
                walletClient
            );

            // 8. 签署哈希
            const signature = await signUserOpHash(userOpHash, walletClient);

            // 9. 更新签名并发送用户操作
            userOperation.signature = signature;

            return await sendUserOperation(
                userOperation as IUserOperation,
                entryPointAddress
            );
        },
        [walletClient, smartAccount, isGasSponsorshipEnabled]
    );
};

export default useSendUserOperation;

这个 Hook 是编排的杰作。它接受一个函数调用数组(每个函数调用都有一个合约地址、ABI、函数名称和参数),并执行整个用户操作生命周期:

  1. 编码函数调用
  2. 对智能账户的调用进行编码(使用 execute 或 executeBatch)
  3. 获取 nonce 和 initCode
  4. 使用虚拟签名创建用户操作
  5. 估计 Gas
  6. 如果启用,处理 Gas 赞助
  7. 计算用户操作哈希
  8. 签署哈希
  9. 更新签名并发送用户操作

这个 Hook 的优点在于,它将账户抽象的所有复杂性抽象为一个函数调用。

在组件中使用 Hook

最后,让我们看看如何在组件中使用 useSendUserOperation Hook。以下是 MintTokens 组件的简化版本:

// ui/components/MintTokens.tsx (simplified)
import { useState } from "react";
import useSendUserOperation from "@/hooks/useSendUserOperation";
import useSmartAccount from "@/hooks/useSmartAccount";
import { simpleAATokenAbi } from "@/abis";
import { parseEther } from "viem";

export function MintTokens() {
    const [amount, setAmount] = useState<string>("");
    const sendUserOperation = useSendUserOperation();
    const smartAccount = useSmartAccount();

    const handleMint = async () => {
        setIsMinting(true);
        try {
            const amountBigInt = BigInt(parseEther(amount));
            if (
                !smartAccount.address ||
                !walletClient ||
                !publicClient ||
                !amountBigInt
            )
                return;

            const userOperationHash = await sendUserOperation([\
                {\
                    contractAddress: process.env\
                        .NEXT_PUBLIC_TOKEN_CONTRACT_ADDRESS as Address,\
                    abi: simpleAATokenAbi,\
                    functionName: "mint",\
                    args: [smartAccount.address, amountBigInt],\
                },\
            ]);
            setUserOperationHash(userOperationHash);
            setShowSuccessModal(true);
            setAmount("");
        } catch (error: unknown) {
            console.log("Error sending user operation: ", error);
            if (
                error instanceof Error &&
                error.message.includes("AA21 didn't pay prefund")
            ) {
                toast.error(
                    "Error minting tokens. Insufficient balance to pay for the user operation. Consider enabling gas sponsorship."
                );
                return;
            }
            toast.error(`Error minting tokens: ${error}`);
        } finally {
            setIsMinting(false);
        }
    };

    // Return JSX
}

该组件非常简单。所有复杂的账户抽象逻辑都隐藏在 sendUserOperation Hook 后面。组件只需要提供合约地址、ABI、函数名称和参数。

结论

多么棒的旅程!我们已经成功地从头开始构建了一个功能齐全的账户抽象 dApp,刻意避免使用便捷库,以便更深入地了解底层机制。通过采用这种底层方法,我们揭开了 ERC-4337 规范的神秘面纱,并暴露了用户操作的内部工作原理。

这种实现的优点在于,尽管它在底层很复杂,但它向我们的组件呈现了一个干净、简单的界面。从获取智能账户反事实地址到手动打包和哈希用户操作的所有复杂逻辑都通过我们的自定义 Hook 优雅地抽象出来。

这种知识非常宝贵。随着账户抽象在以太坊生态系统中持续获得发展势头,理解这些基础知识将会让你脱颖而出。无论你选择在生产应用程序中使用便捷库还是构建自己的自定义解决方案,你现在都拥有做出明智决策的基础知识。

我希望这个系列能够照亮通往更友好的区块链未来的道路。账户抽象的潜力远远超出了我们在这里探索的范围 - 从社交恢复到批量交易,从赞助 Gas 到会话密钥。可能性是巨大的,现在你拥有探索它们的工具。

如果你使用这些概念构建了一些有趣的东西,我很乐意听到!在 Twitter 上与我联系 @0xAdek 或在下面发表评论。

下次再见,祝你构建愉快!

  • 原文链接: blog.blockmagnates.com/a...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
blockmagnates
blockmagnates
The New Crypto Publication on The Block