山陀兒颱風來襲,祝大家平安無事。今天來實作不依靠 Bundler,使用自己的 EOA 來呼叫 EntryPoint.handleOps
,幫合約帳戶送一個 User Operation。
0x0000000071727De22E5E9d8BAf0edAc6f37da032
以下是我使用到的 explorer 和 debug 工具:
forge create --rpc-url $sepolia --account dev \
> --verify --verifier blockscout \
> src/SimpleAccountFactory.sol:SimpleAccountFactory \
> --constructor-args 0x0000000071727de22e5e9d8baf0edac6f37da032
SimpleAccountFactory: 0xE2B9F777c9B137a48f13d1Eb1207cBA9fb92fd4E
cast send --account dev \
--rpc-url $sepolia \
0xE2B9F777c9B137a48f13d1Eb1207cBA9fb92fd4E \
> "createAccount(address,uint256)" \
> 0xd78B5013757Ea4A7841811eF770711e6248dC282 \
> 32
0xA42BEB05D392ACFA445b46D65DCa22dba5A6f0aC
成功建立一個屬於 0xd78 的合約帳戶:0xA42
在 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。
回頭看我的 UserOperation:
accountGasLimits: pack(999_999, 10_000),
accountGasLimits
是由兩者組成:
debug 完後發現是我的 callGasLimit 設太低,導致驗證階段通過,但執行階段被 reverted。
這個 UserOperation 所花費的 gas 是 82,871。
(Gas price 為什麼是 1 wei 我還不清楚,之後補上)
以下這筆交易的資金流,合約帳戶先把錢存進 EntryPoint,EntryPoint 執行完 UserOp 的交易後,再補償(compensate) 受益人。(這邊的受益人同時也是這筆交易的發起者,因為我們是幫自己的 SCA 送 UserOps)
因此,我的 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 gasSimpleAccount 的驗證是一個 ECDSA 的 recover,看來我給 999,999 gas 太多。
執行階段我只給 10,000 gas 難怪被 reverted,右邊有一個 RefundedGas 應該是來自執行階段沒確實執行完,rollback 後的退款。
颱風天休息一下,明天我們再來把這個 UserOp 送成功。
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));
}
}