0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Solidity】Proxyコントラクトの技術要素

Last updated at Posted at 2024-11-18

はじめに(Introduction)

EVM互換ブロックチェーンのコントラクトにおいて、最近ではアップグレード可能なコントラクトにする傾向があります。
アップグレードは、Proxyコントラクトを用いて行うことが大半です。
そこで、Proxyコントラクトの主な技術要素をまとめてみます。

Proxy

image.png

Proxyコントラクトの動きは上図のイメージとなります。

手順としては、Proxyコントラクトから機能を呼び出します。
Proxyコントラクトのfallback関数からdelegatecallを使い、実装(Implementation)コントラクトを呼び出します。
データはProxyコントラクトのStorageに格納されます。

したがって、fallbackdelegatecallStorageが主な技術要素となります。

また、アップグレードは、delegatecallの対象を新しい実装(Implementation)コントラクトに変更することで実現します。

主な技術要素

fallbackStoragedelegatecall について見てみます。

fallback

例えば、以下のコントラクトに対してa()機能の呼び出しは可能ですが、実装されていないb()c()の呼び出しは出来ません。

Fallback.sol
// SPDX-License-Identifier: MIT
pragma solidity <0.9.0;

contract Fallback {
    uint256 internal _a;

    constructor(uint256 a_) {
        _a = a_;
    }

    function a() external view returns (uint256) {
        return _a;
    }
}

試してみます。

scripts/fallback.js
const { ethers } = require("hardhat");

const ABI = [
    "function a() view returns (uint256)",
    "function b() view returns (uint256)",
    "function c() view returns (uint256)",
];

async function main() {
    console.log(">>>>>>>>>>");
    const Fallback = await ethers.getContractFactory("Fallback");
    const fallback = await Fallback.deploy(100);
    const contract = new ethers.Contract(fallback.target, ABI, ethers.provider);
    console.log("a()", await contract.a());
    console.log("b()", await contract.b());
    console.log("c()", await contract.c());
    console.log("<<<<<<<<<<");
}

main().then(() => {
    process.exit(0);
}).catch((error) => {
    console.error(error);
    process.exit(1);
});

以下のコマンドで実行してみます。

npx hardhat run .\scripts\fallback.js

予想通りエラーが出ます。

>>>>>>>>>>
a() 100n
Error: Transaction reverted: function selector was not recognized and there's no fallback function
    <略>

これに対応する為、Solidityではfallback関数というものが提供されています。

コントラクトには、fallback() external [payable] または fallback (bytes calldata input) external [payable] returns (bytes memory output) (どちらも function キーワードなし) を使用して宣言された fallback 関数を最大 1 つ持つことができます。
この関数は external 可視性を持つ必要があります。
フォールバック関数は仮想関数にすることができ、オーバーライドでき、修飾子を持つことができます。

fallback 関数は、他の関数がいずれも指定された関数シグネチャと一致しない場合、またはデータがまったく提供されておらず、Ether受信関数 がない場合に、コントラクトの呼び出し時に実行されます。
fallback 関数は常にデータを受信しますが、Ether も受信するには、payable としてマークする必要があります。

Fallback.solfallback 関数を追加してみます。
単純に a() を呼び出すだけの処理とします。

Fallback.sol
// SPDX-License-Identifier: MIT
pragma solidity <0.9.0;

contract Fallback {
    uint256 internal _a;

    constructor(uint256 a_) {
        _a = a_;
    }

    function a() external view returns (uint256) {
        return _a;
    }

    fallback(bytes calldata input) external returns (bytes memory output) {
        (bool success, bytes memory data) = address(this).call(
            abi.encodeWithSignature("a()")
        );
        require(success);
        output = data;
    }
}

実際に実行してみます。

npx hardhat run .\scripts\fallback.js

結果を見ると、すべて実行に成功し a() と同じ動作をします。

>>>>>>>>>>
a() 100n
b() 100n
c() 100n
<<<<<<<<<<

このように、コントラクトが実装していない関数を呼び出された時、fallback 関数を実装していればこれが呼ばれます。

Storage

コントラクトの変数はStorageという領域に格納されます。
StorageA.sol を見てみます。
_a_bという変数がStorageに格納されます。

StorageA.sol
// SPDX-License-Identifier: MIT
pragma solidity <0.9.0;

contract StorageA {
    uint256 internal _a;
    uint256 internal _b;

    constructor(uint256 a_, uint256 b_) {
        _a = a_;
        _b = b_;
    }

    function a() external view returns (uint256) {
        return _a;
    }

    function b() external view returns (uint256) {
        return _b;
    }
}

