👋

Rc<RefCell<&'a mut [u8]>> in Solana

2024/12/15に公開

この記事はSolana Advent Calendar2024 15日目の記事です。

はじめに

Solanaブロックチェーン上で動作するSolanaのプログラム(スマートコントラクト)は、eBPF(Extended Berkeley Packet Filter)上で実行されます。SolanaプログラムはC言語風のFFIインターフェースを介して、Solana Runtimeからアカウント情報や実行に必要なデータを受け取ります。この低レベルな設計により高いパフォーマンスが得られる一方で、Rustに慣れたエンジニアでも戸惑います。

その一つがAccountInfo構造体が持つ非常に複雑な型、特にRc<RefCell<&'a mut [u8]>>です。

本記事では、Rc<RefCell<&'a mut [u8]>>という型が必要とされる理由やその背後にある考え方について解説します。

1. Solanaプログラムと低レイヤー実行環境の概要

Solanaは、ハイスループットを実現しているブロックチェーンであり、トランザクション処理をeBPF上で実行します。プログラムはバイナリ(eBPF形式)としてオンチェーンにデプロイされ、Solana RuntimeがこれをBPF Loaderを介して呼び出します。

典型的なRust開発では、コンパイラが所有権と借用を静的に検証します。しかし、Solanaプログラムでは、Runtimeが用意したメモリをFFI境界で受け取り、その安全性は一部実行時に保証されることになります。AccountInfoというオンチェーン上に保存されるデータを表す型は、このFFIで渡された生ポインタを安全にラップし、Rustが保証する安全性と結びつける役割を果たします。

特にAccountInfoは、アカウントのアドレス、所有者、lamports、データ領域への参照を保持し、プログラムがアカウントデータを読み書きするための抽象化レイヤーを提供しています。

2. AccountInfoが提供する抽象化とRc<RefCell<&'a mut [u8]>>が出現する理由

AccountInfoには以下のようなフィールドがあります

https://github.com/anza-xyz/agave/blob/master/sdk/account-info/src/lib.rs#L19-L39

ここで注目すべきはlamportsおよびdataフィールドの型です。dataRc<RefCell<&'a mut [u8]>>という複雑な型になっています。この型が必要とされる理由は次の通りです。

  1. FFI境界で渡される生ポインタを安全な参照へ変換するため
    Solana RuntimeはFFIで生ポインタを渡します。これをRustで安全に扱うには、&'a mut [u8]などの適切なライフタイム付きの可変参照が必要です。しかし、単なる参照では所有権が一意となるため、複数箇所で同じアカウントを扱う場合に不便が生じます。

  2. 内部可変性を確保するため(RefCell)
    Rustの借用規則は強力ですが、FFI経由で得たデータを柔軟に更新するためには、常に可変参照を取得できるわけではありません。RefCellは内部可変性を提供し、不変参照からでも実行時に可変アクセスを可能にします。これは、AccountInfoが同時に複数箇所で使われる状況で役立ちます。

  3. 複数所有権を確保するため(Rc)
    &'a mut [u8]などの可変参照は、通常一人の所有者しか許さず、ライフタイム内で一つの可変借用のみが有効です。しかし、SolanaプログラムではCPIなどによって、複数のプログラムが同じアカウントデータを共有する必要があります。Rcを使えば、同じデータへの複数の共有参照を可能にし、クローンを作成することで複数のAccountInfoインスタンスが同一の内部データを共有できます。

これらを総合すると、Rc<RefCell<&'a mut [u8]>>は「FFI経由の生データを安全で可変な共有参照として扱うための仕組み」と言えます。

3. Solana RuntimeとBPF Loaderの役割

Solana Runtimeは、トランザクション実行時に指定されたプログラムをBPF Loaderを介して実行します。その際、プログラムID、利用するアカウント情報、インストラクションデータといった必要な情報を、BPF VM上のメモリ領域に展開します。

BPF LoaderはSolana Runtime内のネイティブプログラムの一種であり、指定されたeBPFプログラム(ユーザが書いたRustプログラムをeBPF用にコンパイルしたもの)をロードし、そのentrypoint関数を呼び出す役割を担います。

Runtime側では、アカウントのlamportsdataはRuntimeが所有し、プログラム実行中のみ、&'a mut u64&'a mut [u8]としてプログラムに貸し出されます。この貸し出しはFFI経由で行われるため、プログラム側はunsafeブロックやRefCellによるランタイムチェックで安全性を確保します。

