コンテンツへスキップ

アップグレード可能なスマートコントラクトの書き方を理解する

分散型アプリケーションにおけるアップグレード可能なスマートコントラクトの重要性を探ります。プロキシパターンを通じて、アップデート、バグ修正、セキュリティ強化を実現する方法を学びましょう。

8分で読める
アップグレード可能なスマートコントラクトの書き方を理解する

スマートコントラクトは、仲介者を不要にする自己実行型プログラムを導入することで、分散型アプリケーションに対する考え方を大きく変えました。分散型ブロックチェーンネットワーク上で透明性をもって実行され、トラストレス性やセキュリティの向上といったメリットをもたらします。しかし、かつては強みとされていたイミュータビリティ(不変性)が、大きな課題を生み出しています。スマートコントラクトは一度デプロイされると変更できないため、この硬直性が必要なアップデートの実装やバグ修正を困難にしています。

この記事では、アップグレード可能なスマートコントラクトの概念を探り、その重要性を解説するとともに、特にLinea zkEVMへの実装に焦点を当てながら、その仕組みを詳しく説明します。

アップグレード可能なスマートコントラクトとは?

アップグレード可能なスマートコントラクトは、デプロイ後の変更や改善を可能にすることで、従来のスマートコントラクトの制限を解消します。従来のスマートコントラクトはイミュータビリティで知られていますが、アップグレード可能なコントラクトはイミュータビリティのセキュリティと柔軟な適応性を兼ね備えています。これにより、開発者はユーザーを混乱させたり、新しいコントラクトへの移行を強いたりすることなく、アップデートを実装できます。

従来のコントラクトでは、バグやセキュリティ上の欠陥はデプロイ後に永続的なものとなります。アップグレード可能なコントラクトはデプロイ後のアップデートを可能にすることでこの問題に対処し、以下の主要な課題を解決します:

1. バグ修正とセキュリティ脆弱性への対応:開発者はデプロイ後に脆弱性にパッチを当て、ユーザーの資産を保護できます。

2. 適応性:アップグレード可能なコントラクトにより、新しいプロトコルとのシームレスな統合が可能となり、アプリケーションを技術の進歩に合わせて最新の状態に保てます。

3. セキュリティの強化:進化するサイバー脅威に対応するため、コントラクト全体を再デプロイすることなく、新しいセキュリティ対策を簡単に実装できます。

4. ガバナンスの柔軟性:分散型自律組織(DAO)はコミュニティ投票を活用してアップグレードプロセスを管理し、透明性とステークホルダーの参加を確保できます。

5. アップデートの簡素化:複雑な再デプロイとデータ移行が必要な従来のコントラクトとは異なり、アップグレード可能なコントラクトはアップデートを効率化し、より使いやすいエクスペリエンスを提供します。

アップグレード可能なスマートコントラクトの仕組み

アップグレード可能なスマートコントラクトを作成する最も一般的な方法は、プロキシパターンです。このアプローチはコントラクトのロジックとデータを分離し、同じデータストレージを維持しながらロジックをアップデートできるようにします。

仕組みは以下の通りです:

  1. プロキシコントラクト:ストレージ(状態変数)を保持し、ユーザーのインターフェースとして機能するコントラクトです。

  2. 実装コントラクト:実行可能なコード(ビジネスロジック)を含むコントラクトです。

  3. デリゲーション:プロキシコントラクトはEthereumのdelegatecallを使用して、関数呼び出しを実装コントラクトに委譲します。これにより、プロキシは同じストレージを維持しながら実装のコードを実行できます。

