Back to Blog

Solana 超入門 02: Solana スマートコントラクトをゼロから書く

June 21, 2024
11 min read

はじめに

2024年、Solanaは時価総額で4位のパブリックブロックチェーンに躍り出て、投資家や開発者の注目を集めました。

BlockSecは、「Solanaを分かりやすく」シリーズをキュレーションしました。このシリーズには、Solanaの基本概念に関する記事、Solanaスマートコントラクトの書き方チュートリアル、Solanaトランザクション分析ガイドが含まれています。目的は、読者がSolanaエコシステムを理解し、Solanaでのプロジェクト開発やトランザクション実行に必要なスキルを習得できるようにすることです。

このシリーズの最初の記事では、Solanaネットワークの主要な概念、その動作メカニズム、アカウントモデル、トランザクションについて掘り下げ、正確で高性能なSolanaコントラクトを作成するための強固な基盤を築きました。

この記事では、記事の投稿と表示を行うためのSolanaプログラム(つまり、Solanaスマートコントラクト)の作成方法を案内します。これにより、最初の記事の概念を強化し、まだ議論していないSolanaのいくつかの機能を紹介します。関連するプログラムとテストコードはGitHubで公開されています。

環境設定

注意: 以下のコマンドはUbuntuシステムのみを対象としています。一部のコマンドはWindowsおよびmacOSシステムでは機能しない場合があります。

👉 代替コマンドを使用するか、ローカル開発環境の設定とSolana CLIのインストールを参照して解決してください。

ローカル環境でSolanaプログラムをコンパイルおよびデプロイします。Solanaプログラム開発に入る前に、いくつかのコマンドと依存関係をインストールする必要があります。

Rust

Solanaプログラムは主にRustプログラミング言語で書かれているため、Rustをインストールするには以下のコマンドを実行する必要があります。

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

node.js & TypeScript

テストスクリプトはTypeScriptで書かれているため、Node.jsとTypeScriptツールチェーンをインストールする必要があります。

curl -fsSL https://deb.nodesource.com/setup_15.x | sudo -E bash -
sudo apt-get install -y nodejs npm
npm install -g typescript
npm install -g ts-node

Solana CLI

次に、ウォレットの作成やコントラクトのデプロイなどのタスクにコマンドを提供するSolana CLIツールをインストールする必要があります。

sh -c "$(curl -sSfL https://release.solana.com/stable/install)"

現在のターミナルにこれらの変更を適用するには、次のコマンドを実行します。

source ~/.profile

solana --versionを実行して、Solana CLIが正常にインストールされたことを確認できます。

$ solana --version
solana-cli 1.17.25 (src:d0ed878d; feat:3580551090, client:SolanaLabs)

ローカルウォレット

次に、ローカルファイルシステムウォレットを生成するために次のコマンドを実行します。

solana-keygen new

デフォルトでは、このコマンドは~/.config/solana/id.jsonに秘密鍵ファイルを生成します。これは後でテストスクリプトで使用します。solana addressコマンドを使用して、生成されたアドレスを表示できます。

$ solana address
9FcGDHvzs8Czo4B1pQq8GPdXfdGHqNqayvh6bu4hVGfy

Devnet

プログラムはdevnetにデプロイされるため、Solana CLIをdevnetに切り替えるには次のコマンドを実行する必要があります。

solana config set --url https://api.devnet.solana.com

SOLトークン

コントラクトのデプロイとテストトランザクションの送信は無料ではないため、SOLトークンをリクエストする必要があります。solana airdrop 2を実行するか、パブリックWebファセットから直接トークンをリクエストできます。

プログラムの作成

紹介するプログラムは、ユーザーが記事を投稿し、現在投稿されているすべての記事を一覧表示できるようにします。3種類の命令を処理します。

  • init命令:現在の記事の最大インデックスを保存するデータアカウントを作成してプログラムを初期化します。
  • post命令:投稿された記事を新しいデータアカウントに保存します。
  • list命令:投稿されたすべての記事をログに記録します。

以下はプログラムの構造です。

$ tree program/
program/
├── Cargo.lock
├── Cargo.toml
└── src
    ├── instructions
    │   ├── init.rs
    │   ├── list.rs
    │   ├── mod.rs
    │   └── post.rs
    ├── lib.rs
    └── processor.rs

Cargo.tomlは、プロジェクトの外部ライブラリを指定します。

[dependencies]
borsh = "0.10.0"
borsh-derive = "0.10.0"
solana-program = "=1.17.25"

注意: solana-programのバージョンは、インストールしたSolana CLIのバージョンと一致する必要があります(solana --versionで確認できます)。一致しない場合、コンパイル中にエラーが発生する可能性があります。

コントラクトのエントリー関数はprocessor.rsで定義されています。ファイルは必要な依存関係のインポートから始まります(他のRustファイルでは同様の部分は省略します)。

