はじめに
初めまして。
『DApps開発入門』という本や色々記事を書いているかるでねです。
以下でも情報発信しているので、興味ある記事があればぜひ読んでみてください!
今回は「ECDSAに依存せずトランザクションの検証・実行・ガス支払いをフレーム単位で自由に定義できるFrame Transaction」についてまとめていきます。
従来のEthereumトランザクションはECDSA署名に縛られていましたが、この提案では「フレーム」という抽象単位を導入し、任意の暗号方式でトランザクションを認証できるようにします。
ポスト量子暗号への移行やAccount Abstractionの本来のビジョンを実現する提案であるEIP8141についてまとめていきます!
以下にまとめられているものを解説しながらまとめていきます。
他にも様々なEIP・BIP・SLIP・CAIP・ENSIP・RFC・ACPについてまとめています。
概要
Frame Transactionの仕組み 全体像
従来のEthereumトランザクションは「ECDSA署名で本人確認 → 処理を実行 → 送信者がガス代を支払う」という流れがプロトコルに固定されていました。
Frame Transactionはこの流れを「フレーム」という小さな実行単位に分解して、自由に組み立てられるようにします。
ポイントは3つです。
- 署名方式が自由 — ECDSAに縛られません。ポスト量子暗号、生体認証、マルチシグなど、VERIFYフレーム内で好きな検証ロジックを実装できます
- ガス代の支払いが分離 — 自分で払う(APPROVE(0x3))ことも、Paymasterに代わりに払ってもらう(自分はAPPROVE(0x1)、PaymasterがAPPROVE(0x2))こともできます
- 複数の操作をまとめられる — SENDERモードのフレームを複数並べれば、approveとswapをアトミックに実行するなど、1トランザクションで複数の操作を安全にまとめられます
この仕組みを実現するために、新しいトランザクションタイプ(0x06)と4つの新しいオペコード(APPROVE、TXPARAM、FRAMEDATALOAD、FRAMEDATACOPY)が導入されます。
基本的な考え方
Frame Transactionのコアとなるアイデアは、1つのトランザクションを複数の「フレーム」に分割するということです。
各フレームは独立した実行単位であり、それぞれが「このフレームは何のためにあるか」を示すモードを持っています。
例えば、あるフレームは「トランザクションの署名を検証する」ためのもの、別のフレームは「送信者としてコントラクトを呼び出す」ためのもの、さらに別のフレームは「ガス代を支払う」ためのものです。
これらのフレームが順番に実行されることで、1つのトランザクション全体の検証・実行・支払いが完了します。
従来のトランザクションでは「署名検証 → 実行 → ガス支払い」がすべてプロトコルにハードコードされていましたが、Frame Transactionではこの流れをユーザーが自由に組み立てられます。
ECDSA以外の署名方式を使いたければ、VERIFYモードのフレームにその検証ロジックを実装すれば良いのです。
ERC-4337との違い
ERC-4337はAccount Abstractionの方向性を推進した重要な提案ですが、プロトコル外(オフチェーン)のインフラに依存していました。
BundlerやEntryPointコントラクトなど追加の仕組みが必要であり、結果としてデータ効率やプロトコルとの統合度において課題が残っています。
ERC-4337については以下の記事を参考にしてください。
EIP8141のFrame Transactionは、これらの課題をプロトコルレベルで解決します。
トランザクション自体に「フレーム」という抽象単位を導入し、検証・実行・ガス支払いのそれぞれをEVM上で自由に定義できる仕組みです。
以下の図はERC-4337とEIP-8141のアーキテクチャの違いを示しています。
ERC-4337はBundlerやEntryPointコントラクトなどコントラクトレベルの追加インフラが必要でしたが、EIP-8141はプロトコルにネイティブに組み込まれるため、これらの中間層が不要になります。
動機
ECDSA依存の問題
現在のEthereumでは、すべてのトランザクションがECDSA(Elliptic Curve Digital Signature Algorithm)という楕円曲線暗号方式で署名されています。
アカウントのアドレスはECDSAの公開鍵から生成され、トランザクションの正当性もECDSA署名で検証されます。
ECDSA(楕円曲線デジタル署名アルゴリズム)
Ethereumで使われている署名方式で、秘密鍵でトランザクションに署名し、公開鍵で検証します。
楕円曲線上の離散対数問題が解けないことを安全性の根拠としていますが、量子コンピュータが実用化されるとこの前提が崩れる可能性があります。
しかし、この仕組みにはいくつかの問題があります。
ポスト量子暗号への移行手段がない
量子コンピュータの発展により、ECDSAの安全性が将来的に脅かされる可能性があります。
ECDSAが使っているsecp256k1という楕円曲線は量子コンピュータに対して脆弱であり、ポスト量子(Post-Quantum)セキュアな暗号方式への移行手段がプロトコルレベルで用意されていません。
secp256k1
Ethereumが使用している楕円曲線のパラメータセットです。
Bitcoinでも同じ曲線が使われています。
量子コンピュータが十分な規模になると、この曲線に基づく署名は短時間で偽造可能になると考えられています。
Account Abstractionの本来のビジョン
Account Abstraction(アカウント抽象化)の本来のビジョンがまだ実現されていないという問題があります。
Account Abstractionとは、アカウントを特定の鍵方式に縛らず、自由にその認証方式や支払い方式を定義できるようにする考え方です。
本来は「アカウント = コードを持つアドレス」であり、そのコードがどのように署名を検証するかはアカウントの自由であるべきです。
Account Abstraction(アカウント抽象化)
Ethereumのアカウントを、特定の署名方式やガス支払い方式に縛られない柔軟な仕組みにすることを目指す概念です。
ソーシャルリカバリ、マルチシグ、生体認証など、ユーザーの用途に合わせた認証方式を自由に選べるようになります。
仕様
トランザクション構造
フレームの3つのモード
各フレームはmodeフィールドを持ち、その下位8ビットで実行モードが決まります。
この提案では3つのモードが定義されています。以下の図で各モードの特徴を比較します。
| モード | 値 | 呼び出し元 | 役割 |
|---|---|---|---|
DEFAULT |
0 |
ENTRY_POINT(address(0xaa)) |
汎用的な呼び出しを実行する。アカウントデプロイなどに使う |
VERIFY |
1 |
ENTRY_POINT(address(0xaa)) |
トランザクションの検証を行う。署名確認とAPPROVEの呼び出しが必須 |
SENDER |
2 | tx.sender |
送信者の名義でコントラクトを呼び出す。事前にsender_approvedがtrueでなければ使えない |
DEFAULTモードは、ENTRY_POINTアドレス(address(0xaa))からの通常のコール呼び出しとして実行されます。
主にスマートアカウントのデプロイに使われます。
VERIFYモードは、トランザクションの認証を担当するフレームです。
STATICCALLと同じくステートを変更できず、実行中にAPPROVEオペコードを呼ばなければトランザクション全体が無効になります。
また、このモードのフレームのdataは署名ハッシュ計算から除外され、他のフレームからも読み取れません。
これは署名データ自体が含まれるフレームなので、署名の計算対象に含めることができないためです。
SENDERモードは、送信者として振る舞うフレームです。
msg.senderがtx.senderに設定されるため、送信者のアカウントとしてコントラクトを操作できます。
ただし、事前にVERIFYフレームでAPPROVE(0x1)またはAPPROVE(0x3)が呼ばれてsender_approvedがtrueになっていなければ、トランザクションは無効です。
モードフラグ
modeフィールドの上位ビット(9ビット目以降)は、実行環境の設定に使われます。
APPROVEオペコードの概要
Frame Transactionでは、トランザクションの「送信者の認証」と「ガス代の支払い」をVERIFYフレーム内で明示的に承認する必要があります。この承認を行うのがAPPROVEという新しいオペコードです。
APPROVEには3つのスコープ(承認範囲)があります。
-
0x1→ 「自分が送信者です」と宣言する(実行承認) -
0x2→ 「自分がガス代を払います」と宣言する(支払い承認) -
0x3→ 両方を同時に宣言する
モードフラグのApproval scopeは、このスコープのうちどれを使えるかを制約する仕組みです。APPROVEの詳細はオペコードセクションで解説します。
| ビット位置 | 意味 | 有効なモード |
|---|---|---|
| 9-10 | Approval scope(承認範囲の制約) | すべてのモード |
| 11 | Atomic batch(アトミックバッチ) |
SENDERモードのみ |
ビット9-10はAPPROVEオペコードで指定できるscopeを制約します。
例えば、ビット9-10が1の場合、そのフレーム内ではAPPROVE(0x1)(実行承認のみ)しか使えません。
ビット11はAtomic batchフラグで、後述するアトミックバッチ機能を有効にします。
RLPシリアライゼーション
Frame Transactionは、新しいトランザクションタイプ0x06としてEIP-2718のTyped Transaction Envelopeに従います。
EIP-2718(Typed Transaction Envelope)
Ethereumのトランザクションに「タイプ」を付ける仕組みです。
トランザクションの先頭1バイトでタイプを示し、タイプごとに異なるペイロード構造を許容します。
EIP-1559(タイプ2)やEIP-4844(タイプ3)もこの仕組みを使っています。
ペイロードは以下のRLPシリアライゼーションで定義されます。
[chain_id, nonce, sender, frames, max_priority_fee_per_gas, max_fee_per_gas, max_fee_per_blob_gas, blob_versioned_hashes]
frames = [[mode, target, gas_limit, data], ...]
RLP(Recursive Length Prefix)
Ethereumで使われるデータのエンコード方式です。
任意のネストした配列やバイト列を、長さのプレフィックスを使って一意にバイト列に変換します。
トランザクションやブロックヘッダのシリアライゼーションに広く使われています。
従来のトランザクションとの大きな違いは、senderフィールドが明示的に含まれていることです。
ECDSAトランザクションでは署名から送信者アドレスを復元できましたが、Frame Transactionでは任意の署名方式を許容するため、送信者を明示的に指定します。
また、valueフィールドがありません。
送信者のアカウントコードがETHの送信を処理できるため、プロトコルレベルでvalueを持つ必要がないからです。
各フレームはmode、target(呼び出し先アドレス)、gas_limit、dataの4つのフィールドで構成されます。
targetがnullの場合、呼び出し先はtx.senderになります。
blobを含まないトランザクションの場合、blob_versioned_hashesは空リスト、max_fee_per_blob_gasは0でなければなりません。
従来のトランザクションとFrame Transactionの構造比較
従来のEIP-1559トランザクション(タイプ2)と比べると、何が変わったかがわかりやすくなります。
| 項目 | 従来(EIP-1559) | Frame Transaction |
|---|---|---|
| 送信者の特定 | ECDSA署名から復元 |
senderフィールドで明示 |
| 送金額 |
valueフィールド |
なし(アカウントコードで処理) |
| 署名 | 固定(v, r, s) | フレーム内の任意ロジック |
| ガス支払い | 送信者が自動で支払い | APPROVE(0x2)を呼んだアカウントが支払い |
| 実行内容 | 1つのto + data
|
複数フレームで自由に構成 |
従来のトランザクションは「1つの宛先に1つの操作」でしたが、Frame Transactionは「複数のフレームを組み合わせて検証・実行・支払いを自由に構成」できます。
制約条件
トランザクションの構造には、静的に検証可能な制約がいくつかあります。
以下のPython擬似コードで示します。
assert tx.chain_id < 2**256
assert tx.nonce < 2**64
assert len(tx.frames) > 0 and len(tx.frames) <= MAX_FRAMES
assert len(tx.sender) == 20
assert (tx.frames[n].mode & 0xFF) < 3
assert len(tx.frames[n].target) == 20 or tx.frames[n].target is None
# Atomic batchフラグ(ビット11)はSENDERモードでのみ有効
# 次のフレームもSENDERモードでなければならない
for i, frame in enumerate(tx.frames):
if (frame.mode >> 10) & 1 == 1:
assert (frame.mode & 0xFF) == 2 # SENDERモードであること
assert i + 1 < len(tx.frames) # 最後のフレームではないこと
assert (tx.frames[i + 1].mode & 0xFF) == 2 # 次のフレームもSENDERモード
フレーム数は1以上MAX_FRAMES(1000)以下です。
Atomic batchフラグはSENDERモードでのみ有効であり、フラグが設定されたフレームの次のフレームもSENDERモードでなければなりません。
modeフィールドのビット構造
modeフィールドは単なるモード番号ではなく、ビット単位で意味が分かれています。
| ビット位置 | 名前 | 値の範囲 | 意味 |
|---|---|---|---|
| 0〜7 | 実行モード | 0〜2 | 0=DEFAULT、1=VERIFY、2=SENDER |
| 9〜10 | Approval scope | 0〜3 | APPROVEで使えるscopeの制限 |
| 11 | Atomic batch | 0 or 1 | 1ならアトミックバッチの一部 |
例えば mode = 0x802 の場合、以下のように分解できます。
| ビット位置 | 値 | 意味 |
|---|---|---|
| 0〜7 | 0x02 |
SENDERモード |
| 9〜10 | 0x00 |
scope制限なし |
| 11 |
0x1(ON) |
Atomic batchフラグON |
1つの整数にモード、承認スコープ制限、アトミック指定の3つの情報が詰め込まれている設計です。
レシートとSignature Hash
Frame Transactionのレシートは、従来のトランザクションレシートとは異なる構造を持ちます。
[cumulative_gas_used, payer, [frame_receipt, ...]]
frame_receipt = [status, gas_used, logs]
payerフィールドが追加されている点が特徴的です。
Frame Transactionでは誰がガス代を支払ったかをトランザクションのペイロードだけでは静的に判定できないため、レシートに記録しておく必要があります。
各フレームのレシートにはstatus(成功/失敗)、gas_used、logsが含まれます。
署名ハッシュの計算では、VERIFYモードのフレームのdataが空に置き換えられます。
def compute_sig_hash(tx):
for i, frame in enumerate(tx.frames):
if (frame.mode & 0xFF) == VERIFY:
tx.frames[i].data = Bytes()
return keccak(rlp(tx))
VERIFYフレームのdataには署名データ自体が含まれるため、署名ハッシュに含めることはできません。
また、将来的に複数のVERIFYフレームの暗号操作を集約(アグリゲート)する可能性も考慮した設計です。
さらに、ガススポンサリングのワークフローでは、送信者が署名した後にスポンサーの入力データを追加する必要があるため、VERIFYフレームのdataは意図的に署名対象外として変更可能にしています。
ただし、VERIFYフレームのtargetは署名ハッシュに含まれるため、送信者はスポンサーのアドレスを明示的に選択する形になります。
なぜVERIFYフレームのdataだけ署名ハッシュから除外するのか
署名ハッシュの計算フローを図で示します。
3つの理由があります。
- VERIFYフレームのdataには署名そのものが入っているため、「署名を含んだデータのハッシュに対して署名する」という循環になってしまいます
- Paymasterの
dataを署名ハッシュから除外することで、ユーザーが署名した後にPaymasterが自分のデータを追加できます。ユーザーが署名し直す必要がありません - ただし
target(誰に検証を依頼するか)は署名ハッシュに含まれるため、ユーザーは「どのPaymasterを使うか」を明示的に選んでいます
オペコード
APPROVE(0xaa)
APPROVEはFrame Transactionで最も重要な新しいオペコードです。
RETURNと似た動きをしますが、単にフレームを終了するだけでなく、トランザクションスコープの承認状態を更新します。
スタックから3つの値を取ります。
| スタック位置 | 値 |
|---|---|
top - 0 |
offset(メモリオフセット) |
top - 1 |
length(データ長) |
top - 2 |
scope(承認スコープ) |
scopeは承認の種類を示す値です。
| scope | 意味 | 条件 |
|---|---|---|
0x1 |
実行承認 |
sender_approved = trueを設定。frame.targetがtx.senderと一致する時のみ有効 |
0x2 |
支払い承認 | nonceをインクリメントし、ガスコスト全額を徴収してpayer_approved = trueを設定。sender_approvedが事前にtrueでなければ無効 |
0x3 |
実行+支払い承認 |
0x1と0x2の両方を同時に実行。frame.targetがtx.senderと一致する時のみ有効 |
APPROVEが呼び出せるのはframe.targetのアドレスで実行中のコードのみです。
ADDRESSがframe.targetと一致しなければrevertします。
これは、トランザクションプールや他のフレームがVERIFYフレームの動作を推論しやすくするための設計です。
重要なポイントとして、sender_approvedとpayer_approvedはそれぞれ一度だけtrueに設定できます。
既にtrueの状態で再度APPROVEしようとするとrevertします。
また、支払い承認(0x2)は実行承認(0x1)の後でなければ呼べません。
つまり、送信者が先にトランザクションを承認し、その後で支払い者がガス代を引き受けるという順序が強制されます。
modeフィールドのビット9-10がApproval scopeを制約することで、フレームが許可する承認範囲をさらに限定できます。
| ビット9-10の値 | 使えるscope |
|---|---|
| 0 | すべてのscope |
| 1 |
0x1のみ |
| 2 |
0x2のみ |
| 3 |
0x3のみ |
承認フローの全体像
Frame Transactionでは「誰が送ったか(実行承認)」と「誰がガスを払うか(支払い承認)」を別々に承認します。
自分でガスも払う場合はAPPROVE(0x3)で両方を一度に承認できます。Paymasterに払ってもらう場合は、自分はAPPROVE(0x1)で送信者承認だけ行い、PaymasterがAPPROVE(0x2)でガス支払いを引き受けます。
TXPARAM(0xb0)
TXPARAMは、トランザクションのヘッダ情報やフレーム情報にアクセスするためのオペコードです。
ガスコストは2です。
スタックからparamとin2の2つの値を取り、対応する情報をスタックに返します。
以下のパラメータテーブルで取得できる情報の一覧を示します。
param |
in2 |
返される値 |
|---|---|---|
0x00 |
0固定 | 現在のトランザクションタイプ |
0x01 |
0固定 | nonce |
0x02 |
0固定 | sender |
0x03 |
0固定 | max_priority_fee_per_gas |
0x04 |
0固定 | max_fee_per_gas |
0x05 |
0固定 | max_fee_per_blob_gas |
0x06 |
0固定 | 最大コスト(basefeeを最大値として計算。blobコストとintrinsicコストを含む) |
0x07 |
0固定 |
blob_versioned_hashesの数 |
0x08 |
0固定 |
compute_sig_hash(tx)(署名ハッシュ) |
0x09 |
0固定 |
framesの数 |
0x10 |
0固定 | 現在実行中のフレームインデックス |
0x11 |
フレームインデックス | target |
0x12 |
フレームインデックス | gas_limit |
0x13 |
フレームインデックス |
mode(下位8ビット) |
0x14 |
フレームインデックス |
dataの長さ(VERIFYフレームの場合は0を返す) |
0x15 |
フレームインデックス |
status(現在のフレームまたは未来のフレームを指定すると例外停止) |
0x16 |
フレームインデックス |
scope(ビット9-10) |
0x17 |
フレームインデックス |
atomic_batch(ビット11、0または1を返す) |
0x08の署名ハッシュを取得できるのが非常に重要です。
EVMの中で署名ハッシュを一から計算するのは複雑でガスコストが高いため、プロトコルが計算済みの署名ハッシュを提供することでスマートアカウントの開発を大幅に簡素化しています。
署名ハッシュを使うことは必須ではありませんが、使わない場合はTransaction Malleability(第三者がトランザクションの一部を改変できる脆弱性)に対処するために、トランザクションデータへの正確なコミットメントを自前で実装する必要があります。
定義されていないparam値を指定すると例外停止になります。
また、status(0x15)で現在実行中のフレームや未来のフレームのステータスを取得しようとしても例外停止します。
FRAMEDATALOADとFRAMEDATACOPY
他のフレームのdataにアクセスするための2つのオペコードも導入されます。
FRAMEDATALOAD(0xb1)は、指定したフレームのdataから32バイトを読み込みます。
ガスコストは3(CALLDATALOADと同じ)です。
スタックからoffsetとframeIndexを取り、読み込んだデータをスタックに置きます。
FRAMEDATACOPY(0xb2)は、指定したフレームのdataをメモリにコピーします。
ガスコストはCALLDATACOPYと同じ(固定コスト3 + メモリ拡張・コピーの可変コスト)です。
スタックからmemOffset、dataOffset、length、frameIndexの4つの値を取ります。
いずれのオペコードでも、VERIFYモードのフレームを対象にした場合はデータが返されません(FRAMEDATALOADはゼロ、FRAMEDATACOPYはコピーしない)。
これは、署名データのプライバシーを保護し、将来的な署名アグリゲーションを可能にするための設計です。
実行フロー
フレームごとの実行ステップ
以下の図はFrame Transactionの実行フロー全体を示しています。
Frame Transactionの実行は以下の手順で進みます。
まず、tx.nonceがstate[tx.sender].nonceと一致するか確認します(ステートフル検証)。
次に、トランザクションスコープの変数を初期化します。
payer_approved = False
sender_approved = False
そして各フレームを順番に実行します。
以下のフロー図はFrame Transactionの実行の流れを示しています。
各フレームは指定されたmode、target、gas_limit、dataで実行されます。
targetがnullの場合はtx.senderが呼び出し先になります。
SENDERモードの場合はmsg.senderがtx.senderに設定され、DEFAULT/VERIFYモードの場合はENTRY_POINTがmsg.senderになります。
ORIGINオペコードは、Frame Transactionではフレームのcallerを返します。
つまり、DEFAULT/VERIFYモードではENTRY_POINT、SENDERモードではtx.senderが返されます。
これはすべてのコールの深さにおいて適用されます。
frame.targetにコードがないアカウント(EOA)の場合は、後述する「Default Code」のロジックが実行されます。
フレーム間で共有されるもの・されないもの
| 項目 | フレーム間の扱い | 具体例 |
|---|---|---|
| warm/coldアクセス記録 | 共有される | フレーム0でアカウントAを読み込むと、フレーム1ではアカウントAはwarm扱い(ガス割引が適用される) |
| Transient Storage | 破棄される | フレーム0でTSTOREした値はフレーム1ではTLOADできない |
EVMではアカウントやストレージに初めてアクセスする時(cold)と2回目以降(warm)でガスコストが異なります。Frame Transactionでは、あるフレームでアクセスしたアカウントは後続フレームでもwarm扱いになるため、繰り返しアクセスのガスコストが節約されます。
一方、Transient Storage(トランザクション内だけの一時ストレージ)はフレーム境界でクリアされるため、フレーム間のデータ受け渡しには使えません。
Atomic Batching
Atomic Batching(アトミックバッチ)
複数の操作を「全部成功するか、全部取り消すか」のどちらかにする仕組みです。
例えば、ERC-20のapproveとswapを1つのバッチにまとめると、swapが失敗した場合にapproveも自動的に取り消されます。
これにより、中途半端なステートでアカウントが放置されるリスクを防ぎます。
連続するSENDERモードのフレームで、最後のフレーム以外にAtomic batchフラグ(ビット11)が設定されているものは、1つの「アトミックバッチ」を形成します。
バッチ内のいずれかのフレームがrevertすると、バッチ内の先行フレームのステート変更もすべて取り消され、後続フレームはスキップされます。
具体的な実行は以下の手順です。
- バッチの最初のフレーム実行前にステートのスナップショットを取る
- バッチ内の各フレームを順番に実行する
- いずれかのフレームがrevertした場合、スナップショットまでステートを巻き戻し、残りのフレームをスキップする
以下の例で動きを確認します。
| フレーム | モード | Atomic Batchフラグ |
|---|---|---|
| 0 | SENDER | あり |
| 1 | SENDER | なし |
| 2 | SENDER | あり |
| 3 | SENDER | あり |
| 4 | SENDER | なし |
フレーム0-1が1つ目のバッチ、フレーム2-4が2つ目のバッチです。
もしフレーム3がrevertした場合、フレーム2と3のステート変更が破棄され、フレーム4はスキップされます。
一方、フレーム0-1のバッチは独立しているため影響を受けません。
この仕組みがないと、例えばapproveとswapを別々のフレームで実行した時に、swapが失敗してもapproveだけが残ってしまい、アカウントが不要な承認状態のまま放置されるリスクがあります。
フラグを使ったシンプルな設計により、新しいモードを追加することなくバッチの境界を明確に示せます。
Atomic Batchingがないと何が困るか
例えばDEXでトークンをスワップする時、通常は2つの操作が必要です。
この2つが別々のフレームで実行され、swapが失敗した場合を考えます。
Atomic Batchingがなければ、approveだけが成功した状態で残ります。つまり、DEXにトークンを使う許可を与えたまま、実際には交換していない状態です。悪意のあるコントラクトがこの許可を悪用する可能性があります。
Atomic Batchingを使うと、swapが失敗した場合にapproveも自動的に取り消されます。「全部成功するか、全部なかったことにするか」のどちらかしかありません。
Default Code(EOA対応)
Frame Transactionでは、コードを持たないアカウント(EOA)でもフレームを実行できるように「Default Code」が定義されています。
この仕組みにより、現在のEOAユーザーもスマートアカウントに移行することなく、ガス抽象化の恩恵を受けられます。
Default Codeの動作をPython擬似コードで示します。
DEFAULT = 0
VERIFY = 1
SENDER = 2
SECP256K1 = 0x0
P256 = 0x1
def default_code(frame, tx):
mode = frame.mode & 0xFF
if mode == VERIFY:
scope = (frame.mode >> 8) & 3
if scope == 0:
revert()
signature_type = frame.data[0]
sig_hash = compute_sig_hash(tx)
if signature_type == SECP256K1:
# frame.data: [signature_type(1byte), v(1byte), r(32bytes), s(32bytes)]
if len(frame.data) != 66:
revert()
v = frame.data[1]
r = frame.data[2:34]
s = frame.data[34:66]
if frame.target != ecrecover(sig_hash, v, r, s):
revert()
elif signature_type == P256:
# frame.data: [signature_type(1byte), r(32bytes), s(32bytes), qx(32bytes), qy(32bytes)]
if len(frame.data) != 129:
revert()
r = frame.data[1:33]
s = frame.data[33:65]
qx = frame.data[65:97]
qy = frame.data[97:129]
if frame.target != keccak256(qx + qy)[12:]:
revert()
if not P256VERIFY(sig_hash, r, s, qx, qy):
revert()
else:
revert()
APPROVE(scope)
elif mode == SENDER:
if frame.target != tx.sender:
revert()
# frame.data: RLPエンコードされた [[target, value, data], ...]
calls = rlp_decode(frame.data)
for call_target, call_value, call_data in calls:
result = evm_call(caller=tx.sender, to=call_target, value=call_value, data=call_data)
if result.reverted:
revert()
elif mode == DEFAULT:
revert()
else:
revert()
Default Codeは3つのモードに対してそれぞれ異なる動作をします。
VERIFYモードでは、frame.dataの先頭バイトで署名タイプを判定します。
0x0の場合はECDSA(secp256k1)で検証し、0x1の場合はP256で検証します。
P256(secp256r1)
NISTが標準化した楕円曲線で、WebAuthnやiOS/AndroidのSecure Enclaveで広く使われています。
量子コンピュータに対する安全性はsecp256k1と同程度ですが、ハードウェアレベルのサポートが充実しており、将来的により安全な暗号方式へのブリッジとしても機能します。
P256アドレスは公開鍵(qx, qy)のkeccakハッシュの下位20バイトで決まります。
EOAがFrame Transactionを使える仕組み
通常のEOA(コードを持たないアカウント)でもFrame Transactionが使えます。コードがないアカウントがフレームのtargetになった場合、プロトコルが自動的に「Default Code」を実行します。
つまり、既存のEOAユーザーもFrame Transactionを使ってPaymasterによるガス代肩代わりやERC-20でのガス支払いができます。さらにP256署名もサポートしているため、将来のポスト量子暗号への移行パスも確保されています。
ECDSA検証では、データは66バイト(タイプ1バイト + v,r,s合計65バイト)です。
ecrecoverで復元したアドレスがframe.targetと一致するか確認します。
P256検証では、データは129バイト(タイプ1バイト + r,s,qx,qy合計128バイト)です。
公開鍵のkeccakハッシュからアドレスを導出し、frame.targetと一致するか確認した上で、P256VERIFYプリコンパイルで署名を検証します。
SENDERモードでは、frame.dataをRLPデコードして呼び出しリスト[[target, value, data], ...]を取得し、それぞれをtx.senderとして順に実行します。
DEFAULTモードではrevertします。
EOAにとってDEFAULTモードで実行する意味がないためです。
ガス計算
トランザクション全体のガスリミット
Frame Transactionのガスリミットは以下の式で計算されます。
tx_gas_limit = FRAME_TX_INTRINSIC_COST + calldata_cost(rlp(tx.frames)) + sum(frame.gas_limit)
FRAME_TX_INTRINSIC_COSTは15,000に固定されています。
calldata_costは標準的なEVMルール(ゼロバイトは4ガス、非ゼロバイトは16ガス)で計算されます。
各フレームのgas_limitの合計がフレーム実行に使えるガスの総量になります。
フレームごとのガス配分
各フレームには独立したgas_limitが割り当てられます。
あるフレームで使い切らなかったガスは、次のフレームには引き継がれません。
これはフレーム間のガス依存を排除し、各フレームの実行を予測可能にするための設計です。
リファンド
すべてのフレームの実行が完了した後、使われなかったガスは支払い者に返却されます。
refund = sum(frame.gas_limit) - total_gas_used
リファンドはAPPROVE(0x2)またはAPPROVE(0x3)を呼んだアカウント(ガス支払い者)に返され、ブロックのガスプールにも戻されます。
このリファンドの仕組みはEIP-3529のストレージリファンドとは別の仕組みです。
トランザクション全体の手数料は以下のように計算されます。
tx_fee = tx_gas_limit * effective_gas_price + blob_fees
blob_fees = len(blob_versioned_hashes) * GAS_PER_BLOB * blob_base_fee
effective_gas_priceはEIP-1559に従って計算され、blob手数料はEIP-4844に従って計算されます。
Mempool
Validation Prefix
Frame Transactionはトランザクションプール(mempool)の運用において、特別な注意が必要です。
検証ロジックが任意のEVMコードであるため、悪意のあるトランザクションがノードのリソースを無駄遣いさせるDoS攻撃のベクトルになり得ます。
この提案ではERC-7562に着想を得つつ、ステーキングやレピュテーションの仕組みを完全に排除したmempoolポリシーを定義しています。
ERC-7562
Account Abstractionのバリデーションルールを定義した規格です。
ERC-4337のBundlerが従うべきルールとして策定されましたが、ステーキングやレピュテーションに基づく例外を許容していました。
EIP8141ではこれらの例外を排除し、より厳格なルールを適用しています。
mempoolルールの核となるのが「Validation Prefix」(検証プレフィックス)という概念です。
Validation Prefixとは、フレームリストの先頭からpayer_approved = trueが設定されるまでの最短のフレーム列を指します。
mempoolのルールはこのValidation Prefixにのみ適用され、支払い承認後のフレーム(ユーザーの操作やpost-opなど)は自由に構成できます。
なぜValidation Prefixが必要なのか
ノードがトランザクションを受け取った時、そのトランザクションが有効かどうかを検証する必要があります。しかしFrame Transactionでは検証ロジックが任意のEVMコードなので、悪意のあるトランザクションがノードの計算リソースを浪費させる可能性があります。
Validation Prefixとは「payer_approved = trueになるまでの最短フレーム列」のことです。この部分だけを検証し、ガス上限(100,000)内で完了しなければ拒否します。payer_approved = true以降のフレーム(ユーザー操作やPost-op)はmempool検証の対象外です。
4つの認識されるプレフィックス
パブリックmempoolが受け入れるValidation Prefixは、以下の4パターンに限定されます。以下の図で4パターンの全体像を示します。
緑の点線で囲まれた部分がValidation Prefixです。グレーのフレーム(user_op / post_op)はValidation Prefix外で、mempool検証の対象外です。
Self Relay
Self Relayは、送信者自身がガス代も支払うパターンです。
基本パターンはself_verifyの1フレームだけです。
+-------------+
| self_verify |
+-------------+
新しいアカウントをデプロイする場合は、先頭にデプロイフレームが追加されます。
+--------+-------------+
| deploy | self_verify |
+--------+-------------+
Canonical Paymaster
Paymaster(ペイマスター)
ユーザーの代わりにガス代を支払ってくれるコントラクトです。
ユーザーはETHを持っていなくても、Paymasterがガス代を立て替えることでトランザクションを実行できます。
ERC-20トークンでの支払いなど、柔軟なガス支払いスキームを実現します。
Paymasterによるガス代肩代わりの流れ
例えばユーザーがETHを持っていないけどUSDCは持っている場合、Paymasterを使ってUSDCでガス代を支払えます。
PaymasterはFrame 1でユーザーのUSDC残高を確認し、次のフレームが適切な送金であることを確認した上でAPPROVE(0x2)を呼びます。ETHでのガス代はPaymasterが立て替え、ユーザーからはUSDCで回収します。
Canonical Paymasterは、送信者の検証と支払い者の検証を分離するパターンです。
基本パターンでは、only_verifyで送信者を検証し、payで支払いを承認します。
+-------------+-----+
| only_verify | pay |
+-------------+-----+
新しいアカウントのデプロイを含む場合はこうなります。
+--------+-------------+-----+
| deploy | only_verify | pay |
+--------+-------------+-----+
Validation Prefix以降のフレームは自由に構成できます。
例えば、user_op(SENDERモード)やpost_op(DEFAULTモード)を任意の数だけ追加できます。
各フレームのサブ分類を整理すると以下のようになります。
| 名前 | モード | 役割 |
|---|---|---|
self_verify |
VERIFY | 送信者と支払いの両方を承認する |
deploy |
DEFAULT | 既知のデプロイヤーでスマートアカウントをデプロイする |
only_verify |
VERIFY | 送信者のみを承認する |
pay |
VERIFY | 支払いのみを承認する |
user_op |
SENDER | ユーザーの操作を実行する |
post_op |
DEFAULT | Paymasterの後処理を実行する |
Validation Prefix全体のガスリミット合計はMAX_VERIFY_GAS(100,000)以下でなければなりません。
ノードはpayer_approved = trueを検出した時点でシミュレーションを即座に停止します。
Paymasterの仕組み
Paymasterはユーザーのガス代を肩代わりするコントラクトですが、1つのPaymasterが多数のアカウントのガスをスポンサーすると、そのPaymasterの残高やストレージの変更によって多数のトランザクションが一度に無効化されるリスクがあります。
これはmempoolのDoS攻撃のベクトルになります。
この提案では、CanonicalとNon-canonicalの2種類のPaymasterで異なるルールを適用することで、この問題に対処しています。
Canonical Paymaster
Canonical Paymasterは、パブリックmempoolで安全に使えるように標準化されたPaymasterコントラクトです。
シングルトンではなく複数のインスタンスがデプロイ可能で、payフレームのターゲットのランタイムコードがCanonical Paymaster実装と完全一致する場合にCanonicalと認識されます。
Canonical Paymasterの重要な特性は、入金されたETHが以下の2つの方法でしか出金できないことです。
- トランザクションのガス代としての支払い
- 遅延期間を経た後の引き出し
この制約により、Canonical Paymasterの残高は予測可能であり、多数のトランザクションをスポンサーしても安全です。
ノードは各Canonical Paymasterの利用可能残高を以下のように管理します。
available_paymaster_balance = state.balance(paymaster) - reserved_pending_cost(paymaster) - pending_withdrawal_amount(paymaster)
reserved_pending_costは、そのPaymasterを使っているpending中のトランザクションの最大コスト合計です。
pending_withdrawal_amountは、遅延引き出し中の金額です。
利用可能残高がトランザクションの最大コスト未満であれば、そのトランザクションは拒否されます。
Canonical Paymasterは明示的に安全な設計になっているため、ノードは汎用的なバリデーショントレースやオペコードルールをそのフレームに適用する必要がありません。
ランタイムコードの一致とAPPROVE(0x1)(ここでの0x1はApproval of paymentのscope 0x2に対するpayフレームの役割。payフレーム自体はAPPROVE(0x1)でなくAPPROVE(0x2)を呼ぶ)の成功、そしてPaymaster固有の会計ルールで判定します。
Non-canonical Paymaster
Non-canonical Paymasterは、遅延引き出しの仕組みがない任意のPaymasterコントラクトです。
その代わり、同時にスポンサーできるpendingトランザクション数をMAX_PENDING_TXS_USING_NON_CANONICAL_PAYMASTER(1件)に制限することで安全性を確保しています。
Non-canonical Paymasterを使うトランザクションが受け入れられるには、以下の2つの条件を満たす必要があります。
- 利用可能残高がトランザクションの最大コスト以上であること
available_paymaster_balance = state.balance(paymaster) - reserved_pending_cost(paymaster)
- そのPaymasterを使っているpendingトランザクション数が
MAX_PENDING_TXS_USING_NON_CANONICAL_PAYMASTER未満であること
Non-canonical Paymasterの主なユースケースは、ユーザーが「ガスアカウント」として使う個人的なPaymasterです。
例えば、ETHを保有するアカウントを1つだけ用意し、ステーブルコインやNFTしか持っていない他のアカウントからのトランザクションのガス代を立て替えるといった使い方ができます。
Default Codeのおかげで、任意のEOAをPaymasterとして使うこともできます。
データ効率
各パターンのバイトサイズ比較
Frame Transactionの重要な利点の1つは、ERC-4337と比較したデータ効率の良さです。
ERC-4337ではUserOperationの各フィールドが32バイト境界でABIエンコードされるため、オーバーヘッドが大きくなりがちでした。
Frame Transactionではフレームデータがコンパクトに格納されます。
以下はスマートアカウントからETHを送金する基本的なトランザクションのバイトサイズです。
| フィールド | バイト数 |
|---|---|
| Txラッパー | 1 |
| Chain ID | 1 |
| Nonce | 2 |
| Sender | 20 |
| Max priority fee | 5 |
| Max fee | 5 |
| Max fee per blob gas | 1 |
| Blob versioned hashes(空) | 1 |
| Framesラッパー | 1 |
| 検証フレーム target | 1 |
| 検証フレーム gas | 2 |
| 検証フレーム data | 65 |
| 検証フレーム mode | 1 |
| 実行フレーム target | 1 |
| 実行フレーム gas | 1 |
| 実行フレーム data | 25 |
| 実行フレーム mode | 1 |
| 合計 | 134 |
nonceは65,536未満、手数料は1,099 gwei未満、検証フレームのtargetはtx.sender(1バイトで表現)、ECDSA署名は65バイトと仮定しています。
EIP-1559トランザクションと比較しても大きな差はなく、追加のオーバーヘッドは送信者と金額をcalldataで明示的に指定する分だけです。
新しいアカウントをデプロイする場合は、追加で以下のサイズが必要です。
| フィールド | バイト数 |
|---|---|
| デプロイフレーム target | 20 |
| デプロイフレーム gas | 3 |
| デプロイフレーム data | 100 |
| デプロイフレーム mode | 1 |
| 追加合計 | 124 |
gasは2^24未満、dataは小さなプロキシコントラクトを仮定しています。
ERC-20でガスを支払うスポンサー付きの場合、追加で以下のサイズが必要です。
| フィールド | バイト数 |
|---|---|
| スポンサー検証フレーム target | 20 |
| スポンサー検証フレーム gas | 3 |
| スポンサー検証フレーム calldata | 0 |
| スポンサー検証フレーム mode | 1 |
| スポンサー送金フレーム target | 20 |
| スポンサー送金フレーム gas | 3 |
| スポンサー送金フレーム calldata | 68 |
| スポンサー送金フレーム mode | 1 |
| スポンサーpost-opフレーム target | 20 |
| スポンサーpost-opフレーム gas | 3 |
| スポンサーpost-opフレーム calldata | 0 |
| スポンサーpost-opフレーム mode | 2 |
| 追加合計 | 141 |
スポンサーのケースでは、同じスポンサーアドレスが3箇所に重複して記載されるため、ある程度の非効率があります。
ただし、ERC-4337のようにフィールドごとに32バイトのABIオーバーヘッドがかからないため、全体としてはより効率的です。
補足
ユースケース
例1 -- シンプルなトランザクション
最も基本的なパターンは、署名検証と実行の2フレームです。
| フレーム | 呼び出し元 | ターゲット | データ | モード |
|---|---|---|---|---|
| 0 | ENTRY_POINT | Null(sender) | 署名 | VERIFY |
| 1 | Sender | ターゲットアドレス | コールデータ | SENDER |
フレーム0で署名を検証しAPPROVE(0x3)を呼びます。
これにより実行承認と支払い承認の両方が完了します。
フレーム1は送信者としてコントラクトを呼び出し、通常どおりRETURNで終了します。
シンプルなETH送金の場合は、フレーム1のターゲットをNull(つまりtx.sender)にして、Default CodeのSENDERモードで[[destination, amount, "0x"]]のような呼び出しリストを実行します。
従来のトランザクションにはvalueフィールドがありましたが、Frame TransactionではアカウントのコードがETH送金を処理するため、valueフィールドは不要です。
新しいアカウントのデプロイを伴う場合は3フレームになります。
| フレーム | 呼び出し元 | ターゲット | データ | モード |
|---|---|---|---|---|
| 0 | ENTRY_POINT | デプロイヤー | Initcode, Salt | DEFAULT |
| 1 | ENTRY_POINT | Null(sender) | 署名 | VERIFY |
| 2 | Sender | Null(sender) | 送金先/金額 | SENDER |
フレーム0でEIP-7997のようなデプロイヤーコントラクトを呼んでスマートアカウントをデプロイし、フレーム1で検証、フレーム2で操作を実行します。
デプロイフレームの時点では送信者はまだ認証されていないため、誰がデプロイしても安全なinitcodeを使う必要があります。
例2 -- Atomic Approve + Swap
| フレーム | 呼び出し元 | ターゲット | データ | モード | Atomic Batch |
|---|---|---|---|---|---|
| 0 | ENTRY_POINT | Null(sender) | 署名 | VERIFY | - |
| 1 | Sender | ERC-20 | approve(DEX, amount) | SENDER | あり |
| 2 | Sender | DEX | swap(...) | SENDER | なし |
フレーム0で署名を検証しAPPROVE(0x3)を呼びます。
フレーム1と2はアトミックバッチを形成します。
フレーム1でERC-20トークンのapproveを行い、フレーム2でDEXのswapを実行します。
もしフレーム2のswapがrevertした場合、フレーム1のapproveも自動的に取り消されます。
DeFiで頻繁に行われるapprove + swapの操作で、swapが失敗した時にapproveだけが残ってしまう問題を根本的に解決できます。
例3 -- ERC-20でのガス支払い
| フレーム | 呼び出し元 | ターゲット | データ | モード |
|---|---|---|---|---|
| 0 | ENTRY_POINT | Null(sender) | 署名 | VERIFY |
| 1 | ENTRY_POINT | スポンサー | スポンサーデータ | VERIFY |
| 2 | Sender | ERC-20 | transfer(スポンサー, 手数料) | SENDER |
| 3 | Sender | ターゲット | コールデータ | SENDER |
| 4 | ENTRY_POINT | スポンサー | Post opコール | DEFAULT |
フレーム0で送信者の署名を検証し、APPROVE(0x1)で実行のみ承認します。
フレーム1ではスポンサーがユーザーのERC-20残高を確認し、次のフレームが適切なERC-20送金であることを確認した上で、APPROVE(0x2)で支払いを承認します。
フレーム2でユーザーがERC-20トークンをスポンサーに送金し、フレーム3でユーザーの本来の操作を実行します。
フレーム4(オプション)ではスポンサーが未払いガスの精算やトークンのETHへの変換を行います。
例4 -- EOAのERC-20ガス支払い
| フレーム | 呼び出し元 | ターゲット | データ | モード |
|---|---|---|---|---|
| 0 | ENTRY_POINT | Null(sender) | (0, v, r, s) | VERIFY |
| 1 | ENTRY_POINT | スポンサー | スポンサー署名 | VERIFY |
| 2 | Sender | ERC-20 | transfer(スポンサー, 手数料) | SENDER |
| 3 | Sender | ターゲット | コールデータ | SENDER |
例3とほぼ同じですが、フレーム0でDefault Code(EOA対応)のECDSA検証が使われています。
signature_type = 0x0でECDSA署名を渡し、APPROVE(0x1)で実行承認します。
これにより、スマートアカウントに移行していないEOAユーザーでも、ERC-20トークンでガスを支払うことができます。
設計判断の理由
Frame Transactionの設計には、いくつかの重要な判断が含まれています。
VERIFYフレームのdataを署名ハッシュから除外して変更可能にしているのは、ガススポンサリングのワークフローで送信者の署名後にスポンサーのデータを追加する必要があるためです。
ただしtargetは署名ハッシュに含まれるため、送信者はスポンサーのアドレスを明示的に選択できます。
APPROVEオペコードでsender_approvedとpayer_approvedをそれぞれ一度だけ設定可能にしているのは、検証フローの予測可能性を高め、トランザクションプールでの推論を容易にするためです。
Atomic Batchingを新しいモードではなくフラグで実装しているのは、フレーム構造をシンプルに保ちながらバッチの境界を明確に示すためです。
Default Codeを導入しているのは、EOAユーザーがスマートアカウントへの移行を待たずにFrame Transactionの恩恵を受けられるようにするためです。
後方互換性
ORIGINオペコードの挙動変更
Frame Transactionでは、ORIGINオペコードの返す値が変更されます。
従来は常にトランザクションの元の送信者を返していましたが、Frame Transactionではフレームのcallerを返します。
DEFAULT/VERIFYモードではENTRY_POINT、SENDERモードではtx.senderです。
これはEIP-7702がすでにORIGINのセマンティクスを変更した前例に沿った変更です。
ORIGIN == CALLERをセキュリティチェックに使っているコントラクト(推奨されていないパターン)は、Frame Transactionの下で異なる動作をする可能性があります。
セキュリティ
DoS攻撃への対策
Frame Transactionはトランザクションプールに新しいDoS攻撃ベクトルをもたらします。
検証ロジックが任意のEVMコードであるため、攻撃者は初回の検証では有効に見えるが後から無効になるトランザクションを大量に送り込むことができます。
例えば、以下のような検証コードを想像してみましょう。
function validateTransaction() external {
require(block.timestamp < SOME_DEADLINE, "expired");
// ... 残りの検証
APPROVE(0x3);
}
このトランザクションは送信時には有効ですが、デッドラインが過ぎると自動的に無効になります。
攻撃者はこのようなトランザクションを大量に送信し、デッドラインが過ぎるのを待つだけでノードのリソースを浪費させることができます。
Validation Prefix内の禁止オペコード
この問題への対策として、Validation Prefix内では以下のオペコードが禁止されています。
| オペコード | 理由 |
|---|---|
ORIGIN (0x32) |
環境依存の値 |
GASPRICE (0x3A) |
ブロックごとに変動 |
BLOCKHASH (0x40) |
ブロックごとに変動 |
COINBASE (0x41) |
ブロックごとに変動 |
TIMESTAMP (0x42) |
ブロックごとに変動 |
NUMBER (0x43) |
ブロックごとに変動 |
PREVRANDAO (0x44) |
ブロックごとに変動 |
GASLIMIT (0x45) |
ブロックごとに変動 |
BASEFEE (0x48) |
ブロックごとに変動 |
BLOBHASH (0x49) |
ブロックごとに変動 |
BLOBBASEFEE (0x4A) |
ブロックごとに変動 |
GAS (0x5A) |
*CALLの直前を除く |
CREATE (0xF0) |
ステート変更 |
CREATE2 (0xF5) |
最初のdeployフレーム内の既知デプロイヤーを除く |
INVALID (0xFE) |
例外停止 |
SELFDESTRUCT (0xFF) |
ステート変更 |
BALANCE (0x31) |
外部残高への依存 |
SELFBALANCE (0x47) |
残高への依存 |
SSTORE (0x55) |
ステート変更 |
TLOAD (0x5C) |
Transient Storage |
TSTORE (0x5D) |
Transient Storage |
SLOADはtx.senderのストレージにのみアクセスできます。
CALL*とEXTCODE*は既存のコントラクトやプリコンパイルを対象にできますが、ストレージやオペコードの制限を遵守する必要があります。
さらに、送信者1アカウントにつきパブリックmempoolに保持できるFrame Transactionは1件のみです。
新しいトランザクションで置き換える場合は、同じnonceと手数料引き上げルールを満たす必要があります。
新しいブロックが受理されると、ノードは含まれたトランザクションを削除し、Paymaster予約を更新し、影響を受けるpendingトランザクションのValidation Prefixを再シミュレーションして、ルールを満たさなくなったトランザクションを排除します。
引用
最後に
今回は「ECDSAに依存しないFrame Transaction」についてまとめてきました。
EthereumがAccount Abstractionの本来のビジョンに向かう上で、Frame Transactionはプロトコルレベルでの大きな一歩となる提案です。
特に、ECDSA以外の署名方式を自由に使えるようになることで、ポスト量子暗号時代への備えが可能になる点が重要です。
他でも色々記事を書いているのでぜひよろしければ読んでいってください!



