🔥

[Astar]wasmコントラクトのストレージレイアウトについて

2023/02/28に公開

この記事は、こちらの「ストレージレイアウト」の部分を補足を加えながら日本語訳したものです。
https://docs.openbrush.io/smart-contracts/upgradeable

何度もすらすら読むべき内容だと思ったので、翻訳しました。

概要

スマートコントラクトはデフォルトでイミュータブル(変更不可能)であり、コントラクトにセキュリティと信頼のレイヤーを追加しています。しかし、ソフトウェアの品質は、反復的なリリースを作成するためにソースコードをアップグレードする能力に依存しています。バグ修正と潜在的な製品改良のために、ある程度の変更可能性が必要です。

アップグレード可能であれば、脆弱性を修正し、徐々に機能を追加する機会を常に残しながら、初期段階で製品を実験し、展開することができます。ink!とcontract-palletが活発に開発されている今、より現実的なものとなっています。アップグレード可能なコントラクトは、分散化を意識して開発すれば、バグではありません。

アップグレードの権利をガバナンスやマルチシグなどの分散型権威にのみ与えることで、分散化を実現することができます。

コントラクトの論理をアップグレードするのは難しいことではありません。ProxyやDiamondパターン、あるいはcontract-palletが提供するset_code_hash関数で実現可能です。最も難しいのは、コントラクトの状態を保存して、新しいロジックと互換性を持たせることです。

ストレージのレイアウト

ストレージの仕組み

コントラクトは、データを保持するためにKey-Valueストレージを使用します。コントラクトの各フィールドは、そのキーを持ち、ストレージセルを占有することができる。これをストレージレイアウトと呼びます。

コンパイル時にインク!はストレージで動作するコードを挿入し、インク!は各タイプをどのストレージセルにどのように格納するかを知っています。このチュートリアルの対象ではありません。重要なのは、各型は一意な識別子によって、各フィールドの操作方法とストレージの操作方法を知っているということです。古いバージョンのink!では、識別子は[u8; 32]でしたが、新しいバージョンではu32になっています。

つまり、各データはその一意な識別子であるストレージキーの下に格納されています。このキーの値はバイト列で、(SCALE コーデックによって)直列化されたデータ型です。ロジックレイヤーは各データタイプのシリアライズとデシリアライズの方法を知っています。そのため、実行中にロジックレイヤーはすべてのデータをストレージキーでデシリアライズし、充填されたコントラクトのストレージ変数を返します。開発者はその変数を操作し、実行が終了する前に、ロジック層はデータをバイト列にシリアライズし、ストレージセルに格納します。

アップグレード可能なストレージレイアウトのルール

コントラクトは、(Diamondパターンのように)複数のロジックレイヤーを持つことができます。そこで、複数のレイヤーに対応したルールを定義しますが、これは Proxy パターンや set_code_hash を使ったアップグレード可能なコントラクトにも当てはまります。

  1. ストレージキーの下に保存されるデータは、すべてのロジックレイヤーで同じシリアライズおよびデシリアライズのメカニズムを使用する必要があります。そうでないと、一部のロジック層でデータ型のデシリアライズができず、失敗します。

  2. 各ロジックユニット(ほとんどの場合、タイプ)は、すべてのロジックレイヤーで同じストレージキーを占有する必要があります。例えば、Mapping<Owner, Balances>を使用してユーザーの残高を追跡するいくつかのロジック層があるとします。同じマッピングを操作する場合は、同じストレージキーを使用する必要があります。そうでなければ、異なるマッピングを扱うことになります。


chatGPTによる図解

  1. あるストレージキーを占有する各フィールドは、その利用フローの中でのみ使用されるべきである。例えば、あるロジック層でトークンAのユーザーの残高を追跡するためにMapping<Owner, Balances>を用意したとする。そうしないと、あるロジック層が別のロジック層を上書きしてしまいます。
    このルールに従えば、ストレージの破損を防ぐことができます。これらのルールは、アップグレードされたロジック層にも適用されます。


chatGPTによる図解

もし、すべてのロジックレイヤーで同じストレージレイアウトを使用し、レイヤーごとにユニークなフィールドを持つ予定がない場合(つまり、将来のアップグレードでストレージレイアウトを変更する予定がない場合)。その場合、自動的に計算されるストレージ・キーですでにそのルールに従っています。しかし、レイヤーごとにユニークなレイアウトを使用したい場合や、将来レイアウトを変更する予定がある場合は、次のセクションが役に立ちます。

ルールを守るための提案

アプローチの記述

各フィールドのストレージキーを手動で設定すると、ルールに従うことはできますが、開発が難しくなります。 ink!では、すべてのユーティリティ特性を手動で実装し、各フィールドで使用するストレージキーを指定することができます。もしあなたのコントラクトが20のフィールドを持つなら、20のストレージキーを設定する必要があります。

