Solanaではじめるスマートコントラクト

2022.06.28

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

Introduction

常時話題にことかかない仮想通貨、近年急速に発展している
NFT、スマートコントラクトなどのweb3関連技術、
それらの中でもSolanaは、世界で最も高速なブロックチェーンであり、
暗号通貨で最も急速に成長しているエコシステムです。
(Solanaの通貨であるSOLは、イーサリアム(ETH)を超える可能性があるともいわれている)
個人的にはSolanaのスマートコントラクトをRustで実装できるのがよいと思っています。

本稿では、Solanaの特徴説明からAnchorフレームワークをつかって
シンプルなスマートコントラクトを動かすところまでやってみます。

Solana?

Solanaは高速かつ安価で、かつ分散性を兼ね備えたブロックチェーンです。
新興ブロックチェーンの中でもかなり評判がよく、
実用性もあるという話のブロックチェーンです。

Solanaは下記のような特徴を持っています。

低コスト

Solanaの持つスケーラビリティにより、開発者とユーザー双方で
トランザクション(ネットワーク内で発生した一定時間内の取引データ)が
0.01ドル未満におさまることが保証されている。

高速

他のブロックチェーンに比べて、とにかく速いです。
1秒あたりのトランザクション処理速度数が約50000件、
ブロックを生成する速度が約0.4秒と、
他の主要なブロックチェーンの10倍〜100倍くらいの速度を発揮します。

安全

Solanaのネットワークは何千もの独立したノードに分散しているので
トランザクションが安全です。

コンセンサスアルゴリズム

ブロックチェーンでは一定時間内の取引を1つのブロックにすべて記録し、
暗号化して最後尾のブロックにつなげていきます。
このとき、ブロック接続の可否を判断するアルゴリズムを、
コンセンサスアルゴリズムと呼びます。

たとえば、ビットコインではPoW(プルーフオブワーク)という
コンセンサスアルゴリズムが用いられています。
これは、仕様に合致したブロックのハッシュ値を探索し、
その値をつかってブロックを接続します。
(ハッシュ値を発見した人に報酬を支払う)

SolanaではPoS(プルーフオブステーク)をベースとし、
コンセンサスアルゴリズムとしてTower BFTを用いています。
このコンセンサスアルゴリズムを支えるコア技術は
PoH(Proof of History)であり、
PoHはトランザクション間の時間経過を検証できる方法を提供します。
※PoH自体はコンセンサスアルゴリズムではない

PoHやコンセンサスアルゴリズムについては下記を参考にしてください。

これらがSolanaの主な特徴です。
速い・安い・安全を兼ね備えた、新世代のブロックチェーンという印象ですね。

SmartContract?

SmartContractはブロックチェーン上で動作するモジュールです。
事前に設定されたルールに従って、ブロックチェーン上のトランザクションを実行したり、
ブロックチェーン外から取り込まれた情報をトリガーにして実行されるプログラムを指します。

Solanaの場合、SmartContract = プログラムであり、
C/C++/Rustを利用してSmartContractを作成可能です。
これらのプログラムは、JSON RPCや各種SDK経由でトランザクションを送信したりできます。
また、Solanaでは、SPL(Solana Program Library)
というプログラムライブラリを標準で用意し、
SPLが提供するトークンやスワップなどのプログラム機能を
プログラム開発者に使ってもらうことを想定しています。

Anchor?

Anchorは、安全なSolanaプログラムをすばやく構築するためのフレームワークです。
CLIからコマンドを実行することで、プログラムの
初期生成やビルド、デプロイ、テストなどの実行が簡単にできます。

Anchorは更新頻度が高く、紹介されているサンプルプログラムなど、
いままでうごいていたものが動かなくなったりビルドできなくなったりします。  
そんなときは以前のバージョンで試すか、
コードを修正するなど、自分の力でなんとかしましょう。

では、AnchorをつかってSolanaのプログラムをつくってみます。

Environment

  • OS : MacOS 12.4
  • Node : v18.2.0

M1 Macで動かしました。

Setup

まずはRustのインストール。

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

RustとCargoのコマンドが使えればOK。