4. entrypoint!マクロとdeserialize関数

entrypoint!マクロは、Solanaプログラムが公開するエントリーポイントを定義するためのマクロです。このマクロは、BPF Loaderが呼び出すentrypointというC言語風のシンボルを定義し、その中でdeserialize関数を呼んでFFI経由で渡された生ポインタinput: *mut u8をパースします。

deserialize関数では、inputから

  • program_id: &Pubkey
  • accounts: Vec<AccountInfo<'a>>
  • instruction_data: &'a [u8]

を復元します。この過程で、lamports&'a mut u64として、data&'a mut [u8]としてメモリを解釈します。しかし、これらは直接AccountInfo構造体に埋め込むのではなく、Rc<RefCell<...>>で包まれます。これにより、借用と参照カウントの仕組みを利用して、プログラム内で安全に複数回アクセスできるようになります。

実際のdeserialize関数のコードでは、RefCell::new(...)Rc::new(...)を用いてAccountInfoフィールドを構築する箇所が確認できます。
https://github.com/anza-xyz/agave/blob/master/sdk/program-entrypoint/src/lib.rs#L398-L443

5. AccountInfo構造体のライフタイム保証

AccountInfoは、アカウントを表す抽象化であり、以下のようなメンバーを持ちます(再掲)

https://github.com/anza-xyz/agave/blob/master/sdk/account-info/src/lib.rs#L19-L39

ここで、'aというライフタイムパラメータが重要です。'aは、このAccountInfoが参照するメモリが有効な間だけ存在することを保証します。Solanaプログラムはentrypointが呼ばれている間、Runtimeが提供するバッファやメモリ領域は有効です。この期間中、AccountInfoはそのライフタイム'aを通じて安全な参照を保持できます。

6. RefCell<T>とRc<T>

RefCell<T>:内部可変性と実行時借用チェック

RefCell<T>は内部にTを保持し、不変借用と可変借用の状態を実行時カウンタで管理します。

  • borrow()が呼ばれるたび、内部の不変借用カウントがインクリメントされます。
  • borrow_mut()が呼ばれると、可変借用カウントが1になる必要があります。また、このとき不変借用カウントが0であることが要求されます。
  • borrow()borrow_mut()から返されるRef<T>RefMut<T>は、スコープ終了時にドロップされ、借用カウントが減少します。

これにより、コンパイラでは検出が困難なFFI境界を含む複雑な借用関係でも、実行時に安全性を保証できます。不正な同時可変借用が発生するとパニックするため注意が必要です。

https://doc.rust-lang.org/book/ch15-05-interior-mutability.html

例えばlamportsRc<RefCell<&'a mut u64>>ですが、account_info.lamports.borrow_mut()を呼ぶとRefMut<&'a mut u64>が取得でき、そこからさらに*演算子を適用することで実際のu64値を変更できます。

Rc<T>:複数所有権と参照カウント

Rc<T>は、参照カウントによる共有所有権を提供します。

Rc<T>は参照カウントを持ち、Rc::clone(&rc_value)を呼ぶと参照カウントが増え、同じデータへの参照を複数の変数で保持できます。AccountInfoをCPIで他のプログラムに渡す際にも、同じRcをクローンして引き回すことで、すべてが同一のu64[u8]バッファを指し示すことができます。

Rcはデータが必要なくなった時点(参照カウントが0になったとき)にのみデータを解放します。Solanaプログラムのエントリーポイント実行中にアカウントデータが無効になることは基本的にないため、Rcの利用でunsafeになることはありません。

https://doc.rust-lang.org/book/ch15-04-rc.html

なぜ2つ同時に必要なのか

&'a mut u64という可変参照は、Rustの厳格な借用ルールに基づき、基本的にある時点において一つの可変参照しか許されません。しかし、この制約だけではSolanaプログラム開発における要求を満たすには柔軟性が不足します。

そこでRefCellの出番です。RefCellは内部可変性という機能を提供し、実行時の借用チェックをパスすることを条件に、一見不変に見えるデータへの可変アクセスを可能にします。これにより、複数の箇所から同一のデータへアクセスできる道が開かれます。

さらに、Rcを組み合わせることで、そのRefCellを複数のAccountInfo間で共有することが可能になります。つまり、Rcによって参照カウント方式の共有所有権を実現することで、複数のAccountInfoが同一のデータを指し示すことができるのです。

