Open8

Solanaのプログラムでより安全で、より可読性の高い書き方を考えてみる

kk

前振り

SolanaのプログラムとしてSPLAnchorのコードを読んで思ったのが、これは絶対にバグを出すと思った。
そこでこのスクラップでは、どこに課題を感じたのか、どう書くと良いのかを考えてみる。

注意点として、これはSolanaのプログラムをこう書くべきだという話ではなく、私ならこう書くけどという程度の話になる。
SPLもAnchorも最適化された書き方をしており、それは分かるけど私の趣味には合わないというだけだ。

kk

Stateのフィールドがpubになっている

https://github.com/solana-labs/solana-program-library/blob/master/token/program/src/state.rs#L16-L29

token programのMintを例にするとフィールドが全てpubになっている。
つまり、このフィールドはどんな状態になってもMintとして正しい状態だと型として表明していることになる。

しかし、おそらくMint型はそうではなく、例えばis_initializedfalseのさいに他のフィールドに有効な値が入っていることは期待されていないと思われる。

そのため、この型は呼び出し元で適切な値を設定されることが期待されており、この型の責務が外部に漏れ出してしまっている。

では、どうあると良いのかと言えば

pub struct Mint {
    mint_authority: COption<Pubkey>,
    supply: u64,
    decimals: u8,
    is_initialized: bool,
    freeze_authority: COption<Pubkey>,
}

impl Mint {
    pub fn mint_authority(&self) -> Option<Pubkey> {
        self.mint_authority.into()
    }

    pub fn supply(&self) -> u64 {
        self.supply
    }

    pub fn decimals(&self) -> u8 {
        self.decimals
    }

    pub fn is_initialized(&self) -> bool {
        self.is_initialized
    }

    pub fn freeze_authority(&self) -> Option<Pubkey> {
        self.freeze_authority.into()
    }

    pub fn initialize(
        mut self,
        mint_authority: Pubkey,
        freeze_authority: Option<Pubkey>,
        decimals: u8,
    ) -> Self {

        // 本来はここで今の状態でinitializedを呼び出して問題ないか検査すべき

        self.mint_authority = COption::Some(mint_authority);
        self.supply = 0;
        self.decimals = decimals;
        self.is_initialized = true;
        self.freeze_authority = freeze_authority.into();
        self
    }
}

というようにpubを外してゲッターと適切な状態を書き込むメソッドを作るべきだ。
もちろん、はじめのコードと比べて書くコード量は増えている。しかし、このMint型が不正な状態になることを防いでおり、どのような呼び出しがあっても安全に利用することが可能になる。

kk

型で状態遷移が表現されていない

先ほどのコードではinitializeという状態変更のメソッドを書いたがこれにも問題がある。
これは型的にどのような状態であってもinitializeを呼び出すことが可能になっている。
そのため、例えばstate.initialize(...).initialize(...)というような意図しない呼び出しが型的できてしまう。

本来的にはinitializeは状態が未初期化のときだけ呼び出し可能であり、それが型として表現されるべきだ。

Mint型は少し面倒なので、もう少し簡単な例でどう書きたいのかを示す。

#[derive(Debug, BorshDeserialize, BorshSerialize)]
pub struct Uninitialized([u8; 36]);

impl Default for State {
    fn default() -> Self {
        State::Uninitialized(Uninitialized([0; 36]))
    }
}

impl Uninitialized {
    pub fn initialize(self, sender: Pubkey, num: i32) -> Active {
        Active { owner: sender, num }
    }
}

impl From<Uninitialized> for State {
    fn from(uninitialized: Uninitialized) -> Self {
        State::Uninitialized(uninitialized)
    }
}

#[derive(Debug, Default, BorshDeserialize, BorshSerialize)]
pub struct Active {
    owner: Pubkey,
    num: i32,
}

impl Active {
    pub fn owner(&self) -> &Pubkey {
        &self.owner
    }

    pub fn num(&self) -> i32 {
        self.num
    }

    pub fn update(self, sender: &Pubkey, num: i32) -> Result<Active, ProgramError> {
        if sender != &self.owner {
            return Err(ProgramError::InvalidAccountData);
        }

        Ok(Active {
            owner: self.owner,
            num,
        })
    }

    pub fn deactivate(self, sender: &Pubkey) -> Result<Inactive, ProgramError> {
        if sender != &self.owner {
            return Err(ProgramError::InvalidAccountData);
        }
        Ok(Inactive::default())
    }
}

impl From<Active> for State {
    fn from(active: Active) -> Self {
        State::Active(active)
    }
}

#[derive(Debug, BorshDeserialize, BorshSerialize)]
pub struct Inactive([u8; 36]);

impl Default for Inactive {
    fn default() -> Self {
        Inactive([0; 36])
    }
}

impl From<Inactive> for State {
    fn from(active: Inactive) -> Self {
        State::Inactive(active)
    }
}

#[derive(Debug, BorshDeserialize, BorshSerialize)]
pub enum State {
    Uninitialized(Uninitialized),
    Active(Active),
    Inactive(Inactive),
}