% rustup --version
rustup 1.24.3 (ce5817a94 2021-05-31)
info: This is the version for the rustup toolchain manager, not the rustc compiler.
info: The currently active `rustc` version is `rustc 1.61.0 (fe5b13d68 2022-05-18)`

% cargo --version
cargo 1.61.0 (a028ae42f 2022-04-29)

そしてSolana CLI toolのインストール。

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

%solana --version
solana-cli 1.10.25 (src:d64f6808; feat:965221688)

yarnのインストール。

% npm install --global yarn

Walletの作成

Solanaプログラムの動作確認をするため、Walletを用意しましょう。
CLIで暗号通貨の送受信に使用されるKeyPairを格納します。

solana-keygenコマンドでKeyPairの作成。

% solana-keygen new

オプションでパスフレーズを追加するように言われますが、
特に入力しなくても問題ないです。  

おそらく、~/.config/solana/id.json
にKeyPairが出力されます。

また、-oで任意の場所に出力することも可能です。
その場合、このあと実行するコマンドでは
--keypairオプションでKeyPairのパスを指定する必要があるかもしれません。

solana-keygen pubkeyで公開鍵(アドレス)を表示できます。

% solana-keygen pubkey
<pub key>

これで準備完了です。

クラスターの設定とairdrop

Solanaは、さまざまな目的で複数のクラスターを用意しています。
それらについて簡単に説明します。

Devnet
開発やお試し用クラスター。
Devnetのトークンは本物ではありません。テスト用のトークンを取得することが可能です。
いつの間にかデータが消えたりします。

Testnet
Solanaのコントリビューターが直近のリリース機能を負荷テストしたりするクラスター。
特にネットワークパフォーマンス、安定性、バリデーターの動作がメインとのこと。
ここのトークンも本物ではなく、テストトークンの取得も可能です。

Mainnet Beta

すべてのSolanaユーザー、バリデーター、トークン所有者のための永続的なクラスター。
Betaとなっているが本番用のクラスターです。
ここで発行されるトークンは実際のSOLとなります。

本稿ではDevnetを使用しますので、
solana configコマンドでdevnetクラスターを選択します。

% solana config set --url devnet

デプロイしたりするのにSOLが少々必要なので、
airdropでSOLを取得します。

#1SOLをairdrop
% solana airdrop 1 
Requesting airdrop of 1 SOL

Signature: xxxxxxxxxxxx

1 SOL

10SOLとか一気に取得しようとすると怒られるので注意。
devnet/testnetでは、なにかしようとしてSOL足らんといわれたら
1ずつairdropしておけばよいかと思います。

Create Anchor Program

ではAnchorのサンプルプログラムを作っていきます。
まずはCargoを使ってAnchorをインストールします。

% cargo install --git https://github.com/project-serum/anchor avm --locked --force

% which anchor
~/.cargo/bin/anchor

% anchor --version
anchor-cli 0.24.2

今回はAnchorをつかったCounterプログラムを作成してみます。

雛形作成

anchor initコマンドでプログラムの雛形を生成します。

% anchor init counterapp

これでCounterAppプロジェクトワークスペースが作成されます。
以下は、主要なディレクトリ/ファイルです。

  • Anchor.toml : プログラムのワークスペース全体の設定を構成するファイル
  • .ancho : プログラムログとテストに使用されるデータ郡
  • app : monorepoを使用する場合に使用するフロントエンド用ディレクトリ
  • programs : プログラム用ディレクトリ
  • testsフォルダー : テスト用ディレクトリ
  • migrations: プログラムの展開スクリプトと移行スクリプト用ディレクトリ

Anchor.tomlは下記のようになっています。  

[features]
seeds = false
[programs.localnet]
counterapp = "<local Program Id>"

[programs.devnet]
counterapp = "<Devnet Program Id>"

[registry]
url = "https://anchor.projectserum.com"

[provider]
cluster = "localnet"
wallet = "<Walletのパス>"

[scripts]
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"

programs.localnetはローカルネット上の
プログラムのアドレス(Program Id)を示しています。
providerでは使用するクラスタとWalletを指定します。
scriptsでは、Anchorのtest時に実行されるスクリプトです。

