FLINTERS Engineer's Blog

FLINTERSのエンジニアが綴る技術ブログ

イーサリアムキラーの本命?Solanaのプログラムを作って遊んでみる!

こんにちは。菅野です。

今年も暗号資産界隈は話題に事欠かないですね!
その仕組みを支えているのはブロックチェーンです。
最近もAmazonGoogleが注力している分野の一つです。

そして2021年に最もホットで技術的に魅力なブロックチェーンといえばSolanaでしょう!

Solanaって?

solana.com

詳しくはググってほしいのですが、簡単に言うと

のような技術的な特徴があります。

私はEthereumやEOSのスマートコントラクトを作って遊んだことがあります。
でも、EthereumはSolidityというあまり馴染みのない言語で書く必要があり、EOSはwasmにコンパイルすることでRustで書くことが出来ましたがRustの強みをあまり活かせてない感があります。
SolanaはLLVMのBPFバックエンドを介してRustプログラムを動かせるのでRustのパワーを活かせると思います。

ちなみにRustとはC並に速いネイティブアプリを作れて、ヌル参照と無縁で、かつ超強力な文法を有する、とてもホットで素晴らしいプログラミング言語です。Rustはいいぞ。

Solanaを試す

Install the Solana Tool Suite | Solana Docs

まずはCLIツールをインストールしましょう。
公式のドキュメント通りに作業すれば入ります。

ツールをインストールしたらウォレットとなる鍵を作成しましょう!

solana-keygen new

で作成できます。ちょっと試すだけなら回復用のパスフレーズ無しで作成しても良いと思います。
シードフレーズを覚えておけと言われますが、本物のお金を入れないのであれば鍵を失くしてもウォレットを再作成すれば良いでしょう。
キーペアは$HOME/.config/solana/id.jsonに保存されます。

さらに接続先の設定をdevnetに設定します。ここは開発用に自由に試せる環境です。

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

そしたら、とりあえずお金を手に入れてみましょう。
solana airdrop 1で1 SOL手に入れることができます。SOLはSolanaでの通貨です。

❯ solana airdrop 1
Requesting airdrop of 1 SOL

Signature: QhBiQrxby8wWCdfiw1e2X2fLDrHYofnTeJAWcUcHaHQhYXN3SZPWWcpm5721r7i8r8haJ77BVfVCfiB18hG8WPu

1 SOL

おめでとうございます!
ポチポチするだけでお金を手に入れることができました!もう働かなくて済みますね!

さっそくプログラムを作って遊ぶ!

無職に思いを馳せたところで早速プログラムを作って遊んでみようと思います。

何を作ろうか悩みますが、とりあえずSOL/USD価格のバイナリオプションを実装してみようと思います。
今回は5分後のSOL/USDが上か下かを一人で賭けて遊ぶというものを作ります。
そんなものをブロックチェーンで実装してどうするんだという気もしますが、複数人でトークンをやり取りするプログラムはかなり複雑(アカウントのやりくりとプログラム呼び出しを多数行わなければならない)になるので簡単なものから作ろうと思います。

Solanaのアカウントとプログラム

Solanaではいろいろなものが"アカウント"として存在します。
ウォレットとしてトークンを保管する場所もアカウントですし、プログラムもアカウントに保存されます(実行可能アカウント)!また、プログラムのデータを保存する場所もアカウントとして別に作ります。

プログラムからは別のプログラムを呼び出すことができ、サブルーチン的な事ができます。
Solanaにはシステムプログラムという組み込みのプログラムがあり、これを使ってトークンの送信などが行えます。

ラクル問題とChainlink

ブロックチェーンでは改ざんが出来ないため公平性が保たれます。それによってトークンのやり取りが不正なく行われます。
でも、例えば外が曇っていたら料金が割引されるというのをブロックチェーンで実現しようとしたら問題が発生します。

人によって「これは曇りだ」「まだ晴れだろ」と判断が分かれたり、嘘の内容のポジショントークをする人も現れるでしょう。
そこでその情報自体もブロックチェーンで民主的に管理することを実現したのがChainlinkに代表されるオラクルネットワークです。

chain.link

Chainlinkでは暗号資産や為替、株などの価格情報が集められて、Solanaのチェーンにも持ってくることが出来るので使ってみようと思います。

Rustのプロジェクトを作成

プログラムを作るために新規のRustのlibプロジェクトを作成しましょう。Rustのインストールなどは割愛。

cargo new <project-name> --lib

作ったらCargo.tomlを編集します。

cargo-features = ["edition2021"]

