👋

SolanaのSPLの書き方を知ろう

2023/10/02に公開

Solana Program Library(以下SPL)はご存知ですか?

https://github.com/solana-labs/solana-program-library

SPLはSolana Labsが開発している標準プログラムライブラリであり、トークン管理やネームサービス、ステークプールといったプログラム(スマートコントラクト)を提供しています。

https://spl.solana.com/

これらのプログラムは一定の書き方がされており、標準的なプログラムの書き方としてわかりやすいのでご紹介したいと思います。

構成

src
├── entrypoint.rs
├── error.rs
├── instruction.rs
├── lib.rs
├── processor.rs
└── state.rs

プログラムによって違いはありますが、大枠はこの構成に現れるファイルを読めばわかります。

1. lib.rs

lib.rsはRustでライブラリを公開するさいのモジュールのルートになります。
ここで、公開する型や関数などを指定する形になります。

#[cfg(not(feature = "no-entrypoint"))]
mod entrypoint;

entrypoint.rsを作成するさいには上記のような形で指定をします。
こうすることでcargo add [crate-name] --feature no-entrypointとして型や関数だけを呼び出すか、動的リンクライブラリとしてentrypointを呼び出せるようにするか呼び出し側で決めることができるようにしています。

2. enrtypoint.rs

entrypoint!(process_instruction);

entrypoint.rsでは関数を登録してSolanaのプログラムで呼び出しすentrypointを定義します。
このentrypoint!マクロが展開されると下記のようなコードが生成されます。

///   # Safety 
#[no_mangle]
pub unsafe extern "C" fn entrypoint(input: *mut u8) -> u64 {
    let (program_id, accounts, instruction_data) =
        unsafe { ::solana_program::entrypoint::deserialize(input) };
    match process_instruction(&program_id, &accounts, &instruction_data) {
        Ok(()) => ::solana_program::entrypoint::SUCCESS,
        Err(error) => error.into(),
    }
}
#[cfg(all(not(feature = "custom-heap"), target_os = "solana"))]
#[global_allocator]
static A: ::solana_program::entrypoint::BumpAllocator = ::solana_program::entrypoint::BumpAllocator {
    start: ::solana_program::entrypoint::HEAP_START_ADDRESS as usize,
    len: ::solana_program::entrypoint::HEAP_LENGTH,
};
#[cfg(all(not(feature = "custom-panic"), target_os = "solana"))]
#[no_mangle]
fn custom_panic(info: &core::panic::PanicInfo<'_>) {
    ::solana_program::msg!( "{}" , info );
}

pub unsafe extern "C" fn entrypoint(input: *mut u8) -> u64というのはRustの動的リンクライブラリの定型句です。
この関数定義の場合、input: *mut u8から書き込み可能なバイト列を受け取り、戻り値に成功かエラーかの整数を返すことが期待されます。

let (program_id, accounts, instruction_data) = unsafe { ::solana_program::entrypoint::deserialize(input) }では受け取ったinputdeserializeしてentrypoint!マクロに渡した関数の引数に変換します。
deserializeの詳細は割愛しますが、inputが内部に長さの情報を持った特殊なフォーマットで、そこに乗っ取ってdeserializeしている形になります。

pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    input: &[u8],
) -> ProgramResult {
    if let Err(error) = Processor::process(program_id, accounts, input) {
        error.print::<CustomError>();
        return Err(error);
    }
    Ok(())
}

マクロに渡す関数は定型句で上記のような形で定義します。
この関数はdeserializeした結果を受け取り、Processorの呼び出しを行い、エラー処理(ここではエラーメッセージの出力)があれば一律に処理をするという仕組みになります。

3. process.rs

pub struct Processor {}
impl Processor {
    fn process_xxx(program_id: &Pubkey, accounts: &[AccountInfo], data: i32) -> ProgramResult {
        // ...
    }
    
    pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> ProgramResult {
        let instruction = TokenInstruction::unpack(input)?;

        match instruction {
	    Instruction::Xxx(data) => process_xxx(program_id, accounts, data)
	    // ...
	}
    }
}

