🐈

Solana Anchor Study

2023/07/20に公開

SolanaでのProgram(SmartContractと他ではいうもの)を作りやすくするためのフレームワーク
AnchorについてStudyしたポイントをまとめます

ProgramとDataを完全に分離するのがSolanaの特徴ですが、
その特徴を活かして安全で効率的な開発をサポートするのがAnchorです。

Install

https://www.anchor-lang.com/docs/installation
rust, solana , yarn は事前にInstallしておく必要があります
anchor を管理する avm をInstall,その後avmでanchorをInstallします。

avm
cargo install --git https://github.com/coral-xyz/anchor avm --locked --force
anchor
% avm install latest
% avm use latest
% anchor --version
anchor-cli 0.28.0

Anchorでのプログラムの例

https://www.anchor-lang.com/docs/the-accounts-struct

上記でinstallしたAnchorコマンドを使用して
anchor init hogehoge と実行すると Anchorを利用したSolanaのProgram templateが出来上がります。

一見するとただのコメントのようにみえる #[xxx] の部分でその行以下の内容を表しています。

  • #[program] : Program Logic部分
  • #[account] : そのデータの所有者を、クレートが使用されているID(declare_id!で作成したもの) に設定することです
  • #[derive(Default)] : このAnchor上で管理するデータアカウントの構造体を定義します
  • #[derive(Accounts)] : 外部のProgramと連携するときのデータ構造を定義します
use anchor_lang::prelude::*;
use anchor_spl::token::TokenAccount;

// declare_id! macro で ProgramのAccountIDを定義している
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");


#[program]
mod hello_anchor {
    use super::*;
    pub fn set_data(ctx: Context<SetData>, data: u64) -> Result<()> {
        if ctx.accounts.token_account.amount > 0 {
            ctx.accounts.my_account.data = data;
        }
        Ok(())
    }
}


#[account]
#[derive(Default)]
pub struct MyAccount {
    data: u64,
    mint: Pubkey
}


#[derive(Accounts)]
pub struct SetData<'info> {
    #[account(mut)]
    pub my_account: Account<'info, MyAccount>,
    #[account(
        constraint = my_account.mint == token_account.mint,
        has_one = owner
    )]
    pub token_account: Account<'info, TokenAccount>,
    pub owner: Signer<'info>
}

constraint

下記の部分では my_account がmutation (再代入可能)であることをチェックしています。

    #[account(mut)]
    pub my_account: Account<'info, MyAccount>,

下記の部分では constarint = で任意のチェックを定義し、
has_one で ownerが一人であることをチェックしています。

    #[account(
        constraint = my_account.mint == token_account.mint,
        has_one = owner
    )]

context (program section)

各エンドポイント関数は、Context最初の引数として型を受け取ります。contextから下記のような情報を取得できます。

  • ctx.accounts : アカウント
  • ctx.program_id : 実行中のプログラムのプログラムID
  • ctx.remaining_accounts: アカウント以外の命令に渡されたアカウント(複数アカウントの情報を参照したいときなど)

custom data param (prgram section)

下記のように# derive で AnchorDeserialize,,, アノテーションを付けた 構造体を定義することでいくらでも追加可能

