【Sui】スマート・コントラクト開発 - MoveとRustの比較(Solana vs. Move)
1章~4章はこちら
5. Solana vs. Move
Moveプログラミングの仕組みとその安全性が理解できたところで、これがスマートコントラクト・プログラミングにどのような意味を持つのか、コンポーザビリティ、使い勝手、安全性の観点から詳しく見ていこう。ここでは、Move/Sui開発とEVM、Rust/Solana/Anchorを比較することで、Moveのプログラミング・モデルがもたらすものを理解する助けとしたい。
5.1. フラッシュレンディング
フラッシュ・レンディングの例から始めよう。フラッシュ・ローンはDeFiにおけるローンの一種であり、借りた金額は同じ取引(トランザクション)内で返済しなければならない。この主な利点は、取引がアトミックであるため、ローンが完全に無担保であることだ。これにより、例えば、元本なしで資産間のアービトラージを実行することができる。
これを実装する主な難点は、フラッシュローン・スマートコントラクト内で貸し出された金額が同じ取引内で返済されることをどのように保証するか、ということだ。ローンが無担保であるためには、取引がアトミックである必要がある。すなわち、貸し出された金額が同じ取引内で返済されない場合、取引全体が失敗しなければならない。
EVMには動的ディスパッチがあるため、次のようにリエントランシーを使用してこれを実装することが可能です:
フラッシュローンのユーザーはカスタムスマートコントラクトを作成し、アップロードする。このコントラクトは呼び出されるとフラッシュローン・スマートコントラクトに制御を渡す
フラッシュローン・スマートコントラクトは、要求されたローン金額をカスタムスマートコントラクトに送り、カスタムスマートコントラクトの executeOperation() コールバック関数を呼び出す
その後、カスタムスマートコントラクトは、受け取ったローン金額を使用して希望する操作(例:アービトラージ)を実行する。
カスタムスマートコントラクトが操作を完了すると、ローン金額をフラッシュローン・スマートコントラクトに返済する必要がある
これでカスタムスマートコントラクトの executeOperation() が終了し、制御がフラッシュローンスマートコントラクトに戻る。この時点でローン金額が正しく返済されたかを確認する
カスタムスマートコントラクトがローン金額を正しく返済していない場合、取引全体が失敗する
これは必要な機能を完璧に実装しているが、リエントランシーに依存しているため、スマートコントラクトプログラミングでは避けたいものだ。なぜならリエントランシーは本質的に非常に危険であり、有名なDAOハッキングを含む多くの脆弱性の根本原因だったからだ。
Solanaでは、各取引は複数のインストラクション(スマートコントラクトの呼び出し)で構成されており、どのインストラクションからも同じ取引内の他のインストラクション(そのプログラムID、インストラクションデータ、およびアカウント)を調べることができます。これにより、次のようにフラッシュローンを実装することが可能です:
Solanaはリエントランシーを許さないので、この点では優れている。しかし、リエントランシーがなく、フラッシュローン・スマートコントラクトがカスタムスマートコントラクトにコールバックすることができない場合、Solanaでどのようにフラッシュローンを実装するのだろうか?これは、インストラクション・イントロスペクションのおかげで可能になる。
Solanaでは、各取引は複数の命令(スマートコントラクトの呼び出し)で構成されており、どの命令からでも、同じトランザクションに存在する他の命令(そのプログラムID、命令データ、アカウント)を検査することができる。これにより、以下のようにフラッシュローンを実装することが可能になる:
フラッシュローン・スマートコントラクトは、借り入れと返済の命令を実装する
ユーザーは、借り入れと返済の命令呼び出しを同じ取引内にまとめることで、フラッシュローン取引を作成する。借り入れ命令は、実行時に返済命令が同じ取引の後にスケジュールされていることをインストラクションイント・ロスペクションを使用して確認する。返済命令呼び出しが存在しないか無効な場合、この時点で取引が失敗する
借り入れと返済の呼び出しの間、借りた資金は他の任意の命令で自由に使用できる
取引の最後に、返済命令呼び出しは資金をフラッシュレンダー・スマートコントラクトに返済する(この命令の存在は借り入れ命令内のイントロスペクションで確認される
興味のある方には、これのプロトタイプ実装がこちらにある(リンク)。
この解決策は十分に機能するが、理想的とは言えない。インストラクション・イントロスペクションは、やや特殊なケースであり、Solanaでは一般的ではない。また開発者が習得する必要のある概念や、実装の際にオーバーヘッドや制限がある。返済命令は取引内に静的に存在する必要があるため、取引実行中にCPI呼び出しで動的に返済を呼び出すことはできない。これは大きな問題ではないが、他のスマートコントラクトと統合する際のコードの柔軟性を制限し、クライアントサイドに複雑さを押し付けることになる。
注記:インストラクション・イントロスペクションなしでフラッシュ・レンディング実装する別のアプローチもある。レンディング・スマートコントラクト内のフラッシュ・レンディング命令が資金を持って任意のスマートコントラクトにCPI呼び出しを行い、そのCPI呼び出しが返された後に資金が正しく返済されたことを確認する。この方法はSPL Lendingプログラムによって実装されている(リンク)。しかし、これには別の問題がある。複数のローンを単一の呼び出しで集約(統合)する汎用的な方法がないのだ。
Moveも動的ディスパッチとリエントランシーを禁止しているが、Solanaとは異なり、フラッシュ・レンディングには非常にシンプルで自然な解決策がある。Moveの線形型システムでは、取引実行中に一度だけ消費されることが保証された構造体を作成することができる。これは「ホットポテト」パターンと呼ばれ、key, store, drop, copyの能力を持たない構造体だ。このパターンを実装するモジュールは通常、その構造体をインスタンス化する関数とそれを破棄する関数を持っている。ホットポテト構造体はdrop, key, storeの能力を持たないため、構造体を消費するために「破棄」関数が呼び出されることが保証されている。他のモジュールの任意の関数に渡すことはできるが、最終的には「破棄」関数に渡される必要がある。これが唯一の方法であり、検証ツールは取引の終わりまでに何かが行われることを要求する(drop能力がないので、任意に破棄することはできない)。
フラッシュ・レンディングを実装するために、これをどのように活用できるか見てみよう:
フラッシュレンディング・スマートコントラクトは「ホットポテト」Receipt構造体を実装する
貸し出し関数を呼び出すことでローンが行われると、呼び出し元に要求された資金(コイン)と返済されるローン額の記録であるReceiptという2つのオブジェクトが送られる
その後、借り手は受け取った資金を希望する操作(例:アービトラージ)に使用する
借り手が操作を完了すると、借りた資金とReceiptを引数として受け取る返済関数を呼び出す。この関数は同じ取引内で呼び出されることが保証されている。なぜなら、呼び出し元がReceiptインスタンスを取り除く方法は他にないからだ。(Receiptインスタンスを破棄したり他のオブジェクトに埋め込むことはできない。これは検証ツールによってチェックされる)
返済関数は、Receiptに埋め込まれたローン情報を読み取り、正しい金額が返済されたかどうかを確認する
この実装例はここにある(リンク)。
Moveのリソース安全機能により、リエントランシーやイントロスペクションを使用せずにフラッシュレンディングが可能になる。これにより、Receiptが信頼できないコードによって変更されることなく、取引の終わりまでに返済関数に戻されることが保証される。これにより、正しい金額の資金が同じ取引内で返済されることが保証される。
この機能は基本的な言語プリミティブを使用して完全に実装されており、Moveの実装では、トランザクションを特別に作成する必要があるSolanaの実装のようなオーバーヘッドに悩まされることはない。また、クライアントサイドに複雑さを押し付けることもない。
フラッシュ・レンディングは、Moveの線形型システムとリソース安全保証が他のプログラミング言語では表現できない機能をどのように表現できるかの良い例である。
5.2. ミント権限ロック
Moveのプログラミングモデルの利点をさらに強調するために、私はSolana(Anchor)とSui Moveの両方で「ミント権限ロック」スマートコントラクトを実装して比較してみた
「ミント権限ロック」スマートコントラクトの機能は、トークン発行(ミント)の機能を拡張し、1つだけでなく、ホワイトリストに登録された複数の当事者(権限者)がトークンをミントできるようにするものだ。このスマートコントラクトに必要な機能は次の通りだ(SolanaとSuiの両方に適用される):
元のトークン発行権限者が「ミントロック」を作成し、スマートコントラクトがトークンの発行を規制できるようにする。呼び出し元はミントロックの管理者になる
管理者は他の当事者に与えられる追加の発行権限を作成でき、彼らが好きな時にミントロックを使用してトークンを発行できるようにする
各ミント権限には、発行できるトークンの量に1日あたりの制限がある
管理者は任意の時点でミント権限を禁止(および解除)できる
管理者権限を他の人に譲渡できる
このスマートコントラクトは、例えば、元のミント権限(管理者)がミントの制御を維持しながら、他のユーザーやスマートコントラクトにトークンのミント権限を与えるために使用できる。これがなければ、ミントの全権限を他の当事者に渡さなければいけないが、悪用されないことを信頼しなければならないので理想的ではない。また、複数の当事者に権限を与えることはできない。
これらのスマートコントラクトの完全な実装はここ(Solana)とここ(Sui)で見ることができる。
注意:このコードを本番環境で使用しないでください!これは教育目的のための例示的なコードであり、機能性のテストは行っていますが、徹底的な監査やセキュリティテストは行っていません。
コードを見て、実装がどのように異なるかを見てみよう。以下は、このスマートコントラクトのSolanaとSuiの完全な実装コードのスクリーンショットだ:
まず注目すべきは、同じ機能を実装するのに、SolanaがSuiの2倍以上のコード量であることだ(Solana 230行 / Sui 104行)。これは非常に重要で、なぜなら、コードが少ないほどバグが少なく、開発時間も短縮されるからだ。
では、なぜSolanaのコード量が多くなってしまうのか?Solanaのコードを詳しく見ると、命令実装(スマートコントラクトのロジック)とアカウントチェックの2つのセクションに分けることができる。命令実装は、Suiのものにかなり近い(Solana 136行 / Sui 104行)。追加の行は、2つのCPIコールの定型コード(約10行ずつ)に起因する。しかし、最も顕著な違いはアカウントチェック(上記のスクリーンショットで赤でマークされたセクション)によるものだ。これはSolanaでは必要(実際には重要)だが、Moveでは必要ではない。アカウントチェックはこのスマートコントラクトの約40%(91行)を占めている!
Moveではアカウントチェックを必要としない。これは、行数の削減のためだけでない。アカウントチェックを正しく実装することは非常に難しく、一つのミスでもユーザー資金の重大な脆弱性と損失を引き起こすことがよくあるのだ。実際、いくつかの最大の(ユーザー資金の損失に関して)Solanaスマートコントラクトの攻撃は、アカウントチェックの不適切な実装によるアカウント置換攻撃だった:
Wormhole($336M)— https://rekt.news/wormhole-rekt/
Cashio($48M)— https://rekt.news/cashio-rekt/
Crema Finance($8.8M)— https://rekt.news/crema-finance-rekt/
これらのチェックを取り除くことが非常に重要であることは明らだ。
では、Moveがこれらのチェックを必要とせずに安全性を保つことができる理由は何だろうか?チェックが実際に何をしているのかを詳しく見てみよう。以下は、ミントするための命令に必要なアカウントチェックだ(権限保有者は、ロックを通じてトークンを発行するためにこれを呼び出す):
6つのチェックがある(赤のハイライト箇所):
提供されたロックアカウントがこのスマートコントラクトによって所有されており、MintLockタイプであることを確認する。CPIコールでミントするためにロックが必要である(権限を保持する)
提供されたミント権限アカウントが提供されたロックに属していることを確認する。ミント権限アカウントは権限状態(その公開鍵、禁止されているかどうかなど)を保持する
命令呼び出し元がこの権限のために必要な鍵を所有していることを確認する(必要な権限がトランザクションに署名)
トークン・プログラムが CPI 呼び出し(残高の追加)でトークン宛先アカウントを変更させるため、トークン宛先アカウントを渡す必要がある。間違ったアカウントが渡されると CPI 呼び出しが失敗するため、ミント・チェックは厳密には必要ないが、それでもチェックを行うことは良いプラクティスである
4と同様。
トークン・プログラムのアカウントが正しく渡されていることを確認する
今回の例におけるアカウントチェックは、次の5つのカテゴリに分類される:
アカウントの所有権チェック(1, 2, 4, 5)
アカウントのタイプチェック(1, 2, 4, 5)
アカウントのインスタンスチェック(特定のアカウントタイプの正しいインスタンスが渡されているかどうか)(2, 5)
アカウントの署名チェック(3)
プログラムアカウントアドレスチェック(6)
注記:これですべてのタイプのアカウントチェックを網羅しているわけではないが、ポイントを説明するには十分だ。
一方、Moveではアカウントチェックやそれに相当するものが必要ない。関数のシグネチャがあるだけだ:
mint_balance関数は4つの引数しか必要としない。これらのうち、lock とcap だけがオブジェクト(アカウントに相当)を表している。
では、なぜSolanaでは6つのアカウントを宣言し、それらに対するさまざまなチェックを手動で実装する必要があるのに対し、Moveでは2つのオブジェクトを渡すだけで明示的なチェックが不要なのか?
Moveでは、これらのチェックの一部はランタイムによって開発者が意識することなく行われ、一部はコンパイル時に検証ツールによって静的に行われ、他の一部は単に構造上必要がない。具体的にみてみよう:
アカウント所有権チェック — Moveの型システムの設計上、不要。Moveの構造体は、そのモジュール内の関数を通じてのみ変更でき、直接変更することはできない。バイトコード検証により、構造体インスタンスが信頼できないコード(他のモジュール)に自由に流れても不正に変更されないことが保証される
アカウントタイプチェック — 不要。Moveの型はスマートコントラクト全体で存在する。型定義はモジュールバイナリに埋め込まれており(これはブロックチェーンに公開され、VMによって実行される)、検証ツールは、関数が呼び出されたときに正しい型が渡されていることをコンパイル/公開時にチェックする
アカウントインスタンスチェック — Moveでは(Solanaでも時々)関数本体で行う。この特定の例では、ジェネリック型パラメータTが lock と cap の引数型に対して、渡された cap(ミント能力/権限)オブジェクトが正しく lock と一致することを保証する(コインタイプTごとにロックは1つだけしか存在しない)
アカウント署名チェック — Suiでは署名を直接扱わない。オブジェクトはユーザーによって所有される。ミント権限は、管理者によって作成されたミント能力オブジェクトの所有権によって付与さる。このオブジェクトへの参照をmint_balance関数に渡すことでミントが可能になる。所有オブジェクトは所有者のみが取引で使用できる。つまり、オブジェクト署名チェックはランタイムによって開発者が意識することなく行われる
要するに、Moveはバイトコード検証を活用して、デジタル資産のプログラミングに非常に自然なプログラミングモデルを実現している。Solanaのモデルはアカウント所有権、署名、CPIコール、PDAなどを中心に展開している。しかし、一歩下がって考えてみると、私たちはそれらを扱うことをあまり望んでいないし、それらは実際にはデジタル資産とは何の関係もない。むしろ、Solanaのプログラミングモデル内で必要な機能を実装するために使用しているものだ。
Solanaでは、プログラム間の呼び出しで型やリソースの安全性を保証するバイトコード検証がないため、任意のプログラムが任意のアカウントを変更できないようにするために、アカウント所有権の概念が必要だ。同様の理由で(プログラム間の呼び出しで型/リソースの安全性がない)、ユーザー所有のオブジェクトがプログラム内外を自由に流れる概念はない。その代わりに、権限を証明するためにアカウント署名を扱う。そして、プログラムもアカウント署名を提供できる必要があるため、PDAが存在する…。
SolanaでもMoveと同じようにプログラム間の型とリソースの安全性を確保することはできるが、アカウント署名やPDAなどの低レベルのビルディングブロックを使用して手動で実装しなければならない。結局のところ、プログラム可能なリソース(線形型)をモデル化するために、低レベルのプリミティブを使用している。これがアカウントチェックの実態であり、型安全性とリソースのモデル化を手動で実装するオーバーヘッドなのだ。
Moveはリソースをネイティブに抽象化しており、PDAのような低レベルのビルディングブロックを導入することなく、リソースを直接扱うことができる。スマートコントラクトの境界を越えた型とリソースの安全性保証は検証ツールによってチェックされ、手動で実装する必要はない。
5.3. Solanaのコンポーザビリティの限界
Solanaでのスマートコントラクトの組み合わせに関する課題を強調する別の例を紹介します。
ミント権限ロックの例で、Suiと比較してSolanaではより多くの入力を宣言する必要があることがわかった(mint_toコールで、Suiの2つのオブジェクトに対して、Solanaでは6つのアカウント)。アカウントのチェックも実装する必要があるため、6つのアカウントを扱うのは2つのオブジェクトを扱うよりも面倒だ。しかし、1つの呼び出しで複数の異なるスマートコントラクトを組み合わせるようになったらどうなるだろうか?
次のようなスマートコントラクトを作成したいとする:
特定のトークン発行のために、トークン発行ロックプログラムからミント権限を所有する
このコントラクトが呼び出されたときに、その権限を使用してユーザーが指定した量のトークンを発行し、それをAMMを使って別のトークンにスワップし、すべて同じ命令内でユーザーに送信する
この例のポイントは、ミント権限ロック・コントラクトとAMMコントラクトをどのように組み合わせるかを示すことだ。これは実用的ではないが、ポイントを説明するために役立つ例だ。現実のスマートコントラクトの組み合わせの例もこれと大差はない。
この命令呼び出しに対するアカウントチェックは次のようになる:
これは17個のアカウントです。各CPI呼び出し(ミントとスワップ)につき約5〜6個のアカウントがあり、プログラムアカウントも含まれる
Suiで同等の関数のシグネチャは次のようになる:
わずか3つのオブジェクトだ
なぜSuiでは3つのオブジェクトに対して、Solanaでは17個のアカウントを渡す必要があるのか?
根本的な理由は、Moveではそれらを埋め込む(ラップする)ことができるからだ。型システムの安全保証により、これが可能になっている。
以下は、AMMプールの状態を保持するSolanaアカウントとSuiオブジェクトの比較である:
Solanaでは、他のアカウントのアドレス(Pubkey)を保存しているが、これはポインタのようなもので、実際のデータを保存しているわけではないことがわかる。これらのアカウントにアクセスするためには、それらを個別に渡す必要があり、正しいアカウントが渡されたかどうかを手動でチェックする必要もある。Moveでは、構造体を互いに埋め込み、その値に直接アクセスできる。どのモジュールからでも、リソースと型安全性の保証を維持したまま、型を混ぜて使用することができる。これもMoveのグローバルな型システムと、バイトコード検証によって実現されるリソース安全性のおかげである。
しかし、多数のアカウントの扱うことの主な問題は、開発者のしにくさにつながるだけではない。複数のスマートコントラクトを組み合わせる際に、多くのアカウントをやり取りしなければならない(つまりチェックしなければならない)ことは、実装の複雑さを生み、セキュリティにも影響する。これらのアカウント間の関係は非常に複雑で、すべての必要なアカウントのチェックと、それらが正しく行われたかどうかを追跡することは非常に困難だ。
実際、Cashioのエクスプロイト(4,800万ドル)ではこのようなことが起こったと私は考えている。これは攻撃を可能にした不十分なアカウントチェックの内訳である。このように、アカウントチェックは非常に複雑になることがある。開発者はチェックを適切に行うつもりだっただろうが、精神的な負荷が大きくなりすぎると非常に簡単にミスを犯してしまう。アカウントが多ければ多いほど、バグが発生する可能性は高くなる。
Moveのグローバルな型システムとより自然なプログラミングモデルにより、スマートコントラクトの組み合わせをより安全に進めることができ、精神的な負荷の限界に達する前に多くの安全性を確保できる。
また、Moveのセキュリティに関してもう一つ考慮すべき点がある。それは、MoveのTCB(Trusted Computing Base)がRust/Anchorよりもはるかに小さいということだ。TCBが小さいということは、スマートコントラクトのコンパイルと実行に関わるコンポーネントが少なくて済むということである。これにより、スマートコントラクトに影響を与える脆弱性の表面積が小さくなり、TCB外のバグはスマートコントラクトの安全性に影響を及ぼさない。
MoveはTCBの削減を念頭に設計されており、TCBをできるだけ減らすために多くの決定がなされた。バイトコード検証は、Moveコンパイラによって実行される多くのチェックをTCBの外に置く。一方、Rust/Anchorでは、より多くのコンポーネントを信頼しなければならず、セキュリティ上重要なバグの表面積ははるかに大きくなる。
5章終わり
この記事が気に入ったらサポートをしてみませんか?