雛形で動作確認

counterapp/src/lib.rsがSmart Contractの本体です。

use anchor_lang::prelude::*;

declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");

#[program]
pub mod counterapp {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize {}

declare_id!ではSmart ContractのプログラムIDを設定しています。
この雛形では実際何もしていません。
また、testsディレクトリにはTypescriptで定義されたテストファイルがあります。

import * as anchor from "@project-serum/anchor";
import { Program } from "@project-serum/anchor";
import { Counterapp } from "../target/types/counterapp";

describe("counterapp", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.Foo as Program<Counterapp>;

  it("Is initialized!", async () => {
    // Add your test here.
    const tx = await program.methods.initialize().rpc();
    console.log("Your transaction signature", tx);
  });
});

Anchorのバージョンによってはprogram.rpc(現在は非推奨)をつかってたり
するので注意。
ここでanchor testコマンドを実行するとbuildとtestが実行されますが、

TypeError: Module "file:///path/rout/tsconfig.json" needs an import assertion of type "json"

みたいなエラーが出ることがあります。
その場合、テスト用パッケージを追加して再度実行しましょう。

% yarn add ts-mocha
% anchor test

・・・

To deploy this program:
  $ solana program deploy /・・・/target/deploy/foo.so
The program address will default to this keypair (override with --program-id):
  /・・・/target/deploy/xxx-keypair.json

Found a 'test' script in the Anchor.toml. Running it as a test suite!

Running test suite: "/・・・/Anchor.toml"
Your transaction signature xxxxxx
    ✔ Is initialized! (122ms)

とりあえずbuildとtestは実行できました。

プログラム実装

では、lib.rsに関数を追加してみます。
数値を保持するCounter構造体を定義し、関数としてCounterの生成と
構造体の持つ数値を増やしたり減らしたりする関数を定義します。

use anchor_lang::prelude::*;

//あとで発行されたprogram idを設定.とりあえずそのまま
declare_id!("<Program Id>");


#[program]
pub mod counterapp {
    use super::*;

    pub fn create(ctx: Context<Counter>) -> Result<()> {
        let counter_account = &mut ctx.accounts.counter_account;
        counter_account.count = 0;
        Ok(())
    }

    pub fn increment(ctx: Context<Increment>) -> Result<()> {
        let counter_account = &mut ctx.accounts.counter_account;
        counter_account.count += 1;
        Ok(())
    }

    pub fn decrement(ctx: Context<Decrement>) -> Result<()> {
        let counter_account = &mut ctx.accounts.counter_account;
        if counter_account.count > 0 {
            counter_account.count -= 1;  
        }
        Ok(())
    }

}

//32byteのcounter_accountを作成
#[derive(Accounts)]
pub struct Counter<'info> {
    
    #[account(init, payer=user, space = 16+16)]
    pub counter_account: Account<'info, CounterAccount>,
    
    #[account(mut)]
    pub user: Signer<'info>,
    
    pub system_program: Program<'info, System>,
}

#[account]
pub struct CounterAccount {
    pub count: u64,
}

#[derive(Accounts)]
pub struct Increment<'info> {
    #[account(mut)]
    pub counter_account: Account<'info, CounterAccount>
}

#[derive(Accounts)]
pub struct Decrement<'info> {
    #[account(mut)]
    pub counter_account: Account<'info, CounterAccount>
}

そして、tests/counterapp.tsで、↑の関数をテストします。

import * as anchor from '@project-serum/anchor';
import { Program } from '@project-serum/anchor';
import { Counterapp } from '../target/types/counterapp';

describe('counterapp', () => {

    const provider = anchor.AnchorProvider.env();
    anchor.setProvider(provider);

    //anchor.workspaceはローカルプロジェクトの
    //すべてのSolanaプログラムにアクセスする方法として提供される
    const program = anchor.workspace.Counterapp as Program<Counterapp>;

    //テストを実行するためのキーペアを生成するヘルパー関数
    const counterAccount = anchor.web3.Keypair.generate();

    it('Is initialized!', async () => {

      await program.methods.create()
          .accounts({
            counterAccount: counterAccount.publicKey,
            user: provider.wallet.publicKey,
            systemProgram: anchor.web3.SystemProgram.programId,
          },
          ).signers([counterAccount])
          .rpc();
    
    });
    it("Increment counter", async () => {

        await program.methods.increment()
        .accounts({
          counterAccount: counterAccount.publicKey,
        })
        .rpc();

    })

    it("Fetch account-1", async () => {
      //cprogram.accountを介して、ounterAccountにアクセス(fetch)
      const account: any = await
      program.account.counterAccount.fetch(counterAccount.publicKey);
      console.log(account.count);
    })

    it("Decrement counter", async () => {
      await program.methods.decrement()
      .accounts({
        counterAccount: counterAccount.publicKey,
      })
      .rpc();
  })
  

    it("Fetch account-2", async () => {
      //cprogram.accountを介して、ounterAccountにアクセス(fetch)
      const account: any = await
      program.account.counterAccount.fetch(counterAccount.publicKey);
      console.log(account.count);
    })
});

テスト実行

修正したテストを実行してみます。

% anchor clean && anchor build

・・・

% anchor test
BPF SDK: ~/.local/share/solana/install/releases/1.10.25/solana-release/bin/sdk/bpf
cargo-build-bpf child: rustup toolchain list -v

To deploy this program:
  $ solana program deploy /path/counterapp/target/deploy/counterapp.so
The program address will default to this keypair (override with --program-id):
  /path/counterapp/target/deploy/counterapp-keypair.json

Found a 'test' script in the Anchor.toml. Running it as a test suite!

Running test suite: "/path/counterapp/Anchor.toml"

yarn run v1.22.15
warning package.json: No license field
$ /path/counterapp/node_modules/.bin/ts-mocha -p ./tsconfig.json -t 1000000 'tests/**/*.ts'

  counterapp
    ✔ Is initialized! (127ms)
    ✔ Increment counter (473ms)
<BN: 1>
    ✔ Fetch account-1
    ✔ Decrement counter (464ms)
<BN: 0>
    ✔ Fetch account-2


  5 passing (1s)

✨  Done in 7.04s.

テストもOKです。

Devnetにデプロイ

では、プログラムをdevnetにデプロイしてみます。
devnetにデプロイするにはSOLが必要なので、

まずは使用するWalletのアドレスを確認。

% solana address
<Public Key>

さきほどのsolana-keygenで-oオプションを使った場合、-kでKeypairを指定。

% solana address -k /payh/yourkeypair/your-keypair.json
<Public Key>

使用するWalletを確認し、そのパスを確認してAnchor.tomlを修正します。
デフォルトなら ~/.config/solana/id.json です。

・・・

[provider]
cluster = "devnet"
wallet = "/path/your/keypair.json"

・・・

また、clusterがlocalnetになっている場合はdevnetに修正しておきます。
そして、solana balanceコマンドを実行してSOLを確認しましょう。
SOLがなければairdropしておきます。

% solana balance
3 SOL

#パス指定なら solana balance --keypair <KeyPairのパス>

ビルドし直します。

% anchor clean && anchor build

devnetへデプロイしましょう。

% anchor deploy
Deploying workspace: https://api.devnet.solana.com
Upgrade authority: /path/your/keypair.json
Deploying program "counterapp"...
Program path: /path/counterapp/target/deploy/counterapp.so...
Program Id: <Ypur Program Id>

Deploy success

デプロイすると、Program Idが発行されます。
これをAnchor.tomlのprograms.devnetと
lib.rsのdeclare_idに設定しましょう。

そして再びテスト実行。
devnetにデプロイ後、テストが実行されます。

% anchor test

・・・

ちなみに、デプロイ後Solana ExplorerでProgram Idを
検索すれば、結果がヒットします。

Summary

今回はSolanaのSmart Contractを、Anchorフレームワークでつくってみました。
環境の準備が最低限で済み、ロジックに集中できるので実装/テストが楽になりますね。   次はSolanaでNFTのmintをやってみます。

References