はじめに
初めまして。
CryptoGamesというブロックチェーンゲーム企業でエンジニアをしている cardene(かるでね) です!
スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。
代表的なゲームはクリプトスペルズというブロックチェーンゲームです。
今回は、スマートコントラクトアカウント(SCA)で、複数処理の呼び出しをアトミックかつガスコストを節約する方法で実行する仕組みを提案しているERC7638についてまとめていきます!
以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。
他にも様々なEIPについてまとめています。
概要
バッチコールエンコーディング(Batch Calls Encoding = BCE)は、スマートコントラクトアカウント(Smart Contract Account = SCA) ウォレットからのトランザクションを1つのトランザクションにまとめ、パラメータをバイト形式でエンコードしてオンチェーンデータを圧縮してガスコストを節約する手法です。
動機
例えば、AさんがNFTを保有しているときに、BさんがAさんのNFTを他のアドレスに送る必要があるとします。
この時、approve
とtransferFrom
という2つのトランザクションを実行する必要があります。
SCAウォレットでは、この2つトランザクションを1回のトランザクションにまとめる方法を提案しています。
また、approve
かtransferFrom
のどちらかが失敗した場合は全ての処理が失敗するアトミック性も確保しています。
複数のパラメーターをバイト形式にエンコードし、てトランザクションのデータを圧縮することでガスコストを節約します。
さらに、この方式ではアトミック性に加えてガス代の支払いを別のアドレスに委任することもできます。
- ユーザーはSCAを通じて複数のトランザクションを実行したい。
- ユーザーは手数料として
10 USDT
をバンドラーに送金。 - バンドラーは複数のトランザクションをまとめて送信して、ガス代をETHで支払って
10 USDT
を受け取ります。
バンドラーとは、複数のコントラクトから実行したいトランザクションをまとめて実行してくれるEOAアカウントです。
コントラクトは秘密鍵を持っていないため、現状トランザクションを実行することはできません。
そのため、EOAアドレスがトランザクションの実行を代理する必要があります。
この代理実行を行う存在がバンドラーです。
より詳しくは以下の記事などを参考にしてください。
ユーザーは実行するトランザクションデータをエンコードして署名を添付してバンドラーに送ります。
バンドラーは、ガスの支払いが不十分であるときトランザクションを送信しません。
トランザクションデータを承認した場合、署名されたトランザクションを送信して、実行後にバンドラーは手数料を受け取ることができます。
ERC4337でもガス支払いの委任も実装できますが、BCEではSCEとERC4337を同時に実行できます。
ERC4337については以下の記事を参考にしてください。
仕様
この提案では、SCAをコントラクトに実装する必要があります。
DappはSCAウォレット拡張機能と通信してユーザーが行いたい処理(インテント)をウォレットに伝え、ウォレットはBCEを使用して複数のトランザクションデータをユーザーのSCAコントラクトに送ります。
Batch Callは複数のCall
バイトで構成され、各Call
バイトは以下のように「to
value \ data
」のエンコーディングで定義されます。
graph LR
A["to (20bytes)"] --- B["value (32bytes)"] --- C["data length (32bytes)"] --- D["data (bytes)"]
-
to
- 呼び出されたコントラクトのアドレス(20 バイト)。
-
value
- コントラクトに送信された ETH の量 (wei 単位)。
data length
データの長さ(バイト単位)。
- コントラクトに送信された ETH の量 (wei 単位)。
-
data
- コントラクトに送信されたエンコードされた関数データ。
複数のCall
データが連結されてBCEが形成されます。
補足
BCEでは複数の処理を効率的にまとめます。
各処理には以下のパラメータが含まれています。
-
to
- 送付先アドレス。
-
value
-
transfer
するETH量。
-
-
data
- 実行する
Call
データ。
- 実行する
従来のアプローチでは、パラメータを以下のような構造体にまとめます。
struct Call {
address to;
uint256 value;
bytes data;
}
Call[] calls;
ただ、このアプローチでは各パラメータのデータ型も一緒にエンコードされるため、データサイズが大きくなりガスコストが上がります。
BCEでは、各パラメータのデータ型が固定されているため、型情報をエンコードする必要がありません。
これにより、エンコードされたデータサイズが小さくなりガスコストが節約されます。
データを読み込むときは連結したデータを分割(slice)して各パラメータを読み取ります。
互換性
この提案ではコンセンサスレイヤーや他のERC標準を変更しないため、Ethereum全体として互換性の問題はありません。
参考実装
この規格では、Batch Callのエンコーディングのみを提案しており、具体的な実装などは実装者にユダめられています。
以下はSCAコントラクトの例です。
ユーザーは複数の処理をアトミックに署名し、バンドラーがユーザーに代わってガスを支払います。
SmartWallet.sol
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract SmartWallet {
using ECDSA for bytes32;
uint32 public valid = 1; //to make AtomSign invalid
address private immutable original;
address public owner;
address public bundler;
mapping(bytes32 => bool) public usedMsgHashes;
modifier onlyBundler() {
require(
bundler == msg.sender,
"onlyBundler: caller is not the bundler"
);
_;
}
modifier onlyOwnerAndOriginal() {
require(
owner == msg.sender || original == msg.sender,
"onlyOwnerAndOriginal: caller is not the owner"
);
_;
}
constructor(address _bundler) {
original = address(this);
owner = msg.sender;
bundler = _bundler;
}
function atomSignCall(
bytes calldata atomCallbytes,
uint32 deadline,
bytes calldata signature
) external onlyBundler {
require(deadline >= block.timestamp, "atomSignCall: Expired");
bytes32 msgHash = keccak256(
bytes.concat(
msg.data[:msg.data.length - signature.length - 32],
bytes32(block.chainid),
bytes20(address(this)),
bytes4(valid)
)
);
require(!usedMsgHashes[msgHash], "atomSignCall: Used msgHash");
require(
owner == msgHash.toEthSignedMessageHash().recover(signature),
"atomSignCall: Invalid Signature"
);
//do calls
uint i;
while(i < atomCallbytes.length) {
address to = address(uint160(bytes20(atomCallbytes[i:i+20])));
uint value = uint(bytes32(atomCallbytes[i+20:i+52]));
uint len = uint(bytes32(atomCallbytes[i+52:i+84]));
(bool success, bytes memory result) = to.call{value: value}(atomCallbytes[i+84:i+84+len]);
if (!success) {
assembly {
revert(add(result, 32), mload(result))
}
}
i += 84 + len;
}
usedMsgHashes[msgHash] = true;
}
/**
* if you signed something then regretted, make it invalid
*/
function makeAtomSignInvalid() public onlyOwnerAndOriginal {
valid = uint32(uint(blockhash(block.number)));
}
}
上記コントラクトは以下の処理を実行します。
- 複数のコールを1つのトランザクションでまとめて実行。
- 署名によってトランザクションの正当性を検証。
- ガスコストを節約。
- トランザクションが有効であることを検証。
変数とマッピング
-
valid
-
atomSign
の有効性を管理する変数。
-
-
original
- コントラクトのアドレス。
-
owner
- コントラクトの所有者アドレス。
-
bundler
- バンドラーのアドレス。
-
usedMsgHashes
- 既に使用されたメッセージハッシュを管理するマッピング。
Modifiers
-
onlyBundler
- 関数呼び出し元がバンドラーであることを検証。
-
onlyOwnerAndOriginal
- 関数呼び出し元が所有者またはオリジナルコントラクトであることを検証。
コンストラクタ
コントラクトがデプロイされたときに所有者とバンドラーのアドレスを設定します。
atomSignCall
署名を検証して複数のトランザクションをバッチ処理で実行する関数です。
また、関数の実行はバンドラー(特定の権限を持つアカウント)のみ行えます。
-
atomCallbytes
- 実行するコールのデータを含むバイト配列。
-
deadline
- トランザクションが有効である期限を示すタイムスタンプ。
-
signature
- トランザクションの正当性を確認するための所有者の署名。
処理の流れ
-
期限の確認
-
deadline
が現在のブロックタイムスタンプ以上であることを確認します。 - 期限切れの場合はエラーを返します。
-
-
メッセージハッシュの生成
- トランザクションデータ (
msg.data
) の末尾から署名の長さと32バイトを除いた部分に、チェーンID、コントラクトアドレス、valid
値を結合してハッシュを生成します。
- トランザクションデータ (
-
メッセージハッシュの未使用確認
- 生成したメッセージハッシュが未使用であることを確認します。
- 既に使用されている場合はエラーを返します。
-
署名の検証
- メッセージハッシュに基づいて所有者の署名を検証します。
- 署名が正当でない場合はエラーを返します。
-
コールの実行
-
atomCallbytes
を解析して、複数のコールを順に実行します。 - 各コールは、宛先 (
to
)、送信するETHの量 (value
)、コールデータ (data
) を含みます。 - コールが失敗した場合は、エラーメッセージを返して
revert
します。
-
-
メッセージハッシュの記録
- 使用済みのメッセージハッシュとしてデータを記録します。
makeAtomSignInvalid
署名の有効性を無効にする関数です。
これにより、誤って署名したトランザクションを後で無効化します。
この関数は、コントラクトの所有者またはオリジナルのコントラクト(デプロイ時のアドレス)のみが呼び出すことができます。
処理の流れ
-
所有者またはオリジナルコントラクトの確認
- 呼び出し元が所有者またはオリジナルコントラクトであることを確認します。
-
有効性の無効化
-
valid
値を現在のブロックハッシュに基づく値に更新します。
-
Bundler.sol
pragma solidity ^0.8.0;
contract Bundler {
address public owner;
modifier onlyOwner() {
require(
owner == msg.sender,
"onlyOwner: caller is not the owner"
);
_;
}
constructor() {
owner = msg.sender;
}
function executeOperation(
address wallet,
bytes calldata data
) public onlyOwner {
(bool success, bytes memory result) = _callTo.call{value: 0}(data);
if (!success) {
assembly {
revert(add(result, 32), mload(result))
}
}
}
}
executeOperation
コントラクト所有者が指定したウォレットアドレスに対して任意の処理(Call
)を実行する関数です。
実行結果が成功したかどうかを確認し、失敗した場合はエラーメッセージを返してrevert
します。。
-
wallet
- 操作を実行する対象のウォレットアドレス。
-
data
- 実行する操作を指定するバイトコードデータ。
処理の流れ
-
所有者の確認
-
onlyOwner
修飾子を使って、関数呼び出し元がコントラクトの所有者であることを確認します。
-
-
コールの実行
- 指定された
wallet
アドレスに対して、data
を用いてコールを実行します。 - コールは
value
が0
の場合に行われます(=ETH
を送らない)。 - 実行結果として
success
とresult
が返されます。-
success
-
Call
が成功したかどうかの真偽値。
-
-
result
-
Call
の結果として返されるデータ。
-
-
- 指定された
-
エラーハンドリング
-
success
がfalse
の場合、Call
は失敗したことを意味します。 - この場合、アセンブリ言語を使って
result
内のエラーメッセージを取り出し、revert
(例外を発生させてトランザクションを元に戻す)します。
-
Solidityのアセンブリについては以下の記事を参考にしてください。
セキュリティ
この提案では、データ圧縮を目的としたデータエンコーディングスキームを導入しています。
これはデータ圧縮のみに焦点を当てており、データの損失や個人データの隠蔽については焦点を当てていません。
引用
George (@JXRow), Zisu (@lazy1523), "ERC-7638: Batch Calls Encoding in SCA [DRAFT]," Ethereum Improvement Proposals, no. 7638, February 2024. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7638.
最後に
今回は「スマートコントラクトアカウント(SCA)で、複数処理の呼び出しをアトミックかつガスコストを節約する方法で実行する仕組みを提案しているERC7638」についてまとめてきました!
いかがだったでしょうか?
質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!
他の媒体でも情報発信しているのでぜひ他も見ていってください!