processor.rsではinputInstructionに変換して、Instructionに対応した処理を呼び出します。(Instructionについては後述します)
ここの変換処理は慣例的にunpackを定義して独自のバイトフォーマットとして扱います。もし、新しくプログラムを作られるならName Serviceの実装のようにborshを使うのが簡単だと思われます。

Instructionに対応した処理の例としてfn process_xxx(program_id: &Pubkey, accounts: &[AccountInfo], data: i32) -> ProgramResultとしていますが、引数は処理の内容によって変わりますが、慣例的にはprogram_id(省略可能)、accountsInstructionで受け取った値(省略可能)の順として定義されることが多いです。

この処理の中でアカウントの情報を書き換えたり、他のプログラムの呼び出しを行います。
そのため、このプログラムがどうやって動いているのか、Instructionで何が変わるのかはこの中を読むことで解析することができます。

4. instruction.rs

instruction.rsにはこのプログラムで実行可能な指示を定義します。

pub enum NameRegistryInstruction {
    /// Create an empty name record
    ///
    /// The address of the name record (account #1) is a program-derived address with the following
    /// seeds to ensure uniqueness:
    ///     * SHA256(HASH_PREFIX, `Create::name`)
    ///     * Account class (account #3)
    ///     * Parent name record address (account #4)
    ///
    /// If this is a child record, the parent record's owner must approve by signing (account #5)
    ///
    /// Accounts expected by this instruction:
    ///   0. `[]` System program
    ///   1. `[writeable, signer]` Funding account (must be a system account)
    ///   2. `[writeable]` Name record to be created (program-derived address)
    ///   3. `[]` Account owner (written into `NameRecordHeader::owner`)
    ///   4. `[signer]` Account class (written into `NameRecordHeader::class`).
    ///                 If `Pubkey::default()` then the `signer` bit is not required
    ///   5. `[]` Parent name record (written into `NameRecordHeader::parent_name). `Pubkey::default()` is equivalent to no existing parent.
    ///   6. `[signer]` Owner of the parent name record. Optional but needed if parent name different than default.
    ///
    Create {
        /// SHA256 of the (HASH_PREFIX + Name) of the record to create, hashing is done off-chain
        hashed_name: Vec<u8>,

        /// Number of lamports to fund the name record with
        lamports: u64,

        /// Number of bytes of memory to allocate in addition to the `NameRecordHeader`
        space: u32,
    },
    
    ...
}

Name Serviceの例にはなりますが、上記のようにenumInstructionの種類とそれぞれの指示で処理に必要になる値を定義します。
慣例としてInstructionで必要になるAccountの順序や属性などの説明がコメントで書きます。

ただ、このコメントからクライアントコードの生成などはできないので、新しくプログラムを組む際に書いて維持するコストはそれなりに高いかと思われます。
今だとshank_macroなどがあるため、そういったツールなどを検討すると良いでしょう。

impl Instruction {
    pub fn unpack(input: &'a [u8]) -> Result<Self, ProgramError> {
        // ...
    }
    
    pub fn pack(&self) -> Vec<u8> {
        // ...
    }
}

Instructionは慣例的にunpack/packを定義します。
どういったフォーマットで読み書きをしているのか知りたい場合は、このどちらかを読むと理解できます。

先に書いた通りborshなどを使う場合は不要です。

また、instruction.rsにはInstructionのファクトリも一緒に定義されることが多いです。

pub fn initialize_mint(
    token_program_id: &Pubkey,
    mint_pubkey: &Pubkey,
    mint_authority_pubkey: &Pubkey,
    freeze_authority_pubkey: Option<&Pubkey>,
    decimals: u8,
) -> Result<Instruction, ProgramError> {
    check_program_account(token_program_id)?;
    let freeze_authority = freeze_authority_pubkey.cloned().into();
    let data = TokenInstruction::InitializeMint {
        mint_authority: *mint_authority_pubkey,
        freeze_authority,
        decimals,
    }
    .pack();

    let accounts = vec![
        AccountMeta::new(*mint_pubkey, false),
        AccountMeta::new_readonly(sysvar::rent::id(), false),
    ];

    Ok(Instruction {
        program_id: *token_program_id,
        accounts,
        data,
    })
}