[package]
name = "solana-test"
version = "0.1.0"
edition = "2021"

[features]
no-entrypoint = []

[dependencies]
solana-program = "1.8.2"
borsh = "0.9.1"
thiserror = "1.0"
# git clone https://github.com/smartcontractkit/chainlink-solana.git
chainlink = { path = "./chainlink-solana", package = "chainlink-solana", features = ["no-entrypoint"] }

[dev-dependencies]
solana-program-test = "1.8.2"
solana-sdk = "1.8.2"

[lib]
crate-type = ["cdylib", "lib"]

依存するchainlink-solanaが依存するクレートのバージョンを修正するためgit clone https://github.com/smartcontractkit/chainlink-solana.gitでローカルに落としてきています。
./chainlink-solana/Cargo.toml内のバージョンを調整しています。

Xargo.tomlも下記の内容で作成します。

[target.bpfel-unknown-unknown.dependencies.std]
features = []

これで開発準備は完了です。

バイナリオプションプログラムを作成

続いてプログラムの中身を作成します。

一つ一つ解説すると長くなるのでまずsrc/lib.rsの完成形を貼ります。

use borsh::{BorshDeserialize, BorshSerialize};
use thiserror::Error;
use solana_program::{
    account_info::{next_account_info, AccountInfo},
    clock::Clock,
    entrypoint,
    entrypoint::ProgramResult,
    msg,
    program_error::ProgramError,
    pubkey::Pubkey,
    sysvar::Sysvar,
};

// プログラムデータ
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct BinaryOptionData {
    pub score: u32,
    pub maturity_timestamp: u32,
    pub strike_price: u64,
    pub is_higher: u8,
    pub is_betting: u8,
}

// プログラム引数
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct BinaryOptionInstruction {
    pub command: u32,
}

const MATURITY_MARGIN: u32 = 5;
const SOL_USD_KEY: &str = "FmAmfoyPXiA8Vhhe6MZTr3U6rZfEZ1ctEHay1ysqCqcf";

#[derive(Clone, Debug, Eq, Error, PartialEq)]
enum BinaryOptionError {
    #[error("the maturity has not reached.")]
    MaturityNotReached,
    #[error("price feed is not available.")]
    MarketPriceNotFound,
    #[error("you must bet first.")]
    NoPosition,
}
impl From<BinaryOptionError> for ProgramError {
    fn from(e: BinaryOptionError) -> Self {
        ProgramError::Custom(e as u32)
    }
}

entrypoint!(process_instruction);
// エントリーポイント
pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    let accounts_iter = &mut accounts.iter();
    // クライアントから渡されたアカウントの情報を取得
    let data_account = next_account_info(accounts_iter)?;
    let feed_account = next_account_info(accounts_iter)?;

    if data_account.owner != program_id || feed_account.key.to_string() != String::from(SOL_USD_KEY) {
        return Err(ProgramError::InvalidAccountData);
    }

    let mut program_data: BinaryOptionData = BinaryOptionData::try_from_slice(&data_account.data.borrow())?;

    let clock = Clock::get()?;
    // 引数を処理
    let instruction: BinaryOptionInstruction = BinaryOptionInstruction::try_from_slice(instruction_data)
        .map_err(|_| ProgramError::InvalidInstructionData)?;
    msg!("コマンド: {}", instruction.command);
    let result: Result<(), ProgramError> = match instruction.command {
        0 => // 結果反映
            if program_data.is_betting == 0 {
                msg!("ポジションがありません");
                Err(BinaryOptionError::NoPosition.into())
            } else if program_data.maturity_timestamp + MATURITY_MARGIN < clock.unix_timestamp as u32 {
                settle(&mut program_data, feed_account)
            } else {
                msg!("満期に達していません");
                Err(BinaryOptionError::MaturityNotReached.into())
            }
        1 | 2 => // ポジション構築
            if program_data.is_betting == 0 {
                let is_higher = if instruction.command == 1 { 1 } else { 0 };
                bet(&mut program_data, is_higher, clock.unix_timestamp as u32, feed_account)
            } else {
                Err(ProgramError::InvalidInstructionData)
            }
        _ => Err(ProgramError::InvalidInstructionData)
    };

    result.and_then(|_| {
        program_data.serialize(&mut &mut data_account.data.borrow_mut()[..])
            .map_err(|e| ProgramError::from(e))
    }).map(|_| ())
}

