티스토리 뷰
계정 추상화 시리즈
2024.04.16 - [블록체인/Ethereum] - ERC-4337: 계정 추상화 - Account, EntryPoint, Paymaster
테스트에 참고한 계정 추상화 구현 컨트랙트
- samples/SimpleAccount
- core/EntryPoint
Foundry 프로젝트
프로젝트 생성
$ forge init contracts
$ cd contracts
라이브러리 설치
$ forge install OpenZeppelin/openzeppelin-contracts eth-infinitism/account-abstraction --no-commit
IAccount 인터페이스
interface IAccount {
function validateUserOp(
PackedUserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external returns (uint256 validationData);
}
struct PackedUserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
bytes32 accountGasLimits;
uint256 preVerificationGas;
bytes32 gasFees;
bytes paymasterAndData;
bytes signature;
}
SimpleAccount 컨트랙트
src/SimpleAccount.sol 파일을 생성하고 복사/붙여 넣으시면 됩니다. 또는 eth-infinitism/account-abstraction 라이브러리의 SimpleAccount 컨트랙트를 참고하시면 됩니다.
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.24;
import {IEntryPoint} from "account-abstraction/interfaces/IEntryPoint.sol";
import {BaseAccount} from "account-abstraction/core/BaseAccount.sol";
import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {TokenCallbackHandler} from "account-abstraction/samples/callback/TokenCallbackHandler.sol";
contract SimpleAccount is BaseAccount, TokenCallbackHandler, UUPSUpgradeable, Initializable {
error InvalidInput();
error OnlyOwner();
error OnlyFromEntryPointOrOwner();
using MessageHashUtils for bytes32;
uint256 public constant SIG_VALIDATION_FAILED = 1;
uint256 public constant SIG_VALIDATION_SUCCESS = 0;
IEntryPoint private immutable _entryPoint;
address public owner;
event SimpleAccountInitialized(IEntryPoint indexed entryPoint, address indexed owner);
modifier onlyOwner() {
_onlyOwner();
_;
}
constructor(IEntryPoint entryPoint_) {
_entryPoint = entryPoint_;
_disableInitializers();
}
/*
===============
| BaseAccount |
===============
*/
function entryPoint() public view virtual override returns (IEntryPoint) {
return _entryPoint;
}
function _validateSignature(PackedUserOperation calldata userOp, bytes32 userOpHash)
internal
virtual
override
returns (uint256 validationData)
{
bytes32 hash = userOpHash.toEthSignedMessageHash();
if (owner != ECDSA.recover(hash, userOp.signature)) {
return SIG_VALIDATION_FAILED;
}
return SIG_VALIDATION_SUCCESS;
}
/*
=================
| Initializable |
=================
*/
function initialize(address _owner) public virtual initializer {
_initialize(_owner);
}
function _initialize(address _owner) internal virtual {
owner = _owner;
emit SimpleAccountInitialized(_entryPoint, _owner);
}
/*
==================
| UUPSUpgradeable |
==================
*/
function _authorizeUpgrade(address newImplementation) internal view override {
(newImplementation);
_onlyOwner();
}
/*
=================
| SimpleAccount |
=================
*/
function version() external pure virtual returns (string memory) {
return "1.0.0";
}
function execute(address target, uint256 value, bytes memory data) external {
_onlyFromEntryPointOrOwner();
_call(target, value, data);
}
function executeBatch(address[] calldata targets, uint256[] calldata values, bytes[] calldata datas) external {
_onlyFromEntryPointOrOwner();
if (targets.length != datas.length || !(values.length == 0 || values.length != targets.length)) {
revert InvalidInput();
}
if (values.length == 0) {
for (uint256 i = 0; i < targets.length; i++) {
_call(targets[i], 0, datas[i]);
}
} else {
for (uint256 i = 0; i < targets.length; i++) {
_call(targets[i], values[i], datas[i]);
}
}
}
function _call(address target, uint256 value, bytes memory data) internal {
(bool success, bytes memory result) = target.call{value: value}(data);
if (!success) {
assembly {
revert(add(result, 32), mload(result))
}
}
}
/**
* check current account deposit in the entryPoint
*/
function getDeposit() public view returns (uint256) {
return entryPoint().balanceOf(address(this));
}
/**
* deposit more funds for this account in the entryPoint
*/
function addDeposit() public payable {
entryPoint().depositTo{value: msg.value}(address(this));
}
/**
* withdraw value from the account's deposit
* @param withdrawAddress target to send to
* @param amount to withdraw
*/
function withdrawDepositTo(address payable withdrawAddress, uint256 amount) public onlyOwner {
entryPoint().withdrawTo(withdrawAddress, amount);
}
/*
=================
| Helper Methods |
=================
*/
function _onlyOwner() internal view {
//directly from EOA owner, or through the account itself (which gets redirected through execute())
if (msg.sender != owner && msg.sender != address(this)) {
revert OnlyOwner();
}
}
function _onlyFromEntryPointOrOwner() internal view {
if (msg.sender != address(entryPoint()) && msg.sender != owner) {
revert OnlyFromEntryPointOrOwner();
}
}
/*
============
| Fallback |
============
*/
receive() external payable {}
}
전반적으로 eth-infinitism/account-abstraction 라이브러리의 SimpleAccount 컨트랙트와 동일하나, 업그레이드 테스트를 위한 용도로 version 함수를 추가하였습니다.
function version() external pure virtual returns (string memory) {
return "1.0.0";
}
SimpleAccount 컨트랙트의 특징
- IAccount 인터페이스 구현
- ECDSA 서명 방식 사용
- 업그레이드 가능 (UUPS Upgradable)
- execute 및 executeBatch 함수를 통해 사용자 작업 실행
- getDeposit, addDeposit, withdrawDepositTo 함수를 통해 EntryPoint에 가스비를 예치 또는 인출 가능
SimpleAccountV2 컨트랙트
업데이트 가능 여부를 확인하기 위해 version 함수를 오버라이딩하여 반환값만 변경해 줍니다.
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.24;
import {IEntryPoint} from "account-abstraction/interfaces/IEntryPoint.sol";
import {SimpleAccount} from "./SimpleAccount.sol";
contract SimpleAccountV2 is SimpleAccount {
constructor(IEntryPoint entryPoint_) SimpleAccount(entryPoint_) {}
function version() external pure virtual override returns (string memory) {
return "2.0.0";
}
}
Foundry 테스트 구성
SimpleAccount.t.sol 파일 생성
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {SimpleAccount} from "../src/SimpleAccount.sol";
import {SimpleAccountV2} from "../src/SimpleAccountV2.sol";
import {IEntryPoint} from "account-abstraction/interfaces/IEntryPoint.sol";
import {EntryPoint} from "account-abstraction/core/EntryPoint.sol";
import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {Counter} from "../src/Counter.sol";
import {UserOpUtils} from "./UserOpUtils.sol";
contract SimpleAccountTest is Test {
...
}
- Test, console : 테스트를 위한 컨트랙트와 라이브러리
- SimpleAccount, SimpleAccountV2 : 계정 추상화 - 스마트 계정 구현체
- IEntryPoint, EntryPoint : EntryPoint 컨트랙트와 인터페이스
- PackedUserOperation : UserOperation을 패킹한 구조체 타입
- ERC1967Proxy : 스마트 계정 컨트랙트를 로직 레이어로 사용하고 ERC1967Proxy는 스토리지 레이어로 사용하므로 스마트 계정의 본체인 셈
- Counter : Foundry 프로젝트 생성 시에 딸려오는 기본 컨트랙트로, 간단한 작업 요청에 사용하기 위한 용도
- UserOpUtils : 테스트용 UserOp를 생성하고 서명하기 위한 유틸리티 컨트랙트. test/UserOpUtils.sol 파일 참고.
테스트 변수 및 이벤트 선언
bytes32 public constant IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
EntryPoint public entryPoint;
SimpleAccount public simpleAccountImpl;
SimpleAccountV2 public simpleAccountV2Impl;
Counter public counter;
UserOpUtils public utils;
uint256 public ownerPrivateKey = 1;
address public owner;
address public bob;
address public beneficiary;
event SimpleAccountInitialized(IEntryPoint indexed entryPoint, address indexed owner);
event UserOperationEvent(
bytes32 indexed userOpHash,
address indexed sender,
address indexed paymaster,
uint256 nonce,
bool success,
uint256 actualGasCost,
uint256 actualGasUsed
);
- IMPLEMENTATION_SLOT : 업그레이드 가능한 컨트랙트에서 로직 레이어의 주소가 저장되는 스토리지 슬롯
- ownerPrivateKey : 스마트 계정 소유자의 프라이빗 키. UserOpHash에 서명할 때 사용
- owner : 스마트 계정 소유자
- bob : 행인 1
- beneficiary : UserOp 실행에 대한 수수료를 받는 계정
setUp 함수
setUp 함수는 각 테스트가 실행되기 전에 실행되는 함수입니다. Hardhat 테스트에서 beforeEach랑 비슷하다고 보시면 됩니다.
function setUp() public {
owner = vm.addr(ownerPrivateKey);
vm.label(owner, "Owner");
vm.deal(owner, 100 ether);
bob = makeAddr("bob");
vm.label(bob, "Bob");
beneficiary = makeAddr("beneficiary");
vm.label(beneficiary, "Beneficiary");
vm.deal(beneficiary, 1 ether);
entryPoint = new EntryPoint();
vm.label(address(entryPoint), "EntryPoint");
simpleAccountImpl = new SimpleAccount(entryPoint);
vm.label(address(simpleAccountImpl), "SimpleAccountImpl");
simpleAccountV2Impl = new SimpleAccountV2(entryPoint);
vm.label(address(simpleAccountV2Impl), "SimpleAccountV2Impl");
counter = new Counter();
vm.label(address(counter), "Counter");
utils = new UserOpUtils();
}
배포된 컨트랙트 목록
- EntryPoint
- SimpleAccount 구현체
- SimpleAccountV2 구현체
- Counter
- UserOpUtils
테스트
[CASE 1] 스마트 계정 배포
function test_Deploy() public {
bytes memory data = abi.encodeWithSelector(simpleAccountImpl.initialize.selector, owner);
vm.expectEmit(true, true, true, true);
emit SimpleAccountInitialized(entryPoint, owner);
vm.prank(owner);
ERC1967Proxy simpleAccountProxy = new ERC1967Proxy(address(simpleAccountImpl), data);
// read the implementation address from the proxy contract
address impl = address(uint160(uint256(vm.load(address(simpleAccountProxy), IMPLEMENTATION_SLOT))));
assertEq(impl, address(simpleAccountImpl));
SimpleAccount simpleAccount = SimpleAccount(payable(address(simpleAccountProxy)));
assertEq(simpleAccount.owner(), owner);
assertEq(address(simpleAccount.entryPoint()), address(entryPoint));
}
1. ERC1967Proxy 컨트랙트의 생성자가 호출됩니다.
constructor(address implementation, bytes memory _data) payable {
ERC1967Utils.upgradeToAndCall(implementation, _data);
}
2. upgradeToAndCall 함수가 호출되고 simpleAccountImpl의 주소를 새로운 로직 레이어로 등록합니다. 그리고 data의 길이가 0보다 기므로, 로직 레이어에 대한 delegatecall을 실행합니다.
function upgradeToAndCall(address newImplementation, bytes memory data) internal {
_setImplementation(newImplementation);
emit Upgraded(newImplementation);
if (data.length > 0) {
Address.functionDelegateCall(newImplementation, data);
} else {
_checkNonPayable();
}
}
3. 호출되는 것은 initialize 함수로, 컨트랙트의 owner를 등록하고 SimpleAccountInitialized 이벤트를 내보냅니다.
function initialize(address _owner) public virtual initializer {
_initialize(_owner);
}
function _initialize(address _owner) internal virtual {
owner = _owner;
emit SimpleAccountInitialized(_entryPoint, _owner);
}
4. 컨트랙트의 배포가 마무리되고 나면 다음 사항들을 확인할 수 있습니다.
- 배포된 simpleAccountProxy 컨트랙트의 IMPLEMENTATION_SLOT에는 simpleAccountImpl의 주소가 등록되어 있어야 합니다.
- simpleAccountProxy 컨트랙트를 SimpleAccount로 캐스팅한 simpleAccount의 owner(delegatecall로 반환된)는 owner여야 합니다.
- 또한 simpleAccount의 entryPoint는 simpleAccountImpl을 배포할 때 생성자에 넣어준 entryPoint의 주소와 동일해야 합니다.
vm.load(컨트랙트 주소, 슬롯 번호)는 컨트랙트의 슬롯 번호에 들어있는 32 바이트 크기의 데이터를 반환합니다.
address impl = address(uint160(uint256(vm.load(address(simpleAccountProxy), IMPLEMENTATION_SLOT))));
assertEq(impl, address(simpleAccountImpl));
SimpleAccount simpleAccount = SimpleAccount(payable(address(simpleAccountProxy)));
assertEq(simpleAccount.owner(), owner);
assertEq(address(simpleAccount.entryPoint()), address(entryPoint));
테스트
$ forge test --mt test_Deploy -vvvv
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/SimpleAccount.t.sol:SimpleAccountTest
[PASS] test_Deploy() (gas: 158754)
Traces:
[158754] SimpleAccountTest::test_Deploy()
├─ [0] VM::expectEmit(true, true, true, true)
│ └─ ← [Return]
├─ emit SimpleAccountInitialized(entryPoint: EntryPoint: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], owner: Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf])
├─ [0] VM::prank(Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf])
│ └─ ← [Return]
├─ [109729] → new ERC1967Proxy@0xF2E246BB76DF876Cef8b38ae84130F4F55De395b
│ ├─ emit Upgraded(implementation: SimpleAccountImpl: [0x2e234DAe75C793f67A35089C9d99245E1C58470b])
│ ├─ [48063] SimpleAccountImpl::initialize(Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf]) [delegatecall]
│ │ ├─ emit SimpleAccountInitialized(entryPoint: EntryPoint: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], owner: Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf])
│ │ ├─ emit Initialized(version: 1)
│ │ └─ ← [Stop]
│ └─ ← [Return] 170 bytes of code
├─ [0] VM::load(ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc) [staticcall]
│ └─ ← [Return] 0x0000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b
├─ [0] VM::assertEq(SimpleAccountImpl: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], SimpleAccountImpl: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]) [staticcall]
│ └─ ← [Return]
├─ [750] ERC1967Proxy::owner() [staticcall]
│ ├─ [360] SimpleAccountImpl::owner() [delegatecall]
│ │ └─ ← [Return] Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf]
│ └─ ← [Return] Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf]
├─ [0] VM::assertEq(Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf], Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf]) [staticcall]
│ └─ ← [Return]
├─ [692] ERC1967Proxy::entryPoint() [staticcall]
│ ├─ [302] SimpleAccountImpl::entryPoint() [delegatecall]
│ │ └─ ← [Return] EntryPoint: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]
│ └─ ← [Return] EntryPoint: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]
├─ [0] VM::assertEq(EntryPoint: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], EntryPoint: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.99ms (190.92µs CPU time)
Ran 1 test suite in 767.84ms (1.99ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
initialize 함수에서 entryPoint를 초기화한 적이 없는데 entryPoint 주소가 정상적으로 반환되는 이유
저는 이걸 보고 적지 않게 당황했는데 다른 분들은 어떠실지 모르겠네요. 분명 스토리지 레이어에는 initialize 함수 호출을 통해서 owner만 초기화가 되었습니다. entryPoint는 구현체 컨트랙트를 생성할 때 생성자에 넘겨준 것이 전부인데 어떻게 이게 가능한 걸까요?
function initialize(address _owner) public virtual initializer {
_initialize(_owner);
}
그 이유는 _entryPoint 변수가 immutable로 선언되어 있기 때문입니다.
IEntryPoint private immutable _entryPoint;
immutable로 선언된 변수는 컨트랙트의 런타임 바이트코드에 저장되기 때문에 프록시를 통해서 로직레이어를 호출하더라도 스토리지 레이어의 스토리지 변수가 반환되는 것이 아닌, 로직 레이어의 바이트코드에 저장된 _entryPoint 값이 반환됩니다. 따라서 로직 레이어의 생성자에서 등록한 _entryPoint 값을 가져오는 것입니다.
[CASE 2] validateUserOp
function test_ValidateUserOp() public {
SimpleAccount simpleAccount = createAccount();
PackedUserOperation memory packedUserOp = utils.packUserOp(address(simpleAccount), simpleAccount.getNonce(), "");
bytes32 userOpHash = entryPoint.getUserOpHash(packedUserOp);
bytes memory signature = utils.signUserOp(ownerPrivateKey, userOpHash);
packedUserOp.signature = signature;
uint256 missingAccountFunds = 10 gwei;
uint256 accountBalanceBefore = address(simpleAccount).balance;
vm.prank(address(entryPoint));
uint256 validationData = simpleAccount.validateUserOp(packedUserOp, userOpHash, missingAccountFunds);
assertEq(validationData, 0);
assertEq(address(entryPoint).balance, missingAccountFunds);
assertEq(address(simpleAccount).balance, accountBalanceBefore - missingAccountFunds);
}
1. owner의 스마트 계정을 생성합니다. vm.deal을 사용해 계정에 1 이더를 넣어줍니다.
function createAccount() public returns (SimpleAccount) {
bytes memory data = abi.encodeWithSelector(simpleAccountImpl.initialize.selector, owner);
vm.prank(owner);
ERC1967Proxy simpleAccountProxy = new ERC1967Proxy(address(simpleAccountImpl), data);
vm.deal(address(simpleAccountProxy), 1 ether);
return SimpleAccount(payable(address(simpleAccountProxy)));
}
2. 계정의 주소, 논스, 빈 calldata를 입력으로 PackedUserOperation을 생성합니다.
PackedUserOperation memory packedUserOp = utils.packUserOp(address(simpleAccount), simpleAccount.getNonce(), "");
UserOpUtils 컨트랙트의 packUserOp 함수는 다음과 같습니다.
function packUserOp(address sender, uint256 nonce, bytes memory data)
public
pure
returns (PackedUserOperation memory)
{
uint128 verificationGasLimit = 200000;
uint128 callGasLimit = 50000;
bytes32 gasLimits = bytes32((uint256(verificationGasLimit)) << 128 | uint256(callGasLimit));
uint256 maxPriorityFeePerGas = 1 gwei;
uint256 maxFeePerGas = 20 gwei;
bytes32 gasFees = bytes32((uint256(maxPriorityFeePerGas)) << 128 | uint256(maxFeePerGas));
return PackedUserOperation({
sender: sender,
nonce: nonce,
initCode: "",
callData: data,
accountGasLimits: gasLimits,
preVerificationGas: 21000,
gasFees: gasFees,
paymasterAndData: "",
signature: ""
});
}
- sender : 스마트 계정 주소
- nonce : 스마트 계정의 논스
- initCode : 팩토리 컨트랙트의 주소 20바이트 + 팩토리 컨트랙트 호출 데이터 (스마트 계정이 배포되어 있지 않은 경우에만 지정)
- callData : 스마트 계정에서 userOp 실행에 사용할 데이터
- accountGasLimits : verificationGasLimit(상위 128비트)와 callGasLimit(하위 128비트)를 패킹한 32 바이트 값
- verificationGasLimit : validateUserOp 실행에 사용할 가스의 한도
- callGasLimit : callData를 사용해 upserOp 실행에 사용할 가스의 한도
- preVerificationGas : 번들러에게 지불할 여분의 가스
- gasFees : maxPriorityFeePerGas(상위 128비트)와 maxFeePerGas(하위 128비트)를 패킹한 32 바이트 값
- maxPriorityFeePerGas : 가스 당 최대 우선순위 수수료 (EIP-1559)
- maxFeePerGas : 가스 당 최대 수수료 (EIP-1559)
- paymasterAndData : 페이마스터 컨트랙트의 주소 20바이트 + 페이마스터 관련 데이터 (페이마스터를 사용하는 경우에만 지정)
- signature : 스마트 계정을 소유한 사용자의 서명
3. 생성된 PackedUserOperation, EntryPoint, 그리고 chainid를 abi 인코딩한 값을 입력으로 해시를 구합니다. PackedUserOperation에는 서명 데이터가 포함되어 있지 않아야 합니다.
bytes32 userOpHash = entryPoint.getUserOpHash(packedUserOp);
function getUserOpHash(PackedUserOperation calldata userOp) public view returns (bytes32) {
return keccak256(abi.encode(userOp.hash(), address(this), block.chainid));
}
PackedUserOperation의 해시는 다음과 같이 계산됩니다.
function hashUserOp(PackedUserOperation memory userOp) public pure returns (bytes32) {
return keccak256(
abi.encodePacked(
userOp.sender,
userOp.nonce,
keccak256(userOp.initCode),
keccak256(userOp.callData),
userOp.accountGasLimits,
userOp.preVerificationGas,
userOp.gasFees,
keccak256(userOp.paymasterAndData)
)
);
}
4. 스마트 계정 소유자의 프라이빗 키를 사용해 방금 생성한 해시에 서명합니다.
bytes memory signature = utils.signUserOp(ownerPrivateKey, userOpHash);
서명 과정은 다음과 같습니다.
- userOpHash를 사용해 EIP-191 서명된 데이터(signend data)를 생성합니다.
- 프라이빗 키와 생성된 데이터를 입력으로 vm.sgin 함수를 실행하서 v, r, s 값을 구합니다.
- r, s, v 순서로 값을 연결하여 65바이트 서명 데이터를 반환합니다.
function signUserOp(uint256 privateKey, bytes32 userOpHash) public pure returns (bytes memory) {
bytes32 digest = userOpHash.toEthSignedMessageHash();
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest);
return abi.encodePacked(r, s, v);
}
function toEthSignedMessageHash(bytes32 messageHash) internal pure returns (bytes32 digest) {
/// @solidity memory-safe-assembly
assembly {
mstore(0x00, "\x19Ethereum Signed Message:\n32") // 32 is the bytes-length of messageHash
mstore(0x1c, messageHash) // 0x1c (28) is the length of the prefix
digest := keccak256(0x00, 0x3c) // 0x3c is the length of the prefix (0x1c) + messageHash (0x20)
}
}
5. packedUserOp에 서명 데이터를 추가하고 missingAccountFunds는 10 gwei로 지정합니다. 그리고 스마트 계정의 잔액을 기록해 놓습니다.
packedUserOp.signature = signature;
uint256 missingAccountFunds = 10 gwei;
uint256 accountBalanceBefore = address(simpleAccount).balance;
6. entryPoint를 호출자로 지정하고 스마트 계정의 validateUserOp 함수를 호출합니다.
vm.prank(address(entryPoint));
uint256 validationData = simpleAccount.validateUserOp(packedUserOp, userOpHash, missingAccountFunds);
function validateUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds)
external
virtual
override
returns (uint256 validationData)
{
_requireFromEntryPoint();
validationData = _validateSignature(userOp, userOpHash);
_validateNonce(userOp.nonce);
_payPrefund(missingAccountFunds);
}
7. validateUserOp 함수에서는 우선 호출자가 신뢰하는 entryPoint인지 확인하고 서명을 검증합니다.
_requireFromEntryPoint();
validationData = _validateSignature(userOp, userOpHash);
function _requireFromEntryPoint() internal view virtual {
require(msg.sender == address(entryPoint()), "account: not from EntryPoint");
}
_validateSignature 함수는 우선 userOpHash를 EIP-191 서명된 데이터로 변환합니다. 그리고 서명된 데이터와 서명을 입력으로 ecrecover 함수를 호출하여 ECDSA 서명을 검증합니다. 서명이 유효한 ECDSA 서명인 경우, SIG_VALIDATION_SUCCESS(0)을 반환하고 유효하지 않다면 SIG_VALIDATION_FAILED(1)을 반환합니다.
function _validateSignature(PackedUserOperation calldata userOp, bytes32 userOpHash)
internal
virtual
override
returns (uint256 validationData)
{
bytes32 hash = userOpHash.toEthSignedMessageHash();
if (owner != ECDSA.recover(hash, userOp.signature)) {
return SIG_VALIDATION_FAILED;
}
return SIG_VALIDATION_SUCCESS;
}
8. validateUserOp 함수는 다음으로 논스의 유효성을 검사하고 missingAccountFunds 만큼의 이더를 entryPoint에게 전송합니다.
_validateNonce(userOp.nonce);
_payPrefund(missingAccountFunds);
SimpleAccount의 _validateNonce는 별다른 동작을 하지 않습니다.
function _validateNonce(uint256 nonce) internal view virtual {}
function _payPrefund(uint256 missingAccountFunds) internal virtual {
if (missingAccountFunds != 0) {
(bool success,) = payable(msg.sender).call{value: missingAccountFunds, gas: type(uint256).max}("");
(success);
//ignore failure (its EntryPoint's job to verify, not account.)
}
}
9. 서명이 유효하므로 validateUserOp 함수에서 반환된 validationData는 0이 되고 entryPoint에게는 missingAccountFunds 만큼의 이더가 전송되었으며, 스마트 계정에서는 missingAccountFunds 만큼의 이더가 차감된 것을 확인할 수 있습니다.
assertEq(validationData, 0);
assertEq(address(entryPoint).balance, missingAccountFunds);
assertEq(address(simpleAccount).balance, accountBalanceBefore - missingAccountFunds);
테스트
$ forge test --mt test_ValidateUserOp -vvvv
[⠒] Compiling...
[⠒] Compiling 4 files with 0.8.24
[⠢] Solc 0.8.24 finished in 3.91s
Compiler run successful!
Ran 1 test for test/SimpleAccount.t.sol:SimpleAccountTest
[PASS] test_ValidateUserOp() (gas: 217779)
Traces:
[217779] SimpleAccountTest::test_ValidateUserOp()
├─ [0] VM::prank(Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf])
│ └─ ← [Return]
├─ [109729] → new ERC1967Proxy@0xF2E246BB76DF876Cef8b38ae84130F4F55De395b
│ ├─ emit Upgraded(implementation: SimpleAccountImpl: [0x2e234DAe75C793f67A35089C9d99245E1C58470b])
│ ├─ [48063] SimpleAccountImpl::initialize(Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf]) [delegatecall]
│ │ ├─ emit SimpleAccountInitialized(entryPoint: EntryPoint: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], owner: Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf])
│ │ ├─ emit Initialized(version: 1)
│ │ └─ ← [Stop]
│ └─ ← [Return] 170 bytes of code
├─ [0] VM::deal(ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], 1000000000000000000 [1e18])
│ └─ ← [Return]
├─ [6428] ERC1967Proxy::getNonce() [staticcall]
│ ├─ [6038] SimpleAccountImpl::getNonce() [delegatecall]
│ │ ├─ [2841] EntryPoint::getNonce(ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], 0) [staticcall]
│ │ │ └─ ← [Return] 0
│ │ └─ ← [Return] 0
│ └─ ← [Return] 0
├─ [2343] UserOpUtils::packUserOp(ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], 0, 0x) [staticcall]
│ └─ ← [Return] PackedUserOperation({ sender: 0xF2E246BB76DF876Cef8b38ae84130F4F55De395b, nonce: 0, initCode: 0x, callData: 0x, accountGasLimits: 0x000000000000000000000000000052080000000000000000000000000007a120, preVerificationGas: 21000 [2.1e4], gasFees: 0x000000000000000000000004a817c8000000000000000000000000003b9aca00, paymasterAndData: 0x, signature: 0x })
├─ [1985] EntryPoint::getUserOpHash(PackedUserOperation({ sender: 0xF2E246BB76DF876Cef8b38ae84130F4F55De395b, nonce: 0, initCode: 0x, callData: 0x, accountGasLimits: 0x000000000000000000000000000052080000000000000000000000000007a120, preVerificationGas: 21000 [2.1e4], gasFees: 0x000000000000000000000004a817c8000000000000000000000000003b9aca00, paymasterAndData: 0x, signature: 0x })) [staticcall]
│ └─ ← [Return] 0x1084417f432b2bb5faf204013042cd48a735ca5cea767d24071c8baa0e7379f8
├─ [1517] UserOpUtils::signUserOp(1, 0x1084417f432b2bb5faf204013042cd48a735ca5cea767d24071c8baa0e7379f8) [staticcall]
│ ├─ [0] VM::sign("<pk>", 0x96d1a3e2f34768d328f16628e1a0720fb4d10d87ab4802eca1135bc1e0b7d2c1) [staticcall]
│ │ └─ ← [Return] 28, 0x2551a85fc474fa613d5f05aaa7248b0d02c3344b8cb9b8aed7e7184b6f418eab, 0x36effab774cbae8f88fe70a8e75a933496dec8047164d1cee5a1c26a74e2ed30
│ └─ ← [Return] 0x2551a85fc474fa613d5f05aaa7248b0d02c3344b8cb9b8aed7e7184b6f418eab36effab774cbae8f88fe70a8e75a933496dec8047164d1cee5a1c26a74e2ed301c
├─ [0] VM::prank(EntryPoint: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f])
│ └─ ← [Return]
├─ [36303] ERC1967Proxy::validateUserOp(PackedUserOperation({ sender: 0xF2E246BB76DF876Cef8b38ae84130F4F55De395b, nonce: 0, initCode: 0x, callData: 0x, accountGasLimits: 0x000000000000000000000000000052080000000000000000000000000007a120, preVerificationGas: 21000 [2.1e4], gasFees: 0x000000000000000000000004a817c8000000000000000000000000003b9aca00, paymasterAndData: 0x, signature: 0x2551a85fc474fa613d5f05aaa7248b0d02c3344b8cb9b8aed7e7184b6f418eab36effab774cbae8f88fe70a8e75a933496dec8047164d1cee5a1c26a74e2ed301c }), 0x1084417f432b2bb5faf204013042cd48a735ca5cea767d24071c8baa0e7379f8, 10000000000 [1e10])
│ ├─ [35805] SimpleAccountImpl::validateUserOp(PackedUserOperation({ sender: 0xF2E246BB76DF876Cef8b38ae84130F4F55De395b, nonce: 0, initCode: 0x, callData: 0x, accountGasLimits: 0x000000000000000000000000000052080000000000000000000000000007a120, preVerificationGas: 21000 [2.1e4], gasFees: 0x000000000000000000000004a817c8000000000000000000000000003b9aca00, paymasterAndData: 0x, signature: 0x2551a85fc474fa613d5f05aaa7248b0d02c3344b8cb9b8aed7e7184b6f418eab36effab774cbae8f88fe70a8e75a933496dec8047164d1cee5a1c26a74e2ed301c }), 0x1084417f432b2bb5faf204013042cd48a735ca5cea767d24071c8baa0e7379f8, 10000000000 [1e10]) [delegatecall]
│ │ ├─ [3000] PRECOMPILES::ecrecover(0x96d1a3e2f34768d328f16628e1a0720fb4d10d87ab4802eca1135bc1e0b7d2c1, 28, 16879852085098430838630584165330332772982403071122920132697662660312100343467, 24848900654535372254484868768841580219463736228046712873444430881450608487728) [staticcall]
│ │ │ └─ ← [Return] 0x0000000000000000000000007e5f4552091a69125d5dfcb7b8c2659029395bdf
│ │ ├─ [23893] EntryPoint::receive{value: 10000000000}()
│ │ │ ├─ emit Deposited(account: ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], totalDeposit: 10000000000 [1e10])
│ │ │ └─ ← [Stop]
│ │ └─ ← [Return] 0
│ └─ ← [Return] 0
├─ [0] VM::assertEq(0, 0) [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertEq(10000000000 [1e10], 10000000000 [1e10]) [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertEq(999999990000000000 [9.999e17], 999999990000000000 [9.999e17]) [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.44ms (2.03ms CPU time)
Ran 1 test suite in 619.97ms (5.44ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
[CASE 3] handleOps
function test_HandleOps() public {
SimpleAccount simpleAccount = createAccount();
bytes memory callData = abi.encodeWithSelector(
SimpleAccount.execute.selector, address(counter), 0, abi.encodeWithSelector(counter.increment.selector)
);
uint256 nonce = simpleAccount.getNonce();
PackedUserOperation memory packedUserOp = utils.packUserOp(address(simpleAccount), nonce, callData);
bytes32 userOpHash = entryPoint.getUserOpHash(packedUserOp);
bytes memory signature = utils.signUserOp(ownerPrivateKey, userOpHash);
packedUserOp.signature = signature;
PackedUserOperation[] memory ops = new PackedUserOperation[](1);
ops[0] = packedUserOp;
uint256 counterBefore = counter.number();
uint256 accountBalanceBefore = address(simpleAccount).balance;
uint256 beneficiaryBalanceBefore = beneficiary.balance;
vm.expectEmit(true, true, true, false);
emit UserOperationEvent(userOpHash, address(simpleAccount), address(0), nonce, true, 0, 0);
entryPoint.handleOps(ops, payable(beneficiary));
assertEq(counter.number(), counterBefore + 1);
assertLt(address(simpleAccount).balance, accountBalanceBefore);
assertGt(beneficiary.balance, beneficiaryBalanceBefore);
}
1. owner의 스마트 계정을 생성하고 이번에는 스마트 계정의 execute 함수를 통해 counter의 increment 함수를 실행하는 calldata를 사용하여 PackedUserOperation을 생성하였습니다.
SimpleAccount simpleAccount = createAccount();
bytes memory callData = abi.encodeWithSelector(
SimpleAccount.execute.selector, address(counter), 0, abi.encodeWithSelector(counter.increment.selector)
);
uint256 nonce = simpleAccount.getNonce();
PackedUserOperation memory packedUserOp = utils.packUserOp(address(simpleAccount), nonce, callData);
2. userOpHash를 구한 다음 서명을 해주고 PackedUserOperation의 동적 배열을 생성하여 0번 인덱스에 앞서 생성한 PackedUserOperation을 저장합니다.
bytes32 userOpHash = entryPoint.getUserOpHash(packedUserOp);
bytes memory signature = utils.signUserOp(ownerPrivateKey, userOpHash);
packedUserOp.signature = signature;
PackedUserOperation[] memory ops = new PackedUserOperation[](1);
ops[0] = packedUserOp;
3. 테스트 결과 비교를 위해 counter의 number와 스마트 계정의 잔액 그리고 beneficiary의 잔액을 기록합니다. 테스트 과정에서 발생할 이벤트도 하나 정도 예측해 줍시다.
uint256 counterBefore = counter.number();
uint256 accountBalanceBefore = address(simpleAccount).balance;
uint256 beneficiaryBalanceBefore = beneficiary.balance;
vm.expectEmit(true, true, true, false);
emit UserOperationEvent(userOpHash, address(simpleAccount), address(0), nonce, true, 0, 0);
4. 앞서 생성한 PackedUserOperation의 동적 배열과 beneficiary의 주소를 입력으로 entryPoint의 handleOps 함수를 호출합니다.
entryPoint.handleOps(ops, payable(beneficiary));
function handleOps(PackedUserOperation[] calldata ops, address payable beneficiary) public nonReentrant {
uint256 opslen = ops.length;
UserOpInfo[] memory opInfos = new UserOpInfo[](opslen);
unchecked {
for (uint256 i = 0; i < opslen; i++) {
UserOpInfo memory opInfo = opInfos[i];
(uint256 validationData, uint256 pmValidationData) = _validatePrepayment(i, ops[i], opInfo);
_validateAccountAndPaymasterValidationData(i, validationData, pmValidationData, address(0));
}
uint256 collected = 0;
emit BeforeExecution();
for (uint256 i = 0; i < opslen; i++) {
collected += _executeUserOp(i, ops[i], opInfos[i]);
}
_compensate(beneficiary, collected);
}
}
5. handleOps 함수는 우선 반복문으로 PackedUserOperation 배열을 돌면서 각 userOp를 검증합니다. (validation loop)
for (uint256 i = 0; i < opslen; i++) {
UserOpInfo memory opInfo = opInfos[i];
(
uint256 validationData,
uint256 pmValidationData
) = _validatePrepayment(i, ops[i], opInfo);
_validateAccountAndPaymasterValidationData(
i,
validationData,
pmValidationData,
address(0)
);
}
_validatePrepayment 함수의 동작 과정은 다음과 같습니다.
- _copyUserOpToMemory 함수를 호출하여 패킹된 userOp를 언패킹하여 메모리에 저장합니다.
- _getRequiredPrefund 함수를 호출하여 계정에서 인출할 금액을 산정합니다.
- _validateAccountPrepayment 함수를 호출합니다.
- sender(계정)가 존재하지 않으면 SenderCreator에게 initCode를 전달하여 sender를 생성합니다.
- sender의 예치금을 확인하고 부족한 금액(missingAccountFunds)을 계산합니다. (paymaster가 없는 경우)
- sender를 IAccount로 캐스팅하고 validateUserOp 함수를 호출합니다.
- sender의 예치금이 requiredPrefund보다 많거나 같다면 validateUserOp 함수의 반환값을 반환합니다.
- _validateAndUpdateNonce 함수를 호출하여 논스를 확인하고 증가시킵니다.
- 검증에 사용한 가스의 양이 verificationGasLimit를 넘지 않았는지 확인하고 outOpInfo를 갱신한 뒤 반환값을 반환합니다. (paymaster 검증은 스킵)
- 유효하지 않은 userOp가 하나라도 있다면 revert됩니다.
function _validatePrepayment(uint256 opIndex, PackedUserOperation calldata userOp, UserOpInfo memory outOpInfo)
internal
returns (uint256 validationData, uint256 paymasterValidationData)
{
uint256 preGas = gasleft();
MemoryUserOp memory mUserOp = outOpInfo.mUserOp;
_copyUserOpToMemory(userOp, mUserOp);
outOpInfo.userOpHash = getUserOpHash(userOp);
// Validate all numeric values in userOp are well below 128 bit, so they can safely be added
// and multiplied without causing overflow.
uint256 verificationGasLimit = mUserOp.verificationGasLimit;
uint256 maxGasValues = mUserOp.preVerificationGas | verificationGasLimit | mUserOp.callGasLimit
| mUserOp.paymasterVerificationGasLimit | mUserOp.paymasterPostOpGasLimit | mUserOp.maxFeePerGas
| mUserOp.maxPriorityFeePerGas;
require(maxGasValues <= type(uint120).max, "AA94 gas values overflow");
uint256 requiredPreFund = _getRequiredPrefund(mUserOp);
validationData = _validateAccountPrepayment(opIndex, userOp, outOpInfo, requiredPreFund, verificationGasLimit);
if (!_validateAndUpdateNonce(mUserOp.sender, mUserOp.nonce)) {
revert FailedOp(opIndex, "AA25 invalid account nonce");
}
unchecked {
if (preGas - gasleft() > verificationGasLimit) {
revert FailedOp(opIndex, "AA26 over verificationGasLimit");
}
}
bytes memory context;
if (mUserOp.paymaster != address(0)) {
(context, paymasterValidationData) = _validatePaymasterPrepayment(opIndex, userOp, outOpInfo, requiredPreFund);
}
unchecked {
outOpInfo.prefund = requiredPreFund;
outOpInfo.contextOffset = getOffsetOfMemoryBytes(context);
outOpInfo.preOpGas = preGas - gasleft() + userOp.preVerificationGas;
}
}
uint256 requiredGas = verificationGasLimit +
callGasLimit +
paymasterVerificationGasLimit +
paymasterPostOpGasLimit +
preVerificationGas;
requiredPrefund = requiredGas * maxFeePerGas
function _validateAccountPrepayment(
uint256 opIndex,
PackedUserOperation calldata op,
UserOpInfo memory opInfo,
uint256 requiredPrefund,
uint256 verificationGasLimit
) internal returns (uint256 validationData) {
unchecked {
MemoryUserOp memory mUserOp = opInfo.mUserOp;
address sender = mUserOp.sender;
_createSenderIfNeeded(opIndex, opInfo, op.initCode);
address paymaster = mUserOp.paymaster;
uint256 missingAccountFunds = 0;
if (paymaster == address(0)) {
uint256 bal = balanceOf(sender);
missingAccountFunds = bal > requiredPrefund ? 0 : requiredPrefund - bal;
}
try IAccount(sender).validateUserOp{gas: verificationGasLimit}(op, opInfo.userOpHash, missingAccountFunds)
returns (uint256 _validationData) {
validationData = _validationData;
} catch {
revert FailedOpWithRevert(opIndex, "AA23 reverted", Exec.getReturnData(REVERT_REASON_MAX_LEN));
}
if (paymaster == address(0)) {
DepositInfo storage senderInfo = deposits[sender];
uint256 deposit = senderInfo.deposit;
if (requiredPrefund > deposit) {
revert FailedOp(opIndex, "AA21 didn't pay prefund");
}
senderInfo.deposit = deposit - requiredPrefund;
}
}
}
function _validateAndUpdateNonce(address sender, uint256 nonce) internal returns (bool) {
uint192 key = uint192(nonce >> 64);
uint64 seq = uint64(nonce);
return nonceSequenceNumber[sender][key]++ == seq;
}
_validateAccountAndPaymasterValidationData 함수는 sender의 validateUserOp 함수에서 반환된 validationData에 aggregator의 주소와 userOp가 유효한 시간 등이 들어있는 경우에만 동작하므로 여기서는 스킵하겠습니다.
6. validation loop를 마치고 나면 검증이 완료된 userOp는 _executeUserOp 함수를 통해 실행됩니다. (execution loop)
for (uint256 i = 0; i < opslen; i++) {
collected += _executeUserOp(i, ops[i], opInfos[i]);
}
function _executeUserOp(uint256 opIndex, PackedUserOperation calldata userOp, UserOpInfo memory opInfo)
internal
returns (uint256 collected)
{
uint256 preGas = gasleft();
bytes memory context = getMemoryBytesFromOffset(opInfo.contextOffset);
bool success;
{
uint256 saveFreePtr;
assembly ("memory-safe") {
saveFreePtr := mload(0x40)
}
bytes calldata callData = userOp.callData;
bytes memory innerCall;
bytes4 methodSig;
assembly {
let len := callData.length
if gt(len, 3) { methodSig := calldataload(callData.offset) }
}
if (methodSig == IAccountExecute.executeUserOp.selector) {
bytes memory executeUserOp = abi.encodeCall(IAccountExecute.executeUserOp, (userOp, opInfo.userOpHash));
innerCall = abi.encodeCall(this.innerHandleOp, (executeUserOp, opInfo, context));
} else {
innerCall = abi.encodeCall(this.innerHandleOp, (callData, opInfo, context));
}
assembly ("memory-safe") {
success := call(gas(), address(), 0, add(innerCall, 0x20), mload(innerCall), 0, 32)
collected := mload(0)
mstore(0x40, saveFreePtr)
}
}
if (!success) {
bytes32 innerRevertCode;
assembly ("memory-safe") {
let len := returndatasize()
if eq(32, len) {
returndatacopy(0, 0, 32)
innerRevertCode := mload(0)
}
}
if (innerRevertCode == INNER_OUT_OF_GAS) {
// handleOps was called with gas limit too low. abort entire bundle.
//can only be caused by bundler (leaving not enough gas for inner call)
revert FailedOp(opIndex, "AA95 out of gas");
} else if (innerRevertCode == INNER_REVERT_LOW_PREFUND) {
// innerCall reverted on prefund too low. treat entire prefund as "gas cost"
uint256 actualGas = preGas - gasleft() + opInfo.preOpGas;
uint256 actualGasCost = opInfo.prefund;
emitPrefundTooLow(opInfo);
emitUserOperationEvent(opInfo, false, actualGasCost, actualGas);
collected = actualGasCost;
} else {
emit PostOpRevertReason(
opInfo.userOpHash,
opInfo.mUserOp.sender,
opInfo.mUserOp.nonce,
Exec.getReturnData(REVERT_REASON_MAX_LEN)
);
uint256 actualGas = preGas - gasleft() + opInfo.preOpGas;
collected = _postExecution(IPaymaster.PostOpMode.postOpReverted, opInfo, context, actualGas);
}
}
}
어셈블리를 사용하고 있어서 코드가 복잡합니다. 우선은 sender가 IAccountExecute 인터페이스를 구현했고 executeUserOp 함수를 호출하는지에 따라 실행 방법이 갈립니다. SimpleAccount는 IAccountExecute 인터페이스를 구현하지 않았으므로 else 블록을 살펴보면 EntryPoint의 innerHandleOp 함수가 호출되는 것을 확인할 수 있습니다.
if (methodSig == IAccountExecute.executeUserOp.selector) {
bytes memory executeUserOp = abi.encodeCall(IAccountExecute.executeUserOp, (userOp, opInfo.userOpHash));
innerCall = abi.encodeCall(this.innerHandleOp, (executeUserOp, opInfo, context));
} else
{
innerCall = abi.encodeCall(this.innerHandleOp, (callData, opInfo, context));
}
innerHandleOp 함수는 calldata를 사용해 sender를 호출합니다. Paymaster 관련된 부분은 스킵합니다.
function innerHandleOp(bytes memory callData, UserOpInfo memory opInfo, bytes calldata context)
external
returns (uint256 actualGasCost)
{
uint256 preGas = gasleft();
require(msg.sender == address(this), "AA92 internal call only");
MemoryUserOp memory mUserOp = opInfo.mUserOp;
uint256 callGasLimit = mUserOp.callGasLimit;
unchecked {
// handleOps was called with gas limit too low. abort entire bundle.
if (gasleft() * 63 / 64 < callGasLimit + mUserOp.paymasterPostOpGasLimit + INNER_GAS_OVERHEAD) {
assembly ("memory-safe") {
mstore(0, INNER_OUT_OF_GAS)
revert(0, 32)
}
}
}
IPaymaster.PostOpMode mode = IPaymaster.PostOpMode.opSucceeded;
if (callData.length > 0) {
bool success = Exec.call(mUserOp.sender, 0, callData, callGasLimit);
if (!success) {
bytes memory result = Exec.getReturnData(REVERT_REASON_MAX_LEN);
if (result.length > 0) {
emit UserOperationRevertReason(opInfo.userOpHash, mUserOp.sender, mUserOp.nonce, result);
}
mode = IPaymaster.PostOpMode.opReverted;
}
}
unchecked {
uint256 actualGas = preGas - gasleft() + opInfo.preOpGas;
return _postExecution(mode, opInfo, context, actualGas);
}
}
_executeUserOp로 돌아가기 전에 _postExecution 함수를 실행하여 userOp를 실행하고 남은 가스를 환불해 줍니다. 환불 금액은 예치금으로 들어가며, 환불액에서 10%의 페널티가 차감됩니다. 이는 userOp를 생성할 때 너무 많은 가스를 요청하여 번들에 다른 userOp가 포함되지 못하게 만든 것에 대한 페널티입니다.
function _postExecution(IPaymaster.PostOpMode mode, UserOpInfo memory opInfo, bytes memory context, uint256 actualGas)
private
returns (uint256 actualGasCost)
{
uint256 preGas = gasleft();
unchecked {
address refundAddress;
MemoryUserOp memory mUserOp = opInfo.mUserOp;
uint256 gasPrice = getUserOpGasPrice(mUserOp);
address paymaster = mUserOp.paymaster;
if (paymaster == address(0)) {
refundAddress = mUserOp.sender;
} else {
refundAddress = paymaster;
if (context.length > 0) {
actualGasCost = actualGas * gasPrice;
if (mode != IPaymaster.PostOpMode.postOpReverted) {
try IPaymaster(paymaster).postOp{gas: mUserOp.paymasterPostOpGasLimit}(
mode, context, actualGasCost, gasPrice
) {
// solhint-disable-next-line no-empty-blocks
} catch {
bytes memory reason = Exec.getReturnData(REVERT_REASON_MAX_LEN);
revert PostOpReverted(reason);
}
}
}
}
actualGas += preGas - gasleft();
// Calculating a penalty for unused execution gas
{
uint256 executionGasLimit = mUserOp.callGasLimit + mUserOp.paymasterPostOpGasLimit;
uint256 executionGasUsed = actualGas - opInfo.preOpGas;
// this check is required for the gas used within EntryPoint and not covered by explicit gas limits
if (executionGasLimit > executionGasUsed) {
uint256 unusedGas = executionGasLimit - executionGasUsed;
uint256 unusedGasPenalty = (unusedGas * PENALTY_PERCENT) / 100;
actualGas += unusedGasPenalty;
}
}
actualGasCost = actualGas * gasPrice;
uint256 prefund = opInfo.prefund;
if (prefund < actualGasCost) {
if (mode == IPaymaster.PostOpMode.postOpReverted) {
actualGasCost = prefund;
emitPrefundTooLow(opInfo);
emitUserOperationEvent(opInfo, false, actualGasCost, actualGas);
} else {
assembly ("memory-safe") {
mstore(0, INNER_REVERT_LOW_PREFUND)
revert(0, 32)
}
}
} else {
uint256 refund = prefund - actualGasCost;
_incrementDeposit(refundAddress, refund);
bool success = mode == IPaymaster.PostOpMode.opSucceeded;
emitUserOperationEvent(opInfo, success, actualGasCost, actualGas);
}
} // unchecked
}
_executeUserOp로 돌아와 sender에 대한 호출이 성공했는지 확인합니다.
assembly ("memory-safe") {
success := call(gas(), address(), 0, add(innerCall, 0x20), mload(innerCall), 0, 32)
collected := mload(0)
mstore(0x40, saveFreePtr)
}
그리고 실패 이유에 따라 다음과 같이 처리됩니다.
- 가스 부족 : revert
- sender의 예치금 부족 : 사용한 수수료 청구
- 그 외 : 사용한 수수료 청구
if (!success) {
bytes32 innerRevertCode;
assembly ("memory-safe") {
let len := returndatasize()
if eq(32,len) {
returndatacopy(0, 0, 32)
innerRevertCode := mload(0)
}
}
if (innerRevertCode == INNER_OUT_OF_GAS) {
// handleOps was called with gas limit too low. abort entire bundle.
//can only be caused by bundler (leaving not enough gas for inner call)
revert FailedOp(opIndex, "AA95 out of gas");
} else if (innerRevertCode == INNER_REVERT_LOW_PREFUND) {
// innerCall reverted on prefund too low. treat entire prefund as "gas cost"
uint256 actualGas = preGas - gasleft() + opInfo.preOpGas;
uint256 actualGasCost = opInfo.prefund;
emitPrefundTooLow(opInfo);
emitUserOperationEvent(opInfo, false, actualGasCost, actualGas);
collected = actualGasCost;
} else {
emit PostOpRevertReason(
opInfo.userOpHash,
opInfo.mUserOp.sender,
opInfo.mUserOp.nonce,
Exec.getReturnData(REVERT_REASON_MAX_LEN)
);
uint256 actualGas = preGas - gasleft() + opInfo.preOpGas;
collected = _postExecution(
IPaymaster.PostOpMode.postOpReverted,
opInfo,
context,
actualGas
);
}
}
모든 userOp를 실행하고 난 뒤에는 마지막으로 beneficiary에게 수수료를 정산하여 모인 금액을 전송합니다.
_compensate(beneficiary, collected);
function _compensate(address payable beneficiary, uint256 amount) internal {
require(beneficiary != address(0), "AA90 invalid beneficiary");
(bool success,) = beneficiary.call{value: amount}("");
require(success, "AA91 failed send to beneficiary");
}
정산이 완료되고 handleOps 함수가 종료되면 다음과 같이 변경된 상태를 비교할 수 있습니다. sender의 execute 함수를 통해 counter의 increment 함수가 실행됐으므로, 현재 counter.number의 반환값은 counterBefore + 1이어야 합니다. 그리고 sender의 잔액은 줄어들어야 하며, beneficiary의 잔액은 늘어나야 합니다.
assertEq(counter.number(), counterBefore + 1);
assertLt(address(simpleAccount).balance, accountBalanceBefore);
assertGt(beneficiary.balance, beneficiaryBalanceBefore);
테스트
$ forge test --mt test_HandleOps -vvvv
[⠒] Compiling...
[⠒] Compiling 6 files with 0.8.24
[⠢] Solc 0.8.24 finished in 3.88s
Compiler run successful!
Ran 1 test for test/SimpleAccount.t.sol:SimpleAccountTest
[PASS] test_HandleOps() (gas: 199176)
Traces:
[199176] SimpleAccountTest::test_HandleOps()
├─ [0] VM::prank(Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf])
│ └─ ← [Return]
├─ [109729] → new ERC1967Proxy@0xF2E246BB76DF876Cef8b38ae84130F4F55De395b
│ ├─ emit Upgraded(implementation: SimpleAccountImpl: [0x2e234DAe75C793f67A35089C9d99245E1C58470b])
│ ├─ [48063] SimpleAccountImpl::initialize(Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf]) [delegatecall]
│ │ ├─ emit SimpleAccountInitialized(entryPoint: EntryPoint: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], owner: Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf])
│ │ ├─ emit Initialized(version: 1)
│ │ └─ ← [Stop]
│ └─ ← [Return] 170 bytes of code
├─ [0] VM::deal(ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], 1000000000000000000 [1e18])
│ └─ ← [Return]
├─ [6428] ERC1967Proxy::getNonce() [staticcall]
│ ├─ [6038] SimpleAccountImpl::getNonce() [delegatecall]
│ │ ├─ [2841] EntryPoint::getNonce(ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], 0) [staticcall]
│ │ │ └─ ← [Return] 0
│ │ └─ ← [Return] 0
│ └─ ← [Return] 0
├─ [2873] UserOpUtils::packUserOp(ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], 0, 0xb61d27f60000000000000000000000005991a2df15a8f6a256d3ec51e99254cd3fb576a9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000) [staticcall]
│ └─ ← [Return] PackedUserOperation({ sender: 0xF2E246BB76DF876Cef8b38ae84130F4F55De395b, nonce: 0, initCode: 0x, callData: 0xb61d27f60000000000000000000000005991a2df15a8f6a256d3ec51e99254cd3fb576a9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x000000000000000000000000000052080000000000000000000000000007a120, preVerificationGas: 21000 [2.1e4], gasFees: 0x000000000000000000000004a817c8000000000000000000000000003b9aca00, paymasterAndData: 0x, signature: 0x })
├─ [2039] EntryPoint::getUserOpHash(PackedUserOperation({ sender: 0xF2E246BB76DF876Cef8b38ae84130F4F55De395b, nonce: 0, initCode: 0x, callData: 0xb61d27f60000000000000000000000005991a2df15a8f6a256d3ec51e99254cd3fb576a9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x000000000000000000000000000052080000000000000000000000000007a120, preVerificationGas: 21000 [2.1e4], gasFees: 0x000000000000000000000004a817c8000000000000000000000000003b9aca00, paymasterAndData: 0x, signature: 0x })) [staticcall]
│ └─ ← [Return] 0x86100d1a9bbf3ee3ae624f4d5d0a8cc5e03d2f25ffe0cb2e3a6cafd1dfcc293f
├─ [1517] UserOpUtils::signUserOp(1, 0x86100d1a9bbf3ee3ae624f4d5d0a8cc5e03d2f25ffe0cb2e3a6cafd1dfcc293f) [staticcall]
│ ├─ [0] VM::sign("<pk>", 0xbfcef6dc1a03eb7987fcd41ee6f1b4f408a6dcf720f1969c2d73ea03de8ea673) [staticcall]
│ │ └─ ← [Return] 28, 0x352fc8cf874ff24f01d0a6c545fa8be9209348fd25a52d0603668e90d12ecc3d, 0x00a7115eb02dc2378f6403242b39808e69a6010de876c7330de9580ceb3cbb4d
│ └─ ← [Return] 0x352fc8cf874ff24f01d0a6c545fa8be9209348fd25a52d0603668e90d12ecc3d00a7115eb02dc2378f6403242b39808e69a6010de876c7330de9580ceb3cbb4d1c
├─ [2283] Counter::number() [staticcall]
│ └─ ← [Return] 0
├─ [0] VM::expectEmit(true, true, true, false)
│ └─ ← [Return]
├─ emit UserOperationEvent(userOpHash: 0x86100d1a9bbf3ee3ae624f4d5d0a8cc5e03d2f25ffe0cb2e3a6cafd1dfcc293f, sender: ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], paymaster: 0x0000000000000000000000000000000000000000, nonce: 0, success: true, actualGasCost: 0, actualGasUsed: 0)
├─ [0] VM::pauseGasMetering()
│ └─ ← [Return]
├─ [0] EntryPoint::handleOps([PackedUserOperation({ sender: 0xF2E246BB76DF876Cef8b38ae84130F4F55De395b, nonce: 0, initCode: 0x, callData: 0xb61d27f60000000000000000000000005991a2df15a8f6a256d3ec51e99254cd3fb576a9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x000000000000000000000000000052080000000000000000000000000007a120, preVerificationGas: 21000 [2.1e4], gasFees: 0x000000000000000000000004a817c8000000000000000000000000003b9aca00, paymasterAndData: 0x, signature: 0x352fc8cf874ff24f01d0a6c545fa8be9209348fd25a52d0603668e90d12ecc3d00a7115eb02dc2378f6403242b39808e69a6010de876c7330de9580ceb3cbb4d1c })], Beneficiary: [0x5c4d2bd3510C8B51eDB17766d3c96EC637326999])
│ ├─ [0] ERC1967Proxy::validateUserOp(PackedUserOperation({ sender: 0xF2E246BB76DF876Cef8b38ae84130F4F55De395b, nonce: 0, initCode: 0x, callData: 0xb61d27f60000000000000000000000005991a2df15a8f6a256d3ec51e99254cd3fb576a9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x000000000000000000000000000052080000000000000000000000000007a120, preVerificationGas: 21000 [2.1e4], gasFees: 0x000000000000000000000004a817c8000000000000000000000000003b9aca00, paymasterAndData: 0x, signature: 0x352fc8cf874ff24f01d0a6c545fa8be9209348fd25a52d0603668e90d12ecc3d00a7115eb02dc2378f6403242b39808e69a6010de876c7330de9580ceb3cbb4d1c }), 0x86100d1a9bbf3ee3ae624f4d5d0a8cc5e03d2f25ffe0cb2e3a6cafd1dfcc293f, 542000000000000 [5.42e14])
│ │ ├─ [0] SimpleAccountImpl::validateUserOp(PackedUserOperation({ sender: 0xF2E246BB76DF876Cef8b38ae84130F4F55De395b, nonce: 0, initCode: 0x, callData: 0xb61d27f60000000000000000000000005991a2df15a8f6a256d3ec51e99254cd3fb576a9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x000000000000000000000000000052080000000000000000000000000007a120, preVerificationGas: 21000 [2.1e4], gasFees: 0x000000000000000000000004a817c8000000000000000000000000003b9aca00, paymasterAndData: 0x, signature: 0x352fc8cf874ff24f01d0a6c545fa8be9209348fd25a52d0603668e90d12ecc3d00a7115eb02dc2378f6403242b39808e69a6010de876c7330de9580ceb3cbb4d1c }), 0x86100d1a9bbf3ee3ae624f4d5d0a8cc5e03d2f25ffe0cb2e3a6cafd1dfcc293f, 542000000000000 [5.42e14]) [delegatecall]
│ │ │ ├─ [3000] PRECOMPILES::ecrecover(0xbfcef6dc1a03eb7987fcd41ee6f1b4f408a6dcf720f1969c2d73ea03de8ea673, 28, 24057008731186068325421086481579390970203952518732113800451119190154018737213, 295183342294659689457976959287508580454715042963846174737274284408726928205) [staticcall]
│ │ │ │ └─ ← [Return] 0x0000000000000000000000007e5f4552091a69125d5dfcb7b8c2659029395bdf
│ │ │ ├─ [0] EntryPoint::receive{value: 542000000000000}()
│ │ │ │ ├─ emit Deposited(account: ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], totalDeposit: 542000000000000 [5.42e14])
│ │ │ │ └─ ← [Stop]
│ │ │ └─ ← [Return] 0
│ │ └─ ← [Return] 0
│ ├─ emit BeforeExecution()
│ ├─ [0] EntryPoint::innerHandleOp(0xb61d27f60000000000000000000000005991a2df15a8f6a256d3ec51e99254cd3fb576a9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, UserOpInfo({ mUserOp: MemoryUserOp({ sender: 0xF2E246BB76DF876Cef8b38ae84130F4F55De395b, nonce: 0, verificationGasLimit: 21000 [2.1e4], callGasLimit: 500000 [5e5], paymasterVerificationGasLimit: 0, paymasterPostOpGasLimit: 0, preVerificationGas: 21000 [2.1e4], paymaster: 0x0000000000000000000000000000000000000000, maxFeePerGas: 1000000000 [1e9], maxPriorityFeePerGas: 20000000000 [2e10] }), userOpHash: 0x86100d1a9bbf3ee3ae624f4d5d0a8cc5e03d2f25ffe0cb2e3a6cafd1dfcc293f, prefund: 542000000000000 [5.42e14], contextOffset: 96, preOpGas: 21000 [2.1e4] }), 0x)
│ │ ├─ [0] ERC1967Proxy::execute(Counter: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 0, 0xd09de08a)
│ │ │ ├─ [0] SimpleAccountImpl::execute(Counter: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 0, 0xd09de08a) [delegatecall]
│ │ │ │ ├─ [0] Counter::increment()
│ │ │ │ │ └─ ← [Stop]
│ │ │ │ └─ ← [Stop]
│ │ │ └─ ← [Return]
│ │ ├─ emit UserOperationEvent(userOpHash: 0x86100d1a9bbf3ee3ae624f4d5d0a8cc5e03d2f25ffe0cb2e3a6cafd1dfcc293f, sender: ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], paymaster: 0x0000000000000000000000000000000000000000, nonce: 0, success: true, actualGasCost: 71000000000000 [7.1e13], actualGasUsed: 71000 [7.1e4])
│ │ └─ ← [Return] 71000000000000 [7.1e13]
│ ├─ [0] Beneficiary::fallback{value: 71000000000000}()
│ │ └─ ← [Stop]
│ └─ ← [Stop]
├─ [0] VM::resumeGasMetering()
│ └─ ← [Return]
├─ [283] Counter::number() [staticcall]
│ └─ ← [Return] 1
├─ [0] VM::assertEq(1, 1) [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertLt(999458000000000000 [9.994e17], 1000000000000000000 [1e18]) [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertGt(1000071000000000000 [1e18], 1000000000000000000 [1e18]) [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 22.66ms (3.31ms CPU time)
Ran 1 test suite in 897.25ms (22.66ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
[CASE 4] handleOps : 검증은 성공했지만 실행 단계에서 문제가 발생한 경우
function test_HandleOpsWithFailedOp() public {
SimpleAccount simpleAccount = createAccount();
bytes memory callData = abi.encodeWithSelector(
SimpleAccount.execute.selector, address(counter), 0, abi.encodeWithSignature("decrement()")
); // call a non-existent function
uint256 nonce = simpleAccount.getNonce();
PackedUserOperation memory packedUserOp = utils.packUserOp(address(simpleAccount), nonce, callData);
bytes32 userOpHash = entryPoint.getUserOpHash(packedUserOp);
bytes memory signature = utils.signUserOp(ownerPrivateKey, userOpHash);
packedUserOp.signature = signature;
PackedUserOperation[] memory ops = new PackedUserOperation[](1);
ops[0] = packedUserOp;
uint256 counterBefore = counter.number();
uint256 accountBalanceBefore = address(simpleAccount).balance;
uint256 beneficiaryBalanceBefore = beneficiary.balance;
bool success = false;
vm.expectEmit(true, true, true, false);
emit UserOperationEvent(userOpHash, address(simpleAccount), address(0), nonce, success, 0, 0);
entryPoint.handleOps(ops, payable(beneficiary));
assertEq(counter.number(), counterBefore);
assertLt(address(simpleAccount).balance, accountBalanceBefore);
assertGt(beneficiary.balance, beneficiaryBalanceBefore);
}
1. 이번에는 Counter 컨트랙트에 존재하지 않는 decrement 함수를 호출하는 callData를 준비합니다.
bytes memory callData = abi.encodeWithSelector(
SimpleAccount.execute.selector, address(counter), 0, abi.encodeWithSignature("decrement()")
); // call a non-existent function
2. 나머지는 동일합니다. 다만 이번에는 실행 단계에서 존재하지 않는 함수를 호출하므로 userOp가 실패하게 됩니다.
bool success = false;
vm.expectEmit(true, true, true, false);
emit UserOperationEvent(userOpHash, address(simpleAccount), address(0), nonce, success, 0, 0);
entryPoint.handleOps(ops, payable(beneficiary));
그러나 handleOps 함수 호출이 완전히 revert되는 것은 아닙니다. call opcode를 사용한 저수준 호출을 통해 실행의 성공 여부를 확인하고, 호출이 실패한 경우에도 실사용된 가스량을 계산한 뒤 _postExecution 함수를 실행하여 후속 조치를 취하고 수수료를 청구합니다.
if (callData.length > 0) {
bool success = Exec.call(mUserOp.sender, 0, callData, callGasLimit);
if (!success) {
bytes memory result = Exec.getReturnData(REVERT_REASON_MAX_LEN);
if (result.length > 0) {
emit UserOperationRevertReason(
opInfo.userOpHash,
mUserOp.sender,
mUserOp.nonce,
result
);
}
mode = IPaymaster.PostOpMode.opReverted;
}
}
unchecked {
uint256 actualGas = preGas - gasleft() + opInfo.preOpGas;
return _postExecution(mode, opInfo, context, actualGas);
}
3. 따라서 계정은 실행이 실패했음에도 수수료를 지급해야 하고, 이에 따라 handleOps 함수가 종료되고 나서 계정의 잔액을 확인해 보면 이전보다 줄어든 것을 확인할 수 있습니다. 그리고 지급된 수수료는 beneficiary에게 전달된 것을 확인할 수 있습니다.
assertEq(counter.number(), counterBefore);
assertLt(address(simpleAccount).balance, accountBalanceBefore);
assertGt(beneficiary.balance, beneficiaryBalanceBefore);
테스트
$ forge test --mt test_HandleOpsWithFailedOp -vvvv
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/SimpleAccount.t.sol:SimpleAccountTest
[PASS] test_HandleOpsWithFailedOp() (gas: 307508)
Traces:
[307508] SimpleAccountTest::test_HandleOpsWithFailedOp()
├─ [0] VM::prank(Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf])
│ └─ ← [Return]
├─ [112211] → new ERC1967Proxy@0xF2E246BB76DF876Cef8b38ae84130F4F55De395b
│ ├─ emit Upgraded(implementation: SimpleAccountImpl: [0xffD4505B3452Dc22f8473616d50503bA9E1710Ac])
│ ├─ [47945] SimpleAccountImpl::initialize(Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf]) [delegatecall]
│ │ ├─ emit SimpleAccountInitialized(entryPoint: EntryPoint: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], owner: Owner: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf])
│ │ ├─ emit Initialized(version: 1)
│ │ └─ ← [Stop]
│ └─ ← [Return] 183 bytes of code
├─ [0] VM::deal(ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], 1000000000000000000 [1e18])
│ └─ ← [Return]
├─ [6303] ERC1967Proxy::getNonce() [staticcall]
│ ├─ [5925] SimpleAccountImpl::getNonce() [delegatecall]
│ │ ├─ [2768] EntryPoint::getNonce(ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], 0) [staticcall]
│ │ │ └─ ← [Return] 0
│ │ └─ ← [Return] 0
│ └─ ← [Return] 0
├─ [2819] UserOpUtils::packUserOp(ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], 0, 0xb61d27f60000000000000000000000008ad159a275aee56fb2334dbb69036e9c7bacee9b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000042baeceb700000000000000000000000000000000000000000000000000000000) [staticcall]
│ └─ ← [Return] PackedUserOperation({ sender: 0xF2E246BB76DF876Cef8b38ae84130F4F55De395b, nonce: 0, initCode: 0x, callData: 0xb61d27f60000000000000000000000008ad159a275aee56fb2334dbb69036e9c7bacee9b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000042baeceb700000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x, signature: 0x })
├─ [1948] EntryPoint::getUserOpHash(PackedUserOperation({ sender: 0xF2E246BB76DF876Cef8b38ae84130F4F55De395b, nonce: 0, initCode: 0x, callData: 0xb61d27f60000000000000000000000008ad159a275aee56fb2334dbb69036e9c7bacee9b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000042baeceb700000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x, signature: 0x })) [staticcall]
│ └─ ← [Return] 0xf29fe99c09bd503cfc9b35ec5752f69c479b8b1dc6ece9e55803b7e0a0e9e8c5
├─ [1493] UserOpUtils::signUserOp(1, 0xf29fe99c09bd503cfc9b35ec5752f69c479b8b1dc6ece9e55803b7e0a0e9e8c5) [staticcall]
│ ├─ [0] VM::sign("<pk>", 0xc57da0dc166019b7972a821f69f0840ca84aa3b0bd2d931fbd1c32c548b05100) [staticcall]
│ │ └─ ← [Return] 27, 0x76d8252a628ea5430aebdf91be786bae934793a6fe75c0a75816835a3dd1b9cb, 0x32ba6f1110f469265e030028f3e51acc39acb44ed071d0cd2c1b0f79dac2df27
│ └─ ← [Return] 0x76d8252a628ea5430aebdf91be786bae934793a6fe75c0a75816835a3dd1b9cb32ba6f1110f469265e030028f3e51acc39acb44ed071d0cd2c1b0f79dac2df271b
├─ [2283] Counter::number() [staticcall]
│ └─ ← [Return] 0
├─ [0] VM::expectEmit(true, true, true, false)
│ └─ ← [Return]
├─ emit UserOperationEvent(userOpHash: 0xf29fe99c09bd503cfc9b35ec5752f69c479b8b1dc6ece9e55803b7e0a0e9e8c5, sender: ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], paymaster: 0x0000000000000000000000000000000000000000, nonce: 0, success: false, actualGasCost: 0, actualGasUsed: 0)
├─ [124685] EntryPoint::handleOps([PackedUserOperation({ sender: 0xF2E246BB76DF876Cef8b38ae84130F4F55De395b, nonce: 0, initCode: 0x, callData: 0xb61d27f60000000000000000000000008ad159a275aee56fb2334dbb69036e9c7bacee9b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000042baeceb700000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x, signature: 0x76d8252a628ea5430aebdf91be786bae934793a6fe75c0a75816835a3dd1b9cb32ba6f1110f469265e030028f3e51acc39acb44ed071d0cd2c1b0f79dac2df271b })], Beneficiary: [0x5c4d2bd3510C8B51eDB17766d3c96EC637326999])
│ ├─ [34213] ERC1967Proxy::validateUserOp(PackedUserOperation({ sender: 0xF2E246BB76DF876Cef8b38ae84130F4F55De395b, nonce: 0, initCode: 0x, callData: 0xb61d27f60000000000000000000000008ad159a275aee56fb2334dbb69036e9c7bacee9b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000042baeceb700000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x, signature: 0x76d8252a628ea5430aebdf91be786bae934793a6fe75c0a75816835a3dd1b9cb32ba6f1110f469265e030028f3e51acc39acb44ed071d0cd2c1b0f79dac2df271b }), 0xf29fe99c09bd503cfc9b35ec5752f69c479b8b1dc6ece9e55803b7e0a0e9e8c5, 5420000000000000 [5.42e15])
│ │ ├─ [33690] SimpleAccountImpl::validateUserOp(PackedUserOperation({ sender: 0xF2E246BB76DF876Cef8b38ae84130F4F55De395b, nonce: 0, initCode: 0x, callData: 0xb61d27f60000000000000000000000008ad159a275aee56fb2334dbb69036e9c7bacee9b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000042baeceb700000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x, signature: 0x76d8252a628ea5430aebdf91be786bae934793a6fe75c0a75816835a3dd1b9cb32ba6f1110f469265e030028f3e51acc39acb44ed071d0cd2c1b0f79dac2df271b }), 0xf29fe99c09bd503cfc9b35ec5752f69c479b8b1dc6ece9e55803b7e0a0e9e8c5, 5420000000000000 [5.42e15]) [delegatecall]
│ │ │ ├─ [3000] PRECOMPILES::ecrecover(0xc57da0dc166019b7972a821f69f0840ca84aa3b0bd2d931fbd1c32c548b05100, 27, 53754811606129441675343667201785247585836254207230032555470903844776029829579, 22945042537161227412133165502527120223291437781297428953224171493388613508903) [staticcall]
│ │ │ │ └─ ← [Return] 0x0000000000000000000000007e5f4552091a69125d5dfcb7b8c2659029395bdf
│ │ │ ├─ [21869] EntryPoint::fallback{value: 5420000000000000}()
│ │ │ │ ├─ emit Deposited(account: ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], totalDeposit: 5420000000000000 [5.42e15])
│ │ │ │ └─ ← [Stop]
│ │ │ └─ ← [Return] 0
│ │ └─ ← [Return] 0
│ ├─ emit BeforeExecution()
│ ├─ [28515] EntryPoint::innerHandleOp(0xb61d27f60000000000000000000000008ad159a275aee56fb2334dbb69036e9c7bacee9b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000042baeceb700000000000000000000000000000000000000000000000000000000, UserOpInfo({ mUserOp: MemoryUserOp({ sender: 0xF2E246BB76DF876Cef8b38ae84130F4F55De395b, nonce: 0, verificationGasLimit: 200000 [2e5], callGasLimit: 50000 [5e4], paymasterVerificationGasLimit: 0, paymasterPostOpGasLimit: 0, preVerificationGas: 21000 [2.1e4], paymaster: 0x0000000000000000000000000000000000000000, maxFeePerGas: 20000000000 [2e10], maxPriorityFeePerGas: 1000000000 [1e9] }), userOpHash: 0xf29fe99c09bd503cfc9b35ec5752f69c479b8b1dc6ece9e55803b7e0a0e9e8c5, prefund: 5420000000000000 [5.42e15], contextOffset: 96, preOpGas: 83424 [8.342e4] }), 0x)
│ │ ├─ [1877] ERC1967Proxy::execute(Counter: [0x8Ad159a275AEE56fb2334DBb69036E9c7baCEe9b], 0, 0x2baeceb7)
│ │ │ ├─ [1477] SimpleAccountImpl::execute(Counter: [0x8Ad159a275AEE56fb2334DBb69036E9c7baCEe9b], 0, 0x2baeceb7) [delegatecall]
│ │ │ │ ├─ [148] Counter::decrement()
│ │ │ │ │ └─ ← [Revert] EvmError: Revert
│ │ │ │ └─ ← [Revert] EvmError: Revert
│ │ │ └─ ← [Revert] EvmError: Revert
│ │ ├─ emit UserOperationEvent(userOpHash: 0xf29fe99c09bd503cfc9b35ec5752f69c479b8b1dc6ece9e55803b7e0a0e9e8c5, sender: ERC1967Proxy: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], paymaster: 0x0000000000000000000000000000000000000000, nonce: 0, success: false, actualGasCost: 90864000000000 [9.086e13], actualGasUsed: 90864 [9.086e4])
│ │ └─ ← [Return] 90864000000000 [9.086e13]
│ ├─ [0] Beneficiary::fallback{value: 90864000000000}()
│ │ └─ ← [Stop]
│ └─ ← [Stop]
├─ [283] Counter::number() [staticcall]
│ └─ ← [Return] 0
├─ [0] VM::assertEq(0, 0) [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertLt(994580000000000000 [9.945e17], 1000000000000000000 [1e18]) [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertGt(1000090864000000000 [1e18], 1000000000000000000 [1e18]) [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.90ms (1.77ms CPU time)
Ran 1 test suite in 1.40s (4.90ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
[CASE N] 그 외의 테스트
리포지토리를 참고해 주세요.
$ forge test
[⠆] Compiling...
No files changed, compilation skipped
Ran 10 tests for test/SimpleAccount.t.sol:SimpleAccountTest
[PASS] test_AddDeposit() (gas: 194689)
[PASS] test_Deploy() (gas: 158754)
[PASS] test_EntryPointExecute() (gas: 184654)
[PASS] test_HandleOps() (gas: 199176)
[PASS] test_HandleOpsWithExecuteBatch() (gas: 206807)
[PASS] test_OwnerExecute() (gas: 182702)
[PASS] test_RevertBobExecute() (gas: 157752)
[PASS] test_Upgrade() (gas: 166699)
[PASS] test_ValidateUserOp() (gas: 217779)
[PASS] test_WithdrawDepositTo() (gas: 188774)
Suite result: ok. 10 passed; 0 failed; 0 skipped; finished in 16.03ms (18.96ms CPU time)
Ran 1 test suite in 28.51ms (16.03ms CPU time): 10 tests passed, 0 failed, 0 skipped (10 total tests)
정리
- entryPoint의 handleOps 함수 호출
- validation loop
- sender로부터 인출할 금액(requiredPrefund) 산정
- sender 주소로 스마트 컨트랙트 계정이 배포되어 있지 않으면 senderCreator에게 initCode를 전달하여 생성
- sender의 예치금을 확인, 부족한 금액(missingAccountFunds) 계산
- sender의 validateUserOp 함수 호출
- sender는 호출자가 신뢰하는 entryPoint인지 검사
- 서명 검증
- 논스 검증
- missingAccountFunds 만큼의 이더를 entryPoint에게 전송
- 이더를 받은 entryPoint의 fallback 함수에서 sender의 예치금 갱신
- sender의 예치금이 인출할 금액과 같거나 더 큰지 확인
- 논스를 검증한 뒤, 논스를 증가시킴
- 사용된 가스의 양이 verificationGasLimit보다 큰지 확인
- 각각의 UserOp에 대해 반복. 하나라도 검증에 실패하면 전체 트랜잭션을 revert
- execution loop
- callGasLimit가 충분한지 검사
- callData를 사용해 sender를 호출 -> sender에서 추가적인 작업 실행
- 작업이 실패하더라도 수수료를 납부해야 함
- 작업을 실행하고 남은 가스비는 sender의 예치금으로 저장
- beneficiary에게 수거한 수수료를 모아 전송
전체 코드
참조
'블록체인 > Ethereum' 카테고리의 다른 글
ERC-4337: 계정 추상화 - 테스트를 통한 Account Factory의 동작 이해 (0) | 2024.04.18 |
---|---|
ERC-4337: 계정 추상화 - 테스트 수정 사항 (0) | 2024.04.17 |
ERC-4337: 계정 추상화 (0) | 2024.04.16 |
소셜 로그인 + 계정 추상화를 사용한 dApp 만들기 (0) | 2024.04.13 |
컨트랙트 ABI (1) | 2024.02.06 |