アルゴリズムとかオーダーとか

仕事で勉強したことなどをまとめてます

EIP-7702の後方互換性 (Backwards Compatibility)

EIP-7702は、イーサリアムの既存の不変条件(Invariants)をいくつか破る変更を含んでいます。 本記事では、EIP-7702の仕様書で言及されている3つの不変条件に加え、EOAの判定方法の変更について、詳細と関連する go-ethereum の実装コードを交えて解説します。なお、本記事の作成には生成AI(Gemini3 Pro)を活用しています。

※invariantsを記事内ではわかりやすく直訳的に「不変条件」と記載してますが、本来の意味を汲むとしたら「暗黙的に守れてきた一貫性」などと意訳したほうが正しいと思います。これらの条件はプロトコルで厳格に守られているものではなく、副次的に一貫性が保たれてきたためです。

1. アカウント残高の減少 (Account Balance Decrease)

不変条件: 「アカウントの残高は、そのアカウントから発信されたトランザクションの結果としてのみ減少する。」

変更点: EIP-7702により、委任されたEOAはコードを持つようになります。そのコードの実行中にETHを送金する外部呼び出し(CALL)を行うことで、トランザクションの起点(Sender)でなくても、そのアカウントの残高を減少させることが可能になります。

関連コード (core/vm/evm.go): resolveCode 関数は、Pragueハードフォーク以降、委任指定子を持つアカウントに対して委任先のコードを返します。 View on GitHub

// core/vm/evm.go

func (evm *EVM) resolveCode(addr common.Address) []byte {
    code := evm.StateDB.GetCode(addr)
    if !evm.chainRules.IsPrague {
        return code
    }
    // 委任指定子(Delegation Designator)がある場合、ターゲットのコードを返す
    if target, ok := types.ParseDelegation(code); ok {
        return evm.StateDB.GetCode(target)
    }
    return code
}

このコードは Call 関数内で解決され、実行されます。 View on GitHub

// core/vm/evm.go - Call関数内

    } else {
        // Initialise a new contract and set the code that is to be used by the EVM.
        code := evm.resolveCode(addr) // ここで委任コードが取得される
        if len(code) == 0 {
            ret, err = nil, nil
        } else {
            // ...
            // 委任されたコードが実行される。このコード内で**ETHを送金するCALLオペコード**が使われれば残高が減る。
            ret, err = evm.Run(contract, input, false) 
            // ...
        }
    }

解説: 他者のトランザクションによる残高減少について

これまでのEOA(外部所有アカウント)は、自分自身がトランザクションの発行者(Sender)となった場合にのみ、ガス代や送金額として残高が減少していました。つまり、誰かが勝手に自分のEOAを操作して残高を減らすことは不可能でした。

しかし、EIP-7702によりEOAがコード(スマートコントラクト)として振る舞えるようになると、他者が発行したトランザクションによって自分のEOAが呼び出され、その委任コード内のロジック(CALLオペコードなど)が実行される可能性があります。これにより、「自分の残高は自分が起点となったトランザクションでしか減らない」という不変条件が破られることになります。

2. EOA nonceの増加 (EOA Nonce Increase)

不変条件: 「EOAのnonceは、自身がトランザクションを発行いた時にしか増加しない。」

変更点: 委任コードを持つEOAはコード実行中に CREATE オペコードを使用することができ、これにより自分以外の人が発行したトランザクションでもnonceが増加します。これまではEOAがコードを実行することはなかったため、自分がトランザクションを発行したとき以外ではnonceが増えることはありませんでした。

関連コード (core/vm/evm.go): create 関数において、呼び出し元のnonceがインクリメントされます。 View on GitHub

// core/vm/evm.go

func (evm *EVM) create(caller common.Address, code []byte, gas uint64, value *uint256.Int, address common.Address, typ OpCode) (ret []byte, createAddress common.Address, leftOverGas uint64, err error) {
    // ...
    nonce := evm.StateDB.GetNonce(caller)
    if nonce+1 < nonce {
        return nil, common.Address{}, gas, ErrNonceUintOverflow
    }
    // ここで実行中にnonceが増加する
    evm.StateDB.SetNonce(caller, nonce+1, tracing.NonceChangeContractCreator)
    // ...
}