#[program]
mod hello_anchor {
    use super::*;
    pub fn set_data(ctx: Context<SetData>, data: Data) -> Result<()> {
 ...
#[derive(AnchorSerialize, AnchorDeserialize, Eq, PartialEq, Clone, Copy, Debug)]
pub struct Data {
    pub data: u64,
    pub age: u8
}

ただし [#account]はもともと Anchor(De)serialize アノテーションを内包しているので、DataAccountとパラメータが等しければ下記で単純化してかける。

#[program]
mod hello_anchor {
    use super::*;
    pub fn set_data(ctx: Context<SetData>, data: MyAccount) -> Result<()> {
...
#[account]
#[derive(Default)]
pub struct MyAccount {
    pub data: u64,
    pub age: u8
}

Errors

#[program]
mod hello_anchor {
    use super::*;
    pub fn set_data(ctx: Context<SetData>, data: MyAccount) -> Result<()> {
        if data.data >= 100 {
            return err!(MyError::DataTooLarge);
        }
	// you can simply with requrie!
	// require!(data.data < 100, MyError::DataTooLarge);

        ctx.accounts.my_account.set_inner(data);
        Ok(())
    }
}

#[error_code]
pub enum MyError {
    #[msg("MyAccount may only hold data below 100")]
    DataTooLarge
}

CPI: Cross-Program Invocations

https://www.anchor-lang.com/docs/cross-program-invocations
puppet-master  =call=> puppet での例

  • template create
    % anchor init puppet  
    % anchor new puppet-master
    

呼び出される側(puppet)

  • 普通のProgram
callee

#[program]
pub mod puppet {
    use super::*;
    pub fn initialize(_ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }

    pub fn set_data(ctx: Context<SetData>, data: u64) -> Result<()> {
        let puppet = &mut ctx.accounts.puppet;
        puppet.data = data;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = user, space = 8 + 8)]
    pub puppet: Account<'info, Data>,
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct SetData<'info> {
    #[account(mut)]
    pub puppet: Account<'info, Data>,
}

#[account]
pub struct Data {
    pub data: u64,
}

呼び出し側 (puppet-master)

  • 呼び出され側のpub SetData Struct をUse している
  • cpi_program, cpi_accounts をContext<PullStrings>で定義する
  • それを利用してcpi_ctx を 生成し、puppet::cpi::set_dataを呼び出す
caller
use anchor_lang::prelude::*;
use puppet::cpi::accounts::SetData;
use puppet::program::Puppet;
use puppet::{self, Data};

declare_id!("HmbTLCmaGvZhKnn1Zfa1JVnp7vkMV4DYVxPLWBVoN65L");

#[program]
mod puppet_master {
    use super::*;
    pub fn pull_strings(ctx: Context<PullStrings>, data: u64) -> Result<()> {
        let cpi_program = ctx.accounts.puppet_program.to_account_info();
        let cpi_accounts = SetData {
            puppet: ctx.accounts.puppet.to_account_info(),
        };
        let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
        puppet::cpi::set_data(cpi_ctx, data)
    }
}

#[derive(Accounts)]
pub struct PullStrings<'info> {
    #[account(mut)]
    pub puppet: Account<'info, Data>,
    pub puppet_program: Program<'info, Puppet>,
}


impl<'info> PullStrings<'info> {
    pub fn set_data_ctx(&self) -> CpiContext<'_, '_, '_, 'info, SetData<'info>> {
        let cpi_program = self.puppet_program.to_account_info();
        let cpi_accounts = SetData {
            puppet: self.puppet.to_account_info()
        };
        CpiContext::new(cpi_program, cpi_accounts)
    }
}
  • Cargo.toml [dependencies] sectionに追加puppet の参照を追加し、cpiを諒することを宣言している
puppet = { path = "../puppet", features = ["cpi"]}

test

下記でpuppetMaster で設定した値が、

    await puppetMasterProgram.methods
      .pullStrings(new anchor.BN(42))
      .accounts({
        puppetProgram: puppetProgram.programId,
        puppet: puppetKeypair.publicKey,
      })
      .rpc()

puppet に反映されていることが確認できます。

   expect(
      (
        await puppetProgram.account.data.fetch(puppetKeypair.publicKey)
      ).data.toNumber()
    ).to.equal(42)

PDA : Program Derived Addresses

PDAはSolanaでもっともわかりにくい概念の一つだとおもいます。
1つ目の用途としては、それらを使用してハッシュマップを作成できます。ユーザーのアドレスから派生したユーザー状態保存機能をPDA作成するような例です。ユーザー アドレスとユーザー状態アカウントがリンクされ、前者があれば後者を簡単に見つけることができるようになります。 ハッシュマップは、どこかで一意のアドレスを管理することなく、プログラム、アカウントごとの強制的な一意性を実現できます。

2つ目の用途としては、PDA を使用してプログラムが CPI(CrossProgramInvocation) に署名できるようにすることができます。これは、プログラムに資産に対する制御を与え、コードで定義されたルールに従って管理できることを意味します。

どのように使うのかをみていったほうが理解出来ます。

一意性 hash mapとしての利用

On-chain program

  • user_stats.bump = *ctx.bumps.get("user_stats").unwrap(); ここで 呼び出しContextのなかからbummpを取得して、PDA自身に保存しています。
  • 下記の部分で payerがuserであるか、 bumpがまだからであるかなどを verification しています
    #[account(
        init,
        payer = user,
        space = 8 + 2 + 4 + 200 + 1, seeds = [b"user-stats", user.key().as_ref()], bump
     )]
    
on-chain program
use anchor_lang::prelude::*;


declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");


