Solana, Hello world スマートコントラクトの説明
Solanaの開発者向けの非常にわかりやすい学習教材があったため紹介します。
こちらのスマートコントラクトチュートリアルの第一回目のサンプルを紹介していきたいと思います。
Githubにソースコードも上がっています。
Solanaの専門用語、Accountについて
Solanaで開発を始めるにあたって、まずaccountという概念を理解する必要があります。
Accountとは、Solanaの専門用語で、Solana上でデータを保存するためのファイルのようなものです。Solanaでは、あらゆる情報がaccountに保存されています。ひとつひとつのaccountには、固有のアドレス(公開鍵)が割り振られています。
たとえばスマートコントラクトで、Aliceが保有しているトークンの残高を管理したい場合は、Aliceのトークン残高を保存するためのalice token accountを作成し、そのaccountに対しデータの読み書きを行います。Bobの残高の管理には、Bob専用のbob token accountを使用します。このようにSolanaでは、スマートコントラクトに加えて、データ保存用の各種accountも用意する必要があります。
Solanaでは、いわゆるスマートコントラクトのことをprogramと呼びます。実はこのprogramもaccountです。Solanaでprogramをdeployすると、programのロジックがprogram accountに保存されます。program accountはロジックデータのみを持ち、ステートの管理には、別途データ保存用のaccountが必要になります。このようにSolanaでは、スマートコントラクトのロジックとステートを完全に切り離して管理します。
サンプルプログラムの説明
src/lib.rsがprogramになります。上の方でGreetingAccountというstructが定義されていますが、これがprogramが管理するステートに相当します。process_instructionで、データを保存するためのaccountを指定し、保存するデータをinput: &[u8]
で入力します。inputはbytesで入力されるので、try_from_slice
でdeserializeしてstructに復元した後、serialize
で再びbytesにして、accountに保存するという処理を行っています。
...
/// Define the type of state stored in accounts
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct GreetingAccount {
pub name: String,
}
// Declare and export the program's entrypoint
entrypoint!(process_instruction);
// Program entrypoint's implementation
pub fn process_instruction(
program_id: &Pubkey, // Public key of the account the GM program was loaded into
accounts: &[AccountInfo], // The account to say GM to
input: &[u8], // String input data, contains the name to say GM to
) -> ProgramResult {
msg!("GM program entrypoint");
// Iterating accounts is safer than indexing
let accounts_iter = &mut accounts.iter();
// Get the account to say GM to
let account = next_account_info(accounts_iter)?;
// The account must be owned by the program in order to modify its data
if account.owner != program_id {
msg!("Greeted account does not have the correct program id");
return Err(ProgramError::IncorrectProgramId);
}
// Deserialize the input data, and store it in a GreetingAccout struct
let input_data = GreetingAccount::try_from_slice(&input).unwrap();
//Say GM in the Program output
msg!("GM {}", input_data.name);
//Serialize the name, and store it in the passed in account
input_data.serialize(&mut &mut account.try_borrow_mut_data()?[..])?;
Ok(())
}
Clientからtransactionを実行してみる
programだけ見てもなにをやっているか分かりづらいですが、clientからtxを実行してみると何をやっているかが見えてくると思います。src/client/main.tsにtx実行用のスクリプトが書いてあります。ローカルノードを立ち上げて、programをdeployしたあとmain.tsを実行すると上記のprocess_instructionをローカルでテストできます。clientでの処理は以下のような流れになります。
-
establishConnection
でローカルノードに接続する -
establishPayer
でウォレットを接続する -
checkProgram
でprogramがdeployされているかチェックし、データ保存用のaccountを作成する -
sayGm
でprocess_instructionを実行する -
reportGm
でaccountのデータを読み取り、実際に値が更新されているか確認する
src/client/gm_program.tsに具体的な処理が書いてあります。clientの処理には、@solana/web3.jsというSolana専用のjsモジュールを使用します。
accountの作成スクリプト
accountの概念を理解するため、account作成のためのスクリプトを見てみます。はじめにaccountのアドレスをcreateWithSeed
から生成します。payerにはaccountを作成する人のアドレス、NAME_FOR_GMに今回保存するGreetingAccount.nameの文字列、programIdにdeployしたprogramのアドレスを入力します。payerを引数にすることでユーザー固有のaccountアドレスが生成できます。
// Derive the address (public key) of a greeting account from the program so that it's easy to find later.
greetedPubkey = await PublicKey.createWithSeed(
payer.publicKey,
NAME_FOR_GM,
programId,
);
次にcreateAccountWithSeedでaccountを作成します。この操作でgreetedPubkeyをアドレスとして持つ新規accountが作成されます。spaceという引数がありますが、これはaccountのデータサイズが何バイトかを指定するパラメータです。Solanaでは、accountのデータサイズに応じて、accountを存続させるための手数料をSOLで支払う必要があります。事前にgetMinimumBalanceForRentExemptionでaccount存続のための最低rentを計算し、lamportsフィールドに入力してrentの支払いを行っています。もしrentが足りない場合エラーとなります。最後のprogramIdフィールドがacccountのオーナーとなるため、ここにdeployしたprogramアドレスを入力することで、programからaccount情報を書き換えられるようになります。
const lamports = await connection.getMinimumBalanceForRentExemption(
GREETING_SIZE,
);
const transaction = new Transaction().add(
SystemProgram.createAccountWithSeed({
fromPubkey: payer.publicKey,
basePubkey: payer.publicKey,
seed: NAME_FOR_GM,
newAccountPubkey: greetedPubkey,
lamports,
space: GREETING_SIZE,
programId,
}),
);
await sendAndConfirmTransaction(connection, transaction, [payer]);
process_instructionの実行
inputとなるGreetingAccountオブジェクトをserializeしてbytesにして、instructionを送ります。
keysがprocess_instructionの引数であるaccountsに対応し、dataがinputに対応しています。
let data = borsh.serialize(GmAccount.schema, gm);
const data_to_send = Buffer.from(data);
console.log(data_to_send)
const instruction = new TransactionInstruction({
keys: [{ pubkey: greetedPubkey, isSigner: false, isWritable: true }],
programId,
data: data_to_send
});
await sendAndConfirmTransaction(
connection,
new Transaction().add(instruction),
[payer],
);
accountの情報の確認
getAccountInfoメソッドで、accountに保存されているデータを取得できます。得られる情報はbytesのため、deserializeすることで実際の値を確認できます。
export async function reportGm(): Promise<void> {
const accountInfo = await connection.getAccountInfo(greetedPubkey);
if (accountInfo === null) {
throw 'Error: cannot find the greeted account';
}
const greeting = borsh.deserialize(
GmAccount.schema,
GmAccount,
accountInfo.data,
);
console.log(
greetedPubkey.toBase58(),
'GM was said to ',
greeting.name
);
}
実行結果
main.tsを実行すると以下のような結果が得られます。Glass Chewer
という文字列をinputとしてtxを実行し、作成したaccountにGlass Chewer
が保存されていることが確認できます。
Using program 7DShxMcwxg3kZPkFCoGEAvwu7XzZpXWpfhXYJ3R39HBT
Creating account AfaVBfqqXo63g8rLkAFfaomFb21mWm4dRBbcdvTp76Ht to say hello to
Saying hello to Glass Chewer with key AfaVBfqqXo63g8rLkAFfaomFb21mWm4dRBbcdvTp76Ht
<Buffer 0c 00 00 00 47 6c 61 73 73 20 43 68 65 77 65 72>
AfaVBfqqXo63g8rLkAFfaomFb21mWm4dRBbcdvTp76Ht GM was said to Glass Chewer
Success
まとめ
Solanaではaccountを使ってデータの管理をしている。実際のデータのやり取りはbytesで行うため、適宜serialize, deserializeが必要となる。
Discussion