fn settle(program_data: &mut BinaryOptionData, feed_account: &AccountInfo) -> Result<(), ProgramError> {
    let price = chainlink::get_round(&chainlink::id(), feed_account, program_data.maturity_timestamp as i64)?;
    if let Some(chainlink::state::Submission(ts, settlement_price)) = price {
        msg!("満期時刻: {}", ts);
        msg!("清算価格: {}", settlement_price as u64);
        msg!("行使価格: {}", program_data.strike_price);
        msg!("賭け: {}", if program_data.is_higher == 1 { "上" } else { "下" });
        if program_data.is_higher == 0 && program_data.strike_price > settlement_price as u64
        || program_data.is_higher == 1 && program_data.strike_price < settlement_price as u64 {
            msg!("当たり😎フッ");
            program_data.score += 1;
        } else {
            msg!("外れた🥺ピエン");
            program_data.score -= 1;
        }
    } else {
        msg!("価格が取得できませんでした😅");
        program_data.score -= 1;
    }
    program_data.is_betting = 0;
    Ok(())
}

fn bet(program_data: &mut BinaryOptionData, is_higher: u8, current_timestamp: u32, feed_account: &AccountInfo) -> Result<(), ProgramError> {
    if let Some(current_price) = chainlink::get_price(&chainlink::id(), feed_account)? {
        program_data.strike_price = current_price as u64;
        program_data.maturity_timestamp = current_timestamp + 300; // 満期は5分後
        program_data.is_higher = is_higher;
        program_data.is_betting = 1;
        Ok(())
    } else {
        Err(BinaryOptionError::MarketPriceNotFound.into())
    }
}

データはバイナリで読み書きするのでborshでシリアライズをしています。
エントリポイントではプログラムIDと渡されたアカウントのスライスとプログラム引数のバイナリを取得できます。

渡されるアカウントはプログラムの呼び出し側で指定するのでチェックしています。
プログラムでは呼び出し側で指定したアカウント以外の情報を取ってくることは出来ません。

これらの情報を使ってプログラムを組み立てます。
ログはmsg!マクロで出力します。println!などは使えません。
その他、ブロックチェーンの特性上使えないものがいくつかあります。

あとはコードから察してください。
数値がオーバーフローし放題で危なっかしいですが、気の所為です。

いざデプロイ!

プログラムが出来上がったのでこいつを全世界に公開します。
テストを書いてないだろ!と怒られそうですが、お試しなので…

通常のバイナリではなくBPFバイトコードを出力する必要があるので下記のコマンドでビルドします。

cargo build-bpf

完了すると./target/deploy配下にsoファイルが出来上がるので、コンソールに表示されている通りにsolana program deployコマンドでデプロイします。

ちなみに1 SOLではお金不足でデプロイ出来ませんでした。
デプロイ失敗するとsolana program show --buffersで見ることが出来る中間状態が出来上がるのですが、全部切り戻すにはsolana program close --buffersで戻せます。

2 SOLある状態でデプロイが出来ました。

❯ solana program deploy /path_to_project/target/deploy/xxxxxx.so
Program Id: CdmSsDB2BbEcdj9ouATnUuQoJg5WgR2DDdCbYYbHH67p

デプロイ完了するとプログラムIDが確認できるので覚えておきます。

クライアントも必要

早速動かしたいのですが、チェーン上のプログラムを動かすプログラムも必要になってきます。
@solana/web3.jsというjsのライブラリがあるのでこれを使って作っていきます。

出来ました。node.jsで動くCLIアプリをTypeScriptで作りました。

models.ts

import * as borsh from "borsh";

// Data
export const Higher = 1;
export const Lower = 0;
export type Bet = typeof Higher | typeof Lower;

export const Yes = 1;
export const No = 0;
export type IsBetting = typeof Yes | typeof No;

export type BinaryOptionAccount = {
  score: number;
  maturityTimestamp: number;
  strikePrice: number;
  isHigher: Bet;
  isBetting: IsBetting;
};

const BinaryOptionSchema = new Map([
  [
    Object,
    {
      kind: "struct",
      fields: [
        ["score", "u32"],
        ["maturityTimestamp", "u32"],
        ["strikePrice", "u64"],
        ["isHigher", "u8"],
        ["isBetting", "u8"],
      ],
    },
  ],
]);

export function serializeData(data: BinaryOptionAccount): Uint8Array {
  return borsh.serialize(BinaryOptionSchema, data);
}

export function deserializeData(data: Buffer): BinaryOptionAccount {
  return borsh.deserialize(BinaryOptionSchema, Object, data) as BinaryOptionAccount;
}

export const initialData: BinaryOptionAccount = {
  score: 0,
  maturityTimestamp: 0,
  strikePrice: 0,
  isHigher: 0,
  isBetting: 0,
};