この、RefCellによる内部可変性とRcによる共有所有権の組み合わせは、SolanaのCPIを行うのに必要になります。

7. CPI時のAccountInfoクローン問題

単純なインストラクション内でAccountInfoを使う場合はクローンは必要ありません。borrow()borrow_mut()を適宜呼ぶことで、datalamportsにアクセスできます。

しかし、CPI(Cross Program Invocation)でSolanaプログラムから他のSolanaプログラムを呼び出すさいに、AccountInfoを渡す必要があり、クローンが必要になることがあります。

Rcが存在するため、クローンは参照カウントを増やすだけの軽量な操作です。ただし、CPI先での利用を想定して、借用状況が整合性を取らなければなりません。これはとても重要でRefCellに対して複数の可変借用が同時に行われると実行時にパニックを発生させます。CPI先や複数箇所でborrow_mut()を乱用しないように注意してください。

8. AccountInfoを用いた生データアクセスコード例

ここで実際のコード例を示します。例えば、AccountInfoからlamportsを加算するコードは以下のようになります。

use solana_program::account_info::AccountInfo;
use solana_program::entrypoint::ProgramResult;

fn add_lamports(account: &AccountInfo, amount: u64) -> ProgramResult {
    let mut lamports_ref = account.lamports.borrow_mut(); // RefMut<&mut u64>を取得
    let lamports_ptr = &mut **lamports_ref; // &mut &mut u64から&mut u64を取得
    *lamports_ptr += amount;
    Ok(())
}

borrow_mut()によって実行時チェックされた上で可変参照が取得でき、&mut u64としてlamportsを操作できます。

アカウントデータ(dataフィールド)の場合は、以下のようになります。

use solana_program::account_info::AccountInfo;
use solana_program::entrypoint::ProgramResult;
use solana_program::program_error::ProgramError;

fn write_byte_to_data(account: &AccountInfo, offset: usize, value: u8) -> ProgramResult {
    let mut data_ref = account.data.borrow_mut(); // RefMut<&mut [u8]>を取得
    let data_slice = &mut **data_ref; // &mut &mut [u8]から&mut [u8]を取得
    if offset < data_slice.len() {
        data_slice[offset] = value;
        Ok(())
    } else {
        Err(ProgramError::InvalidArgument)
    }
}

RefCell経由で&mut [u8]を取得し、バイト単位で書き込みが可能です。

9. borshを用いた複雑データ構造の読み書き

生のバイト列を直接扱うのはエラーが生じやすく煩雑です。borsh)を利用すれば、struct定義を元にシリアライズ・デシリアライズを容易に行うことができます。

use borsh::{BorshSerialize, BorshDeserialize};
use solana_program::account_info::AccountInfo;
use solana_program::entrypoint::ProgramResult;
use solana_program::program_error::ProgramError;

#[derive(BorshSerialize, BorshDeserialize, Debug)]
struct MyData {
    counter: u64,
    flag: bool,
}

fn increment(account: &AccountInfo) -> Result<MyData, ProgramError> {
    let mut data_ref = &mut account.data.borrow_mut(); // RefMut<&mut [u8]>を取得
    let state = borsh::from_slice::<MyDataに変換>(data)?; // MyDataにSiriアライズ
    
    state.counter = data.counter + 1;

    data.copy_from_slice(borsh::to_vec(&state)?.as_slice()); // dataにデシリアライズして書き込み
}

borshを使うと、&[u8]&mut [u8]を任意の構造体へシリアライズ/デシリアライズでき、コードが簡潔かつ安全になります。

まとめ

Rc<RefCell<&'a mut [u8]>>という型は、Solana特有の実行環境とRustの所有権・借用モデルを統合するための必要な型です。

前提となる知識はここまでの解説で十分なので、ここからより深めるには実際のコードを読む、書くということをお勧めします。
Solanaの入門でよく使われるEscrowという題材を実装したコード、解説もあるのでぜひこちらを参照しながら実装にチャレンジしてみてください。

https://github.com/k-kinzal/solana-escrow
https://github.com/k-kinzal/solana-escrow-books

また、Solanaが公開しているプログラムのコードも参考になりますのでお勧めです。

https://github.com/anza-xyz/agave/tree/master/programs
https://github.com/solana-labs/solana-program-library

動くものを作るという観点ならAnchorをお勧めしますが、学習という観点では低レイヤー部分を理解できると幅が広がるため、一度はぜひ挑戦してください。

Discussion