#[program]
pub mod game {
    use super::*;
    // handler function
    pub fn create_user_stats(ctx: Context<CreateUserStats>, name: String) -> Result<()> {
        let user_stats = &mut ctx.accounts.user_stats;
        user_stats.level = 0;
        if name.as_bytes().len() > 200 {
            // proper error handling omitted for brevity
            panic!();
        }
        user_stats.name = name;
        user_stats.bump = *ctx.bumps.get("user_stats").unwrap();
        Ok(())
    }
    // handler function (add this next to the create_user_stats function in the game module)
    pub fn change_user_name(ctx: Context<ChangeUserName>, new_name: String) -> Result<()> {
        if new_name.as_bytes().len() > 200 {
            // proper error handling omitted for brevity
            panic!();
        }
        ctx.accounts.user_stats.name = new_name;
        Ok(())
    }

}

#[account]
pub struct UserStats {
    level: u16,
    name: String,
    bump: u8,
}


// validation struct
#[derive(Accounts)]
pub struct CreateUserStats<'info> {
    #[account(mut)]
    pub user: Signer<'info>,
    // space: 8 discriminator + 2 level + 4 name length + 200 name + 1 bump
    #[account(
        init,
        payer = user,
        space = 8 + 2 + 4 + 200 + 1, seeds = [b"user-stats", user.key().as_ref()], bump
    )]
    pub user_stats: Account<'info, UserStats>,
    pub system_program: Program<'info, System>,
}

// validation struct
#[derive(Accounts)]
pub struct ChangeUserName<'info> {
    pub user: Signer<'info>,
    #[account(mut, seeds = [b"user-stats", user.key().as_ref()], bump = user_stats.bump)]
    pub user_stats: Account<'info, UserStats>,
}

Client sample

Client側でのポイント

  • PublicKey.findProgramAddress でClient側でPDAを導出できること
  • PDA導出のために 必要なのは、 seed, account, programidの3つ
  • createUserStats('brian') を呼び出すときにsignerは指定していない。実質sinerは program自体になる。
  • ややこしいですが、このtransactionのpayerは wallet のaccount です。
client
import * as anchor from '@coral-xyz/anchor'
import { Program } from '@coral-xyz/anchor'
import { PublicKey } from '@solana/web3.js'
import { Game } from '../target/types/game'
import { expect } from 'chai'


describe('game', async () => {
  const provider = anchor.AnchorProvider.env()
  anchor.setProvider(provider)


  const program = anchor.workspace.Game as Program<Game>


  it('Sets and changes name!', async () => {
    const [userStatsPDA, tmp] = await PublicKey.findProgramAddress(
      [
        anchor.utils.bytes.utf8.encode('user-stats'),
        provider.wallet.publicKey.toBuffer(),
      ],
      program.programId
    )

    await program.methods
      .createUserStats('brian')
      .accounts({
        user: provider.wallet.publicKey,
        userStats: userStatsPDA,
      })
      .rpc()


    expect((await program.account.userStats.fetch(userStatsPDA)).name).to.equal(
      'brian'
    )


    await program.methods
      .changeUserName('tom')
      .accounts({
        user: provider.wallet.publicKey,
        userStats: userStatsPDA,
      })
      .rpc()


    expect((await program.account.userStats.fetch(userStatsPDA)).name).to.equal(
      'tom'
    )
  })
})

test の中で各値を出力すると下記のように もともとの wallet publickeyとは 違う
PDAがPublicKey.findProgramAddress により導出されていることがわかります。

    console.log("pubkey",provider.wallet.publicKey);        
    console.log("programId",program.programId);    
    console.log({userStatsPDA});
    console.log({tmp});    

pubkey PublicKey [PublicKey(ygBV6tFPDMjJuZTH3FnwbzzcGABgWsrTabvyE5vMR6M)] {
  _bn: <BN: e84d3bfa6917d571a64fabbde7285d6a172fb179ae0309cfb5d2cd0433e7cc6>
}
programId PublicKey [PublicKey(Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS)] {
  _bn: <BN: da075cb2ff5ec6817613de530b692a8735477769da47430cbd8154335c4a8327>
}
{
  userStatsPDA: PublicKey [PublicKey(6zjRVFqTpvq3UYpHzcef5kSnBXFhmVtd8WfHpoCLNSnU)] {
    _bn: <BN: 591481f6feaf282810878abbb71b6d8bc5229b138df32db37856f5b28a842329>
  }
}
{ tmp: 252 }	

PDAによるCPIへの署名