どのようにStorageに格納されるのかについてはSolidityのドキュメントに記載されています。

動的なサイズの配列とマッピング(下記参照)を除き、データはスロット0に格納される最初のステート変数からアイテムごとに連続的に格納される。

値はStorageのスロット(Slot)という番地のような値の場所に格納されます。
_a_buint256なのでスロット 01 に格納されてるはずです、以下のスクリプトで見てみます。

scripts/storage.js
const { ethers } = require("hardhat");

async function main() {
    console.log(">>>>>>>>>>");
    const StorageA = await ethers.getContractFactory("StorageA");
    const storageA = await StorageA.deploy(100, 200);
    console.log("storageA#a()", await storageA.a());
    console.log("storageA#b()", await storageA.b());
    console.log('storageA #0', await ethers.provider.getStorage(storageA.target, 0));
    console.log('storageA #1', await ethers.provider.getStorage(storageA.target, 1));
    console.log("<<<<<<<<<<");
}

main().then(() => {
    process.exit(0);
}).catch((error) => {
    console.error(error);
    process.exit(1);
});

以下のコマンドで実行します。

npx hardhat run .\scripts\storage.js

結果が以下となります。

>>>>>>>>>>
storageA#a() 100n
storageA#b() 200n
storageA #0 0x0000000000000000000000000000000000000000000000000000000000000064
storageA #1 0x00000000000000000000000000000000000000000000000000000000000000c8
<<<<<<<<<<

スロット 0 には 0x64 なので 100 となり a() と同じ値となります。
スロット 1 には 0xc8 なので 200 となり b() と同じ値となります。

_a_bという変数の順番を逆(_b_a)にしたが StorageB を作成します。

StorageB.sol
// SPDX-License-Identifier:  MIT
pragma solidity <0.9.0;

contract StorageB {
    uint256 internal _b;
    uint256 internal _a;

    constructor(uint256 a_, uint256 b_) {
        _a = a_;
        _b = b_;
    }

    function a() external view returns (uint256) {
        return _a;
    }

    function b() external view returns (uint256) {
        return _b;
    }
}

StorageA.sol と StorageB.sol の比較をしてみます。

scripts/storage.js
const { ethers } = require("hardhat");

async function main() {
    console.log(">>>>>>>>>>");
    const StorageA = await ethers.getContractFactory("StorageA");
    const storageA = await StorageA.deploy(100, 200);
    console.log("storageA#a()", await storageA.a());
    console.log("storageA#b()", await storageA.b());
    console.log('storageA #0', await ethers.provider.getStorage(storageA.target, 0));
    console.log('storageA #1', await ethers.provider.getStorage(storageA.target, 1));
    const StorageB = await ethers.getContractFactory("StorageB");
    const storageB = await StorageB.deploy(100, 200);
    console.log("storageB#a()", await storageB.a());
    console.log("storageB#b()", await storageB.b());
    console.log('storageB #0', await ethers.provider.getStorage(storageB.target, 0));
    console.log('storageB #1', await ethers.provider.getStorage(storageB.target, 1));
    console.log("<<<<<<<<<<");
}

main().then(() => {
    process.exit(0);
}).catch((error) => {
    console.error(error);
    process.exit(1);
});

以下のコマンドで実行します。

npx hardhat run .\scripts\storage.js

結果が以下となります。

>>>>>>>>>>
storageA#a() 100n
storageA#b() 200n
storageA #0 0x0000000000000000000000000000000000000000000000000000000000000064
storageA #1 0x00000000000000000000000000000000000000000000000000000000000000c8
storageB#a() 100n
storageB#b() 200n
storageB #0 0x00000000000000000000000000000000000000000000000000000000000000c8
storageB #1 0x0000000000000000000000000000000000000000000000000000000000000064
<<<<<<<<<<

a()b() の結果は同じですが、スロット 01 は入れ替わっています。
ソースコードに記述する順番によってスロットの場所が変わることを覚えておいてください。

delegatecall

fallback から呼び出される delegatecall は以下のような機能となります。

メッセージ呼び出しには、delegatecall という特別なバリエーションがあります。これは、ターゲット アドレスのコードが呼び出しコントラクトのコンテキスト (つまり、アドレス) で実行され、msg.sendermsg.value の値が変わらないという点を除けば、メッセージ呼び出しと同じです。
これは、コントラクトが実行時に別のアドレスからコードを動的にロードできることを意味します。ストレージ、現在のアドレス、残高は引き続き呼び出しコントラクトを参照し、コードのみが呼び出されたアドレスから取得されます。