続いてコントラクトのエントリー関数の定義が続きます。

プログラムの10行目では、Solanaライブラリのentrypoint!マクロが、エントリー関数がprocess_instructionであることを指定しています。この関数は3つのパラメータを受け取ります。

  • program_id: 現在のプログラムのデプロイされたアドレス。
  • accounts: 命令に関与するすべてのアカウント。
  • instruction_data: 命令の処理に使用されるバイト配列。

process_instructionは、instruction_dataの最初のバイトを抽出して命令の種類を識別し、対応する関数を呼び出して処理します。次に、これらの3つの関数の実装方法を見ていきます。

init

3つの関数のそれぞれは、program/src/instructionsディレクトリ内の同じ名前のファイルに定義されています。まずはinit.rsから始めましょう。

init命令は追加のバイト配列を必要としないため、program_idaccountsの2つのパラメータのみを受け取ります。

12行目から15行目では、プログラムは必要なアカウント情報を順番に抽出します。次に、find_program_address関数を呼び出して、現在の記事の最大インデックスを保存するために使用されるデータアカウントのindex_pda_keyアドレスを計算します。その後、プログラムはindex_pda_keyindex_pdaのアドレスと等しいことをアサートします。

ここで、index_pdaのアドレスはPDA(Program Derived Addresses)と呼ばれる特別なアドレスです。公開鍵から生成される「ウォレット」アカウントアドレスとは異なり、PDAはオプションのバイト配列、"bump"と呼ばれるバイト、およびプログラムのアドレスから導出されます。バイト配列とプログラムアドレスは呼び出し元によって明示的に提供され、bumpはfind_program_address関数によって自動的に生成されて返されます。bumpは、このように作成されたアドレスが公開鍵によって生成されたアドレスと衝突しないことを保証します。

index_pdaのアドレスが期待通りであることを確認した後、SystemProgramによって提供されるcreate_account命令を使用してindex_pdaを作成します。22行目では、プログラムはIndexAccountData型の変数を生成します。これは、現在の記事の最大インデックス(初期値は0)を記録します。下の図に示すように、この型はBorshSerializeBorshDeserializeトレイトを実装しており、Borsh形式でシリアライズおよびデシリアライズできます。

23行目から24行目では、アカウントデータ保存に必要なスペースとアカウント作成の最低レントを計算します。

27行目から37行目では、SystemProgramからcreate_account命令を作成し、invoke_signedメソッドを使用して呼び出します。あるプログラム内で別のプログラムの命令を呼び出すアクションは、CPI(Cross-Program Invocation)と呼ばれます。Solanaでは、invokeinvoke_signedの2つの関数を使用してCPIを実行できます。

invoke関数は指定された命令を直接処理し、その関数シグネチャは次のとおりです。

