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
にcall
opcodeを利用して関数実行を受け渡しています。
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