SolanaのプログラムでCRUDしてみよう
Solanaのプログラム(スマートコントラクト)の学習を兼ねて、基本パターンとなるCRUDを作ってみましょう。
Accountについて
実際にCRUDを作り始める前に簡単にアカウントについて解説します。
アカウントはSolanaブロックチェーン上でトランザクションの結果の状態を保持します。
語弊はありますが、データベースで言うレコードのようなものだと認識していただくと理解が早いかもしれません。
AccountInfo<'a>
Solanaのプログラム上ではアカウントはAccountInfo<'a>
という型で表現されます。
#[derive(Clone)]
#[repr(C)]
pub struct AccountInfo<'a> {
/// Public key of the account
pub key: &'a Pubkey,
/// The lamports in the account. Modifiable by programs.
pub lamports: Rc<RefCell<&'a mut u64>>,
/// The data held in this account. Modifiable by programs.
pub data: Rc<RefCell<&'a mut [u8]>>,
/// Program that owns this account
pub owner: &'a Pubkey,
/// The epoch at which this account will next owe rent
pub rent_epoch: Epoch,
/// Was the transaction signed by this account's public key?
pub is_signer: bool,
/// Is the account writable?
pub is_writable: bool,
/// This account's data contains a loaded program (and is now read-only)
pub executable: bool,
}
key
はアカウントを示すアドレスです。
このkey
を元にアカウントの作成、更新、削除、取得を行います。
lamports
はアカウントに設定された残高で、これが0になるとこのアカウントは削除されます。
2年分以上の賃料をあらかじめ設定することで賃料支払いは免除され、恒久的にアカウントを保持できます。
このlamports
はプログラム上で書き込み可能で、0にすることでアカウントを削除できます。
data
はアカウントに設定された独自データを表すバイト列です。
このdata
はプログラム上で書き込み可能で、CRUDした結果の状態を書き込みます。
また、このdata
への書き込みを制限する場合、data
内に書き込める人のアドレスを含めて、プログラム中でチェックすることで実現します。
owner
はこのアカウントの所有者を示します。アカウントはこのowner
のみが変更可能です。
つまりプログラムからCRUDするにはowner
にプログラムのアドレスが設定されたアカウントを扱います。
プログラム上ではowner
がプログラムのアドレス以外のアカウントも扱いますが、それらは読み込みで使うアカウントか、他のプログラム呼び出しで使うアカウントになります。
rent_epoch
は次の賃料を支払う時間です。
is_signer
はトランザクション内で特定のアカウントが署名を行ったかどうかを示します。
例えば、CRUDで特定の人のみが操作を行う際にアカウントの所有者の許可が必要になります。
この時is_signer
がtrueであれば、そのアカウントはトランザクションに署名しており信頼できます。
is_signerが
false`の場合はアカウントがトランザクションに署名していないため、その操作が所有者の承認なしに行われている可能性があり、操作を続行してはいけません。
is_writable
はそのアカウントがプログラムで書き込み可能かを示します。
この値は後述のAccountMeta
の作成時に指定しますが、is_signer
がtrue
の場合は常にtrue
になります。
executable
はこのアカウントが実行可能 = プログラムのアカウントであることを示します。
AccountMeta
Solanaのプログラムを呼び出すさいに、プログラムで読み取り、または書き込みされるアカウントをAccountMeta
という型で表します。
#[repr(C)]
#[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct AccountMeta {
/// An account's public key.
pub pubkey: Pubkey,
/// True if an `Instruction` requires a `Transaction` signature matching `pubkey`.
pub is_signer: bool,
/// True if the account data or metadata may be mutated during program execution.
pub is_writable: bool,
}
key
はアカウントを示すアドレスです。
is_signer
はトランザクション内でアカウントを署名するかを示します。
このis_signer
がtrue
の場合はトランザクションで署名が必須になります。
is_writable
はそのアカウントがプログラムで書き込み可能かを示します。
CRUDの例
今回は例として作成者のみが数値を保存できるCRUDを作成してみましょう。
今回はフレームワークを使わないSPLのようなスタイルで作成していきます。(完全なSPLと同じ形は面倒なのでほどほどに省略します)
SPLについては詳しくは上記を参照してください。
State
アカウントのdata
で保存するデータ型を作成します。
#[derive(Debug, BorshDeserialize, BorshSerialize, PartialEq)]
pub enum Status {
Uninitialized,
Active,
Inactive,
}
#[derive(Debug, BorshDeserialize, BorshSerialize)]
pub struct State {
pub status: Status,
pub owner: Pubkey,
pub num: i32,
}
status
でデータの状態を管理し、未初期化→アクティブ→インアクティブという状態遷移を表します。
owner
はアカウントの作成者のアカウントアドレスを保存し、更新、削除は作成者のみが変更できるようにします。
num
はメインのデータで整数値を保存します。
deriveマクロでborshのBorshDeserialize
、BorshSerialize
を指定しているのはこの型とバイト列を相互に変換するためのものになります。
borshではstatus
はenum
なので1バイト、owner
はPubKey
なので32バイト、num
はi32
なので4バイトの連続したバイト列になります。(可変長な型では実データの前に長さが入ります)
Instruction
次にプログラムを呼び出すさいに渡す命令の型を作成します。
#[derive(Debug, BorshDeserialize, BorshSerialize, PartialEq)]
pub enum Instruction {
Create(i32),
Update(i32),
Delete,
}
CとUで整数値を登録し、Dでアカウントデータを削除します。
Rはプログラムを経由させる必要がないので命令には含めません。
共通部分
共通部分としてSolanaランタイムからの呼び出しインターフェース、Instruction
による処理の分岐部分を作成します。
entrypoint!(process_instruction);
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
if let Err(error) = processor::process(program_id, accounts, instruction_data) {
return Err(error);
}
Ok(())
}
fn process_create(program_id: &Pubkey, accounts: &[AccountInfo], n: i32) -> ProgramResult {
todo!()
}
fn process_update(program_id: &Pubkey, accounts: &[AccountInfo], n: i32) -> ProgramResult {
todo!()
}
fn process_delete(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult {
todo!()
}
pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> ProgramResult {
let instruction = Instruction::deserialize(&mut &input[..])?;
match instruction {
Instruction::Create(n) => process_create(program_id, accounts, n),
Instruction::Update(n) => process_update(program_id, accounts, n),
Instruction::Delete => process_delete(program_id, accounts),
}
}
これでクライアントからプログラムを呼び出し、Cとしてprocess_create
、Uとしてprocess_update
、Dとしてprocess_delete
を実行できる状態になりました。
Create
Createの処理としてprocess_create
の中身を実装しましょう。
pub fn process_create(program_id: &Pubkey, accounts: &[AccountInfo], n: i32) -> ProgramResult {
let account_iter = &mut accounts.iter();
let payer = next_account_info(account_iter)?;
let state = next_account_info(account_iter)?;
if !payer.is_signer {
msg!("Payer is not signer");
return Err(ProgramError::MissingRequiredSignature);
}
if state.owner != program_id {
msg!("Account does not have the correct program id");
return Err(ProgramError::IncorrectProgramId);
}
let mut data = State::deserialize(&mut &state.data.borrow()[..])?;
if data.status != Status::Uninitialized {
msg!("Account is already initialized");
return Err(ProgramError::AccountAlreadyInitialized);
}
data.status = Status::Active;
data.num = n;
data.serialize(&mut &mut state.data.borrow_mut()[..])?;
Ok(())
}
全体の流れとしてはaccounts
から必要とするアカウントを取り出し、期待したアカウントであることを検査し、アカウントにInstruction
から受け取った値を書き込みます。
let account_iter = &mut accounts.iter();
let payer = next_account_info(account_iter)?;
let state = next_account_info(account_iter)?;
まず、初めにnext_account_info
を利用して必要なアカウントをaccounts
から取り出します。
作成者のみが更新可能なプログラムになるため、作成者のアカウントと、このプログラムでデータを書き込むアカウントの2つをaccounts
から取り出します。
ここまでのプログラム中でaccounts
内にどういったデータが入るのか定義されていませんでしたが、これはクライアント側で指定したAccountMeta
に対応したAccount
が入る形になります。
そのため、こちらの期待しているアカウントがaccounts
内にない可能性があることは注意してください。
if !payer.is_signer {
msg!("Payer is not signer");
return Err(ProgramError::MissingRequiredSignature);
}
if state.owner != program_id {
msg!("Account does not have the correct program id");
return Err(ProgramError::IncorrectProgramId);
}
次に、アカウントが期待したアカウントなのか検査しています。
検査内容の1つ目は作成者のアカウントがトランザクション内で署名済みかを検査しています。
これは作成者のみがアカウントを更新できるように許可するという仕様上、この検査を行わないとこのアカウントが作成者のアカウントであることを確認できません。
より厳密に検査するならowner
がSystem Programであることの検査もした方がいいかもしれません。
ここで言う厳密にというのは暗黙的に期待しているものを明示し、想定外の入力を許さないということであり、おそらく検査しなくても仕様上は特に問題はありません。(たぶん)
検査内容の2つ目はこのプログラムで扱うデータを持つアカウントのowner
が自身のプログラムかどうかをチェックしています。
しかし、先に述べた通り、アカウントのowner
以外はアカウントを変更できないため検査は必須ではありません。
This is because a malicious user could create accounts with arbitrary data and then pass these accounts to the program in place of valid accounts. The arbitrary data could be crafted in a way that leads to unexpected or harmful program behavior.
こちらの話に合わせるなら念のため検査しておくと良さそうです。(SPLのコードを見ても検査されてないケースは見かけるので本当に念の為という話だと思われます)
let mut data = State::deserialize(&mut &state.data.borrow()[..])?;
if data.status != Status::Uninitialized {
msg!("Account is already initialized");
return Err(ProgramError::AccountAlreadyInitialized);
}
data.status = Status::Active;
data.num = n;
data.serialize(&mut &mut state.data.borrow_mut()[..])?;
最後にアカウントのデータ部を読み取り、Createして良いか検査し、状態を変更してアカウントに書き込みます。
未初期化のアカウントのデータ部はデータの長さで0埋めされたバイト列になります。
そのため、State
のstatus
は0の値、つまりUninitialized
になっていることを検査しています。
Update
UpdateもCreteと流れは同じになります。
fn process_update(program_id: &Pubkey, accounts: &[AccountInfo], num: i32) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let payer = next_account_info(account_info_iter)?;
let state = next_account_info(account_info_iter)?;
if !payer.is_signer {
msg!("Payer is not signer");
return Err(ProgramError::MissingRequiredSignature);
}
if state.owner != program_id {
msg!("Account does not have the correct program id");
return Err(ProgramError::IncorrectProgramId);
}
let mut data = State::deserialize(&mut &state.data.borrow()[..])?;
if data.status != Status::Active {
msg!("Account is not active");
return Err(ProgramError::InvalidAccountData);
}
if &data.owner != payer.key {
msg!("Payer is not owner");
return Err(ProgramError::InvalidAccountData);
}
data.num = num;
data.serialize(&mut &mut state.data.borrow_mut()[..])?;
Ok(())
}
CreateではUninitialized
か検査しましたが、Updateでは初期化済みのためActive
かを検査します。
また、送信者のkey
がデータに保存したPubKey
と一致しているかを検査することで、Updateは作成者のみが更新できるという仕様を実現しています。
そのため、誰でも更新できるようにしたいならこの検査を外せばいいですし、特定の複数の人が更新できるようにするならデータに保存するPubKeyを増やすことで更新できる人を増やすことができます。
Delete
DeleteもCreateやUpdateと変わりませんが、アカウント削除時の定型句としてlamports
の操作が入ります。
fn process_delete(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult {
let accounts_iter = &mut accounts.iter();
let payer = next_account_info(accounts_iter)?;
let state = next_account_info(accounts_iter)?;
let refund = next_account_info(accounts_iter)?;
if !payer.is_signer {
msg!("Payer is not signer");
return Err(ProgramError::MissingRequiredSignature);
}
if state.owner != program_id {
msg!("Account does not have the correct program id");
return Err(ProgramError::IncorrectProgramId);
}
let mut data = State::deserialize(&mut &state.data.borrow()[..])?;
if data.status != Status::Active {
msg!("Account is not active");
return Err(ProgramError::InvalidAccountData);
}
if &data.owner != payer.key {
msg!("Payer is not owner");
return Err(ProgramError::InvalidAccountData);
}
data.status = Status::Inactive;
data.owner = Pubkey::default();
data.num = 0;
data.serialize(&mut &mut state.data.borrow_mut()[..])?;
let source_amount: &mut u64 = &mut state.lamports.borrow_mut();
let dest_amount: &mut u64 = &mut refund.lamports.borrow_mut();
*dest_amount = dest_amount.saturating_add(*source_amount);
*source_amount = 0;
Ok(())
}
先に述べた通りlamport
を0にすることでアカウントが削除されます。
let source_amount: &mut u64 = &mut state.lamports.borrow_mut();
let dest_amount: &mut u64 = &mut refund.lamports.borrow_mut();
*dest_amount = dest_amount.saturating_add(*source_amount);
*source_amount = 0;
0にするためにはlamports
を移動させる必要があり、移動先のアカウントを指定してそちらに移動させています。
data.status = Status::Inactive;
data.owner = Pubkey::default();
data.num = 0;
データの状態を変更は不要に思われるかもしれませんが、lamport
を0にしても即座に削除されるわけではないため、不整合が起きないように変更しています。
Read
Readはプログラムではなくクライアント側で実装します。
今回はRustで簡単なクライアントを書いて、対象のアカウントの情報を取得します。
let client = RpcClient::new("https://api.devnet.solana.com");
let account = client.get_account_data("[作成したアカウントのアドレス]").unwarap();
let data = State::try_from_slice(&bytes).unwarap();
println("{:?}", data);
この読み取り方からわかるようにアカウントのアドレスをキーとして取得します。
List
アカウントを一覧で取得した場合もクライアント側で実装します。
let accounts = self
.rpc
.get_program_accounts("[プログラムのアドレス]")
.await?;
for (_, account) in accounts.iter() {
println!("{:?}", account);
}
上記のように書くことでプログラムに紐づく"全ての"アカウントを取得できます。
もし、対象のアカウント量が数件から数百件ぐらいなら安定して高速に動作するかもしれません。
しかし、数千、数万とアカウントが増えるに従ってこの取得方法では動作しなくなります。
例えば公開されているRPCエンドポイントなどは制限が結構厳しくなっています。
Mainetなどでは
Maximum amount of data per 30 second: 100 MB
とある通り30秒あたり100MBの制限があるため、ここの制限にかかり取得できなくなるケースが想定されます。
また、UXとして100MBのダウンロードしないと描画できないというのはかなり体験が悪いです。
そのため、緩和策としてget_program_accounts_with_config
を使い、data_slice
を指定して取得するのをアカウントアドレスだけにして、filters
で特定のデータだけに絞り込む必要があります。
詳しくは上記の記事にそちらを参照してください。
ただし、これはあくまでも緩和策であり、こういった方法を使っても取得できなくなるケースは起こりえます。
もし、完全な解決を求めるなら、クライアント、もしくはサーバーでアカウントを同期してページネーション可能な読み込み用のデータベースを構築するか、そういった機能を提供するSaaSの採用を考えなければなりません。
おわりに
SolanaでCRUDする例を作成してみました。
これはブロックチェーンに書き込みをするための基礎であり、SolanaではPDAやCPIを利用することでもう少し複雑な表現などもすることができます。
より深くSolanaを知りたい方には上記のドキュメントを熟読するのがおすすめです。
Discussion