EthereumのProxy Contractについて理解する
Proxy Contractとは?
EthereumにおいてProxy Contractとは、本体となるスマートコントラクトのインターフェースを提供し、スマートコントラクトのアップデートを行うためのスマートコントラクトです。
Ethereumではその特性上、一度デプロイしたスマートコントラクトは変更ができません。
そのため、スマートコントラクトのロジックを書き換えたい場合は別のアドレスに再度デプロイする必要があります。さらに、ただデプロイするだけではなくスマートコントラクトに存在したデータ(状態変数)も新しいスマートコントラクトに移行する必要があります。
もしそんなことをしたら一度のスマートコントラクトのアップデートには莫大なコスト(ガス代)がかかってしまいます。
そうならないための解決策が**プロキシコントラクト(Proxy Contract)**なのです。
それではプロキシコントラクトについて詳しく見ていきましょう!
Delegatecall
Delegatecallはあるコントラクトが他のコントラクトの関数を呼び出す時、呼び出し元コントラクトの状態をそのまま保持するというEthereumの機能です。
といっても抽象的すぎて何を言っているかわからないと思うので以下に具体例を示します。
まずは通常の関数呼び出しについて見てみます。
外部アカウント(EOA)がContractAの関数を呼び出し、ContractAがContractBの関数を呼び出しています。
注目して欲しいのはトランザクションのコンテキスト(msg.sender、msg.value、storage)です。
ContractAとContractBではコンテキストが異なっています。msg.senderは直前にトランザクションを送信したアドレスとなり、storageは呼び出された関数が実装されているコントラクトのものになっています。
次にDelegatecallを利用した関数の呼び出しを見てみます。
先ほどと似ていますが、ContractAがContractBの関数を呼び出す部分がb.delegatecallとなっています。
トランザクションのコンテキストを見てみましょう。
ContractBのコンテキストに注目して下さい。
通常の呼び出しだとすべてのコンテキストがContractAと異なっていますが、delegatecallで関数を呼び出すとすべてのコンテキストがContractAと一致しています。
つまり、ContractBでは何も状態が変更されておらず、ロジックのみを提供したということになります。
これがdelegatecallの特徴です。
Proxy Contractを実装する
それではdelegatecallを使って実際にProxy Contractを実装してみましょう。
イメージは以下の図のようにProxyを使ってLogic1とLogic2を切り替えられるようにしてみます。
環境構築
hardhatでプロジェクトを立ち上げます。
mkdir proxy
npm init -y
npm i hardhat
npx hardhat init
Contractを実装する
contract Proxy {
address implementaion;
function changeImplementation(address _implementation) external {
implementaion = _implementation;
}
fallback() external {
(bool success,) = implementaion.call(msg.data);
require(success);
}
}
contract Logic1 {
uint public x = 0;
function changeX(uint _x) external {
x = _x;
}
}
contract Logic2 {
uint public x = 0;
function changeX(uint _x) external {
x = _x;
}
function tripleX() external {
x *= 3;
}
}
ProxyとLogic1とLogic2というコントラクトを実装しました。
詳しい説明は省略しますが、ProxyのchangeImplementationでProxyが呼び出すコントラクトを変更できるようにしています。また、fallback関数を利用することで、Proxyを一般化しています。
Logic1とLogic2には簡単なコントラクトを実装しています。
Proxyコントラクトではcallを使用しているため、この状態だとimplementationに保存されたコントラクトアドレスのコンテキストでトランザクションが実行されます。
ユニットテストを実行する
ここまでの挙動が想定通りかユニットテストで確認してみましょう。
ここでは
- Logic1が想定通りに動くこと
- Logic1からLogic2へのアップグレードが上手くいくこと
をテストしています。
import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { AddressLike, BigNumberish, getAddress } from "ethers";
const { assert } = require("chai");
import { ethers } from "hardhat";
describe("Proxy", function () {
// We define a fixture to reuse the same setup in every test.
// We use loadFixture to run this setup once, snapshot that state,
// and reset Hardhat Network to that snapshot in every test.
async function deployFixture() {
const Proxy = await ethers.getContractFactory("Proxy");
const proxy = await Proxy.deploy();
const Logic1 = await ethers.getContractFactory("Logic1");
const logic1 = await Logic1.deploy();
const Logic2 = await ethers.getContractFactory("Logic2");
const logic2 = await Logic2.deploy();
const proxyAsLogic1 = await ethers.getContractAt(
"Logic1",
await proxy.getAddress()
);
const proxyAsLogic2 = await ethers.getContractAt(
"Logic2",
await proxy.getAddress()
);
return { proxy, logic1, logic2, proxyAsLogic1, proxyAsLogic2 };
}
// コントラクトのstorageを読む関数
async function lookupUint(contractAddr: AddressLike, slot: BigNumberish) {
return parseInt(await ethers.provider.getStorage(contractAddr, slot));
}
describe("Deployment", function () {
// Logic1が想定通りに動くかのテスト
it("Should work logic1", async function () {
const { proxy, logic1, proxyAsLogic1 } = await loadFixture(deployFixture);
await proxy.changeImplementation(await logic1.getAddress());
assert.equal(await lookupUint(await logic1.getAddress(), "0x0"), 0);
await proxyAsLogic1.changeX(52);
assert.equal(await lookupUint(await logic1.getAddress(), "0x0"), 52);
});
// Logic1->Logic2にアップグレードするテスト
it("Should work upgrades", async function () {
const { proxy, logic1, logic2, proxyAsLogic1, proxyAsLogic2 } = await loadFixture(deployFixture);
await proxy.changeImplementation(await logic1.getAddress());
assert.equal(await lookupUint(await logic1.getAddress(), "0x0"), 0);
await proxyAsLogic1.changeX(45);
assert.equal(await lookupUint(await logic1.getAddress(), "0x0"), 45);
await proxyAsLogic2.changeX(25);
assert(await lookupUint(await proxy.getAddress(), "0x0"), 75);
});
});
});
テストを実行します。
npx hardhat test ./test/Proxy.ts
成功しました🎉
npx hardhat test ./test/Proxy.ts
Proxy
Deployment
✔ Should work logic1 (2129ms)
✔ Should work upgrades (79ms)
2 passing (2s)
Proxy Contractをアップデートする
ここまでは通常のcallを使ってProxyに実装されるコントラクトを切り替えていました。
先ほども言いましたがこのままだとLogic1とLogic2のコンテキストで関数が実行されるので、それぞれのコントラクトが持っている状態変数xが変更の対象となっています。
つまりLogic1からLogic2へのアップグレードが行われるとLogic1が持っていたデータ(ここでは状態変数x)がリセットされてしまうのです。
今回はxしかデータがないので問題がなさそうに見えますが、もしLogic1が膨大なデータを持っていた場合はどうでしょうか?ただ関数をアップグレードしたいだけなのにデータごとすべてアップデートするとなるとお金と時間がいくらあっても足りないですよね。
それではそんな問題を解決するコントラクトにアップデートしていきます。
EIP1967について
それではProxyをアップデートします。
contract Proxy {
address implementation;
uint x = 0;
function changeImplementation(address _implementation) external {
implementation = _implementation;
}
fallback() external {
(bool success, ) = implementation.delegatecall(msg.data);
require(success);
}
}
contract Logic1 {
uint x = 0;
function changeX(uint _x) external {
x = _x;
}
}
contract Logic2 {
uint x = 0;
function changeX(uint _x) external {
x = _x;
}
function tripleX() external {
x *= 3;
}
}
implemetaion.callをimplementation.delegatecallに変更しました。
また、Proxyコントラクトに状態変数xを追加して、データの管理をProxyに集約しました。
これでコントラクトをアップグレードしてもデータ自体はProxyが持つことになるので、アップグレードするコントラクトのロジックのみに集中できます。
テストコードも書き換えましょう。
// 省略
// Logic1->Logic2にアップグレードするテスト
it("Should work upgrades", async function () {
const { proxy, proxyAsLogic1, proxyAsLogic2, logic1, logic2 } =
await loadFixture(deployFixture);
await proxy.changeImplementation(await logic1.getAddress());
assert.equal(await lookupUint(await proxy.getAddress(), "0x0"), 0);
await proxyAsLogic1.changeX(45);
assert.equal(await lookupUint(await proxy.getAddress(), "0x0"), 45);
await proxy.changeImplementation(await logic2.getAddress());
assert.equal(await lookupUint(await proxy.getAddress(), "0x0"), 45);
await proxyAsLogic2.changeX(25);
await proxyAsLogic2.tripleX();
assert(await lookupUint(await proxy.getAddress(), "0x0"), 75);
});
// 省略
先ほど書いたテストを一部変更しました。
具体的にはlogic1からlogic2にアップグレードしたときにProxyの状態変数xを参照して、適切な値が入っているかを確認するようにしています。
テストを実行します。
npx hardhat test ./test/Proxy.ts
Proxy
Deployment
✔ Should work logic1 (1708ms)
1) Should work upgrades
1 passing (2s)
1 failing
1) Proxy
Deployment
Should work upgrades:
AssertionError: expected 1.3241613105987439e+48 to equal +0
+ expected - actual
-1.3241613105987439e+48
+0
at Context.<anonymous> (test/Proxy.ts:53:14)
at processTicksAndRejections (node:internal/process/task_queues:96:5)
失敗しました。なぜでしょう?
これはContractのStorage slotの衝突から起こります。
Storage slotについて知らない方は以下の記事をご覧ください。
contract Proxy {
address implementation; // 0x0
uint x = 0; // 0x1
//省略
}
contract Logic1 {
uint x = 0; // 0x0
//省略
}
contract Logic2 {
uint x = 0; // 0x0
//省略
}
上記見ていただくとわかるように、Logic1とLogic2には0x0にxが保存されていますが、Proxyでは0x1にxが保存されています。先ほどのテストのAssertionError: expected 1.3241613105987439e+48 to equal +0
というのはProxyの0x0に保存されているimplementationを参照しているために起こったのでした。
このようにアップグレード可能なプロキシにおいてデリゲートコールの対象となるコントラクトのアドレスを格納するスロットを標準化するための規格がEIP1967です。
具体的にはkeccak256('eip1967.proxy.implementation')
で得られた値のスロットに対象のコントラクトのアドレスが格納されます。
イーサリアムのストレージスロットの操作を補助するライブラリを実装しましょう。
実装と言ってもイーサリアム公式からコピペするだけです。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;
library StorageSlot {
struct AddressSlot {
address value;
}
struct BooleanSlot {
bool value;
}
struct Bytes32Slot {
bytes32 value;
}
struct Uint256Slot {
uint256 value;
}
/**
* @dev Returns an `AddressSlot` with member `value` located at `slot`.
*/
function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
assembly {
r.slot := slot
}
}
/**
* @dev Returns an `BooleanSlot` with member `value` located at `slot`.
*/
function getBooleanSlot(bytes32 slot) internal pure returns (BooleanSlot storage r) {
assembly {
r.slot := slot
}
}
/**
* @dev Returns an `Bytes32Slot` with member `value` located at `slot`.
*/
function getBytes32Slot(bytes32 slot) internal pure returns (Bytes32Slot storage r) {
assembly {
r.slot := slot
}
}
/**
* @dev Returns an `Uint256Slot` with member `value` located at `slot`.
*/
function getUint256Slot(bytes32 slot) internal pure returns (Uint256Slot storage r) {
assembly {
r.slot := slot
}
}
}
詳しい説明は省きますが、何となくイーサリアムのstorageSlot操作を補助するんだくらいに思っておいて下さい。
StorageSlot使ってProxyコントラクトを書き換える
それではProxyコントラクトを完成させましょう。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;
// Uncomment this line to use console.log
// import "hardhat/console.sol";
import "./StorageSlot.sol";
contract Proxy {
function changeImplementation(address _implementation) external {
StorageSlot.getAddressSlot(keccak256('eip1967.proxy.implementation')).value = _implementation;
}
fallback() external {
(bool success, ) = StorageSlot.getAddressSlot(keccak256('eip1967.proxy.implementation')).value.delegatecall(msg.data);
require(success);
}
}
contract Logic1 {
uint x = 0;
function changeX(uint _x) external {
x = _x;
}
}
contract Logic2 {
uint x = 0;
function changeX(uint _x) external {
x = _x;
}
function tripleX() external {
x *= 3;
}
}
changeImplementationとfallback関数の中身が変わっています。
これでストレージスロットの衝突なく、Proxyにデータを集約させることができます。
先ほど失敗したテストを実行しましょう。
npx hardhat test ./test/Proxy.ts
Compiled 1 Solidity file successfully
Proxy
Deployment
✔ Should work logic1 (1884ms)
✔ Should work upgrades (110ms)
2 passing (2s)
無事成功しました🎉
これでデータはProxyに集約させつつ、ロジック部分だけを簡単にアップグレードできるようになりました!
まとめ
今回はEthereumのProxy Contractについて理解しました。
Proxyを使ってアップグレード可能なコントラクトを実装することができるようになります。
一度デプロイすると絶対変更不可能のEthereumにおいて必ず必要な技術なのでこの機会にぜひ理解しましょう!
参考
Discussion