Chapter 12: 更新可能なスマートコントラクト | Solidity Programming Essentialsを読む
イーサリアムの知識を整理するために2022年6月発売のSolidity Programming Essentials 2nd Editionを読み進める試みです。この記事ではChapter12「Upgradable Smart Contracts」を読み進めます。
読書ログは以下のスクラップで逐次更新していきます。
この章で扱われるトピックは以下の通りです。
- Learning what constitutes upgradability
- Understanding dependency injection
- Reviewing problematic smart contracts
- Implementing simple solutions with inheritance
- Implementing simple solutions with composition
- Implementing advanced solutions using proxy contracts
- Writing upgradable contracts with upgradable storage
仕様上スマートコントラクトは更新できない問題
一般的なソフトウェアと違い、スマートコントラクトは一度デプロイすると新しいコントラクトに置き換えることができません(!)。スマートコントラクト内のコードを一切変更せず同じコントラクトを再度デプロイしても新しいアドレスが生成されるだけで、元のアドレスのコードが書き換わることはないのです。
とはいえバグの修正などでコントラクトを更新したくなることは当然あります。その場合、古いコントラクトのアドレスではなく新しいコントラクトアドレスを利用してもらうように促せば良いかも知れませんが、厄介なことにコントラクトは状態とEther残高を保持しており、新しいコントラクトを正常に動かすためにはデータ移行の必要性があります。労力的なコストだけでなく金銭的なコストもかかるため、あまり望ましくありません。
というわけで本章では、アップグレード可能なコントラクトの設計・実装について学んでいきます。
DI(Dependency Injection: 依存性注入)を理解する
カンの良い人はお気づきかも知れませんが、コントラクトへはアドレスを経由してアクセスできるため、この考え方を利用すれば実装コントラクトとユーザーが実際にアクセスするコントラクトを分離することができます。
インターフェースさえ変わらなければ、アップグレードがある場合は実装コントラクトをデプロイして、実装コントラクトの新しいアドレスをユーザーが実際にアクセスするコントラクトに再設定すれば良いのです。これでユーザーがアクセスするコントラクトのアドレス自体は変わらずに、コントラクトの実装だけをアップグレードすることができます。このような設計の考え方を依存性注入(以下DI)と呼びます。
DIのタイミングには以下の2つがあります。
- デプロイ時
- デプロイ後
以下で具体的な実装を見ていきます。
デプロイ時に依存コントラクトのアドレスを渡せるようにする
デプロイ時にはコンストラクタを経由して依存コントラクトのアドレスを渡せるようにします。以下はIndependentContractのアドレスをデプロイ時にDependentSmartContractに渡せるようにコンストラクタを記述した例です。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract IndependentContract {
mapping(address => uint256) balances;
constructor(uint256 amount) public {
balances[msg.sender] = amount;
}
}
contract DependentSmartContract {
IndependentContract indeContract;
constructor(address indc) public {
indeContract = IndependentContract(indc);
}
}
デプロイ後に依存コントラクトのアドレスを渡せるようにする
デプロイ後にも依存コントラクトのアドレスを変更できるようにする必要があります。DependentSmartContractにデプロイ後もアドレス変更ができるよう実装を追加した例が以下です。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract IndependentContract {
mapping(address => uint256) balances;
constructor(uint256 amount) public {
balances[msg.sender] = amount;
}
}
contract DependentSmartContract {
IndependentContract indeContract;
constructor(address bankc) public {
indeContract = IndependentContract(bankc);
}
function changeContractAddress(address _address) public {
indeContract = IndependentContract(_address);
}
}
問題のあるコントラクトをレビューする
それではもう少し具体的な例から学んでいきましょう。銀行が利用するBankコントラクトがあるとします。アドレス毎の残高を管理するbalancesステート変数があり、口座の引落(Debit)や貸出(Credit)を行う関数を備えています。コンストラクタには口座残高を初期設定する機能があり、この例の場合はmsg.sender(コンストラクタを実行した外部アカウントのアドレス)としているので、銀行自体の残高を設定することになります。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Bank {
mapping(address => uint256) public balances;
constructor(uint256 amount) public {
balances[msg.sender] = amount;
}
function Debit(address accountAddress, uint256 amount) public returns (bool) {
balances[accountAddress] = balances[accountAddress] - amount;
return true;
}
function Credit(address accountAddress, uint256 amount)
public
returns (bool)
{
balances[accountAddress] = balances[accountAddress] + amount;
return true;
}
}
次にBankコントラクトを利用するBankClientの例を考えてみます。コンストラクタにBankコントラクトのアドレスを設定できるようにすることで、デプロイ済みのBankコントラクトにアクセスできるよう実装しています。実際にはコンパイル時にSolidityコンパイラがBankコントラクトのインターフェースを知る必要があるため、Bankのインターフェースをimport "./Bank.sol"といった形で渡す必要がありますが、この例の場合は両コード共に同じファイルに記述しているとします。
contract BankClient {
Bank bankContract;
constructor(address bankc) public {
bankContract = Bank(bankc);
}
function NewTransaction(address to, uint256 amount) public returns (bool) {
bankContract.Debit(msg.sender, amount);
bankContract.Credit(to, amount);
return true;
}
}
BankClientコントラクトはBankコントラクトの更新があったとしても新しいアドレスを再設定することで実装を入れ替えることができます。一方でBankコントラクトの更新があった場合はどうなるでしょう?
継承を利用したシンプルな解決策を実装する
Bankコントラクトを更新する場合、ネックになるのがステート変数です。更新後のコントラクトにステート変数を引き継ぐことはできません。というわけで、次は状態と機能を分離することによって、より更新しやすい設計に変更していくことにします。
状態をBankStorageコントラクト、機能をBankコントラクトに分離したのが以下の例です。継承を利用することにより、Bankコントラクトからステート変数にアクセスできるようにしています。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract BankStorage {
mapping(address => uint256) public balances;
}
contract Bank is BankStorage {
constructor(uint256 amount) {
balances[msg.sender] = amount;
}
function Debit(address accountAddress, uint256 amount) public returns (bool) {
balances[accountAddress] = balances[accountAddress] - amount;
return true;
}
function Credit(address accountAddress, uint256 amount)
public
returns (bool)
{
balances[accountAddress] = balances[accountAddress] + amount;
return true;
}
}
また先ほどの例ではBankClientのデプロイ後、更新したBankのアドレスを再設定することができませんでした。再設定できるようNewBankAddress関数を加えておきます。
contract BankClient {
Bank bankContract;
constructor(address bankc) public {
bankContract = Bank(bankc);
}
function NewBankAddress(address bankc) {
bankContract = Bank(bankc);
}
function NewTransaction(address to, uint256 amount) public returns (bool) {
bankContract.Debit(msg.sender, amount);
bankContract.Credit(to, amount);
return true;
}
}
さて、次にBankコントラクトをアップグレードしたNewBankコントラクトを作成したいとします。ステート変数を管理しているBankStorageコントラクトを継承する形で実装します。
contract NewBank is BankStorage {
constructor(uint256 amount) {
balances[msg.sender] = amount;
}
function Debit(address accountAddress, uint256 amount) public returns (bool) {
require(balances[accountAddress] + amount >= amount);
balances[accountAddress] = balances[accountAddress] - amount;
return true;
}
function Credit(address accountAddress, uint256 amount)
public
returns (bool)
{
require(balances[accountAddress] - amount <= amount);
balances[accountAddress] = balances[accountAddress] + amount;
return true;
}
}
これで新しくNewBankコントラクトをデプロイしたあと、BankClientコントラクトのNewBankAddress関数でNewBankコントラクトのアドレスを設定すれば、これまでのステート変数を引き継いだ状態でコントラクトのアップグレードができるというわけです。
次にオブジェクト指向プログラミングのコンポジションを使ったパターンを考えてみます。
コンポジションを利用したシンプルな解決策を実装する
前の例ではBankがBankStorageを継承することでステート変数へのアクセスを実現していましたが、次は継承を使わずにアクセスできるようにします。BankClientがBankのアドレスを持つことでBankへのアクセスを可能にしていたのと同様、BankStorageのアドレスを持つことでアクセス可能な状態にします。
そのためにBankStorageにgetterとsetterを実装しておきます。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract BankStorage {
mapping(address => uint256) public balances;
function SetBalance(address addr, uint256 amount) public returns (bool) {
balances[addr] = amount;
}
function GetBalance(address addr) public returns (uint256) {
return balances[addr];
}
}
次にBankコントラクトはBankStorageを継承しなくなった代わりに、BankStorageのアドレスを保持できるようにしておきます。
contract Bank {
BankStorage store;
constructor(uint256 amount, address bankstorage) {
store = BankStorage(bankstorage);
store.SetBalance(msg.sender, amount);
}
function SetBankStorage(address bankstorage) public {
store = BankStorage(bankstorage);
}
function Debit(address accountAddress, uint256 amount) public returns (bool) {
store.SetBalance(
accountAddress,
(store.GetBalance(accountAddress) - amount)
);
return true;
}
function Credit(address accountAddress, uint256 amount)
public
returns (bool)
{
store.SetBalance(accountAddress, store.GetBalance(accountAddress) + amount);
return true;
}
}
BankClientのコードを変更する必要はありません。
プロキシコントラクトを利用した高度な実装例
プロキシとは直訳では代理人という意味です。以下の例ではProxyContractという代理人を介して、更新可能なコントラクトとクライアントコントラクト間の通信を実現してみます。この実装のためにはアセンブリを使う必要があるので、高度な例というわけです。
まず以下のようにIBankingインターフェースと、ステート変数を保持するmystorageコントラクトがあるとします。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
interface IBanking {
function NewBankingCustomer(uint256 customerid, address custaddress) external;
function GetBankingCustomer(uint256 customerid) external returns (address);
}
contract mystorage {
mapping(uint256 => address) public customers;
function GetCustomer(uint256 customerid) internal returns (address) {
return customers[customerid];
}
function SetCustomer(uint256 customerid, address custaddress)
internal
returns (bool)
{
customers[customerid] = custaddress;
return true;
}
}
次にIBankingインターフェースを実装するコントラクトとしてManageCustomerコントラクトを実装します。
contract ManageCustomer is IBanking, mystorage {
function NewBankingCustomer(uint256 customerid, address custaddress)
external
override
{
SetCustomer(customerid, custaddress);
}
function GetBankingCustomer(uint256 customerid)
external
override
returns (address)
{
address addr = GetCustomer(customerid);
return addr;
}
}
さて、このManageCustomerコントラクトを更新可能にするためにはどうすれば良いでしょうか?
そこでプロキシコントラクトです。プロキシコントラクトには受け渡し先のコントラクトアドレスと、fallback関数を用意します。プロキシコントラクトの関数を呼び出すとfallback関数が呼び出され、関数名やパラメータの情報をそのまままるっと受け渡し先のコントラクトに受け渡すという訳です。
以下がその実装例です。maincontractにcallopcodeを利用して関数実行を受け渡しています。
contract ProxyContract {
address maincontract;
function AssignContract(address addr) public {
maincontract = addr;
}
fallback() external payable {
address mc = maincontract;
assembly {
let startAddress := mload(0x40)
calldatacopy(startAddress, 0, calldatasize())
let result := call(100000, mc, 0, aa, calldatasize, 0, 0)
let sz := returndatasize()
returndatacopy(startAddress, 0, sz)
if eq(result, 0) {
revert(0, 0)
}
return(startAddress, sz)
}
}
}
ProxyContractの中身はfallback関数でしかないので、呼び出し側はProxyContractの中身を知る必要はありません。なので呼び出し側はシンプルにこんな風に書けます。
contract Client {
address maincontract;
function AssignContract(address addr) public {
maincontract = addr;
}
function NewBankingCustomer(uint256 customerid, address custaddress)
public
returns (bool)
{
maincontract.call(msg.data);
return true;
}
function GetBankingCustomer(uint256 customerid) public returns (bool) {
maincontract.call(msg.data);
return true;
}
}
この設計の利点はClientコントラクトがManagerClientコントラクトに依存しないということです。
アップグレード可能なストレージを設計する
この章の内容は以上です。
Discussion