Delegatecall.sol から ImplContract.sol を呼び出してみます。

確認用のコードの為、Proxy コードとしてはNGです。

Delegatecall.sol
// SPDX-License-Identifier:  MIT
pragma solidity <0.9.0;

contract Delegatecall {
    address private _impl;

    constructor(address impl_) {
        _impl = impl_;
    }

    fallback(bytes calldata input) external returns (bytes memory output) {
        (bool success, bytes memory data) = address(_impl).delegatecall(input);
        require(success);
        output = data;
    }
}
ImplContract.sol
// SPDX-License-Identifier:  MIT
pragma solidity <0.9.0;

contract ImplContract {
    address private _a;

    function a() external view returns (address) {
        return _a;
    }

    function sender() external view returns (address) {
        return msg.sender;
    }
}

以下のコードで動かしてみます。

scripts\delegatecall.js
const { ethers } = require("hardhat");

const ABI = [
    "function a() view returns (address)",
    "function sender() view returns (address)",
];

async function main() {
    console.log(">>>>>>>>>>");
    const [deployer] = await ethers.getSigners();
    const ImplContract = await ethers.getContractFactory("ImplContract");
    const implContract = await ImplContract.deploy();
    const Delegatecall = await ethers.getContractFactory("Delegatecall");
    const delegatecall = await Delegatecall.deploy(implContract.target);
    const contract = new ethers.Contract(delegatecall.target, ABI, deployer);
    console.log('----- address  -----');
    console.log('ImplContract   ', implContract.target);
    console.log('Delegatecall   ', delegatecall.target);
    console.log('deployer       ', deployer.address);
    console.log('----- function -----');
    console.log('sender()       ', await contract.sender());
    console.log('a()            ', await contract.a());
    console.log('----- storage  -----');
    console.log('ImplContract #0', await ethers.provider.getStorage(implContract.target, 0));
    console.log('Delegatecall #0', await ethers.provider.getStorage(delegatecall.target, 0));
    console.log("<<<<<<<<<<");
}

main().then(() => {
    process.exit(0);
}).catch((error) => {
    console.error(error);
    process.exit(1);
});

以下のコマンドで実行します。

npx hardhat run .\scripts\delegatecall.js

結果が以下となります。

>>>>>>>>>>
----- address  -----
ImplContract    0x5FbDB2315678afecb367f032d93F642f64180aa3
Delegatecall    0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
deployer        0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
----- function -----
sender()        0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
a()             0x5FbDB2315678afecb367f032d93F642f64180aa3
----- storage  -----
ImplContract #0 0x0000000000000000000000000000000000000000000000000000000000000000
Delegatecall #0 0x0000000000000000000000005fbdb2315678afecb367f032d93f642f64180aa3
<<<<<<<<<<

sender()deployer が同じ値になります。
通常の call だと Delegatecall のコントラクトアドレスになりますが、delegatecall を使用している為、Delegatecall コントラクトを呼び出した deployer アドレスが msg.sender となります。

ストレージを見てみます。
ImplContract コントラクトの変数 _a には何も設定していませんが、 a() は値を返しています。
delegatecall を使用しているので、Delegatecall コントラクトのスロット 0 を参照している為です。
Proxy を実装するには Storage に注意する必要があります。

その他の技術要素

Proxyを利用する場合に気を付けることに、Storage と コンストラクタ があります。

ERC1967

Proxy コントラクトが Storaga にデータを格納する時には実装コントラクトが格納するデータとスロットが衝突してはいけません。
Proxy には 実装コントラクトのアドレスを保持する必要があります。
ERC1967は、Proxyコントラクト と 実装コントラクトのストレージ衝突回避用のEIPです。

プロキシとロジック コントラクト間のストレージ使用の衝突を回避するために、ロジック コントラクトのアドレスは通常、コンパイラによって割り当てられないことが保証されている特定のストレージ スロット (たとえば、OpenZeppelin コントラクトの 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc) に保存されます。この EIP では、プロキシ情報を格納するための一連の標準スロットを提案しています。これにより、ブロック エクスプローラーなどのクライアントは、この情報を適切に抽出してエンド ユーザーに表示でき、ロジック コントラクトは必要に応じてそれに基づいて操作できます。

Initializable

