티스토리 뷰
ERC-4337: 계정 추상화 - 테스트를 통한 Paymaster와 LegacyTokenPaymaster의 동작 이해
piatoss 2024. 4. 18. 17:44계정 추상화 시리즈
2024.04.18 - [블록체인/Ethereum] - ERC-4337: 계정 추상화 - 테스트를 통한 Account Factory의 동작 이해
2024.04.17 - [블록체인/Ethereum] - ERC-4337: 계정 추상화 - 테스트 수정 사항
2024.04.17 - [블록체인/Ethereum] - ERC-4337: 계정 추상화 - 테스트를 통한 Account와 EntryPoint의 동작 이해
2024.04.16 - [블록체인/Ethereum] - ERC-4337: 계정 추상화 - Account, EntryPoint, Paymaster
테스트에 참고한 컨트랙트
- core/BasePaymaster.sol
- samples/LegacyTokenPaymaster.sol
이전 게시글에서 이어집니다.
BasePaymaster의 특징
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.23;
/* solhint-disable reason-string */
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/introspection/IERC165.sol";
import "../interfaces/IPaymaster.sol";
import "../interfaces/IEntryPoint.sol";
import "./UserOperationLib.sol";
/**
* Helper class for creating a paymaster.
* provides helper methods for staking.
* Validates that the postOp is called only by the entryPoint.
*/
abstract contract BasePaymaster is IPaymaster, Ownable {
IEntryPoint public immutable entryPoint;
uint256 internal constant PAYMASTER_VALIDATION_GAS_OFFSET = UserOperationLib.PAYMASTER_VALIDATION_GAS_OFFSET;
uint256 internal constant PAYMASTER_POSTOP_GAS_OFFSET = UserOperationLib.PAYMASTER_POSTOP_GAS_OFFSET;
uint256 internal constant PAYMASTER_DATA_OFFSET = UserOperationLib.PAYMASTER_DATA_OFFSET;
constructor(IEntryPoint _entryPoint) Ownable(msg.sender) {
_validateEntryPointInterface(_entryPoint);
entryPoint = _entryPoint;
}
//sanity check: make sure this EntryPoint was compiled against the same
// IEntryPoint of this paymaster
function _validateEntryPointInterface(IEntryPoint _entryPoint) internal virtual {
require(IERC165(address(_entryPoint)).supportsInterface(type(IEntryPoint).interfaceId), "IEntryPoint interface mismatch");
}
/// @inheritdoc IPaymaster
function validatePaymasterUserOp(
PackedUserOperation calldata userOp,
bytes32 userOpHash,
uint256 maxCost
) external override returns (bytes memory context, uint256 validationData) {
_requireFromEntryPoint();
return _validatePaymasterUserOp(userOp, userOpHash, maxCost);
}
/**
* Validate a user operation.
* @param userOp - The user operation.
* @param userOpHash - The hash of the user operation.
* @param maxCost - The maximum cost of the user operation.
*/
function _validatePaymasterUserOp(
PackedUserOperation calldata userOp,
bytes32 userOpHash,
uint256 maxCost
) internal virtual returns (bytes memory context, uint256 validationData);
/// @inheritdoc IPaymaster
function postOp(
PostOpMode mode,
bytes calldata context,
uint256 actualGasCost,
uint256 actualUserOpFeePerGas
) external override {
_requireFromEntryPoint();
_postOp(mode, context, actualGasCost, actualUserOpFeePerGas);
}
/**
* Post-operation handler.
* (verified to be called only through the entryPoint)
* @dev If subclass returns a non-empty context from validatePaymasterUserOp,
* it must also implement this method.
* @param mode - Enum with the following options:
* opSucceeded - User operation succeeded.
* opReverted - User op reverted. The paymaster still has to pay for gas.
* postOpReverted - never passed in a call to postOp().
* @param context - The context value returned by validatePaymasterUserOp
* @param actualGasCost - Actual gas used so far (without this postOp call).
* @param actualUserOpFeePerGas - the gas price this UserOp pays. This value is based on the UserOp's maxFeePerGas
* and maxPriorityFee (and basefee)
* It is not the same as tx.gasprice, which is what the bundler pays.
*/
function _postOp(
PostOpMode mode,
bytes calldata context,
uint256 actualGasCost,
uint256 actualUserOpFeePerGas
) internal virtual {
(mode, context, actualGasCost, actualUserOpFeePerGas); // unused params
// subclass must override this method if validatePaymasterUserOp returns a context
revert("must override");
}
/**
* Add a deposit for this paymaster, used for paying for transaction fees.
*/
function deposit() public payable {
entryPoint.depositTo{value: msg.value}(address(this));
}
/**
* Withdraw value from the deposit.
* @param withdrawAddress - Target to send to.
* @param amount - Amount to withdraw.
*/
function withdrawTo(
address payable withdrawAddress,
uint256 amount
) public onlyOwner {
entryPoint.withdrawTo(withdrawAddress, amount);
}
/**
* Add stake for this paymaster.
* This method can also carry eth value to add to the current stake.
* @param unstakeDelaySec - The unstake delay for this paymaster. Can only be increased.
*/
function addStake(uint32 unstakeDelaySec) external payable onlyOwner {
entryPoint.addStake{value: msg.value}(unstakeDelaySec);
}
/**
* Return current paymaster's deposit on the entryPoint.
*/
function getDeposit() public view returns (uint256) {
return entryPoint.balanceOf(address(this));
}
/**
* Unlock the stake, in order to withdraw it.
* The paymaster can't serve requests once unlocked, until it calls addStake again
*/
function unlockStake() external onlyOwner {
entryPoint.unlockStake();
}
/**
* Withdraw the entire paymaster's stake.
* stake must be unlocked first (and then wait for the unstakeDelay to be over)
* @param withdrawAddress - The address to send withdrawn value.
*/
function withdrawStake(address payable withdrawAddress) external onlyOwner {
entryPoint.withdrawStake(withdrawAddress);
}
/**
* Validate the call is made from a valid entrypoint
*/
function _requireFromEntryPoint() internal virtual {
require(msg.sender == address(entryPoint), "Sender not EntryPoint");
}
}
- 추상(abstract) 컨트랙트로, 상속과 오버라이딩을 통해 _validatePaymasterUserOp 함수와 _postOp 함수를 구현해야 합니다.
- 신뢰하는 EntryPoint로부터만 요청을 받습니다.
- 검증 단계에서 validatePaymasterUserOp가 실행되고 사용자 작업이 실행되고 나면 후속 조치로 postOp가 실행됩니다.
- EntryPoint 컨트랙트로 이더를 예치, 인출하는 기능과 이더를 스테이킹, 언스테이킹하는 기능이 구현되어 있습니다.
SimplePaymaster 구현
BasePaymaster가 추상 컨트랙트로 작성되었으므로 이를 상속하여 화이트리스트에 기반하여 수수료를 대납해 주는 SimplePaymaster를 다음과 같이 간단하게 구현하였습니다.
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.24;
import {IEntryPoint} from "account-abstraction/interfaces/IEntryPoint.sol";
import {BasePaymaster} from "account-abstraction/core/BasePaymaster.sol";
import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol";
import {UserOperationLib} from "account-abstraction/core/UserOperationLib.sol";
contract SimplePaymaster is BasePaymaster {
error SenderNotWhitelisted(address sender);
error MaxCostExceeded(uint256 cost);
using UserOperationLib for PackedUserOperation;
uint256 constant SIG_VALIDATION_FAILED = 1;
uint256 constant SIG_VALIDATION_SUCCESS = 0;
uint256 private _maxCost;
mapping(address => bool) private _whitelisted;
event Whitelisted(address indexed account, bool state);
event MaxCostChanged(uint256 maxCost);
constructor(IEntryPoint _entryPoint, uint256 maxCost_) BasePaymaster(_entryPoint) {
_maxCost = maxCost_;
}
function setWhitelisted(address account, bool state) external onlyOwner {
_whitelisted[account] = state;
emit Whitelisted(account, state);
}
function setWhitelistedBatch(address[] calldata accounts, bool state) external onlyOwner {
for (uint256 i = 0; i < accounts.length; i++) {
_whitelisted[accounts[i]] = state;
emit Whitelisted(accounts[i], state);
}
}
function isWhitelisted(address account) public view returns (bool) {
return _whitelisted[account];
}
function setMaxCost(uint256 maxCost_) external onlyOwner {
_maxCost = maxCost_;
emit MaxCostChanged(maxCost_);
}
/**
* Payment validation: check if paymaster agrees to pay.
* Must verify sender is the entryPoint.
* Revert to reject this request.
* Note that bundlers will reject this method if it changes the state, unless the paymaster is trusted (whitelisted).
* The paymaster pre-pays using its deposit, and receive back a refund after the postOp method returns.
* @param userOp - The user operation.
* @param userOpHash - Hash of the user's request data.
* @param maxCost - The maximum cost of this transaction (based on maximum gas and gas price from userOp).
* @return context - Value to send to a postOp. Zero length to signify postOp is not required.
* @return validationData - Signature and time-range of this operation, encoded the same as the return
* value of validateUserOperation.
* <20-byte> sigAuthorizer - 0 for valid signature, 1 to mark signature failure,
* other values are invalid for paymaster.
* <6-byte> validUntil - last timestamp this operation is valid. 0 for "indefinite"
* <6-byte> validAfter - first timestamp this operation is valid
* Note that the validation code cannot use block.timestamp (or block.number) directly.
*/
function _validatePaymasterUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost)
internal
view
override
returns (bytes memory context, uint256 validationData)
{
(userOpHash); // silence warnings (unused variables)
address sender = userOp.getSender();
if (!isWhitelisted(sender)) {
revert SenderNotWhitelisted(sender);
}
if (maxCost > _maxCost) {
revert MaxCostExceeded(maxCost);
}
return (abi.encode(sender), SIG_VALIDATION_SUCCESS);
}
/**
* Post-operation handler.
* Must verify sender is the entryPoint.
* @param mode - Enum with the following options:
* opSucceeded - User operation succeeded.
* opReverted - User op reverted. The paymaster still has to pay for gas.
* postOpReverted - never passed in a call to postOp().
* @param context - The context value returned by validatePaymasterUserOp
* @param actualGasCost - Actual gas used so far (without this postOp call).
* @param actualUserOpFeePerGas - the gas price this UserOp pays. This value is based on the UserOp's maxFeePerGas
* and maxPriorityFee (and basefee)
* It is not the same as tx.gasprice, which is what the bundler pays.
*/
function _postOp(PostOpMode mode, bytes calldata context, uint256 actualGasCost, uint256 actualUserOpFeePerGas)
internal
virtual
override
{
(mode, actualGasCost, actualUserOpFeePerGas); // silence warnings (unused variables
address sender = _decodeContext(context);
if (!isWhitelisted(sender)) {
revert SenderNotWhitelisted(sender);
}
}
function _encodeContext(address sender) internal pure returns (bytes memory) {
return abi.encode(sender);
}
function _decodeContext(bytes memory context) internal pure returns (address) {
return abi.decode(context, (address));
}
}
_validatePaymasterUserOp 함수의 구현은 다음과 같습니다.
- sender가 화이트리스트에 등록되지 않았다면 검증 작업은 revert됩니다.
- 대납할 금액 maxCost가 컨트랙트의 소유자가 정한 _maxCost보다 크다면 검증 작업은 revert 됩니다.
- 검증이 성공하면 sender를 abi 인코딩하여 context 값으로, SIG_VALIDATION_SUCCESS(0)을 validationData로 반환합니다.
function _validatePaymasterUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost)
internal
view
override
returns (bytes memory context, uint256 validationData)
{
(userOpHash); // silence warnings (unused variables)
address sender = userOp.getSender();
if (!isWhitelisted(sender)) {
revert SenderNotWhitelisted(sender);
}
if (maxCost > _maxCost) {
revert MaxCostExceeded(maxCost);
}
return (_encodeContext(sender), SIG_VALIDATION_SUCCESS);
}
_postOp 함수의 구현은 다음과 같습니다.
- 사용자 작업의 성공 여부(mode)나 실제 청구된 가스비 등은 일단 무시합니다.
- context 값을 디코딩하여 sender의 주소를 가져오고 sender가 화이트리스트에 등록되어 있는지 다시 한번 확인합니다. 등록되어 있지 않다면 revert 됩니다.
function _postOp(PostOpMode mode, bytes calldata context, uint256 actualGasCost, uint256 actualUserOpFeePerGas)
internal
virtual
override
{
(mode, actualGasCost, actualUserOpFeePerGas); // silence warnings (unused variables
address sender = _decodeContext(context);
if (!isWhitelisted(sender)) {
revert SenderNotWhitelisted(sender);
}
}
LegacyTokenPaymaster의 특징
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.23;
/* solhint-disable reason-string */
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "../core/BasePaymaster.sol";
import "../core/UserOperationLib.sol";
import "../core/Helpers.sol";
/**
* A sample paymaster that defines itself as a token to pay for gas.
* The paymaster IS the token to use, since a paymaster cannot use an external contract.
* Also, the exchange rate has to be fixed, since it can't reference an external Uniswap or other exchange contract.
* subclass should override "getTokenValueOfEth" to provide actual token exchange rate, settable by the owner.
* Known Limitation: this paymaster is exploitable when put into a batch with multiple ops (of different accounts):
* - while a single op can't exploit the paymaster (if postOp fails to withdraw the tokens, the user's op is reverted,
* and then we know we can withdraw the tokens), multiple ops with different senders (all using this paymaster)
* in a batch can withdraw funds from 2nd and further ops, forcing the paymaster itself to pay (from its deposit)
* - Possible workarounds are either use a more complex paymaster scheme (e.g. the DepositPaymaster) or
* to whitelist the account and the called method ids.
*/
contract LegacyTokenPaymaster is BasePaymaster, ERC20 {
using UserOperationLib for PackedUserOperation;
//calculated cost of the postOp
uint256 constant public COST_OF_POST = 15000;
address public immutable theFactory;
constructor(address accountFactory, string memory _symbol, IEntryPoint _entryPoint) ERC20(_symbol, _symbol) BasePaymaster(_entryPoint) {
theFactory = accountFactory;
//make it non-empty
_mint(address(this), 1);
//owner is allowed to withdraw tokens from the paymaster's balance
_approve(address(this), msg.sender, type(uint256).max);
}
/**
* helpers for owner, to mint and withdraw tokens.
* @param recipient - the address that will receive the minted tokens.
* @param amount - the amount it will receive.
*/
function mintTokens(address recipient, uint256 amount) external onlyOwner {
_mint(recipient, amount);
}
/**
* transfer paymaster ownership.
* owner of this paymaster is allowed to withdraw funds (tokens transferred to this paymaster's balance)
* when changing owner, the old owner's withdrawal rights are revoked.
*/
function transferOwnership(address newOwner) public override virtual onlyOwner {
// remove allowance of current owner
_approve(address(this), owner(), 0);
super.transferOwnership(newOwner);
// new owner is allowed to withdraw tokens from the paymaster's balance
_approve(address(this), newOwner, type(uint256).max);
}
//Note: this method assumes a fixed ratio of token-to-eth. subclass should override to supply oracle
// or a setter.
function getTokenValueOfEth(uint256 valueEth) internal view virtual returns (uint256 valueToken) {
return valueEth / 100;
}
/**
* validate the request:
* if this is a constructor call, make sure it is a known account.
* verify the sender has enough tokens.
* (since the paymaster is also the token, there is no notion of "approval")
*/
function _validatePaymasterUserOp(PackedUserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund)
internal view override returns (bytes memory context, uint256 validationData) {
uint256 tokenPrefund = getTokenValueOfEth(requiredPreFund);
uint256 postOpGasLimit = userOp.unpackPostOpGasLimit();
require( postOpGasLimit > COST_OF_POST, "TokenPaymaster: gas too low for postOp");
if (userOp.initCode.length != 0) {
_validateConstructor(userOp);
require(balanceOf(userOp.sender) >= tokenPrefund, "TokenPaymaster: no balance (pre-create)");
} else {
require(balanceOf(userOp.sender) >= tokenPrefund, "TokenPaymaster: no balance");
}
return (abi.encode(userOp.sender), SIG_VALIDATION_SUCCESS);
}
// when constructing an account, validate constructor code and parameters
// we trust our factory (and that it doesn't have any other public methods)
function _validateConstructor(PackedUserOperation calldata userOp) internal virtual view {
address factory = address(bytes20(userOp.initCode[0 : 20]));
require(factory == theFactory, "TokenPaymaster: wrong account factory");
}
/**
* actual charge of user.
* this method will be called just after the user's TX with mode==OpSucceeded|OpReverted (account pays in both cases)
* BUT: if the user changed its balance in a way that will cause postOp to revert, then it gets called again, after reverting
* the user's TX , back to the state it was before the transaction started (before the validatePaymasterUserOp),
* and the transaction should succeed there.
*/
function _postOp(PostOpMode mode, bytes calldata context, uint256 actualGasCost, uint256 actualUserOpFeePerGas) internal override {
//we don't really care about the mode, we just pay the gas with the user's tokens.
(mode);
address sender = abi.decode(context, (address));
uint256 charge = getTokenValueOfEth(actualGasCost + COST_OF_POST * actualUserOpFeePerGas);
//actualGasCost is known to be no larger than the above requiredPreFund, so the transfer should succeed.
_transfer(sender, address(this), charge);
}
}
- 그 자체로 ERC-20 토큰 컨트랙트이면서 Paymaster 컨트랙트입니다.
- _validatePaymasterUserOp 함수에서 사용자가 대납을 요구한 비용을 토큰으로 치환하여 사용자의 토큰 잔액이 tokenPrefund 이상인지 검증합니다.
- 만약 sender가 initCode를 통해 새로 생선 된 스마트 계정이라면, _validateConstructor 함수를 통해 Paymaster가 신뢰하는 Factory로부터 생성된 스마트 계정인지 검증합니다.
- 이더와 토큰의 교환비는 100:1로 하드코딩 되어 있습니다.
- _postOp 함수는 후속 조치로 실제로 사용된 수수료 + Paymaster에서 부과하는 수수료만큼의 토큰을 스마트 계정에서 인출해 갑니다.
Foundry 테스트 구성
Paymaster.t.sol 파일 생성
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {IEntryPoint} from "account-abstraction/interfaces/IEntryPoint.sol";
import {SimpleEntryPoint} from "../src/SimpleEntryPoint.sol";
import {SenderCreator} from "account-abstraction/core/SenderCreator.sol";
import {SimpleAccountFactory} from "../src/SimpleAccountFactory.sol";
import {SimpleAccount} from "../src/SimpleAccount.sol";
import {SimplePaymaster} from "../src/SimplePaymaster.sol";
import {LegacyTokenPaymaster} from "account-abstraction/samples/LegacyTokenPaymaster.sol";
import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol";
import {Counter} from "../src/Counter.sol";
import {UserOpUtils} from "./utils/UserOpUtils.sol";
import "./utils/Artifacts.sol";
contract PaymasterTest is Test {
...
}
- Test, console : 테스트를 위한 컨트랙트와 라이브러리
- IEntryPoint, SimpleEntryPoint : EntryPoint 컨트랙트와 인터페이스
- SenderCreator : EntryPoint에게 sender 생성 요청을 받아 Factory로 전달하는 브로커
- SimpleAccountFactory : 스마트 계정의 팩토리 컨트랙트
- SimpleAccount : 계정 추상화 - 스마트 계정 구현체
- SimplePaymaster : 방금 BasePaymaster를 상속하여 구현한 화이트리스트 기반 Paymaster
- LegacyTokenPaymaster : 토큰으로 스마트 계정에 비용을 청구하는 Paymaster
- PackedUserOperation : UserOperation을 패킹한 구조체 타입
- Counter : Foundry 프로젝트 생성 시에 딸려오는 기본 컨트랙트로, 간단한 작업 요청에 사용하기 위한 용도
- UserOpUtils : 테스트용 UserOp를 생성하고 서명하기 위한 유틸리티 컨트랙트. test/utils/UserOpUtils.sol 파일 참고
- Artifacts : https://piatoss3612.tistory.com/162 참고. 컨트랙트를 배포해서 사용해도 무방. 이 방식을 사용한다고 해서 별다른 이점은 없습니다.
테스트 변수 및 이벤트 선언
error SenderNotWhitelisted(address sender);
error FailedOp(uint256 opIndex, string reason);
error FailedOpWithRevert(uint256 opIndex, string reason, bytes inner);
uint256 public constant SALT = 10000;
uint256 public constant INITIAL_MAX_COST = 1 ether;
uint256 public constant PAYMASTER_DEPOSIT = 10 ether;
SimpleEntryPoint public entryPoint;
SenderCreator public senderCreator;
SimpleAccountFactory public factory;
SimpleAccount public impl;
SimplePaymaster public paymaster;
LegacyTokenPaymaster public tokenPaymaster;
Counter public counter;
UserOpUtils public utils;
uint256 public ownerPrivateKey = uint256(keccak256("owner"));
address public owner;
address public deployer;
address public beneficiary;
- INITIAL_MAX_COST : 최대 1 이더의 가스비를 대납할 수 있도록 설정한 값입니다. PackedUserOperation을 생성할 때 다른 값들을 기본적으로 높게 잡아놔서 1 이더로 여유롭게 잡아놨습니다.
- PAYMASTER_DEPOSIT : Paymaster에서 EntryPoint로 예치될 예치금의 액수입니다.
setUp 함수
function setUp() public {
owner = vm.addr(ownerPrivateKey);
vm.label(owner, "Owner");
vm.deal(owner, 100 ether);
deployer = makeAddr("deployer");
vm.label(deployer, "Deployer");
vm.deal(deployer, 100 ether);
beneficiary = makeAddr("beneficiary");
vm.label(beneficiary, "Beneficiary");
vm.deal(beneficiary, 1 ether);
entryPoint = SimpleEntryPoint(payable(ENTRYPOINT_ADDRESS));
vm.etch(address(entryPoint), ENTRYPOINT_BYTECODE);
vm.label(address(entryPoint), "EntryPoint");
senderCreator = SenderCreator(SENDERCREATOR_ADDRESS);
vm.etch(address(senderCreator), SENDERCREATOR_BYTECODE);
vm.label(address(senderCreator), "SenderCreator");
factory = SimpleAccountFactory(FACTORY_ADDRESS);
vm.etch(address(factory), FACTORY_BYTECODE);
vm.label(address(factory), "SimpleAccountFactory");
impl = SimpleAccount(payable(IMPL_ADDRESS));
vm.etch(address(impl), IMPL_BYTECODE);
vm.label(address(impl), "SimpleAccountImpl");
vm.startPrank(deployer);
paymaster = new SimplePaymaster(entryPoint, INITIAL_MAX_COST);
vm.label(address(paymaster), "Paymaster");
tokenPaymaster = new LegacyTokenPaymaster(address(factory), "LTP", entryPoint);
vm.label(address(tokenPaymaster), "LegacyTokenPaymaster");
counter = new Counter();
vm.label(address(counter), "Counter");
utils = new UserOpUtils();
vm.label(address(utils), "UserOpUtils");
paymaster.deposit{value: PAYMASTER_DEPOSIT}();
tokenPaymaster.deposit{value: PAYMASTER_DEPOSIT}();
vm.stopPrank();
assertEq(entryPoint.balanceOf(address(paymaster)), PAYMASTER_DEPOSIT);
}
컨트랙트 배포에 이어 setUp 함수의 하단에서 SimplePaymaster와 LegacyTokenPaymaster의 deposit 함수를 통해 EntryPoint 컨트랙트에 10 이더를 예치하는 코드가 실행됩니다.
배포된 컨트랙트 목록
- EntryPoint
- SenderCreator
- SimpleAccountFactory
- SimpleAccount
- SimplePaymaster
- LegacyTokenPaymaster
- Counter
- UserOpUtils
테스트
[CASE 1] 화이트리스트에 등록된 sender의 수수료 대납
function test_HandleOpsWithWhitelistedAccountAndEthPayment() public {
SimpleAccount account = factory.createAccount(owner, SALT);
PackedUserOperation[] memory ops = getrUserOps(address(account), address(paymaster));
vm.prank(deployer);
paymaster.setWhitelisted(address(account), true);
uint256 counterBefore = counter.number();
uint256 depositBefore = entryPoint.balanceOf(address(paymaster));
uint256 beneficiaryBalanceBefore = beneficiary.balance;
entryPoint.handleOps(ops, payable(beneficiary));
assertEq(counter.number(), counterBefore + 1);
assertLt(entryPoint.balanceOf(address(paymaster)), depositBefore);
assertGt(beneficiary.balance, beneficiaryBalanceBefore);
}
1. owner의 주소와 SALT를 사용해 스마트 계정을 생성합니다. handleOps 함수를 호출하는 과정에서 생성하는 것도 가능합니다만, 이 경우에는 initCode를 추가해 주셔야 합니다.
SimpleAccount account = factory.createAccount(owner, SALT);
2. handleOps 함수의 인수로 들어갈 ops를 생성합니다.
PackedUserOperation[] memory ops = getrUserOps(address(account), address(paymaster));
getUserOps 함수는 다음과 같습니다. 이전에 PackedUserOperation을 생성하던 방식에서 Paymaster를 사용하기 위해 paymasterAndData가 추가되었습니다.
function getrUserOps(address accountAddr, address paymasterAddr) internal view returns (PackedUserOperation[] memory) {
uint256 nonce = entryPoint.getNonce(accountAddr, 0);
bytes memory callData = abi.encodeWithSelector(
SimpleAccount.execute.selector, address(counter), 0, abi.encodeWithSelector(counter.increment.selector)
);
PackedUserOperation memory packedUserOp = utils.packUserOp(accountAddr, nonce, callData);
bytes memory paymasterAndData = utils.packPaymasterAndData(address(paymasterAddr), 20000, 15000);
packedUserOp.paymasterAndData = paymasterAndData;
bytes32 userOpHash = entryPoint.getUserOpHash(packedUserOp);
bytes memory signature = utils.signUserOp(ownerPrivateKey, userOpHash);
packedUserOp.signature = signature;
PackedUserOperation[] memory ops = new PackedUserOperation[](1);
ops[0] = packedUserOp;
return ops;
}
paymasterAndData는 Paymaster의 주소 20바이트, validationGasLimit 16바이트, 그리고 postOpGasLimit 16바이트를 패킹하여 생성할 수 있습니다.
- validationGasLimit : Paymaster 검증 단계에서의 가스 한도
- postOpGasLimit : 작업을 실행하고 postOp를 실행하는 단계에서의 가스 한도
function packPaymasterAndData(address paymaster, uint256 validationGasLimit, uint256 postOpGasLimit)
public
pure
returns (bytes memory paymasterAndData)
{
paymasterAndData =
abi.encodePacked(paymaster, bytes16(uint128(validationGasLimit)), bytes16(uint128(postOpGasLimit)));
}
3. Paymaster 컨트랙트의 소유자(deployer)가 스마트 계정의 주소(account)를 화이트리스트에 등록합니다.
vm.prank(deployer);
paymaster.setWhitelisted(address(account), true);
4. handleOps 함수가 호출되기 전의 상태를 일부 기록하고 handleOps 함수를 호출합니다.
uint256 counterBefore = counter.number();
uint256 depositBefore = entryPoint.balanceOf(address(paymaster));
uint256 beneficiaryBalanceBefore = beneficiary.balance;
entryPoint.handleOps(ops, payable(beneficiary));
5. handleOps 함수의 검증 루프가 실행됩니다. 스마트 계정의 validateUserOp가 성공적으로 실행되고 난 뒤, paymasterAndData가 설정되어 있으므로 _validatePaymasterPrepayment 함수가 호출됩니다.
bytes memory context;
if (mUserOp.paymaster != address(0)) {
(context, paymasterValidationData) = _validatePaymasterPrepayment(
opIndex,
userOp,
outOpInfo,
requiredPreFund
);
}
_validatePaymasterPrepayment 함수에서는 Paymaster 컨트랙트의 validatePaymasterUserOp 함수가 호출됩니다. 앞서 살펴봤다시피, validatePaymasterUserOp 함수에서는 sender가 화이트리스트에 등록되어 있는지 확인하고 requiredPreFund(maxCost)가 owner가 지정한 _maxCost를 넘어서는지 확인합니다. 그리고 sender의 주소를 인코딩하여 context로, 0을 validationData로 반환합니다.
function _validatePaymasterPrepayment(
uint256 opIndex,
PackedUserOperation calldata op,
UserOpInfo memory opInfo,
uint256 requiredPreFund
) internal returns (bytes memory context, uint256 validationData) {
unchecked {
uint256 preGas = gasleft();
MemoryUserOp memory mUserOp = opInfo.mUserOp;
address paymaster = mUserOp.paymaster;
DepositInfo storage paymasterInfo = deposits[paymaster];
uint256 deposit = paymasterInfo.deposit;
if (deposit < requiredPreFund) {
revert FailedOp(opIndex, "AA31 paymaster deposit too low");
}
paymasterInfo.deposit = deposit - requiredPreFund;
uint256 pmVerificationGasLimit = mUserOp.paymasterVerificationGasLimit;
try IPaymaster(paymaster).validatePaymasterUserOp{gas: pmVerificationGasLimit}(
op, opInfo.userOpHash, requiredPreFund
) returns (bytes memory _context, uint256 _validationData) {
context = _context;
validationData = _validationData;
} catch {
revert FailedOpWithRevert(opIndex, "AA33 reverted", Exec.getReturnData(REVERT_REASON_MAX_LEN));
}
if (preGas - gasleft() > pmVerificationGasLimit) {
revert FailedOp(opIndex, "AA36 over paymasterVerificationGasLimit");
}
}
}
6. 검증 루프가 마무리되고 실행 루프가 실행됩니다.
for (uint256 i = 0; i < opslen; i++) {
collected += _executeUserOp(i, ops[i], opInfos[i]);
}
7. 사용자 작업이 성공하고 나면 또는 실패한 경우의 일부 케이스에 _postExecution 함수가 실행됩니다.
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
}
Paymaster의 주소가 zero address가 아니고 context의 길이가 0보다 큰 경우, 후속 조치를 위해 Paymaster의 postOp 함수를 호출합니다. postOp 함수는 context를 디코딩하여 sender의 주소를 구한 뒤, sender가 화이트리스트에 등록되어 있는지 재확인을 합니다.
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);
}
}
}
}
8. 여차저차 handleOps 함수가 종료되고 나면 다음 사항들을 확인할 수 있습니다.
- Counter의 number가 handleOps를 실행하기 전보다 1만큼 증가된 상태여야 합니다.
- Paymaster의 예치금이 handleOps를 실행하기 전보다 감소된 상태여야 합니다.
- beneficiary의 이더 잔액이 handleOps를 실행하기 전보다 증가된 상태여야 합니다.
assertEq(counter.number(), counterBefore + 1);
assertLt(entryPoint.balanceOf(address(paymaster)), depositBefore);
assertGt(beneficiary.balance, beneficiaryBalanceBefore);
테스트
$ forge test --mt test_HandleOpsWithWhitelistedAccountAndEthPayment -vvvv
[⠢] Compiling...
[⠰] Compiling 2 files with 0.8.24
[⠔] Solc 0.8.24 finished in 4.13s
Compiler run successful!
Ran 1 test for test/PayMaster.t.sol:PaymasterTest
[PASS] test_HandleOpsWithWhitelistedAccountAndEthPayment() (gas: 353757)
Traces:
[353757] PaymasterTest::test_HandleOpsWithWhitelistedAccountAndEthPayment()
├─ [152583] SimpleAccountFactory::createAccount(Owner: [0x7c8999dC9a822c1f0Df42023113EDB4FDd543266], 10000 [1e4])
│ ├─ [112211] → new ERC1967Proxy@0x75fe56829E9d4867837306E3bDDdcdC066416865
│ │ ├─ emit Upgraded(implementation: SimpleAccountImpl: [0xffD4505B3452Dc22f8473616d50503bA9E1710Ac])
│ │ ├─ [47945] SimpleAccountImpl::initialize(Owner: [0x7c8999dC9a822c1f0Df42023113EDB4FDd543266]) [delegatecall]
│ │ │ ├─ emit SimpleAccountInitialized(entryPoint: EntryPoint: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], owner: Owner: [0x7c8999dC9a822c1f0Df42023113EDB4FDd543266])
│ │ │ ├─ emit Initialized(version: 1)
│ │ │ └─ ← [Stop]
│ │ └─ ← [Return] 183 bytes of code
│ └─ ← [Return] ERC1967Proxy: [0x75fe56829E9d4867837306E3bDDdcdC066416865]
├─ [2768] EntryPoint::getNonce(ERC1967Proxy: [0x75fe56829E9d4867837306E3bDDdcdC066416865], 0) [staticcall]
│ └─ ← [Return] 0
├─ [2819] UserOpUtils::packUserOp(ERC1967Proxy: [0x75fe56829E9d4867837306E3bDDdcdC066416865], 0, 0xb61d27f60000000000000000000000001240fa2a84dd9157a0e76b5cfe98b1d52268b264000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000) [staticcall]
│ └─ ← [Return] PackedUserOperation({ sender: 0x75fe56829E9d4867837306E3bDDdcdC066416865, nonce: 0, initCode: 0x, callData: 0xb61d27f60000000000000000000000001240fa2a84dd9157a0e76b5cfe98b1d52268b264000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x, signature: 0x })
├─ [961] UserOpUtils::packPaymasterAndData(Paymaster: [0x8Ad159a275AEE56fb2334DBb69036E9c7baCEe9b], 50000 [5e4], 50000 [5e4]) [staticcall]
│ └─ ← [Return] 0x8ad159a275aee56fb2334dbb69036e9c7bacee9b0000000000000000000000000000c3500000000000000000000000000000c350
├─ [1966] EntryPoint::getUserOpHash(PackedUserOperation({ sender: 0x75fe56829E9d4867837306E3bDDdcdC066416865, nonce: 0, initCode: 0x, callData: 0xb61d27f60000000000000000000000001240fa2a84dd9157a0e76b5cfe98b1d52268b264000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x8ad159a275aee56fb2334dbb69036e9c7bacee9b0000000000000000000000000000c3500000000000000000000000000000c350, signature: 0x })) [staticcall]
│ └─ ← [Return] 0x0c1f5a8df00a8b09966afba22359a21c1f711d760268abf2e3edd5bd59933bba
├─ [3993] UserOpUtils::signUserOp(907111799109225873672206001743429201758838553092777504370151546632448000192 [9.071e74], 0x0c1f5a8df00a8b09966afba22359a21c1f711d760268abf2e3edd5bd59933bba) [staticcall]
│ ├─ [0] VM::sign("<pk>", 0xdede256a55145101b75dd99012d647b492de0e07e2803fded291ef969bad9e1b) [staticcall]
│ │ └─ ← [Return] 27, 0x3489fe97fb76cd33a7252a3ae0b9b898fab530ae6863055a35e28c7f83ecc3ea, 0x12390d572346a181b615588e3e13febfd3c6414e502e70d82d8e785184e394fd
│ └─ ← [Return] 0x3489fe97fb76cd33a7252a3ae0b9b898fab530ae6863055a35e28c7f83ecc3ea12390d572346a181b615588e3e13febfd3c6414e502e70d82d8e785184e394fd1b
├─ [0] VM::prank(Deployer: [0xaE0bDc4eEAC5E950B67C6819B118761CaAF61946])
│ └─ ← [Return]
├─ [26205] Paymaster::setWhitelisted(ERC1967Proxy: [0x75fe56829E9d4867837306E3bDDdcdC066416865], true)
│ ├─ emit Whitelisted(account: ERC1967Proxy: [0x75fe56829E9d4867837306E3bDDdcdC066416865], state: true)
│ └─ ← [Stop]
├─ [2283] Counter::number() [staticcall]
│ └─ ← [Return] 0
├─ [2545] EntryPoint::balanceOf(Paymaster: [0x8Ad159a275AEE56fb2334DBb69036E9c7baCEe9b]) [staticcall]
│ └─ ← [Return] 10000000000000000000 [1e19]
├─ [105678] EntryPoint::handleOps([PackedUserOperation({ sender: 0x75fe56829E9d4867837306E3bDDdcdC066416865, nonce: 0, initCode: 0x, callData: 0xb61d27f60000000000000000000000001240fa2a84dd9157a0e76b5cfe98b1d52268b264000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x8ad159a275aee56fb2334dbb69036e9c7bacee9b0000000000000000000000000000c3500000000000000000000000000000c350, signature: 0x3489fe97fb76cd33a7252a3ae0b9b898fab530ae6863055a35e28c7f83ecc3ea12390d572346a181b615588e3e13febfd3c6414e502e70d82d8e785184e394fd1b })], Beneficiary: [0x5c4d2bd3510C8B51eDB17766d3c96EC637326999])
│ ├─ [5460] ERC1967Proxy::validateUserOp(PackedUserOperation({ sender: 0x75fe56829E9d4867837306E3bDDdcdC066416865, nonce: 0, initCode: 0x, callData: 0xb61d27f60000000000000000000000001240fa2a84dd9157a0e76b5cfe98b1d52268b264000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x8ad159a275aee56fb2334dbb69036e9c7bacee9b0000000000000000000000000000c3500000000000000000000000000000c350, signature: 0x3489fe97fb76cd33a7252a3ae0b9b898fab530ae6863055a35e28c7f83ecc3ea12390d572346a181b615588e3e13febfd3c6414e502e70d82d8e785184e394fd1b }), 0x0c1f5a8df00a8b09966afba22359a21c1f711d760268abf2e3edd5bd59933bba, 0)
│ │ ├─ [4925] SimpleAccountImpl::validateUserOp(PackedUserOperation({ sender: 0x75fe56829E9d4867837306E3bDDdcdC066416865, nonce: 0, initCode: 0x, callData: 0xb61d27f60000000000000000000000001240fa2a84dd9157a0e76b5cfe98b1d52268b264000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x8ad159a275aee56fb2334dbb69036e9c7bacee9b0000000000000000000000000000c3500000000000000000000000000000c350, signature: 0x3489fe97fb76cd33a7252a3ae0b9b898fab530ae6863055a35e28c7f83ecc3ea12390d572346a181b615588e3e13febfd3c6414e502e70d82d8e785184e394fd1b }), 0x0c1f5a8df00a8b09966afba22359a21c1f711d760268abf2e3edd5bd59933bba, 0) [delegatecall]
│ │ │ ├─ [3000] PRECOMPILES::ecrecover(0xdede256a55145101b75dd99012d647b492de0e07e2803fded291ef969bad9e1b, 27, 23764083315210779731292864328595571816955946046339784204112651346398036149226, 8242433629124017286329525379298637228692047074603340051719559689309765473533) [staticcall]
│ │ │ │ └─ ← [Return] 0x0000000000000000000000007c8999dc9a822c1f0df42023113edb4fdd543266
│ │ │ └─ ← [Return] 0
│ │ └─ ← [Return] 0
│ ├─ [3230] Paymaster::validatePaymasterUserOp(PackedUserOperation({ sender: 0x75fe56829E9d4867837306E3bDDdcdC066416865, nonce: 0, initCode: 0x, callData: 0xb61d27f60000000000000000000000001240fa2a84dd9157a0e76b5cfe98b1d52268b264000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x8ad159a275aee56fb2334dbb69036e9c7bacee9b0000000000000000000000000000c3500000000000000000000000000000c350, signature: 0x3489fe97fb76cd33a7252a3ae0b9b898fab530ae6863055a35e28c7f83ecc3ea12390d572346a181b615588e3e13febfd3c6414e502e70d82d8e785184e394fd1b }), 0x0c1f5a8df00a8b09966afba22359a21c1f711d760268abf2e3edd5bd59933bba, 7420000000000000 [7.42e15])
│ │ └─ ← [Return] 0x00000000000000000000000075fe56829e9d4867837306e3bdddcdc066416865, 0
│ ├─ emit BeforeExecution()
│ ├─ [30750] EntryPoint::innerHandleOp(0xb61d27f60000000000000000000000001240fa2a84dd9157a0e76b5cfe98b1d52268b264000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, UserOpInfo({ mUserOp: MemoryUserOp({ sender: 0x75fe56829E9d4867837306E3bDDdcdC066416865, nonce: 0, verificationGasLimit: 200000 [2e5], callGasLimit: 50000 [5e4], paymasterVerificationGasLimit: 50000 [5e4], paymasterPostOpGasLimit: 50000 [5e4], preVerificationGas: 21000 [2.1e4], paymaster: 0x8Ad159a275AEE56fb2334DBb69036E9c7baCEe9b, maxFeePerGas: 20000000000 [2e10], maxPriorityFeePerGas: 1000000000 [1e9] }), userOpHash: 0x0c1f5a8df00a8b09966afba22359a21c1f711d760268abf2e3edd5bd59933bba, prefund: 7420000000000000 [7.42e15], contextOffset: 1248, preOpGas: 62101 [6.21e4] }), 0x00000000000000000000000075fe56829e9d4867837306e3bdddcdc066416865)
│ │ ├─ [22088] ERC1967Proxy::execute(Counter: [0x1240FA2A84dd9157a0e76B5Cfe98B1d52268B264], 0, 0xd09de08a)
│ │ │ ├─ [21689] SimpleAccountImpl::execute(Counter: [0x1240FA2A84dd9157a0e76B5Cfe98B1d52268B264], 0, 0xd09de08a) [delegatecall]
│ │ │ │ ├─ [20340] Counter::increment()
│ │ │ │ │ └─ ← [Stop]
│ │ │ │ └─ ← [Stop]
│ │ │ └─ ← [Return]
│ │ ├─ [1245] Paymaster::postOp(0, 0x00000000000000000000000075fe56829e9d4867837306e3bdddcdc066416865, 84579000000000 [8.457e13], 1000000000 [1e9])
│ │ │ └─ ← [Stop]
│ │ ├─ emit UserOperationEvent(userOpHash: 0x0c1f5a8df00a8b09966afba22359a21c1f711d760268abf2e3edd5bd59933bba, sender: ERC1967Proxy: [0x75fe56829E9d4867837306E3bDDdcdC066416865], paymaster: Paymaster: [0x8Ad159a275AEE56fb2334DBb69036E9c7baCEe9b], nonce: 0, success: true, actualGasCost: 94466000000000 [9.446e13], actualGasUsed: 94466 [9.446e4])
│ │ └─ ← [Return] 94466000000000 [9.446e13]
│ ├─ [0] Beneficiary::fallback{value: 94466000000000}()
│ │ └─ ← [Stop]
│ └─ ← [Stop]
├─ [283] Counter::number() [staticcall]
│ └─ ← [Return] 1
├─ [0] VM::assertEq(1, 1) [staticcall]
│ └─ ← [Return]
├─ [545] EntryPoint::balanceOf(Paymaster: [0x8Ad159a275AEE56fb2334DBb69036E9c7baCEe9b]) [staticcall]
│ └─ ← [Return] 9999905534000000000 [9.999e18]
├─ [0] VM::assertLt(9999905534000000000 [9.999e18], 10000000000000000000 [1e19]) [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertGt(1000094466000000000 [1e18], 1000000000000000000 [1e18]) [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 12.23ms (2.95ms CPU time)
Ran 1 test suite in 987.23ms (12.23ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
[CASE 2] 화이트리스트에 등록되지 않은 sender가 대납을 요청하는 경우
case 1과는 달리, deployer가 account 주소를 화이트리스트에 등록하지 않았습니다. 이로 인해 Paymaster의 validatePaymasterUserOp 함수에서는 SenderNotWhitelisted 에러가 반환되고, handleOps에서는 FailedOpWithRevert 에러가 반환됩니다.
function test_RevertHandleOpsWithNotWhitelistedAccount() public {
SimpleAccount account = factory.createAccount(owner, SALT);
PackedUserOperation[] memory ops = getrUserOps(address(account), address(paymaster));
bytes memory innerError = abi.encodeWithSelector(SenderNotWhitelisted.selector, address(account));
vm.expectRevert(abi.encodeWithSelector(FailedOpWithRevert.selector, 0, "AA33 reverted", innerError));
entryPoint.handleOps(ops, payable(beneficiary));
}
[CASE 3] 화이트리스트에는 등록되었으나, Paymaster의 예치금이 부족한 경우
function test_RevertHandleOpsWithInsufficientDeposit() public {
SimpleAccount account = factory.createAccount(owner, SALT);
PackedUserOperation[] memory ops = getrUserOps(address(account), address(paymaster));
vm.startPrank(deployer);
paymaster.setWhitelisted(address(account), true);
paymaster.withdrawTo(payable(deployer), PAYMASTER_DEPOSIT);
vm.stopPrank();
vm.expectRevert(abi.encodeWithSelector(FailedOp.selector, 0, "AA31 paymaster deposit too low"));
entryPoint.handleOps(ops, payable(beneficiary));
}
스마트 계정의 validateUserOp는 정상적으로 실행이 되지만, validatePaymasterUserOp 함수가 실행되기 전에 예치금이 너무 적다는 이유로 오류가 발생합니다.
[CASE 4] sender가 토큰으로 LegacyTokenPaymaster에 대납을 요청하는 경우
function test_HandleOpsWithLegacyTokenPayment() public {
SimpleAccount account = factory.createAccount(owner, SALT);
PackedUserOperation[] memory ops = getrUserOps(address(account), address(tokenPaymaster));
vm.prank(deployer);
tokenPaymaster.mintTokens(address(account), 1 ether);
assertEq(tokenPaymaster.balanceOf(address(account)), 1 ether);
uint256 counterBefore = counter.number();
uint256 depositBefore = entryPoint.balanceOf(address(tokenPaymaster));
uint256 accountTokenBalanceBefore = tokenPaymaster.balanceOf(address(account));
uint256 beneficiaryBalanceBefore = beneficiary.balance;
entryPoint.handleOps(ops, payable(beneficiary));
assertEq(counter.number(), counterBefore + 1);
assertLt(entryPoint.balanceOf(address(tokenPaymaster)), depositBefore);
assertLt(tokenPaymaster.balanceOf(address(account)), accountTokenBalanceBefore);
assertGt(beneficiary.balance, beneficiaryBalanceBefore);
}
1. 스마트 계정과 userOps를 생성하는 과정은 동일한데, 이번 경우에는 LegacyTokenPaymaster에게 대납을 요청할 것이므로 LegacyTokenPaymaster의 주소를 넣어 userOps를 생성합니다.
SimpleAccount account = factory.createAccount(owner, SALT);
PackedUserOperation[] memory ops = getrUserOps(address(account), address(tokenPaymaster));
2. 토큰과 이더의 교환을 통해 대납이 이루어지므로, 테스트가 성공하려면 스마트 계정이 일정량의 토큰을 가지고 있어야 합니다. 따라서 Paymaster의 소유자가 스마트 계정 주소로 1개의 토큰을 민팅해 줍니다.
vm.prank(deployer);
tokenPaymaster.mintTokens(address(account), 1 ether);
assertEq(tokenPaymaster.balanceOf(address(account)), 1 ether);
3. 이후의 실행과정은 SimplePaymaster와 유사하며, LegacyTokenPaymaster의 구현에 의해 토큰을 이더로 교환하는 부분만 차이가 납니다. EntryPoint의 handleOps 함수를 실행하고 나면 다음 사항들을 확인할 수 있습니다.
- Counter의 number가 handleOps를 실행하기 전보다 1만큼 증가된 상태여야 합니다.
- LegacyTokenPaymaster 의 예치금이 handleOps를 실행하기 전보다 감소된 상태여야 합니다.
- 스마트 계정의 토큰 잔액이 handleOps를 실행하기 전보다 감소된 상태여야 합니다.
- beneficiary의 이더 잔액이 handleOps를 실행하기 전보다 증가된 상태여야 합니다.
uint256 counterBefore = counter.number();
uint256 depositBefore = entryPoint.balanceOf(address(tokenPaymaster));
uint256 accountTokenBalanceBefore = tokenPaymaster.balanceOf(address(account));
uint256 beneficiaryBalanceBefore = beneficiary.balance;
entryPoint.handleOps(ops, payable(beneficiary));
assertEq(counter.number(), counterBefore + 1);
assertLt(entryPoint.balanceOf(address(tokenPaymaster)), depositBefore);
assertLt(tokenPaymaster.balanceOf(address(account)), accountTokenBalanceBefore);
assertGt(beneficiary.balance, beneficiaryBalanceBefore);
테스트
$ forge test --mt test_HandleOpsWithLegacyTokenPayment -vvvv
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/PayMaster.t.sol:PaymasterTest
[PASS] test_HandleOpsWithLegacyTokenPayment() (gas: 369719)
Traces:
[369719] PaymasterTest::test_HandleOpsWithLegacyTokenPayment()
├─ [152583] SimpleAccountFactory::createAccount(Owner: [0x7c8999dC9a822c1f0Df42023113EDB4FDd543266], 10000 [1e4])
│ ├─ [112211] → new ERC1967Proxy@0x75fe56829E9d4867837306E3bDDdcdC066416865
│ │ ├─ emit Upgraded(implementation: SimpleAccountImpl: [0xffD4505B3452Dc22f8473616d50503bA9E1710Ac])
│ │ ├─ [47945] SimpleAccountImpl::initialize(Owner: [0x7c8999dC9a822c1f0Df42023113EDB4FDd543266]) [delegatecall]
│ │ │ ├─ emit SimpleAccountInitialized(entryPoint: EntryPoint: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], owner: Owner: [0x7c8999dC9a822c1f0Df42023113EDB4FDd543266])
│ │ │ ├─ emit Initialized(version: 1)
│ │ │ └─ ← [Stop]
│ │ └─ ← [Return] 183 bytes of code
│ └─ ← [Return] ERC1967Proxy: [0x75fe56829E9d4867837306E3bDDdcdC066416865]
├─ [2768] EntryPoint::getNonce(ERC1967Proxy: [0x75fe56829E9d4867837306E3bDDdcdC066416865], 0) [staticcall]
│ └─ ← [Return] 0
├─ [2819] UserOpUtils::packUserOp(ERC1967Proxy: [0x75fe56829E9d4867837306E3bDDdcdC066416865], 0, 0xb61d27f6000000000000000000000000ff2bd636b9fc89645c2d336aeade2e4abafe1ea5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000) [staticcall]
│ └─ ← [Return] PackedUserOperation({ sender: 0x75fe56829E9d4867837306E3bDDdcdC066416865, nonce: 0, initCode: 0x, callData: 0xb61d27f6000000000000000000000000ff2bd636b9fc89645c2d336aeade2e4abafe1ea5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x, signature: 0x })
├─ [961] UserOpUtils::packPaymasterAndData(LegacyTokenPaymaster: [0x1240FA2A84dd9157a0e76B5Cfe98B1d52268B264], 15000 [1.5e4], 20000 [2e4]) [staticcall]
│ └─ ← [Return] 0x1240fa2a84dd9157a0e76b5cfe98b1d52268b26400000000000000000000000000003a9800000000000000000000000000004e20
├─ [1966] EntryPoint::getUserOpHash(PackedUserOperation({ sender: 0x75fe56829E9d4867837306E3bDDdcdC066416865, nonce: 0, initCode: 0x, callData: 0xb61d27f6000000000000000000000000ff2bd636b9fc89645c2d336aeade2e4abafe1ea5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x1240fa2a84dd9157a0e76b5cfe98b1d52268b26400000000000000000000000000003a9800000000000000000000000000004e20, signature: 0x })) [staticcall]
│ └─ ← [Return] 0xa2a6ccdcf0f95c300277e4b331604397f37ec1bc210a36f514f193d850eae9e2
├─ [3993] UserOpUtils::signUserOp(907111799109225873672206001743429201758838553092777504370151546632448000192 [9.071e74], 0xa2a6ccdcf0f95c300277e4b331604397f37ec1bc210a36f514f193d850eae9e2) [staticcall]
│ ├─ [0] VM::sign("<pk>", 0xbc0a1436aa0f5f4c4ebd2e6506267db37fd3d2f1001c3552a13f2dd0ae82f1b8) [staticcall]
│ │ └─ ← [Return] 27, 0xb2b14872b80d0306f356194e2d67b53ae119603b7c96320cc594620082c366cd, 0x2fbceb174cf54f7c1432a1e216774e0392d71fa7825c4a596b1c20256d8fb705
│ └─ ← [Return] 0xb2b14872b80d0306f356194e2d67b53ae119603b7c96320cc594620082c366cd2fbceb174cf54f7c1432a1e216774e0392d71fa7825c4a596b1c20256d8fb7051b
├─ [0] VM::prank(Deployer: [0xaE0bDc4eEAC5E950B67C6819B118761CaAF61946])
│ └─ ← [Return]
├─ [31814] LegacyTokenPaymaster::mintTokens(ERC1967Proxy: [0x75fe56829E9d4867837306E3bDDdcdC066416865], 1000000000000000000 [1e18])
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: ERC1967Proxy: [0x75fe56829E9d4867837306E3bDDdcdC066416865], value: 1000000000000000000 [1e18])
│ └─ ← [Stop]
├─ [597] LegacyTokenPaymaster::balanceOf(ERC1967Proxy: [0x75fe56829E9d4867837306E3bDDdcdC066416865]) [staticcall]
│ └─ ← [Return] 1000000000000000000 [1e18]
├─ [0] VM::assertEq(1000000000000000000 [1e18], 1000000000000000000 [1e18]) [staticcall]
│ └─ ← [Return]
├─ [2283] Counter::number() [staticcall]
│ └─ ← [Return] 0
├─ [2545] EntryPoint::balanceOf(LegacyTokenPaymaster: [0x1240FA2A84dd9157a0e76B5Cfe98B1d52268B264]) [staticcall]
│ └─ ← [Return] 10000000000000000000 [1e19]
├─ [597] LegacyTokenPaymaster::balanceOf(ERC1967Proxy: [0x75fe56829E9d4867837306E3bDDdcdC066416865]) [staticcall]
│ └─ ← [Return] 1000000000000000000 [1e18]
├─ [112083] EntryPoint::handleOps([PackedUserOperation({ sender: 0x75fe56829E9d4867837306E3bDDdcdC066416865, nonce: 0, initCode: 0x, callData: 0xb61d27f6000000000000000000000000ff2bd636b9fc89645c2d336aeade2e4abafe1ea5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x1240fa2a84dd9157a0e76b5cfe98b1d52268b26400000000000000000000000000003a9800000000000000000000000000004e20, signature: 0xb2b14872b80d0306f356194e2d67b53ae119603b7c96320cc594620082c366cd2fbceb174cf54f7c1432a1e216774e0392d71fa7825c4a596b1c20256d8fb7051b })], Beneficiary: [0x5c4d2bd3510C8B51eDB17766d3c96EC637326999])
│ ├─ [5460] ERC1967Proxy::validateUserOp(PackedUserOperation({ sender: 0x75fe56829E9d4867837306E3bDDdcdC066416865, nonce: 0, initCode: 0x, callData: 0xb61d27f6000000000000000000000000ff2bd636b9fc89645c2d336aeade2e4abafe1ea5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x1240fa2a84dd9157a0e76b5cfe98b1d52268b26400000000000000000000000000003a9800000000000000000000000000004e20, signature: 0xb2b14872b80d0306f356194e2d67b53ae119603b7c96320cc594620082c366cd2fbceb174cf54f7c1432a1e216774e0392d71fa7825c4a596b1c20256d8fb7051b }), 0xa2a6ccdcf0f95c300277e4b331604397f37ec1bc210a36f514f193d850eae9e2, 0)
│ │ ├─ [4925] SimpleAccountImpl::validateUserOp(PackedUserOperation({ sender: 0x75fe56829E9d4867837306E3bDDdcdC066416865, nonce: 0, initCode: 0x, callData: 0xb61d27f6000000000000000000000000ff2bd636b9fc89645c2d336aeade2e4abafe1ea5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x1240fa2a84dd9157a0e76b5cfe98b1d52268b26400000000000000000000000000003a9800000000000000000000000000004e20, signature: 0xb2b14872b80d0306f356194e2d67b53ae119603b7c96320cc594620082c366cd2fbceb174cf54f7c1432a1e216774e0392d71fa7825c4a596b1c20256d8fb7051b }), 0xa2a6ccdcf0f95c300277e4b331604397f37ec1bc210a36f514f193d850eae9e2, 0) [delegatecall]
│ │ │ ├─ [3000] PRECOMPILES::ecrecover(0xbc0a1436aa0f5f4c4ebd2e6506267db37fd3d2f1001c3552a13f2dd0ae82f1b8, 27, 80824918996840894553822724821576997337440302536999582809795846337667134613197, 21592493670166774682370287800672793849125319473584435108482817468316376872709) [staticcall]
│ │ │ │ └─ ← [Return] 0x0000000000000000000000007c8999dc9a822c1f0df42023113edb4fdd543266
│ │ │ └─ ← [Return] 0
│ │ └─ ← [Return] 0
│ ├─ [2272] LegacyTokenPaymaster::validatePaymasterUserOp(PackedUserOperation({ sender: 0x75fe56829E9d4867837306E3bDDdcdC066416865, nonce: 0, initCode: 0x, callData: 0xb61d27f6000000000000000000000000ff2bd636b9fc89645c2d336aeade2e4abafe1ea5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, accountGasLimits: 0x00000000000000000000000000030d400000000000000000000000000000c350, preVerificationGas: 21000 [2.1e4], gasFees: 0x0000000000000000000000003b9aca00000000000000000000000004a817c800, paymasterAndData: 0x1240fa2a84dd9157a0e76b5cfe98b1d52268b26400000000000000000000000000003a9800000000000000000000000000004e20, signature: 0xb2b14872b80d0306f356194e2d67b53ae119603b7c96320cc594620082c366cd2fbceb174cf54f7c1432a1e216774e0392d71fa7825c4a596b1c20256d8fb7051b }), 0xa2a6ccdcf0f95c300277e4b331604397f37ec1bc210a36f514f193d850eae9e2, 6120000000000000 [6.12e15])
│ │ └─ ← [Return] 0x00000000000000000000000075fe56829e9d4867837306e3bdddcdc066416865, 0
│ ├─ emit BeforeExecution()
│ ├─ [38113] EntryPoint::innerHandleOp(0xb61d27f6000000000000000000000000ff2bd636b9fc89645c2d336aeade2e4abafe1ea5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000, UserOpInfo({ mUserOp: MemoryUserOp({ sender: 0x75fe56829E9d4867837306E3bDDdcdC066416865, nonce: 0, verificationGasLimit: 200000 [2e5], callGasLimit: 50000 [5e4], paymasterVerificationGasLimit: 15000 [1.5e4], paymasterPostOpGasLimit: 20000 [2e4], preVerificationGas: 21000 [2.1e4], paymaster: 0x1240FA2A84dd9157a0e76B5Cfe98B1d52268B264, maxFeePerGas: 20000000000 [2e10], maxPriorityFeePerGas: 1000000000 [1e9] }), userOpHash: 0xa2a6ccdcf0f95c300277e4b331604397f37ec1bc210a36f514f193d850eae9e2, prefund: 6120000000000000 [6.12e15], contextOffset: 1248, preOpGas: 61143 [6.114e4] }), 0x00000000000000000000000075fe56829e9d4867837306e3bdddcdc066416865)
│ │ ├─ [22088] ERC1967Proxy::execute(Counter: [0xfF2Bd636B9Fc89645C2D336aeaDE2E4AbaFe1eA5], 0, 0xd09de08a)
│ │ │ ├─ [21689] SimpleAccountImpl::execute(Counter: [0xfF2Bd636B9Fc89645C2D336aeaDE2E4AbaFe1eA5], 0, 0xd09de08a) [delegatecall]
│ │ │ │ ├─ [20340] Counter::increment()
│ │ │ │ │ └─ ← [Stop]
│ │ │ │ └─ ← [Stop]
│ │ │ └─ ← [Return]
│ │ ├─ [8608] LegacyTokenPaymaster::postOp(0, 0x00000000000000000000000075fe56829e9d4867837306e3bdddcdc066416865, 83621000000000 [8.362e13], 1000000000 [1e9])
│ │ │ ├─ emit Transfer(from: ERC1967Proxy: [0x75fe56829E9d4867837306E3bDDdcdC066416865], to: LegacyTokenPaymaster: [0x1240FA2A84dd9157a0e76B5Cfe98B1d52268B264], value: 986210000000 [9.862e11])
│ │ │ └─ ← [Stop]
│ │ ├─ emit UserOperationEvent(userOpHash: 0xa2a6ccdcf0f95c300277e4b331604397f37ec1bc210a36f514f193d850eae9e2, sender: ERC1967Proxy: [0x75fe56829E9d4867837306E3bDDdcdC066416865], paymaster: LegacyTokenPaymaster: [0x1240FA2A84dd9157a0e76B5Cfe98B1d52268B264], nonce: 0, success: true, actualGasCost: 97135000000000 [9.713e13], actualGasUsed: 97135 [9.713e4])
│ │ └─ ← [Return] 97135000000000 [9.713e13]
│ ├─ [0] Beneficiary::fallback{value: 97135000000000}()
│ │ └─ ← [Stop]
│ └─ ← [Stop]
├─ [283] Counter::number() [staticcall]
│ └─ ← [Return] 1
├─ [0] VM::assertEq(1, 1) [staticcall]
│ └─ ← [Return]
├─ [545] EntryPoint::balanceOf(LegacyTokenPaymaster: [0x1240FA2A84dd9157a0e76B5Cfe98B1d52268B264]) [staticcall]
│ └─ ← [Return] 9999902865000000000 [9.999e18]
├─ [0] VM::assertLt(9999902865000000000 [9.999e18], 10000000000000000000 [1e19]) [staticcall]
│ └─ ← [Return]
├─ [597] LegacyTokenPaymaster::balanceOf(ERC1967Proxy: [0x75fe56829E9d4867837306E3bDDdcdC066416865]) [staticcall]
│ └─ ← [Return] 999999013790000000 [9.999e17]
├─ [0] VM::assertLt(999999013790000000 [9.999e17], 1000000000000000000 [1e18]) [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertGt(1000097135000000000 [1e18], 1000000000000000000 [1e18]) [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.96ms (3.80ms CPU time)
Ran 1 test suite in 677.62ms (8.96ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
정리
- BasePaymaster를 기반으로 Paymaster를 자유롭게 구현할 수 있습니다.
- context는 Paymaster의 validatePaymasterUserOp에서 생성되어 반환되며, 길이가 0이라면 postOp가 호출되지 않고 0보다 크다면 호출되므로, 후속 작업이 필요한 경우에 context를 활용합니다.
- ERC-20 토큰을 사용한 대납이 가능하며, DEX를 사용하여 토큰을 이더로 교환하는 등의 추가적인 구현도 가능합니다.
전체 코드
참조
'블록체인 > Ethereum' 카테고리의 다른 글
ERC-4337: 계정 추상화 - 간단 정리 (0) | 2024.04.23 |
---|---|
ERC-4337: 계정 추상화 - 테스트를 통한 Aggregator의 동작 이해 (0) | 2024.04.22 |
ERC-4337: 계정 추상화 - 테스트를 통한 Account Factory의 동작 이해 (0) | 2024.04.18 |
ERC-4337: 계정 추상화 - 테스트 수정 사항 (0) | 2024.04.17 |
ERC-4337: 계정 추상화 - 테스트를 통한 Account와 EntryPoint의 동작 이해 (0) | 2024.04.17 |