所有者のみが更新可能な整数値を扱う状態になる。
このように書くことで、その状態でできることだけを型的に呼び出しが可能な状態を書けるようになる。

https://zenn.dev/kinzal/books/aa109c0c428089

このあたりについては昔に詳しく書いたのでこちらを参照すること。

余談

余談だが状態変化なのでselfで所有権を消費し、新しい型を返すようにしている。
しかし、Solanaのプログラムではアカウントのデータ周りはミュータブルな構造を取っており、&mut selfで直接書き換えた方がメモリ的にも、パフォーマンス的にも良いものになる。(誤差レベルだと思うけど)

kk

シグネチャを見ても処理がわからない

SPL
https://github.com/solana-labs/solana-program-library/blob/master/name-service/program/src/processor.rs#L26-L32

Anchor
https://github.com/coral-xyz/anchor-by-example/blob/master/programs/onchain-voting/programs/onchain-voting/src/lib.rs#L15

SPL、Anchorともにシグネチャだけを見ても内部の実装がどうなるのかわからず、コードを読まなければわからない。
簡単な例にはなるが、シグネチャだけで実装がわかる例をいくつか書いてみる。

シグネチャ
fn foo<T>(v: T) -> T
実装は1パターンだけになる
fn foo<T>(v: T) -> T {
  return T;
}
シグネチャ
fn foo<T, U>(v: T) -> U whereT: Into<U>
実装は1パターンだけになる
fn foo<T, U>(v: T) -> U
where
    T: Into<U>,
{
    v.into()
}
シグネチャ
struct Foo {
    a: i32,
    b: i32,
}

fn foo(mut f: Foo, a: i32, b: i32) -> Foo
Fooを書き換えて返すことを期待している
fn foo(mut f: Foo, a: i32, b: i32) -> Foo {
    f.a = a;
    f.b = b;
    f
}