pub fn invoke(
    instruction: &Instruction,
    account_infos: &[AccountInfo<'_>]
) -> Result<(), ProgramError>

invoke_signedinvokeの唯一の違いは、invoke_signedがPDAアカウントの署名方法を提供することです。PDAアカウントは公開鍵と秘密鍵を持たないため、直接署名を提供できません。invoke_signedはこの問題を解決します。この関数はsigners_seedsと呼ばれる追加のパラメータを受け取ります。その関数シグネチャは次のとおりです。

find_program_address関数と同様に、invoke_signedは提供されたsigners_seedsと呼び出し元のprogram_idに基づいてPDAアドレスのセットを計算します。account_infosにこれらのPDAアドレスが含まれている場合、それらは署名を提供したかのように署名者としてマークされます。

pub fn invoke_signed(
    instruction: &Instruction,
    account_infos: &[AccountInfo<'_>],
    signers_seeds: &[&[&[u8]]]
) -> Result<(), ProgramError>

ここでは、create_account命令は作成されるアカウントを署名者として要求するため、index_pdaの署名を提供するためにinvoke_signedを使用する必要があります。

プログラムの最後に、コントラクトはindex_dataserializeメソッドを呼び出してデータをシリアライズし、index_pdadataフィールドに書き込みます。

post_article

次にpost命令の実装を見てみましょう。init命令とは異なり、post命令は投稿された記事を受け取るため、instruction_data_innerという追加のパラメータがあり、記事のシリアライズされたデータが保存されます。

いつものように、プログラムは命令に必要なアカウント情報を抽出することから始まります。記事ごとに個別のPDAアカウントを生成するため、プログラムは23行目でnew_article_pdaという追加のアカウントを受け取ります。

init命令と同様に、27行目から30行目ではfind_program_addressを使用して、渡されたindex_pdaのアドレスが正しいことを確認します。その後、そのdataフィールドのデータをデシリアライズして、現在割り当てられている最大インデックスを読み取ります。

33行目から40行目でもnew_article_pdaのアドレスを確認します。プログラムは、文字列「ARTICLE_PDA」と現在のインデックスを使用して新しいアドレスを生成します。インデックスは記事がアップロードされるたびに増加し、生成されるアドレスが一意であることを保証します。

次に、プログラムはinstruction_data_innerPostArticleData型の記事データにデシリアライズします。

この構造体はtitlecontentの2つのフィールドのみを持ちます。45行目から49行目では、記事が1つのアカウントに収まるサイズを超えていないかを確認します。

次のステップもinit命令と同様です。プログラムはまずアカウントに必要なスペースとレントを計算し、次にSystemProgramcreate_account命令を呼び出して記事を保存するためのPDAアカウントを作成します。最後に、投稿された記事はnew_article_pdadataフィールドにシリアライズされ、現在の最大インデックスは1つ増加してindex_pdadataフィールドにシリアライズされます。

list_articles

最後に、list命令の実装を見てみましょう。この命令はすべての記事を一覧表示する必要があるため、15行目では記事を保存しているすべてのPDAアカウントを表すためにベクトルが使用され、index_pdaアカウントのアドレスも検証されます。

その後、プログラムはベクトルのサイズが現在の最大インデックスと同じであることを検証し、各アカウントのアドレスの正確性をチェックします。

プログラムの最後では、各アカウントを反復処理し、solana-programmsg!マクロを使用して、デシリアライズされた記事の内容を一つずつ出力します。

トランザクションテスト

コントラクトをテストするために、リポジトリのclient/main.tsにあるTypeScriptスクリプトを使用します。

スクリプトの先頭では、必要なすべてのライブラリをインポートし、3つのグローバル定数を定義します。KEYPAIR_PATHは、solana-keygen newを使用して生成された秘密鍵ファイルのパスを示します。PROGRAM_IDは、前のセクションで記述したデプロイされたプログラムのアドレスです。POST_ARTICLE_SCHEMAは、記事のシリアライゼーションに使用されるオブジェクトです。

スクリプトのメインボディでは、まずloadKeyFromFile関数を呼び出して秘密鍵ファイルを解析し、トランザクション手数料の支払い者としてKeypairオブジェクトを取得します。次に、@solana/web3.jsによって提供されるfindProgramAddressSyncメソッドを使用して、indexPdaのアドレスを計算します。このメソッドはRustコントラクトと同じアルゴリズムを使用しており、同じパラメータで同じアドレスを計算します。connectionオブジェクトは、テストにdevnetを使用することを指定します。

次に、最初の初期化トランザクションを送信します。単一の命令を含むトランザクションを作成します。この命令のdataフィールドには「0」という1バイトのみが含まれており、これはinit命令の呼び出しであることを示します。トランザクションは送信され、sendAndConfirmTransactionを使用して確認されます。

2番目のトランザクションは、post命令を呼び出して記事を投稿します。56行目から63行目では、スクリプトは2つの記事をシリアライズし、対応するPDAアドレスを計算します。その後、2つのpost命令が単一のトランザクションに追加され、これらの記事が作成されます。

最後のトランザクションはlist命令を呼び出し、トランザクションが確認された後にログが出力されます。

スクリプトでプログラムをテストするには、KEYPAIR_PATHを秘密鍵のパスに置き換えます(デフォルトパスでない場合)。次に、以下のコマンドを実行してRustスマートコントラクトをコンパイルしてデプロイします。

cd program
cargo build-bpf
solana program deploy ./target/deploy/hello_solana.so

その後、デプロイされたアドレスをコピーしてPROGRAM_IDフィールドに貼り付けます。プロジェクトのルートディレクトリでnpm installを実行して依存関係をインストールし、npm startを実行してスクリプトを実行します。

その後、Solscanを使用してテストトランザクションの実行詳細を表示できます。

記事公開のための2番目のトランザクションを直接見てみましょう。これは2つの命令を含み、各命令のInstruction Data Rawの最初のバイトは0x01です。さらに、各命令にはSystem ProgramCreateAccount命令の呼び出しである内部命令が含まれています。

同様に、すべての記事を一覧表示するための3番目のトランザクションを検査できます。Program Logsセクションでは、2つの記事のインデックス、タイトル、コンテンツが出力されていることがわかります。

結論

この記事では、まずSolanaプログラミングおよびランタイム環境をローカルでセットアップする方法を説明しました。次に、Solanaコントラクトの実装ロジックを詳述しました。最後に、TypeScriptスクリプトを使用してコントラクトの機能をテストしました。このステップバイステップのチュートリアルを通じて、簡単なSolanaスマートコントラクトの書き方を学んだことと思います🥳。

次回の記事では、Phalcon Explorer(Phalcon Explorerは現在Solanaをサポートしています)を使用してSolanaトランザクションを表示および分析する方法について詳細なガイドを提供します。お楽しみに!

このシリーズの他の記事を読む: