iT邦幫忙

2024 iThome 鐵人賽

DAY 19
0
Modern Web

web3 短篇集系列 第 19

實作自己送 UserOperation - Part 1

  • 分享至 

  • xImage
  •  

山陀兒颱風來襲,祝大家平安無事。今天來實作不依靠 Bundler,使用自己的 EOA 來呼叫 EntryPoint.handleOps,幫合約帳戶送一個 User Operation。

  • Network: sepolia
  • EntryPoint: 0x0000000071727De22E5E9d8BAf0edAc6f37da032

以下是我使用到的 explorer 和 debug 工具:

事前準備

部署 SimpleAccountFactory.sol

forge create --rpc-url $sepolia --account dev \
> --verify --verifier blockscout \             
> src/SimpleAccountFactory.sol:SimpleAccountFactory \
> --constructor-args 0x0000000071727de22e5e9d8baf0edac6f37da032

SimpleAccountFactory: 0xE2B9F777c9B137a48f13d1Eb1207cBA9fb92fd4E

部署 SimpleAccount.sol

cast send --account dev \
--rpc-url $sepolia \
0xE2B9F777c9B137a48f13d1Eb1207cBA9fb92fd4E \
> "createAccount(address,uint256)" \
> 0xd78B5013757Ea4A7841811eF770711e6248dC282 \
> 32

SimpleAccount (proxy)

0xA42BEB05D392ACFA445b46D65DCa22dba5A6f0aC

成功建立一個屬於 0xd78 的合約帳戶:0xA42

Send User Operations

在 foundry 專案寫一個 Script,讓合約帳戶 (account) 的 owner 製作一個 UserOp 與簽章,UserOp 的交易內容是將 account 內的 0.01 ether 轉給 recipient。最後呼叫 EntryPoint.handleOps 送出 UserOps,並填上受益人 (beneficiary) 為 owner 自己。

uint256 privateKey = vm.envUint("PRIVATE_KEY");
address owner = vm.addr(privateKey);

PackedUserOperation memory userOp = PackedUserOperation({
    sender: account,
    nonce: 0,
    initCode: bytes(""),
    callData: abi.encodeCall(SimpleAccount.execute, (recipient, 0.01 ether, "")),
    accountGasLimits: pack(999_999, 10_000),
    preVerificationGas: 0,
    gasFees: pack(20, 1),
    paymasterAndData: bytes(""),
    signature: bytes("")
});

bytes32 userOpHash = IEntryPoint(entryPoint).getUserOpHash(userOp);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, MessageHashUtils.toEthSignedMessageHash(userOpHash));
userOp.signature = abi.encodePacked(r, s, v);

logUserOp(userOp);

PackedUserOperation[] memory ops = new PackedUserOperation[](1);
ops[0] = userOp;

vm.startBroadcast(privateKey);

IEntryPoint(entryPoint).handleOps(ops, payable(owner));

vm.stopBroadcast();

模擬跑 script:

forge script --rpc-url $sepolia script/SendUserOps.s.sol 

出現 AA21 didn't pay prefund 的錯誤,因為我的 account 裡面沒有 ETH,不但沒辦法轉帳,在那之前就先被 EntryPoint 檢查出沒錢支付手續費。

於是轉 0.5 ETH 給合約錢包後,再模擬一次,又出現錯誤 AA24 signature error,原來是我私鑰放錯了..

再試一次,本地測試成功以後,加上 --broadcast 送出真正的交易:

forge script --rpc-url $sepolia script/SendUserOps.s.sol --broadcast

交易成功,但是 UserOp 失敗,Scope explorer 標示 Out of gas。

  • userOpHash: 0xe0c1e50c252c76cdcb5d31a68a87e107988fa0e2822ccfb54aa8bb5d4351d2fb
  • txHash: 0x97f047618dee547187f248ea978d5c3b930a8523aeae492444e65898a1e5a5ca

回頭看我的 UserOperation:

accountGasLimits: pack(999_999, 10_000),

accountGasLimits 是由兩者組成:

  • verificationGasLimit: 999,999
  • callGasLimit: 10,000

debug 完後發現是我的 callGasLimit 設太低,導致驗證階段通過,但執行階段被 reverted。

這個 UserOperation 所花費的 gas 是 82,871。

(Gas price 為什麼是 1 wei 我還不清楚,之後補上)

以下這筆交易的資金流,合約帳戶先把錢存進 EntryPoint,EntryPoint 執行完 UserOp 的交易後,再補償(compensate) 受益人。(這邊的受益人同時也是這筆交易的發起者,因為我們是幫自己的 SCA 送 UserOps)

  • 1,009,999 wei 來自於 verificationGasLimit + callGasLimit。
  • 82,871 wei 是 UserOperation 的 actualGasCost。

因此,我的 SCA 在 EntryPoint 的存款還有 927,128,來自於上面兩個數字相減。之後的 UserOperation 可以使用這裡存的錢當作手續費。

cast call --rpc-url $sepolia \
$entrypoint \
"balanceOf(address)" \
0xA42BEB05D392ACFA445b46D65DCa22dba5A6f0aC
cast td 0x00000000000000000000000000000000000000000000000000000000000e2598

回到 UserOp 的 gas 問題,看一下 tenderly 的 gas profiler:

  • 驗證階段:_validatePrepayment 需要 72,346 gas
  • 執行階段:_executeUserOp 需要 37,026 gas

SimpleAccount 的驗證是一個 ECDSA 的 recover,看來我給 999,999 gas 太多。

執行階段我只給 10,000 gas 難怪被 reverted,右邊有一個 RefundedGas 應該是來自執行階段沒確實執行完,rollback 後的退款。

颱風天休息一下,明天我們再來把這個 UserOp 送成功。

Reference

完整程式碼

SendUserOps.s.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.27;

import {Script, console} from "forge-std/Script.sol";
import "@account-abstraction/contracts/interfaces/PackedUserOperation.sol";
import "@account-abstraction/contracts/core/EntryPoint.sol";
import "@account-abstraction/contracts/interfaces/IEntryPoint.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import "../src/SimpleAccount.sol";

contract SendUserOps is Script {
    address constant entryPoint = 0x0000000071727De22E5E9d8BAf0edAc6f37da032;
    address constant account = 0xA42BEB05D392ACFA445b46D65DCa22dba5A6f0aC;
    address constant recipient = 0x9e8f8C3Ad87dBE7ACFFC5f5800e7433c8dF409F2;

    function run() public {
        uint256 privateKey = vm.envUint("PRIVATE_KEY");
        address owner = vm.addr(privateKey);

        PackedUserOperation memory userOp = PackedUserOperation({
            sender: account,
            nonce: 0,
            initCode: bytes(""),
            callData: abi.encodeCall(SimpleAccount.execute, (recipient, 0.01 ether, "")),
            accountGasLimits: pack(999_999, 10_000),
            preVerificationGas: 0,
            gasFees: pack(20, 1),
            paymasterAndData: bytes(""),
            signature: bytes("")
        });

        bytes32 userOpHash = IEntryPoint(entryPoint).getUserOpHash(userOp);
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, MessageHashUtils.toEthSignedMessageHash(userOpHash));
        userOp.signature = abi.encodePacked(r, s, v);

        logUserOp(userOp);

        PackedUserOperation[] memory ops = new PackedUserOperation[](1);
        ops[0] = userOp;

        vm.startBroadcast(privateKey);

        IEntryPoint(entryPoint).handleOps(ops, payable(owner));

        vm.stopBroadcast();
    }

    // ========================== Utils ============================

    function pack(uint256 a, uint256 b) internal pure returns (bytes32) {
        return bytes32((a << 128) | b);
    }

    function logUserOp(PackedUserOperation memory userOp) internal pure {
        console.log(userOp.sender);
        console.log(userOp.nonce);
        console.logBytes(userOp.initCode);
        console.logBytes(userOp.callData);
        console.logBytes32(userOp.accountGasLimits);
        console.log(toHexString(userOp.preVerificationGas));
        console.logBytes32(userOp.gasFees);
        console.logBytes(userOp.paymasterAndData);
        console.logBytes(userOp.signature);
    }

    function toHexDigit(uint8 d) internal pure returns (bytes1) {
        if (0 <= d && d <= 9) {
            return bytes1(uint8(bytes1("0")) + d);
        } else if (10 <= uint8(d) && uint8(d) <= 15) {
            return bytes1(uint8(bytes1("a")) + d - 10);
        }
        revert("Invalid hex digit");
    }

    function toHexString(uint256 a) internal pure returns (string memory) {
        uint256 count = 0;
        uint256 b = a;
        while (b != 0) {
            count++;
            b /= 16;
        }
        bytes memory res = new bytes(count);
        for (uint256 i = 0; i < count; ++i) {
            b = a % 16;
            res[count - i - 1] = toHexDigit(uint8(b));
            a /= 16;
        }
        return string.concat("0x", string(res));
    }
}


上一篇
認識 User Operation
下一篇
實作自己送 UserOperation - Part 2
系列文
web3 短篇集30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言