実装コントラクトにはコンストラクターで初期化することができません。
実装コンストラクタのストレージに値を格納してしまうからです。

プロキシされたコントラクトはコンストラクタを使用しないので、コンストラクタのロジックを外部の initializer 関数に移すのが一般的です。
そして、この initializer 関数を保護し、一度しか呼び出されないようにする必要があります。
このコントラクトが提供するイニシャライザー修飾子は、この効果をもたらします。

そのため初期化は delegatecall 経由で initializer を呼び出す必要があります。

UUPSUpgradeable

実際にProxyコントラクトと実装コントラクトを作成して、upgradeしてみます。

Proxyコントラクトは以下となります、openzeppelinが提供しているERC1967Proxyを使います。

TestProxy.sol
// SPDX-License-Identifier: MIT
pragma solidity <0.9.0;

import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

contract TestProxy is ERC1967Proxy {
    constructor(
        address implementation,
        bytes memory _data
    ) ERC1967Proxy(implementation, _data) {}
}

実装コントラクトV1です。
initialize関数で初期化を行います。

ImplContractV1.sol
// SPDX-License-Identifier: MIT
pragma solidity <0.9.0;

import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";

contract ImplContractV1 is UUPSUpgradeable, Initializable {
    address private _owner;
    uint256 internal _a;

    function initialize(uint256 a_) public initializer {
        _owner = msg.sender;
        _a = a_;
    }

    function a() external view virtual returns (uint256) {
        return _a;
    }

    function version() external view virtual returns (uint64) {
        return _getInitializedVersion();
    }

    function _authorizeUpgrade(
        address newImplementation
    ) internal virtual override {
        require(newImplementation != address(0), "ZeroAddress");
        require(_owner == msg.sender, "Unauthorized");
    }
}

実装コントラクトV2です。
V1をそのままコピーして_bという変数を追加しています。
initialize2で初期化します、修飾子 reinitializer でバージョン2を指定しています。

ImplContractV2.sol
// SPDX-License-Identifier: MIT
pragma solidity <0.9.0;

import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";

contract ImplContractV2 is UUPSUpgradeable, Initializable {
    address private _owner;
    uint256 internal _a;

    uint256 internal _b;

    function initialize(uint256 a_) public initializer {
        _owner = msg.sender;
        _a = a_;
    }

    function initialize2(uint256 b_) public reinitializer(2) {
        _b = b_;
    }

    function a() external view virtual returns (uint256) {
        return _a;
    }

    function b() external view virtual returns (uint256) {
        return _b;
    }

    function version() external view virtual returns (uint64) {
        return _getInitializedVersion();
    }

    function _authorizeUpgrade(
        address newImplementation
    ) internal virtual override {
        require(newImplementation != address(0), "ZeroAddress");
        require(_owner == msg.sender, "Unauthorized");
    }
}

実装コントラクトV3です。
V2を継承して、_cという変数を追加しています。
initialize3で初期化します、修飾子 reinitializer でバージョン3 を指定しています。

ImplContractV3.sol
// SPDX-License-Identifier: MIT
pragma solidity <0.9.0;

import {ImplContractV2} from "./ImplContractV2.sol";

contract ImplContractV3 is ImplContractV2 {
    uint256 internal _c;

    function initialize3(uint256 c_) public reinitializer(3) {
        _c = c_;
    }

    function c() external view virtual returns (uint256) {
        return _c;
    }
}

以下のコードで動かしてみます。

scripts/proxy.js
const { ethers } = require("hardhat");

const ABI = [
    "function a() view returns (uint256)",
    "function b() view returns (uint256)",
    "function c() view returns (uint256)",
    "function version() view returns (uint64)",
    "function upgradeToAndCall(address newImplementation, bytes memory data)",
    "function initialize(uint256 a_)",
    "function initialize2(uint256 b_)",
    "function initialize3(uint256 c_)",
];