解説: 実行中のNonce増加について

これまでのEOAでは、Nonceが増加するのは「自身がトランザクションを発行した時(開始時)」のみでした。 EIP-7702導入後はこれに加え、以下の2つのタイミングでもNonceが増加するようになります。

  1. コード実行中のCREATE: 委任されたコード内で CREATE オペコードを使用すると、コントラクト作成のためにNonceが増加します。
  2. 委任の適用(Authorization): 委任設定(Authorization List)が適用される際、リプレイ防止のために委任元のNonceがインクリメントされます。署名が必要であるため、Nonceの増加は事前に把握できますが、実際にいつ増加するかはsetCodeTxを発行してくれる第三者に依存します。

3. tx.origin == msg.sender の意味 (Topmost Frame)

不変条件: 「tx.origin == msg.sender が真であるのは、実行の最上位フレーム(topmost frame)のみである。」

変更点: 委任されたアカウント(tx.origin)がコードを実行し、そのコードからさらに別のコントラクトを呼び出す(サブコールする)場合、そのサブコールにおいて msg.sendertx.origin と一致しますが、それは最上位フレームではありません。

説明: この現象は、委任コード設定済みのEOAが自分自身のアドレス宛にトランザクションを送信した場合に簡単に発生します。

具体的なシナリオ:

  1. Alice (EOA) が、自身のコードを「バッチ実行コントラクト」に委任する特別なトランザクション (SetCodeTx) を送信します。

    • 注釈: EIP-7702のコード委任は永続的です。
    • 実行順序: 詳細は後述の「付録: EIP-7702トランザクションの実行順序」を参照してください。簡単に言うと、トランザクション実行前に委任が適用されるため、Aliceが自分自身を呼び出す時点で既にコードは有効になっています。
  2. フレーム 0 (最上位):

    • EVMは Alice のアカウントを実行します(中身は委任コード)。
    • このコードが、TargetContract の関数を呼び出します(サブコール)。
  3. フレーム 1 (サブコール):
    • TargetContract が実行されます。
    • tx.originAlice です(トランザクション発行者)。
    • msg.senderAlice です(フレーム0の実行主体)。
    • したがって、tx.origin == msg.senderTrue になります。

これはAliceがほかのコントラクトを呼び出しても成立します。重要なのはcallスタックのどこかで、Alice自身のアドレスがcallされてることです。

理論上の変更点: 技術的には、「msg.sender == tx.origin ならば、呼び出し元はEOAであり、コードを実行していない」という従来の不変条件が崩れることになります。EIP-7702では、tx.origin であるEOAがコード(委任先ロジック)を実行している最中に他のコントラクトを呼び出せるようになるため、理論上はこのチェックをアトミック性やリエントランシー保護の根拠にすることができなくなります。

とはいえ、事前のコードチェックによりPectraアップデート以前のスマートコントラクトで、 msg.sender == tx.origin でリエントランシーを防いでいるスマートコントラクトは存在していないことが確認済みです。

4. EXTCODEHASH の挙動変化 (EOA判定の崩れ)

不変条件: 「EXTCODEHASH の結果が 0xc5d246... (=Keccak256(""))であればEOAである。」

変更点: EIP-7702により、委任されたEOAは委任指定子 (Delegation Designator) を持ちます。このEOAに対して EXTCODEHASH を実行すると、委任指定子のハッシュ値(非ゼロ、かつ 0xc5d246... でもない値)が返されます。これにより、実態はEOAであるにもかかわらず、従来のロジックではコントラクトと誤判定されてしまいます。

関連コード (core/vm/instructions.go): opExtCodeHash は、アカウントが空 (Empty) であれば 0 を返し、そうでなければ StateDB.GetCodeHash の結果を返します。 View on GitHub