このシグネチャが読みにくい要因としてはSolanaの`AccountInfo<'a>'が

https://github.com/solana-labs/solana/blob/95810d876a7cf8bdf9991ff5b887074c8d835de1/sdk/program/src/account_info.rs#L25

というように書き込み可能な参照を持っている = ミュータブルな構造になっているせいというのもある。
また、CPIなどで他のプログラムを呼び出したりするため、副作用のない関数になっていないせいというのもある。
そのため、例のように実装がシグネチャで定まるかと言えば、難しいものはある。とはいえ、読めなさすぎるのはたしかなのでもう少しシグネチャに拘りたい。

解決方法は次の話と同一になるため、そちらで記載する。

kk

processorの責務が大きい

https://github.com/solana-labs/solana-program-library/blob/master/name-service/program/src/processor.rs#L25-L123

例えばName Serviceのprocess_createを例にするとこの関数では4つのことを行なっている

  1. accountの取り出し
  2. accountの検査
  3. dataの検査 (Deserializeできることも含む)
  4. dataの変更と反映

つまり、この関数は複数の責務を持っている。
本来的にここでやりたいことは3と4であり、状態を適切に変更することになる。

自身が所有する整数値を書き換えるプログラムでの例になるが、シグネチャとしては下記のように書きたい。

fn process_create(
    sender: &Signer<Token>, // Signer<T>は署名されたアカウントを表すdata部がTの型
    mut state: Data<State>, // Data<T>はプログラムが書き込み可能なdata部がTの型
    num: i32,
) -> ProgramResult {

シグネチャだけで話せば、statemutなのでこれを書き換えるのがこの関数の責務になる。
その書き換えるために必要な情報として署名されたsenderと、整数値のnumが引き渡される。

先の状態遷移の話も含めれば、Data<StateA> -> Result<Data<StateB>, ProgramError>な型にしたい気持ちもあるが、CPIなどで他のプログラムを呼び出すケースを考えるとProgramResultを返すで十分だとも思う。

もちろん、このように書くということはprocess_createを呼び出すprocessのところで適切な型変換が必要になってくるため、processの肥大化を招きやすい。
しかし、Solanaランタイムの呼び出しインターフェースのentrypointからprocessは自動生成しやすい箇所であり、Rustであればマクロで良しなにできるので、そこまでできれば問題にならない。

また、Signer<T>Data<T>のような特定の状態を表す型を作ったが、これだけでは型として足りていない。
そのため、先のマクロの話も合わせるとこういった型表現のできるフレームワークを作っていく必要があり、少し難しさはある。

おまけ

勉強も兼ねてSigner<T>Data<T>という型は実際に書いているので供養も兼ねて貼っておく。
正直、まだ作り込みは甘いし、ミュータブルの脱却のための参照の排除の是非などもう少し考えることはある。

use borsh::{BorshDeserialize, BorshSerialize};
use solana_program::account_info::AccountInfo;
use solana_program::epoch_schedule::Epoch;
use solana_program::program_error::ProgramError;
use solana_program::pubkey::Pubkey;
use std::ops::{Deref, DerefMut};

pub struct Unknown;

pub struct Account<T> {
    key: Pubkey,
    lamports: u64,
    data: T,
    owner: Pubkey,
    rent_epoch: Epoch,
    is_signer: bool,
    is_writable: bool,
    executable: bool,
}

impl<T> Deref for Account<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.data
    }
}

impl<T> DerefMut for Account<T> {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.data
    }
}

impl<T> Account<T> {
    pub fn key(&self) -> &Pubkey {
        &self.key
    }

    pub fn lamports(&self) -> u64 {
        self.lamports
    }

    pub fn owner(&self) -> &Pubkey {
        &self.owner
    }

    pub fn rent_epoch(&self) -> Epoch {
        self.rent_epoch
    }

    pub fn is_signer(&self) -> bool {
        self.is_signer
    }

    pub fn is_writable(&self) -> bool {
        self.is_writable
    }

    pub fn executable(&self) -> bool {
        self.executable
    }
}

impl<T> Account<T>
where
    T: BorshDeserialize,
{
    pub fn from_account(account: AccountInfo) -> Result<Self, ProgramError> {
        let data = account.try_borrow_data()?;
        let data = T::try_from_slice(&data[..])?;
        Ok(Self {
            key: account.key.clone(),
            lamports: **account.lamports.borrow(),
            data,
            owner: account.owner.clone(),
            rent_epoch: account.rent_epoch,
            is_signer: account.is_signer,
            is_writable: account.is_writable,
            executable: account.executable,
        })
    }
}

impl Account<Unknown> {
    pub fn from_account(account: AccountInfo) -> Result<Self, ProgramError> {
        Ok(Self {
            key: account.key.clone(),
            lamports: **account.lamports.borrow(),
            data: Unknown,
            owner: account.owner.clone(),
            rent_epoch: account.rent_epoch,
            is_signer: account.is_signer,
            is_writable: account.is_writable,
            executable: account.executable,
        })
    }
}

pub struct Data<T>(Account<T>);

impl<T> Deref for Data<T> {
    type Target = Account<T>;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl<T> DerefMut for Data<T> {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.0
    }
}

impl<T> Data<T>
where
    T: BorshDeserialize,
{
    pub fn from_account(account: AccountInfo, program_id: &Pubkey) -> Result<Self, ProgramError> {
        if account.owner != program_id {
            return Err(ProgramError::IncorrectProgramId);
        }
        if !account.is_writable {
            return Err(ProgramError::InvalidArgument);
        }
        Ok(Self(Account::<T>::from_account(account)?))
    }
}

impl<T> Data<T>
where
    T: BorshSerialize + Default,
{
    pub fn data<F>(mut self, f: F) -> Result<Self, ProgramError>
    where
        F: FnOnce(T) -> Result<T, ProgramError>,
    {
        let data = std::mem::take(&mut self.0.data);
        self.0.data = f(data)?;

        Ok(self)
    }

    pub fn replace(&mut self, data: T) {
        self.data = data;
    }
}

pub struct Signer<T>(Account<T>);

impl<T> Deref for Signer<T> {
    type Target = Account<T>;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl<T> DerefMut for Signer<T> {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.0
    }
}

impl<T> Signer<T> {
    pub fn transfer_lamports_from<U>(&mut self, source: &mut Account<U>) {
        self.lamports += source.lamports;
        source.lamports = 0;
    }
}

impl<T> Signer<T>
where
    T: BorshDeserialize,
{
    pub fn from_account(account: AccountInfo) -> Result<Self, ProgramError> {
        if !account.is_signer {
            return Err(ProgramError::MissingRequiredSignature);
        }
        if !account.is_writable {
            return Err(ProgramError::InvalidArgument);
        }
        Ok(Self(Account::<T>::from_account(account)?))
    }
}

impl Signer<Unknown> {
    pub fn from_account(account: AccountInfo) -> Result<Self, ProgramError> {
        if !account.is_signer {
            return Err(ProgramError::MissingRequiredSignature);
        }
        if !account.is_writable {
            return Err(ProgramError::InvalidArgument);
        }
        Ok(Self(Account::<Unknown>::from_account(account)?))
    }
}
kk

この書き方は本当に必要か?

おそらくSolanaのプログラムという観点ではあまり必要とされないだろうという予想はある。

Solanaのプログラムは短く、長くてもせいぜい数千行で脳内メモリで処理しきれる量であり、状態遷移の複雑さもあまりないのでそこまで困らないだろうなと。
最悪ユニットテストでパターンを書けばどうにかなる。

おそらくSoalanaのプログラムで問題になるのはCPIなど他のプログラムと関わる部分やオフチェーンとの連動であり、プログラム管理外も含めた状態管理をどうするかというところになると思う。
その部分は書き方を変えても解決できないところのため、これによってバグを大きく減らせるということはあまりないと思う。

また、この書き方はSolanaでは一般的ではないため、Solanaのプログラムに慣れた人たちには少しギョッとしてしまう。
こういった書き方の部分はある種の作法であり、その言語やフレームワークといったところに合わせて書いた方がいろいろと効率はよくなる。

つまりは趣味。