SolanaのSPLの書き方を知ろう
Solana Program Library(以下SPL)はご存知ですか?
SPLはSolana Labsが開発している標準プログラムライブラリであり、トークン管理やネームサービス、ステークプールといったプログラム(スマートコントラクト)を提供しています。
これらのプログラムは一定の書き方がされており、標準的なプログラムの書き方としてわかりやすいのでご紹介したいと思います。
構成
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) }
では受け取ったinput
をdeserialize
して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
ではinput
をInstruction
に変換して、Instruction
に対応した処理を呼び出します。(Instruction
については後述します)
ここの変換処理は慣例的にunpack
を定義して独自のバイトフォーマットとして扱います。もし、新しくプログラムを作られるならName Serviceの実装のようにborshを使うのが簡単だと思われます。
Instruction
に対応した処理の例としてfn process_xxx(program_id: &Pubkey, accounts: &[AccountInfo], data: i32) -> ProgramResult
としていますが、引数は処理の内容によって変わりますが、慣例的にはprogram_id
(省略可能)、accounts
、Instruction
で受け取った値(省略可能)の順として定義されることが多いです。
この処理の中でアカウントの情報を書き換えたり、他のプログラムの呼び出しを行います。
そのため、このプログラムがどうやって動いているのか、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の例にはなりますが、上記のようにenum
でInstruction
の種類とそれぞれの指示で処理に必要になる値を定義します。
慣例として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
にはAccountInfo
のdata
をSerialize
/Deserialize
可能なデータ型を定義します。
慣例としてデータ型はSealed
、IsInitialized
、Pack
トレイトを実装します。
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-deriveでProgramError
のCustom
に渡す、整数値への変換を行います。
このエラー型はProgramError
で表現可能な範囲であれば省略可能です。
ただし、entrypoint
で書いたerror.print::<CustomError>();
というようなエラー出力するなら必要になります。そのさいには空のstruct
などで型だけでも作っておくと良いです。
おわりに
今回はSPLの全体像を中心に解説しました。
そのため、Solanaの細かい用語や、中の細かい実装については省略しています。
こちらの記事ではそういった箇所にも踏み込んで解説されているので、本記事と合わせて読んでいただくことで、より理解を深めることができるのでおすすめです。
また、今回紹介したSPLはあくまで慣例であり、プログラムを必ずこう書かなければならないという訳ではありません。
例えばSPLのスタイルではなく、フレームワークのAnchorを使った書き方をするなど他の書き方は可能です。
ただ、SPLはSolanaプログラムがどういった動きをしているのか理解するのに、とても良い書き方なので一度は手を出してみることをおすすめします。
Discussion