対策: EIP-7702以降、「EOAである」ことは「コードがない」ことを保証しません。もっと言えば、EOAとコントラクトアカウントを区別することは危険です。

  • リエントランシー保護の徹底: 相手がEOAであってもコード実行のリスクがあるため、例外なく Checks-Effects-Interactions パターンや ReentrancyGuard を適用してください。

(参考)純粋にEOAかどうかを判別する方法: セキュリティ(コールバックの有無)ではなく、純粋に「秘密鍵で管理されるアカウント(EOA)か、スマートコントラクトか」を判別したい場合は、以下のロジックを使用します。

EIP-3541により、0xefから始まるバイトコードのデプロイは禁止されています。一方、EIP-7702の委任EOAは0xef01...という委任指定子をコードとして持ちます。 したがって、「EXTCODEHASHがnullハッシュ値(0xc5d2...)」または「コード先頭が0xef」であれば、そのアドレスはEOAであると判断できます。

※厳密的にはEXTCODEHASHがnullハッシュ値を返してもコントラクトアカウントであるケースもありますが、限定的なケースなのでここでは無視しします。

function isEOA(address addr) internal view returns (bool) {
    bytes32 codeHash;
    assembly { codeHash := extcodehash(addr) }
    
    // 1. コードがない (従来のEOA)
    // keccak256("") == 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
    // ※ codeHash == 0 (アカウント非存在) はEOAかコントラクトか不定なため除外
    if (codeHash == 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470) {
        return true;
    }
    
    // 2. コードがある場合、先頭が 0xef なら EIP-7702 EOA
    // (EIP-3541により、通常のコントラクトは 0xef で始めることができないため)
    bytes1 firstByte;
    assembly {
        // 先頭1バイトだけをメモリにコピー
        extcodecopy(addr, mload(0x40), 0, 1)
        firstByte := mload(mload(0x40))
    }
    return firstByte == 0xef;
}

付録A: EIP-7702トランザクションの実行順序

EIP-7702トランザクション (SetCodeTx) において、「委任の適用」と「トランザクション実行」がどのような順序で行われるかは、セキュリティを理解する上で重要です。

ソースコード (core/state_transition.go) を確認すると、以下の順序で処理が行われます。 View on GitHub

  1. 委任の適用 (applyAuthorization): まず、トランザクションに含まれる認証リスト (AuthList) が処理され、対象のアカウントにコードが設定されます。
  2. トランザクション実行 (evm.Call): その直後に、通常のトランザクション処理(to アドレスへの呼び出しなど)が実行されます。
// core/state_transition.go の execute 関数内

// 1. EIP-7702 認証の適用 (委任コードの設定)
if msg.SetCodeAuthorizations != nil {
    for _, auth := range msg.SetCodeAuthorizations {
        st.applyAuthorization(&auth)
    }
}

// ... (中略) ...

// 2. トランザクションの実行 (自分への呼び出しなど)
ret, st.gasRemaining, vmerr = st.evm.Call(msg.From, st.to(), msg.Data, st.gasRemaining, value)

この順序により、Aliceが自分宛てにトランザクションを送信した場合、evm.Call が実行される時点では既にAliceのアカウントに委任コードが設定されているため、そのコードが即座に実行されることになります。

付録B: 委任コードに対するEXTCODEHASHの挙動について

EIP-7702の「Rationale」セクションには、EXTCODEHASHの挙動について以下の記述があります。

Other code retrieving operations like EXTCODEHASH do not automatically follow delegations, they operate on the delegation indicator itself. If instead delegations were followed, an account would be able to temporarily masquerade as having a particular codehash, which would break contracts that rely on codehashes as a definition of possible account behavior.

つまり、もし EXTCODEHASH が委任先のコードハッシュを返してしまうと、アカウントが一時的に特定のコードハッシュを持っているかのように「なりすます」ことができてしまい、コードハッシュを信頼しているコントラクトを壊すことになるため、あえて委任指定子自体のハッシュを返す設計になっています。