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

Stateのフィールドがpubになっている
token programのMint
を例にするとフィールドが全てpub
になっている。
つまり、このフィールドはどんな状態になってもMint
として正しい状態だと型として表明していることになる。
しかし、おそらくMint
型はそうではなく、例えばis_initialized
がfalse
のさいに他のフィールドに有効な値が入っていることは期待されていないと思われる。
そのため、この型は呼び出し元で適切な値を設定されることが期待されており、この型の責務が外部に漏れ出してしまっている。
では、どうあると良いのかと言えば
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
型が不正な状態になることを防いでおり、どのような呼び出しがあっても安全に利用することが可能になる。

型で状態遷移が表現されていない
先ほどのコードでは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),
}
所有者のみが更新可能な整数値を扱う状態になる。
このように書くことで、その状態でできることだけを型的に呼び出しが可能な状態を書けるようになる。
このあたりについては昔に詳しく書いたのでこちらを参照すること。
余談
余談だが状態変化なのでself
で所有権を消費し、新しい型を返すようにしている。
しかし、Solanaのプログラムではアカウントのデータ周りはミュータブルな構造を取っており、&mut self
で直接書き換えた方がメモリ的にも、パフォーマンス的にも良いものになる。(誤差レベルだと思うけど)

シグネチャを見ても処理がわからない
SPL
Anchor
SPL、Anchorともにシグネチャだけを見ても内部の実装がどうなるのかわからず、コードを読まなければわからない。
簡単な例にはなるが、シグネチャだけで実装がわかる例をいくつか書いてみる。
fn foo<T>(v: T) -> T
fn foo<T>(v: T) -> T {
return T;
}
fn foo<T, U>(v: T) -> U whereT: Into<U>
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
fn foo(mut f: Foo, a: i32, b: i32) -> Foo {
f.a = a;
f.b = b;
f
}
このシグネチャが読みにくい要因としてはSolanaの`AccountInfo<'a>'が
というように書き込み可能な参照を持っている = ミュータブルな構造になっているせいというのもある。
また、CPIなどで他のプログラムを呼び出したりするため、副作用のない関数になっていないせいというのもある。
そのため、例のように実装がシグネチャで定まるかと言えば、難しいものはある。とはいえ、読めなさすぎるのはたしかなのでもう少しシグネチャに拘りたい。
解決方法は次の話と同一になるため、そちらで記載する。

processorの責務が大きい
例えばName Serviceのprocess_create
を例にするとこの関数では4つのことを行なっている
-
account
の取り出し -
account
の検査 -
data
の検査 (Deserializeできることも含む) -
data
の変更と反映
つまり、この関数は複数の責務を持っている。
本来的にここでやりたいことは3と4であり、状態を適切に変更することになる。
自身が所有する整数値を書き換えるプログラムでの例になるが、シグネチャとしては下記のように書きたい。
fn process_create(
sender: &Signer<Token>, // Signer<T>は署名されたアカウントを表すdata部がTの型
mut state: Data<State>, // Data<T>はプログラムが書き込み可能なdata部がTの型
num: i32,
) -> ProgramResult {
シグネチャだけで話せば、state
がmut
なのでこれを書き換えるのがこの関数の責務になる。
その書き換えるために必要な情報として署名された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)?))
}
}

個人的に気になってるフレームワーク
状態周りは解釈不一致で少し違うのだけど、process
周りは解釈一致していて気になっている。

思想の背景
あとは状態遷移はプログラミングにおいて最も難しいと思っているとかその辺り。

この書き方は本当に必要か?
おそらくSolanaのプログラムという観点ではあまり必要とされないだろうという予想はある。
Solanaのプログラムは短く、長くてもせいぜい数千行で脳内メモリで処理しきれる量であり、状態遷移の複雑さもあまりないのでそこまで困らないだろうなと。
最悪ユニットテストでパターンを書けばどうにかなる。
おそらくSoalanaのプログラムで問題になるのはCPIなど他のプログラムと関わる部分やオフチェーンとの連動であり、プログラム管理外も含めた状態管理をどうするかというところになると思う。
その部分は書き方を変えても解決できないところのため、これによってバグを大きく減らせるということはあまりないと思う。
また、この書き方はSolanaでは一般的ではないため、Solanaのプログラムに慣れた人たちには少しギョッとしてしまう。
こういった書き方の部分はある種の作法であり、その言語やフレームワークといったところに合わせて書いた方がいろいろと効率はよくなる。
つまりは趣味。