お疲れ様です。 次世代ビジネス推進部の荻原です。
今回は、ERC4337についてです。 以前も
の2つの記事を書いたのですが、さらに深く調べたことをまとめました。以前書いたものはまだまだ理解が甘かったな…という反省を込めて書いていきます。
今回は3本立てです。最終的にUserOperationの作り方とどのように実行されるかを理解した上で、Pythonで実行してみることをゴールにします。
第1回目の今回は、UserOperationを実行するEntryPointコントラクトの
handleOps()のソースコードを読んでわかったことをまとめます。ここを理解することでUserOperationの作り方とどのように実行されるかがわかるようになるかと思います。handleOps()の中でPaymasterに関する実装もあるので、UserOperation実行時のガス代についてもコードを読んだ上で実際に検証してみました。
目次
1. handleOps()について 2. _validatePrepayment()(資金準備のチェック) 3. _validateAccountAndPaymasterValidationData()(PaymasterAndDataのsignatureとタイムスタンプの検証) 4. _executeUserOp()(callDataの実行) 5. _compensate()(ガス代の払い戻し)
1. handleOps()について
EntryPointの
handleOps()のコードを追ってみます。
eth-infinitismから提供されているaccount-abstractionのEntryPointを使います。
// EntryPoint.sol
function handleOps(UserOperation[] 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);
} //unchecked
}
主に
_validatePrepayment()、_validateAccountAndPaymasterValidationData()、_executeUserOp()、_compensate()の4つを実行しています。
_validatePrepayment()、_validateAccountAndPaymasterValidationData()の2つは関数名の通りUserOperationの各値の検証やinitCodeの実行をします。_executeUserOp()はUserOperationのcallDataに基づいて処理を実行します。メインの部分と言えます。_compensate()は_executeUserOp()でかかったガスを指定したアカウント(beneficiary)に払い戻します。
それぞれについて見ていきます。
2. _validatePrepayment()(資金準備のチェック)
ガス代支払いのための資金が準備できているかをチェックしています。
// EntryPoint.sol
function _validatePrepayment(uint256 opIndex, UserOperation calldata userOp, UserOpInfo memory outOpInfo)
private 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 maxGasValues = mUserOp.preVerificationGas | mUserOp.verificationGasLimit | mUserOp.callGasLimit |
userOp.maxFeePerGas | userOp.maxPriorityFeePerGas;
require(maxGasValues <= type(uint120).max, "AA94 gas values overflow");
uint256 gasUsedByValidateAccountPrepayment;
(uint256 requiredPreFund) = _getRequiredPrefund(mUserOp);
(gasUsedByValidateAccountPrepayment, validationData) = _validateAccountPrepayment(opIndex, userOp, outOpInfo, requiredPreFund);
if (!_validateAndUpdateNonce(mUserOp.sender, mUserOp.nonce)) {
revert FailedOp(opIndex, "AA25 invalid account nonce");
}
//a "marker" where account opcode validation is done and paymaster opcode validation is about to start
// (used only by off-chain simulateValidation)
numberMarker();
bytes memory context;
if (mUserOp.paymaster != address(0)) {
(context, paymasterValidationData) = _validatePaymasterPrepayment(opIndex, userOp, outOpInfo, requiredPreFund, gasUsedByValidateAccountPrepayment);
}
unchecked {
uint256 gasUsed = preGas - gasleft();
if (userOp.verificationGasLimit < gasUsed) {
revert FailedOp(opIndex, "AA40 over verificationGasLimit");
}
outOpInfo.prefund = requiredPreFund;
outOpInfo.contextOffset = getOffsetOfMemoryBytes(context);
outOpInfo.preOpGas = preGas - gasleft() + userOp.preVerificationGas;
}
}
部分的にピックアップします。
次の箇所からGas関連の各値(preVerificationGas 、verificationGasLimit、callGasLimit、maxFeePerGas、maxPriorityFeePerGas)には
type(uint120).max == 2^(120)−1を超える値は指定できないことがわかります。
// EntryPoint.sol
// _validatePrepayment()
uint256 maxGasValues = mUserOp.preVerificationGas | mUserOp.verificationGasLimit | mUserOp.callGasLimit | userOp.maxFeePerGas | userOp.maxPriorityFeePerGas;
require(maxGasValues <= type(uint120).max, "AA94 gas values overflow");
そして以下でガス代の支払いのために準備しておくべき金額を取得しています。
(uint256 requiredPreFund) = _getRequiredPrefund(mUserOp);
準備とは、
EntryPoint.depositTo()を実行して、EntryPointにガス代の支払いに使用するための資金を送っておくことです。実装はEntryPointが継承しているStakeManagerにあります。
// StakeManager.sol
function _incrementDeposit(address account, uint256 amount) internal {
DepositInfo storage info = deposits[account];
uint256 newAmount = info.deposit + amount;
require(newAmount <= type(uint112).max, "deposit overflow");
info.deposit = uint112(newAmount);
}
function depositTo(address account) public payable {
_incrementDeposit(account, msg.value);
DepositInfo storage info = deposits[account];
emit Deposited(account, info.deposit);
}
function getDepositInfo(address account) public view returns (DepositInfo memory info) {
return deposits[account];
}
UserOperation実行時のガス代は
deposits[<address>].depositに格納されている分の資金から支払われます。<address>はPaymasterを使用する場合はPaymasterのaddress、使用しない場合はSmart Contract Accountのaddress(sender)です。
EntryPoint.depositTo()は次のように実行します。Truffleでの実行を例に取ります。
truffle(development)> var entryPoint = await EntryPoint.at("<EntryPointのaddress>");
truffle(development)> entryPoint.depositTo("<address>", {"value": <送金する額>});
この送金する額がどのくらい必要かは
_getRequiredPrefund()の実装を見るとわかります。
// EntryPoint.sol
function _getRequiredPrefund(MemoryUserOp memory mUserOp) internal pure returns (uint256 requiredPrefund) {
unchecked {
//when using a Paymaster, the verificationGasLimit is used also to as a limit for the postOp call.
// our security model might call postOp eventually twice
uint256 mul = mUserOp.paymaster != address(0) ? 3 : 1;
uint256 requiredGas = mUserOp.callGasLimit + mUserOp.verificationGasLimit * mul + mUserOp.preVerificationGas;
requiredPrefund = requiredGas * mUserOp.maxFeePerGas;
}
}
少しわかりやすく書き直します。
# Paymasterを使う場合
requiredPrefund = (callGasLimit + verificationGasLimit * 3 + preVerificationGas) * maxFeePerGas
# 使わない場合
requiredPrefund = (callGasLimit + verificationGasLimit + preVerificationGas) * maxFeePerGas
UserOperationの
callGasLimit、verificationGasLimit、preVerificationGas、maxFeePerGasの値を決めていれば上記の式からオフチェーンで計算することが可能です。
requiredPrefundを超える値をEntryPoint.depositTo()で送金すればOKです。
_validatePrepayment()の続きを見ていきます。
// EntryPoint.sol
// _validatePrepayment()
(gasUsedByValidateAccountPrepayment, validationData) = _validateAccountPrepayment(opIndex, userOp, outOpInfo, requiredPreFund);
_validateAccountPrepayment()はUserOperationの検証をします。Paymasterを使わない場合はここでSmart Contract Accountのdepositが足りているかチェックします。Paymasterを使用する場合はこの関数内ではまだdepositのチェックは行われません。
具体的に見ていきます。
// EntryPoint.sol
function _validateAccountPrepayment(uint256 opIndex, UserOperation calldata op, UserOpInfo memory opInfo, uint256 requiredPrefund)
internal returns (uint256 gasUsedByValidateAccountPrepayment, uint256 validationData) {
unchecked {
uint256 preGas = gasleft();
MemoryUserOp memory mUserOp = opInfo.mUserOp;
address sender = mUserOp.sender;
_createSenderIfNeeded(opIndex, opInfo, op.initCode);
address paymaster = mUserOp.paymaster;
numberMarker();
uint256 missingAccountFunds = 0;
if (paymaster == address(0)) {
uint256 bal = balanceOf(sender);
missingAccountFunds = bal > requiredPrefund ? 0 : requiredPrefund - bal;
}
try IAccount(sender).validateUserOp{gas : mUserOp.verificationGasLimit}(op, opInfo.userOpHash, missingAccountFunds)
returns (uint256 _validationData) {
validationData = _validationData;
} catch Error(string memory revertReason) {
revert FailedOp(opIndex, string.concat("AA23 reverted: ", revertReason));
} catch {
revert FailedOp(opIndex, "AA23 reverted (or OOG)");
}
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 = uint112(deposit - requiredPrefund);
}
gasUsedByValidateAccountPrepayment = preGas - gasleft();
}
}
initCodeが
0xでなければ_createSenderIfNeeded()でSmart Contract Accountがdeployされます。
// EntryPoint.sol
function _createSenderIfNeeded(uint256 opIndex, UserOpInfo memory opInfo, bytes calldata initCode) internal {
if (initCode.length != 0) {
address sender = opInfo.mUserOp.sender;
if (sender.code.length != 0) revert FailedOp(opIndex, "AA10 sender already constructed");
address sender1 = senderCreator.createSender{gas : opInfo.mUserOp.verificationGasLimit}(initCode);
if (sender1 == address(0)) revert FailedOp(opIndex, "AA13 initCode failed or OOG");
if (sender1 != sender) revert FailedOp(opIndex, "AA14 initCode must return sender");
if (sender1.code.length == 0) revert FailedOp(opIndex, "AA15 initCode must create sender");
address factory = address(bytes20(initCode[0 : 20]));
emit AccountDeployed(opInfo.userOpHash, sender, factory, opInfo.mUserOp.paymaster);
}
}
具体的には
senderCreator.createSender{gas : opInfo.mUserOp.verificationGasLimit}(initCode)
の部分です。
SenderCreatorはEntryPointが継承しているコントラクトです。このときにGasとして
verificationGasLimitが指定されています。
// EntryPoint.sol
// _validateAccountPrepayment()
try IAccount(sender).validateUserOp{gas : mUserOp.verificationGasLimit}(op, opInfo.userOpHash, missingAccountFunds)
という部分でsenderとなるSmart Contract Accountの
validateUserOp()を実行してUserOperationの検証をしています。validateUserOp()は自由に実装できるため、それ次第でどのようなUserOperationが通すのかを決めることができます。実行時のGasとしてはverificationGasLimitが指定されます。
ここまでで
verificationGasLimitはSmart Contract AccountのdeployとvalidateUserOp()に使用されることがわかりました。
_validatePrepayment()に戻ると、最後の方の以下の部分からverificationGasLimitはこの関数内でここまでに使われるガス以上の値が必要なことがわかります。
// EntryPoint.sol
// _validatePrepayment()
if (userOp.verificationGasLimit < gasUsed) {
revert FailedOp(opIndex, "AA40 over verificationGasLimit");
}
3. _validateAccountAndPaymasterValidationData()(PaymasterAndDataのsignatureとタイムスタンプの検証)
PaymasterAndDataに含まれるsignatureとタイムスタンプの検証をします。
特に説明することはないのでコードは省略します。
ここまででUserOperationのcallDataを実行するための検証や準備が終わりました。
4. _executeUserOp()(callDataの実行)
callDataの実行をします。
// EntryPoint.sol
function _executeUserOp(uint256 opIndex, UserOperation calldata userOp, UserOpInfo memory opInfo) private returns (uint256 collected) {
uint256 preGas = gasleft();
bytes memory context = getMemoryBytesFromOffset(opInfo.contextOffset);
try this.innerHandleOp(userOp.callData, opInfo, context) returns (uint256 _actualGasCost) {
collected = _actualGasCost;
} catch {
bytes32 innerRevertCode;
assembly {
returndatacopy(0, 0, 32)
innerRevertCode := mload(0)
}
// handleOps was called with gas limit too low. abort entire bundle.
if (innerRevertCode == INNER_OUT_OF_GAS) {
//report paymaster, since if it is not deliberately caused by the bundler,
// it must be a revert caused by paymaster.
revert FailedOp(opIndex, "AA95 out of gas");
}
uint256 actualGas = preGas - gasleft() + opInfo.preOpGas;
collected = _handlePostOp(opIndex, IPaymaster.PostOpMode.postOpReverted, opInfo, context, actualGas);
}
}
メインとなっているのは
innerHandleOp()です。
// EntryPoint.sol
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;
uint callGasLimit = mUserOp.callGasLimit;
unchecked {
// handleOps was called with gas limit too low. abort entire bundle.
if (gasleft() < callGasLimit + mUserOp.verificationGasLimit + 5000) {
assembly {
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;
//note: opIndex is ignored (relevant only if mode==postOpReverted, which is only possible outside of innerHandleOp)
return _handlePostOp(0, mode, opInfo, context, actualGas);
}
}
下記の箇所からこの時点でのGasの残りが
callGasLimit + mUserOp.verificationGasLimit + 5000より小さいとエラーになるのでcallGasLimitやverificationGasLimitに大きすぎる値を指定したりGasを小さく設定してしまうと上手くいきません。
callGasLimitやverificationGasLimitはここまでの箇所である程度大きくないといけない制約があったのでGasが足りない場合はhandleOps()を実行するトランザクションでGasを大きめに指定すると良かったです。
// EntryPoint.sol
// innerHandleOp()
if (gasleft() < callGasLimit + mUserOp.verificationGasLimit + 5000) {
assembly {
mstore(0, INNER_OUT_OF_GAS)
revert(0, 32)
}
}
callDataが
0xでなければ次の箇所で実行されます。GasとしてはcallGasLimitが指定されています。
// EntryPoint.sol
// innerHandleOp()
bool success = Exec.call(mUserOp.sender, 0, callData, callGasLimit);
最後の
_handlePostOp()はSmart Contract AccountまたはPaymasterが前払いしていた資金から余った分を戻しています。
5. _compensate()(ガス代の払い戻し)
_executeUserOp()でかかったガス代をbeneficiaryに払い戻します。このあたりについてトランザクションのガス代との関係性がよくわからなかったので検証してみました。
5-1. コードを見てみる
_executeUserOp()で返された値の合計collectedが払い戻しの分です。これがどのように計算されているのか追ってみます。
// EntryPoint.sol
// handleOps()
uint256 collected = 0;
emit BeforeExecution();
for (uint256 i = 0; i < opslen; i++) {
collected += _executeUserOp(i, ops[i], opInfos[i]);
}
_compensate(beneficiary, collected);
4章で説明した、
_executeUserOp()とその中で実行されているinnerHandleOp()と_handlePostOp()について、大体以下のような実装になっていました。_executeUserOp()についてはinnerHandleOp()と_handlePostOp()に被らない部分のGasについてのみ含まれるようになっています。
// 関数の最初
uint256 preGas = gasleft();
// なんやかんやあって
...
// 関数の最後の方
uint256 actualGas = preGas - gasleft() + opInfo.preOpGas;
opInfo.preOpGasは_validatePrepayment()内の以下で求められています。
// EntryPoint.sol
// _validatePrepayment()
outOpInfo.preOpGas = preGas - gasleft() + userOp.preVerificationGas;
つまり、
beneficiaryに払い戻されるGas = _validatePrepayment()とinnerHandleOp()と_handlePostOp()と_executeUserOp()で使われたGas + preVerificationGas
です。
5-2. 実際に実行した結果を見てみる
以下についてUserOperationの実行前後で比較してみます。
handleOps()を実行するトランザクションに署名するEOA(Tx署名EOAと呼びます)のbalancehandleOps()のbeneficiaryでアドレスを指定するEOA(beneficiaryと呼びます)のbalance- UserOperationに署名するEOA(signerと呼びます)のbalance
- Paymasterのdeposit
ちなみに、Paymasterのbalanceに関しては今回使うことはないので
+-0です。
結果は
- Tx署名EOA:
-655695001049112 - beneficiary:
+624450000999120 - signer:
+-0 - Paymasterのdeposit:
+624450000999120
でした。具体的な値はそのときどきで変わってきます。
baseFeePerGasが
8wei、maxPriorityFeePerGasが5000000000weiだったので1Gasあたりのガス代は5000000008weiです。この値で上記の値を割ると
- Tx署名EOAは
655755001049208 / 5000000008 = 131139Gas分支払っていました - beneficiaryには
624450000999120 / 5000000008 = 124890Gas分支払われていました - Paymasterのdepositからは
624450000999120 / 5000000008 = 124890Gas分支払われていました
トランザクション実行にかかったGasは
131139だったので、Tx署名EOAと一致しています。これは通常通りマイナーに支払われていると思います。
beneficiaryについて先ほど読んだソースコードから推察すると、
_validatePrepayment()、innerHandleOp()、_handlePostOp()、_executeUserOp()で使用されたGas分がPaymasterのdepositからbeneficiaryに支払われたことになると思います。
これらの差分
131139 - 124890がそれ以外で使われたGasということになります。
ここまででわかったことを踏まえて、Paymasterがスポンサーになるということはどういうことかをまとめてみます。
- Paymasterのbalanceから直接UserOperation実行のトランザクションのガス代が支払われるわけではない
- 事前にEntryPointのPaymasterのdepositに任意のアカウントから送金しておき、そこから
handleOps()実行時にかかったガス代の一部が引かれる - その分は、
handleOpes()実行時にbeneficiaryとして指定したアカウントに送金される - 通常のトランザクションと同様にトランザクションを発行したアカウントからトランザクション全体でかかったガス代は引かれる
さいごに
これでEntryPointの
handleOps()の全体的な流れとUserOperation実行時のガス代についてわかったかと思います。次回は実際にUserOperationを実行してERC4337のSmart Contract Accountから同様に作成したSmart Contract AccountへNFTをtransferするための準備を行っていきます。
参考
荻原菜穂