アップグレードが必要な場合、プロキシコントラクトは新しい実装コントラクトに向けられ、コントラクトのストレージを変更したりユーザーを混乱させたりすることなく、ロジックのアップデートが可能になります。このプロセスを説明するために、DAOプロキシコントラクトを使った例を示します:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract DAOProxy {
    // Storage slot for the address of the implementation contract
    bytes32 private constant implementationSlot = keccak256("simple.dao.proxy.implementation");
    // Storage slot for the admin address
    bytes32 private constant adminSlot = keccak256("simple.dao.proxy.admin");

    // Modifier to restrict access to admin
    modifier onlyAdmin() {
        require(msg.sender == _admin(), "Caller is not admin");
        _;
    }

    // Constructor to set the initial implementation and admin
    constructor(address _implementation) {
        _setImplementation(_implementation);
        _setAdmin(msg.sender);
    }

    // Fallback function to delegate calls to the implementation
    fallback() external payable {
        _delegate(implementation());
    }

    // Receive function to handle plain Ether transfers
    receive() external payable {
        _delegate(implementation());
    }

    // Internal function to delegate the call to the implementation
    function _delegate(address _implementation) internal {
        assembly {
            // Copy msg.data
            calldatacopy(0, 0, calldatasize())
            // Call the implementation
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
            // Copy the returned data
            returndatacopy(0, 0, returndatasize())
            // Handle result
            switch result
            case 0 {
                // Revert if the call failed
                revert(0, returndatasize())
            }
            default {
                // Return data if the call succeeded
                return(0, returndatasize())
            }
        }
    }

    // Getter for the implementation address
    function implementation() internal view returns (address impl) {
        bytes32 slot = implementationSlot;
        assembly {
            impl := sload(slot)
        }
    }

    // Getter for the admin address
    function _admin() internal view returns (address adm) {
        bytes32 slot = adminSlot;
        assembly {
            adm := sload(slot)
        }
    }

    // Function to upgrade the implementation contract
    function upgradeTo(address newImplementation) external onlyAdmin {
        _setImplementation(newImplementation);
    }

    // Internal function to set the implementation address
    function _setImplementation(address newImplementation) internal {
        bytes32 slot = implementationSlot;
        assembly {
            sstore(slot, newImplementation)
        }
    }

    // Function to change admin
    function changeAdmin(address newAdmin) external onlyAdmin {
        _setAdmin(newAdmin);
    }

    // Internal function to set the admin address
    function _setAdmin(address newAdmin) internal {
        bytes32 slot = adminSlot;
        assembly {
            sstore(slot, newAdmin)
        }
    }
}
コピー
    bytes32 private constant implementationSlot = keccak256("simple.dao.proxy.implementation");
    bytes32 private constant adminSlot = keccak256("simple.dao.proxy.admin");
コピー

注意すべき重要な点が2つあります。実装コントラクトのアドレス用のストレージスロットと、管理者アドレス用のストレージスロットです。これらはプロキシコントラクト内の実装アドレスのストレージスロットを計算するために使用される、意図的かつ固有の識別子です。

delegatecallを使ったプロキシパターン(この関数については後ほど詳しく説明します)を使用する場合、実装コントラクトはプロキシのストレージのコンテキストで動作します。これは以下を意味します:

  • ストレージ空間の共有:両方のコントラクトが同じストレージレイアウトを共有します。

  • 上書きのリスク:慎重に管理しないと、プロキシコントラクトの変数が実装コントラクトによって上書きされる可能性があります。

ハッシュ化された固有の識別子を使用することで:

  • 分離:プロキシが使用するストレージスロットが実装コントラクトの変数と競合しにくくなります。

  • 一貫性:実装アドレスが必要なコントラクト全体で、同じストレージスロットが一貫して使用されます。

constructor(address _implementation) {
        _setImplementation(_implementation);
        _setAdmin(msg.sender);
    }
コピー

もう一つの重要な点は、プロキシコントラクトのコンストラクターです。_implementationパラメーターが含まれており、実装コントラクトを先にデプロイし、プロキシコントラクトのデプロイ時にそのアドレスを引数として渡す必要があります。

 fallback() external payable {
        _delegate(implementation());
    }

    receive() external payable {
        _delegate(implementation());
    }
コピー

次に、fallback関数とreceive関数があります。これらはSolidityの関数であり、実装コントラクトへの呼び出しとEther転送の転送において重要な役割を果たします。コントラクトが明示的に定義された関数のいずれにも一致しない呼び出しや資金を受け取る状況を処理します。これらの関数の詳細な説明は以下の通りです:

1. fallback関数

  • フォールバックの仕組み:Solidityのfallback関数は、主に2つの条件下で実行される特殊な関数です:- コントラクトに存在しない関数が呼び出された場合。

  • 関数シグネチャに一致しないデータがコントラクトに送信された場合。

  • デリゲーション:DAOProxyコントラクトのコンテキストでは、fallback関数はそのような呼び出しをすべて現在の実装コントラクトに委譲します。

DAOProxyでの動作:

  • 関数呼び出しの処理:- ユーザーがプロキシコントラクトに定義されていない関数を呼び出すと、fallback関数がトリガーされます。

  • プロキシコントラクトには主にupgradeToやchangeAdminなどの管理機能が含まれており、実装コントラクトのビジネスロジック関数は含まれていないため、これは一般的なケースです。

  • 実装へのデリゲーション:- fallback関数は_delegate(implementation())を呼び出し、呼び出しを実装コントラクトに転送します。

  • _delegateは、プロキシのストレージコンテキストで動作しながら実装のコードを実行するためにdelegatecallを使用する内部関数です。

  • 実行コンテキスト:- すべての状態変更はプロキシのストレージで発生します。

  • msg.senderとmsg.valueは変更されず、元の呼び出し元のコンテキストが保持されます。

