🫥

SolanaのプログラムでCRUDしてみよう

2023/10/07に公開

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_signerfalse`の場合はアカウントがトランザクションに署名していないため、その操作が所有者の承認なしに行われている可能性があり、操作を続行してはいけません。

is_writableはそのアカウントがプログラムで書き込み可能かを示します。
この値は後述のAccountMetaの作成時に指定しますが、is_signertrueの場合は常に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_signertrueの場合はトランザクションで署名が必須になります。

is_writableはそのアカウントがプログラムで書き込み可能かを示します。

CRUDの例

今回は例として作成者のみが数値を保存できるCRUDを作成してみましょう。

今回はフレームワークを使わないSPLのようなスタイルで作成していきます。(完全なSPLと同じ形は面倒なのでほどほどに省略します)

https://zenn.dev/kinzal/articles/c2c8ced07ecd40

SPLについては詳しくは上記を参照してください。

State

アカウントのdataで保存するデータ型を作成します。

state.rs
#[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マクロでborshBorshDeserializeBorshSerializeを指定しているのはこの型とバイト列を相互に変換するためのものになります。
borshではstatusenumなので1バイト、ownerPubKeyなので32バイト、numi32なので4バイトの連続したバイト列になります。(可変長な型では実データの前に長さが入ります)

Instruction

次にプログラムを呼び出すさいに渡す命令の型を作成します。

instruction.rs
#[derive(Debug, BorshDeserialize, BorshSerialize, PartialEq)]
pub enum Instruction {
    Create(i32),
    Update(i32),
    Delete,
}

CとUで整数値を登録し、Dでアカウントデータを削除します。
Rはプログラムを経由させる必要がないので命令には含めません。

共通部分

共通部分としてSolanaランタイムからの呼び出しインターフェース、Instructionによる処理の分岐部分を作成します。

lib.rs
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(())
}
processor.rs
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の中身を実装しましょう。

processor.rs
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以外はアカウントを変更できないため検査は必須ではありません。

https://mattmazur.com/2021/12/09/understanding-solanas-instruction-modified-data-of-an-account-it-does-not-own-error/

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埋めされたバイト列になります。
そのため、Statestatusは0の値、つまりUninitializedになっていることを検査しています。

Update

UpdateもCreteと流れは同じになります。

processor.rs
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の操作が入ります。

processor.rs
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);
}

上記のように書くことでプログラムに紐づく"全ての"アカウントを取得できます。

もし、対象のアカウント量が数件から数百件ぐらいなら安定して高速に動作するかもしれません。
しかし、数千、数万とアカウントが増えるに従ってこの取得方法では動作しなくなります。

https://docs.solana.com/cluster/rpc-endpoints

例えば公開されているRPCエンドポイントなどは制限が結構厳しくなっています。
Mainetなどでは

Maximum amount of data per 30 second: 100 MB

とある通り30秒あたり100MBの制限があるため、ここの制限にかかり取得できなくなるケースが想定されます。

また、UXとして100MBのダウンロードしないと描画できないというのはかなり体験が悪いです。
そのため、緩和策としてget_program_accounts_with_configを使い、data_sliceを指定して取得するのをアカウントアドレスだけにして、filtersで特定のデータだけに絞り込む必要があります。

https://lorisleiva.com/paginating-and-ordering-accounts-in-solana
https://www.soldev.app/course/paging-ordering-filtering-data

詳しくは上記の記事にそちらを参照してください。
ただし、これはあくまでも緩和策であり、こういった方法を使っても取得できなくなるケースは起こりえます。

もし、完全な解決を求めるなら、クライアント、もしくはサーバーでアカウントを同期してページネーション可能な読み込み用のデータベースを構築するか、そういった機能を提供するSaaSの採用を考えなければなりません。

おわりに

SolanaでCRUDする例を作成してみました。
これはブロックチェーンに書き込みをするための基礎であり、SolanaではPDAやCPIを利用することでもう少し複雑な表現などもすることができます。

https://docs.solana.com/developers
https://solanacookbook.com/#contributing

より深くSolanaを知りたい方には上記のドキュメントを熟読するのがおすすめです。

Discussion