主な提案は、ストレージレイアウトをロジックユニットの束として設計し、ロジックユニットに一意のストレージキーを割り当てることです。ロジックユニットは、1つのフィールドでも複数のフィールドでもかまいません。ロジック・ユニットのスコープでは、ロジック・ユニットのストレージ・キーでオフセットされた自動計算キーを使用することもできますし、また同じアプローチでロジックをより多くのユニットに分割することもできます。

この方法では、ユニットを自由に並べることができます。ロジックユニットを追加/削除/交換しても、各ロジックユニットがブロックチェーンのストレージにそのスペースを持つので、ストレージのレイアウトを気にする必要はありません。ストレージのキーが一意であれば、それらのスペースが重なることはありません。


chatGPTによる図解

OpenBrushはopenbrush::upgradeable_storage属性マクロを提供し、指定されたストレージキーで必要なすべての特性を実装します(ストレージキーはマクロへの必須の入力引数です)。また、フィールドが初期化されていない場合は、デフォルト値で初期化します(アップグレード中は、新しいフィールドはまだ初期化されないので、実際のフィールドになる可能性があります)。このマクロを使用して、ロジックユニットを定義することができます。

ビジネスユースケースごとのロジックユニット

以下のように、すべてのフィールドをロジック・ユニットに含めることができます。

#[openbrush::upgradeable_storage(0x123)]
pub struct Data {
    balances: Mapping<Owner, Balance>,
    total_owners: u128,
}

これによって、コードが読みやすくなり、ビジネスロジックによって分離されるようになります。しかし、将来のアップグレードのためにいくつかの制限を追加することになります。

将来のアップグレードのための制限事項

ストレージに独立したスペースを持たない各フィールドは、ほとんどの場合、フィールドの順序に依存します(新しいインクを使用する場合は、ネーミングもそうなるかもしれません!)。そのため、フィールドを削除したり、順序(と命名)を変更したりすることはできません。

しかし、新しいフィールドを追加することはできます。そのためには、将来の型のために、コントラクトの中で空の型 Option<()> を持つフィールドを1つ確保することができます。

#[openbrush::upgradeable_storage(0x123)]
pub struct Data {
    balances: Mapping<Owner, Balance>,
    total_owners: u128,
    _reserved: Option<()>,
}

そのフィールドのデフォルト値はNoneである。しかし、将来的には、何らかの有用な型と値でそれを開始することができます。

#[openbrush::upgradeable_storage(0x123)]
pub struct Data {
    balances: Mapping<Owner, Balance>,
    total_owners: u128,
    _reserved: Option<DataExtend>,
}

impl Data {
    fn extension(&mut self) -> &mut DataExtension {
        &mut self._reserved.unwrap_or_default()
    }
}

#[derive(Default)]
pub struct DataExtension {
  owners_blacklist: Mapping<Owner, ()>,
  _reserved: Option<()>,
}

こちらのコードの内容は、chatGPTによると以下のようになります。


chatGPTによる図解

そのため、将来何度もコントラクトを修正すると、_reservedフィールドの深いスタック、つまり多くのデッドフィールドが発生する可能性があります。新しいロジック・ユニットを作って、古いものを埋め込むことはいつでもできます。そこで、今、自分にとって何が良いかを判断する必要があります。新しいロジック・ユニットを作って古いロジックを埋め込むか、新しいフィールドを現在のものに追加するかです。

各フィールドごとのロジックユニット

このように、各フィールドごとに固有の型を作ることができます。

#[openbrush::upgradeable_storage(0x123)]
pub struct Balances(openbrush::storage::Mapping<AccountId, Balance>);

#[openbrush::upgradeable_storage(0x124)]
pub struct TotalOwners(u128);

制限はありませんが、コードを読みにくくしましたし、ユニークな構造をたくさん持っているかもしれません。

ストレージ・キーの一意性

ストレージ・キーは、各ロジック・ユニットごとに一意でなければなりません。それぞれのキーを手動で割り当てることもできますし、ハッシュ関数を使って自動化することもできます。

OpenBrushはopenbrush::storage_unique_key!マクロを提供し、構造体へのパスに基づいてストレージ・キーを生成します。必要な入力引数は1つで、構造体の名前です。

#[openbrush::upgradeable_storage(openbrush::storage_unique_key!(Data))]
pub struct Data {
    balances: Mapping<Owner, Balance>,
    total_owners: u128,
    _reserved: Option<()>,
}

or

pub const STORAGE_KEY: u32 = openbrush::storage_unique_key!(Data);

#[openbrush::upgradeable_storage(STORAGE_KEY)]
pub struct Data {
    balances: Mapping<Owner, Balance>,
    total_owners: u128,
    _reserved: Option<()>,
}

Discussion