async function main() {
    console.log(">>>>>>>>>>");
    const [deployer] = await ethers.getSigners();
    console.log('deployer', deployer.address);

    const ImplContractV1 = await ethers.getContractFactory("ImplContractV1");
    const implContractV1 = await ImplContractV1.deploy();
    const IMPL_ADDRESS = ethers.toBigInt(ethers.keccak256(ethers.toUtf8Bytes("eip1967.proxy.implementation"))) - 1n;
    const IFACE = new ethers.Interface(ABI);

    const Proxy = await ethers.getContractFactory("TestProxy");
    let data = IFACE.encodeFunctionData("initialize", [100]);
    const proxy = await Proxy.deploy(implContractV1.target, data);
    const contract = new ethers.Contract(proxy.target, ABI, deployer);

    console.log('ImplContractV1', implContractV1.target);
    console.log('version()', await contract.version());
    console.log('Proxy #0', await ethers.provider.getStorage(proxy.target, 0));
    console.log('Proxy #1', ethers.toBigInt(await ethers.provider.getStorage(proxy.target, 1)));
    console.log('Proxy #2', ethers.toBigInt(await ethers.provider.getStorage(proxy.target, 2)));
    console.log('Proxy #3', ethers.toBigInt(await ethers.provider.getStorage(proxy.target, 3)));
    console.log('Proxy ##', await ethers.provider.getStorage(proxy.target, IMPL_ADDRESS));

    // upgrade V2
    const ImplContractV2 = await ethers.getContractFactory("ImplContractV2");
    const implContractV2 = await ImplContractV2.deploy();
    data = IFACE.encodeFunctionData("initialize2", [200]);
    tx = await contract.upgradeToAndCall(implContractV2.target, data);
    receipt = await tx.wait();

    console.log('ImplContractV2', implContractV2.target);
    console.log('version()', await contract.version());
    console.log('Proxy #0', await ethers.provider.getStorage(proxy.target, 0));
    console.log('Proxy #1', ethers.toBigInt(await ethers.provider.getStorage(proxy.target, 1)));
    console.log('Proxy #2', ethers.toBigInt(await ethers.provider.getStorage(proxy.target, 2)));
    console.log('Proxy #3', ethers.toBigInt(await ethers.provider.getStorage(proxy.target, 3)));
    console.log('Proxy ##', await ethers.provider.getStorage(proxy.target, IMPL_ADDRESS));

    // upgrade V3
    const ImplContractV3 = await ethers.getContractFactory("ImplContractV3");
    const implContractV3 = await ImplContractV3.deploy();
    data = IFACE.encodeFunctionData("initialize3", [300]);
    tx = await contract.upgradeToAndCall(implContractV3.target, data);
    receipt = await tx.wait();

    console.log('ImplContractV3', implContractV3.target);
    console.log('version()', await contract.version());
    console.log('Proxy #0', await ethers.provider.getStorage(proxy.target, 0));
    console.log('Proxy #1', ethers.toBigInt(await ethers.provider.getStorage(proxy.target, 1)));
    console.log('Proxy #2', ethers.toBigInt(await ethers.provider.getStorage(proxy.target, 2)));
    console.log('Proxy #3', ethers.toBigInt(await ethers.provider.getStorage(proxy.target, 3)));
    console.log('Proxy ##', await ethers.provider.getStorage(proxy.target, IMPL_ADDRESS));

    console.log('a()', await contract.a());
    console.log('b()', await contract.b());
    console.log('c()', await contract.c());
    console.log("<<<<<<<<<<");
}

main().then(() => {
    process.exit(0);
}).catch((error) => {
    console.error(error);
    process.exit(1);
});

以下のコマンドで実行します。

npx hardhat run .\scripts\proxy.js

結果は以下となります。

>>>>>>>>>>
deployer 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
ImplContractV1 0x5FbDB2315678afecb367f032d93F642f64180aa3
version() 1n
Proxy #0 0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266
Proxy #1 100n
Proxy #2 0n
Proxy #3 0n
Proxy ## 0x0000000000000000000000005fbdb2315678afecb367f032d93f642f64180aa3
ImplContractV2 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
version() 2n
Proxy #0 0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266
Proxy #1 100n
Proxy #2 200n
Proxy #3 0n
Proxy ## 0x0000000000000000000000009fe46736679d2d9a65f0992f2272de9f3c7fa6e0
ImplContractV3 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9
version() 3n
Proxy #0 0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266
Proxy #1 100n
Proxy #2 200n
Proxy #3 300n
Proxy ## 0x000000000000000000000000dc64a140aa3e981100a9beca4e685f962f0cf6c9
a() 100n
b() 200n
c() 300n
<<<<<<<<<<

実装コントラクトのアドレスが変更されていることがわかります。
また、新しく追加した変数も更新されていることがわかります。
結果からアップグレードが出来ていることがわかります。

まとめ(Conclusion)

Proxyコントラクトの技術要素について、コードも含めてある程度理解できたと思います。
実装するにあたり、実装コントラクト側の実装に注意しないと、Storageのスロット衝突が起こってしまうので、注意が必要だと感じました。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?