// Instruction
export const Settle = 0;
export const BettingHigher = 1;
export const BettingLower = 2;
export type Command = typeof Settle | typeof BettingHigher | typeof BettingLower;
const BinaryOptionInstructionSchema = new Map([[Object, { kind: "struct", fields: [["command", "u32"]] }]]);

export function makeInstructionData(command: Command): Uint8Array {
  return borsh.serialize(BinaryOptionInstructionSchema, { command });
}

main.ts

import { readFile } from "fs/promises";
import { program } from "commander";
import { DateTime } from "luxon";
import {
  PublicKey,
  Connection,
  Transaction,
  TransactionInstruction,
  sendAndConfirmTransaction,
  Keypair,
  SystemProgram,
} from "@solana/web3.js";
import { BettingHigher, BettingLower, Command, deserializeData, initialData, makeInstructionData, serializeData, Settle } from "./models";

// 実行ユーザーのキーペアと、プログラムで使うアカウントの公開鍵
const userKeyPair = Keypair.fromSecretKey(
  Uint8Array.from(JSON.parse(await readFile(`${process.env.HOME}/.config/solana/id.json`, { encoding: "utf8" }))),
);
const programKey = new PublicKey("CdmSsDB2BbEcdj9ouATnUuQoJg5WgR2DDdCbYYbHH67p");
const seed = "test";
const programDataAccountKey = await PublicKey.createWithSeed(userKeyPair.publicKey, seed, programKey);
const solUsdKey = new PublicKey("FmAmfoyPXiA8Vhhe6MZTr3U6rZfEZ1ctEHay1ysqCqcf"); // Chainlink SOL/USD

// SolanaのDevNet
const connection = new Connection("https://api.devnet.solana.com");

// 引数に応じたコマンドを実行
program.command("betHigher").description("5分後の値上がりに賭ける").action(() => runProgram(BettingHigher));
program.command("betLower").description("5分後の値下がりに賭ける").action(() => runProgram(BettingLower));
program.command("settle").description("賭けの結果を得点に反映する").action(() => runProgram(Settle));
program.command("showScore").description("スコアを見る").action(showScore);
program.command("showPosition").description("賭けの状況を確認").action(showPosition);
program.command("showBalance").description("ウォレットの残高確認").action(showBalance);
await program.parseAsync(process.argv);

async function runProgram(command: Command) {
  // 初回実行時にデータ領域を確保する
  const programDataInfo = await connection.getAccountInfo(programDataAccountKey);
  if (programDataInfo === null) {
    const dataSize = serializeData(initialData).length;
    const minimumBalanceForRentExemption = await connection.getMinimumBalanceForRentExemption(dataSize);

    const createAccountTx = new Transaction().add(
      SystemProgram.createAccountWithSeed({
        fromPubkey: userKeyPair.publicKey,
        basePubkey: userKeyPair.publicKey,
        seed: seed,
        newAccountPubkey: programDataAccountKey,
        lamports: minimumBalanceForRentExemption,
        space: dataSize,
        programId: programKey,
      }),
    );
    const createTx = await sendAndConfirmTransaction(connection, createAccountTx, [userKeyPair]);
    console.log("データアカウントを作成しました", createTx);
  }

  // プログラム実行
  const instructionData = makeInstructionData(command);
  const programInstruction = new TransactionInstruction({
    keys: [
      { pubkey: programDataAccountKey, isSigner: false, isWritable: true },
      { pubkey: solUsdKey, isSigner: false, isWritable: false },
    ],
    programId: programKey,
    data: Buffer.from(instructionData),
  });
  const programTx = await sendAndConfirmTransaction(connection, new Transaction().add(programInstruction), [userKeyPair]);
  console.log("プログラムを実行しました", programTx);

  // トランザクションの結果の確認
  const txResult = await connection.getConfirmedTransaction(programTx);
  txResult?.meta?.logMessages?.forEach(x => console.log(x));
}

async function getProgramData() {
  const programDataInfo = await connection.getAccountInfo(programDataAccountKey);
  if (!programDataInfo) {
    throw new Error("データがありません");
  }
  return programDataInfo.data;
}

async function showScore() {
  const { score } = deserializeData(await getProgramData());
  const max = 0xffffffff + 1
  const signedScore = max / 2 > score ? score : score - max;
  console.log("スコア:", signedScore);
}

async function showPosition() {
  const data = deserializeData(await getProgramData());
  function show() {
    return `賭け:${data.isHigher ? "値上がり" : "値下がり"}
行使価格: ${data.strikePrice * Math.pow(10, -9)} SOL/USD
満期: ${DateTime.fromSeconds(data.maturityTimestamp).toISO()}
    `;
  }
  console.log(data.isBetting ? show() : "ポジションはありません");
}