2. receive関数

  • Etherの受信: Solidity 0.6.0で導入されたreceive関数は、コントラクトがデータなしでEtherを受け取ったとき(つまりmsg.dataが空のとき)に実行されます。

  • Ether転送のデリゲーション: DAOProxyコントラクトでは、単純なEther転送が実装コントラクトに委譲されます。

DAOProxyでの動作:

  • Ether転送の処理:- 誰かがデータなしでプロキシコントラクトのアドレスにEtherを送ると、receive関数がトリガーされます。

  • これは、特定の関数を呼び出さずにtoアドレスとvalue(Ether量)のみを指定するトランザクションを通じて発生します。

  • 実装へのデリゲーション:- fallback関数と同様に、receiveは_delegate(implementation())を呼び出し、Ether転送を実装コントラクトに転送します。

  • 実装コントラクトは、受け取ったEtherの処理方法(例:残高の更新、イベントの発行)を定義します。

 function _delegate(address _implementation) internal {
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }
コピー

次に_delegate関数があります。これにより、プロキシは元の呼び出しのコンテキストを保持しながら、関数呼び出しを実装コントラクトに転送できます。これにより、コントラクトのアドレスを変更したり状態を中断したりすることなく、コントラクトのロジックを時間をかけてアップグレードできます。

その他の関数は、プロキシコントラクトの状態変数を管理するためのゲッターとセッターです。これを実現するためにassemblyキーワードを使用します。これにより、開発者はコントラクト内に低レベルのアセンブリコードを直接記述できます。この機能はEthereum Virtual Machine(EVM)命令への直接アクセスを提供し、コントラクトの動作をより精密に制御したり、ガス消費を最適化したり、Solidityの高レベルコードでは直接利用できないか非効率な操作を実行したりすることを可能にします。

ここで重要な疑問が生じます:このプロキシコントラクトを使って実装スマートコントラクトとどのようにやり取りするのでしょうか?

これを行うには、実装のスマートコントラクトABIが必要です。プロキシが呼び出しを実装コントラクトに委譲するため、呼び出せる関数を知るためにそのABIが必要です。また、ブロックチェーンとやり取りする際は、実装コントラクトではなくプロキシコントラクトのアドレスを使用します。プロキシが状態を保持し呼び出しを実装に委譲するため、すべてのやり取りはプロキシを通じて行う必要があります。

JavaScriptプロジェクトでEthers.jsを使ってブロックチェーンとやり取りする場合、以下のようになります:

const proxyAddress = "0xProxyContractAddress";
const implementationABI = [ /* ABI array of the implementation contract */ ];

const proxyContract = new ethers.Contract(proxyAddress, implementationABI, provider);

// For a function 'setValue(uint256 newValue)'
await proxyContract.connect(signer).setValue(42);

// For a function 'getValue()'
const value = await proxyContract.getValue();
console.log("Value:", value);
コピー

このようにして、プロキシコントラクトを使って実装コントラクト内の仮想的な関数setValueを呼び出しています。

アップグレード可能なコントラクトについて説明しているので、もう一つの重要な疑問が生じます:コントラクトをどのようにアップグレードするのでしょうか?

まず、アップグレードできるのは管理者(プロキシコントラクトをデプロイした人)のみです。これを行うには、管理者はまず実装コントラクトの新しいバージョンをブロックチェーンにデプロイする必要があります。その後、プロキシコントラクトのupgradeTo関数を呼び出し、新しいコントラクトアドレスをパラメーターとして渡します。以下のようになります:

await proxyContract.connect(adminSigner).upgradeTo(newImplementationAddress);

スマートコントラクトの未来を受け入れる

アップグレード可能なスマートコントラクトは、デプロイ後の変更や改善を可能にするため、ブロックチェーン開発において重要な役割を果たします。プロキシパターンを活用することで、開発者はユーザーエクスペリエンスを損なったり資産を移行したりすることなく、新機能をシームレスに導入し、バグを修正し、変化する要件に対応できます。

しかし、大きな力には大きな責任が伴います。アップグレード可能なコントラクトには、その仕組み、セキュリティへの影響、およびベストプラクティスを十分に理解した上でアプローチすることが不可欠です。ストレージレイアウトの一貫性の確保、堅牢なアクセス制御の実装、アップグレードの徹底的なテストは、潜在的なリスクから保護するための重要なステップです。

アップグレード可能なコントラクトの構築に取り組む際は、OpenZeppelinのUpgrades Pluginなどの確立されたツールを活用して、プロセスを効率化しセキュリティを強化することを検討してください。コーディングを楽しんでください!

AIによる翻訳です。エラーが含まれている可能性があります。常に情報を確認してください。

翻訳を評価する

この記事の執筆者: