はじめに(Introduction)
EVM互換ブロックチェーンのコントラクトにおいて、最近ではアップグレード可能なコントラクトにする傾向があります。
アップグレードは、Proxyコントラクトを用いて行うことが大半です。
そこで、Proxyコントラクトの主な技術要素をまとめてみます。
Proxy
Proxyコントラクトの動きは上図のイメージとなります。
手順としては、Proxyコントラクトから機能を呼び出します。
Proxyコントラクトのfallback
関数からdelegatecall
を使い、実装(Implementation)コントラクトを呼び出します。
データはProxyコントラクトのStorage
に格納されます。
したがって、fallback
、delegatecall
、Storage
が主な技術要素となります。
また、アップグレードは、delegatecall
の対象を新しい実装(Implementation)コントラクトに変更することで実現します。
主な技術要素
fallback
、Storage
、delegatecall
について見てみます。
fallback
例えば、以下のコントラクトに対してa()
機能の呼び出しは可能ですが、実装されていないb()
やc()
の呼び出しは出来ません。
// 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;
}
}
試してみます。
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.sol
に fallback
関数を追加してみます。
単純に a()
を呼び出すだけの処理とします。
// 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に格納されます。
// 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
と_b
はuint256
なのでスロット 0
と 1
に格納されてるはずです、以下のスクリプトで見てみます。
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
を作成します。
// 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
の比較をしてみます。
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()
の結果は同じですが、スロット 0
と 1
は入れ替わっています。
ソースコードに記述する順番によってスロットの場所が変わることを覚えておいてください。
delegatecall
fallback
から呼び出される delegatecall
は以下のような機能となります。
メッセージ呼び出しには、delegatecall という特別なバリエーションがあります。これは、ターゲット アドレスのコードが呼び出しコントラクトのコンテキスト (つまり、アドレス) で実行され、
msg.sender
とmsg.value
の値が変わらないという点を除けば、メッセージ呼び出しと同じです。
これは、コントラクトが実行時に別のアドレスからコードを動的にロードできることを意味します。ストレージ、現在のアドレス、残高は引き続き呼び出しコントラクトを参照し、コードのみが呼び出されたアドレスから取得されます。
Delegatecall.sol
から ImplContract.sol
を呼び出してみます。
確認用のコードの為、Proxy コードとしてはNGです。
// 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;
}
}
// 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;
}
}
以下のコードで動かしてみます。
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を使います。
// 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
関数で初期化を行います。
// 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を指定しています。
// 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 を指定しています。
// 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;
}
}
以下のコードで動かしてみます。
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
のスロット衝突が起こってしまうので、注意が必要だと感じました。