/** 実行ユーザーのSOLの残高 */
async function showBalance() {
  const userAccountInfo = await connection.getAccountInfo(userKeyPair.publicKey);
  const lamports = userAccountInfo?.lamports ?? 0;
  console.log(`残高: ${lamports} lamports (${lamports * Math.pow(10, -9)} SOL)`);
}

機能はコードを見て察してください…。

プログラムアカウントの公開鍵はデプロイしたプログラムのプログラムIDのことです。
データ用のアカウントはウォレットのキーとプログラムのキーとシードから生成します。
ChainlinkのSOL/USDの配信データのアカウントもコードに含めています。

作成したオンチェーンのプログラムはデータ保存用のアカウントが必要になりますが、クライアント側で初回実行時に作成するようにしています。

borsh.jsが符号ありの数値を扱えない(?)ようなので、マイナスになる場合があるスコアはクライアント側で適当に解釈しています。

ちなみにlamportsは10^-9 SOLで、ビットコインのSatoshiのようなものです。

出来上がったので遊んでみます。

node.jsアプリの引数にbetHigherを指定して実行し、5分後の値上がりに賭けてみます。

データアカウントを作成しました 2QSutiYH31F9Zg1bB1M2WcD3Q3GbYgMYt53EP4oY1uPHbiyY4zi8yoawg8qHwsn8RKoNz7HfQ9UNMWKRf3puxYc5
プログラムを実行しました 5Ux8oPmdiESNVaNAUJPD2gj9iKdNwQ956Jh4szcod1mHeevrgq2dNGR9fYzoNGP3se78FMCB7wUAfGaQP98Nu2cf
Program CdmSsDB2BbEcdj9ouATnUuQoJg5WgR2DDdCbYYbHH67p invoke [1]
Program log: コマンド: 1
Program CdmSsDB2BbEcdj9ouATnUuQoJg5WgR2DDdCbYYbHH67p consumed 16706 of 200000 compute units
Program CdmSsDB2BbEcdj9ouATnUuQoJg5WgR2DDdCbYYbHH67p success

showPositionでポジションを確認できます。

賭け:値上がり
行使価格: 241.293561145 SOL/USD
満期: 2021-11-04T13:23:52.000+09:00

ドキドキしながら5分間待ちます。

満期がやってきたら、settleで結果を精算します。

プログラムを実行しました 2FoSwMPX6ChYHhBZbNbrDYJq91W3HevvBdnqJiSHC1fcQ9iEdV6o1ea88XRJ1jmaJgHNBFTKsPDBe2qcTLfbwwQe
Program CdmSsDB2BbEcdj9ouATnUuQoJg5WgR2DDdCbYYbHH67p invoke [1]
Program log: コマンド: 0
Program log: 満期時刻: 1635999831
Program log: 清算価格: 241103000000
Program log: 行使価格: 241293561145
Program log: 賭け: 上
Program log: 外れた🥺ピエン
Program CdmSsDB2BbEcdj9ouATnUuQoJg5WgR2DDdCbYYbHH67p consumed 20044 of 200000 compute units
Program CdmSsDB2BbEcdj9ouATnUuQoJg5WgR2DDdCbYYbHH67p success

外れました🥺

showScoreでスコアが確認できます。

スコア: -1

…とまぁ、こんな遊びができます。

発行したトランザクションブロックチェーンに記録されるので、全世界から確認することが出来ます。

Solana explorerという便利なものがあり、下記URLから先程の実際のトランザクションをブラウザで確認できます。
https://explorer.solana.com/tx/2FoSwMPX6ChYHhBZbNbrDYJq91W3HevvBdnqJiSHC1fcQ9iEdV6o1ea88XRJ1jmaJgHNBFTKsPDBe2qcTLfbwwQe?cluster=devnet
ログにしっかりとピエンと刻まれていて良いですね。

デプロイしたプログラムは誰でも実行出来る状態なので、node.jsのクライアントをそのままコピペして、ウォレットを作って少量のトークンを入れておけば動作します。
気が向いたら遊んでみてください。

ブロックチェーンはまだ発展すると思います

個人的には、ブロックチェーン界隈は投機ばかりが行われていて技術的な側面があまり注目されていないように思っています。
もっと画期的なスマートコントラクトやブロックチェーン周りのサービスが発展していくことを願っています。

最後まで読んでいただきありがとうございます。