On-chain program

  • CPIでも利用したPuppet-masterのプログラムを改変します
  • ctx.accounts.set_data_ctx().with_signer(&[&[bump][..]]) ここでbumpのみをつかってsignerの設定をしています。通常ここは seedもあわせて指定することでAccountごとのPDAになるのですが、今回はProgramとしてのPDAにしているのでAcccountは含まれていません。
on-chain program
use anchor_lang::prelude::*;
use puppet::cpi::accounts::SetData;
use puppet::program::Puppet;
use puppet::{self, Data};


declare_id!("HmbTLCmaGvZhKnn1Zfa1JVnp7vkMV4DYVxPLWBVoN65L");


#[program]
mod puppet_master {
    use super::*;
    pub fn pull_strings(ctx: Context<PullStrings>, bump: u8, data: u64) -> Result<()> {
        let bump = &[bump][..];
        puppet::cpi::set_data(
            ctx.accounts.set_data_ctx().with_signer(&[&[bump][..]]),
            data,
        )
    }
}


#[derive(Accounts)]
pub struct PullStrings<'info> {
    #[account(mut)]
    pub puppet: Account<'info, Data>,
    pub puppet_program: Program<'info, Puppet>,
    /// CHECK: only used as a signing PDA
    pub authority: UncheckedAccount<'info>,
}


impl<'info> PullStrings<'info> {
    pub fn set_data_ctx(&self) -> CpiContext<'_, '_, '_, 'info, SetData<'info>> {
        let cpi_program = self.puppet_program.to_account_info();
        let cpi_accounts = SetData {
            puppet: self.puppet.to_account_info(),
            authority: self.authority.to_account_info(),
        };
        CpiContext::new(cpi_program, cpi_accounts)
        
    }
}

Client sample

  • client側でPDAを導出するときに PublicKey.findProgramAddress([], puppetMasterProgram.programId) のようにprogramIdしか指定しないのでどのアカウントで実行しても同じ対象を操作していることになります。
  • 下記でPullStringを呼び出すときに導出したPDA, Bumpを渡すことでpuppet master自身が、 puppetを呼び出すときのsigner になっています。
    await puppetMasterProgram.methods
          .pullStrings(puppetMasterBump, new anchor.BN(42))
          .accounts({
    	puppetProgram: puppetProgram.programId,
    	puppet: puppetKeypair.publicKey,
    	authority: puppetMasterPDA,
          })
          .rpc()
    
client sample
import * as anchor from '@coral-xyz/anchor'
import { Program } from '@coral-xyz/anchor'
import { Keypair, PublicKey } from '@solana/web3.js'
import { Puppet } from '../target/types/puppet'
import { PuppetMaster } from '../target/types/puppet_master'
import { expect } from 'chai'


describe('puppet', () => {
  const provider = anchor.AnchorProvider.env()
  anchor.setProvider(provider)


  const puppetProgram = anchor.workspace.Puppet as Program<Puppet>
  const puppetMasterProgram = anchor.workspace
    .PuppetMaster as Program<PuppetMaster>


  const puppetKeypair = Keypair.generate()


  it('Does CPI!', async () => {
    const [puppetMasterPDA, puppetMasterBump] =
      await PublicKey.findProgramAddress([], puppetMasterProgram.programId)


    await puppetProgram.methods
      .initialize(puppetMasterPDA)
      .accounts({
        puppet: puppetKeypair.publicKey,
        user: provider.wallet.publicKey,
      })
      .signers([puppetKeypair])
      .rpc()


    await puppetMasterProgram.methods
      .pullStrings(puppetMasterBump, new anchor.BN(42))
      .accounts({
        puppetProgram: puppetProgram.programId,
        puppet: puppetKeypair.publicKey,
        authority: puppetMasterPDA,
      })
      .rpc()


    expect(
      (
        await puppetProgram.account.data.fetch(puppetKeypair.publicKey)
      ).data.toNumber()
    ).to.equal(42)
  })
})

感想

以上 Anchorを使った、Solanaのon-chain program構築のポイント理解でした。
Solanaではprogramとdataが分離されているからこそ、 プログラム自身がデータを扱うためにPDAという仕組みがあるのかと理解していました。ただPDAには 一意性という別の側面の特性もあるため、これらを組み合わせてつかうことで 署名、権限管理がスーパーユーザー(管理者)による署名を必要なく、Program自身で完結する書き方ができるということの最初の一歩が理解できました。 これらをうまく活かした 分散して、スケールするプログラムをつくってみたくなりました。

Discussion