例えばtokenプログラムでは上記のように、特定のInstructionを作成するための関数が定義します。
特になくてもプログラムとしては動作しますが、テストやRustクライアントを作る際にあると便利です。

5. state.rs

state.rsにはAccountInfodataSerialize/Deserialize可能なデータ型を定義します。

慣例としてデータ型はSealedIsInitializedPackトレイトを実装します。

pub trait Sealed: Sized {}

SealedトレイトはSized特性を追加します。

pub trait IsInitialized {
    /// Is initialized
    fn is_initialized(&self) -> bool;
}

IsInitializedトレイトはデータ型が初期化済みかを戻すis_initializedを提供します。

pub trait Pack: Sealed {
    /// The length, in bytes, of the packed representation
    const LEN: usize;
    #[doc(hidden)]
    fn pack_into_slice(&self, dst: &mut [u8]);
    #[doc(hidden)]
    fn unpack_from_slice(src: &[u8]) -> Result<Self, ProgramError>;

    /// Get the packed length
    fn get_packed_len() -> usize {
        Self::LEN
    }

    /// Unpack from slice and check if initialized
    fn unpack(input: &[u8]) -> Result<Self, ProgramError>
    where
        Self: IsInitialized,
    {
        let value = Self::unpack_unchecked(input)?;
        if value.is_initialized() {
            Ok(value)
        } else {
            Err(ProgramError::UninitializedAccount)
        }
    }

    /// Unpack from slice without checking if initialized
    fn unpack_unchecked(input: &[u8]) -> Result<Self, ProgramError> {
        if input.len() != Self::LEN {
            return Err(ProgramError::InvalidAccountData);
        }
        Self::unpack_from_slice(input)
    }

    /// Pack into slice
    fn pack(src: Self, dst: &mut [u8]) -> Result<(), ProgramError> {
        if dst.len() != Self::LEN {
            return Err(ProgramError::InvalidAccountData);
        }
        src.pack_into_slice(dst);
        Ok(())
    }
}

Packトレイトはデータ型とバイト列を変換する機能を提供します。

ただし、これらは慣例のためであり、新しくプログラムを組むときにはborshでも十分代用可能です。

6. error.rs

error.rsではProgramErrorでは表現しきれないカスタムエラー型を作成します。

#[derive(Clone, Debug, Eq, Error, FromPrimitive, PartialEq)]
pub enum CustomError {
  // ...
}
impl From<CustomError> for ProgramError {
    fn from(e: CustomError) -> Self {
        ProgramError::Custom(e as u32)
    }
}
impl<T> DecodeError<T> for CustomError {
    fn type_of() -> &'static str {
        // ...
    }
}

impl PrintProgramError for CustomError {
    // ...
}

thiserrorでエラーを表現し、num-deriveProgramErrorCustomに渡す、整数値への変換を行います。

このエラー型はProgramErrorで表現可能な範囲であれば省略可能です。
ただし、entrypointで書いたerror.print::<CustomError>();というようなエラー出力するなら必要になります。そのさいには空のstructなどで型だけでも作っておくと良いです。

おわりに

今回はSPLの全体像を中心に解説しました。
そのため、Solanaの細かい用語や、中の細かい実装については省略しています。

こちらの記事ではそういった箇所にも踏み込んで解説されているので、本記事と合わせて読んでいただくことで、より理解を深めることができるのでおすすめです。

また、今回紹介したSPLはあくまで慣例であり、プログラムを必ずこう書かなければならないという訳ではありません。
例えばSPLのスタイルではなく、フレームワークのAnchorを使った書き方をするなど他の書き方は可能です。
ただ、SPLはSolanaプログラムがどういった動きをしているのか理解するのに、とても良い書き方なので一度は手を出してみることをおすすめします。

Discussion